Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5732925f64 | ||
|
|
021284b852 | ||
|
|
30719a2960 | ||
|
|
6ce7b2e800 | ||
|
|
080ac390d3 |
@@ -1,3 +1,14 @@
|
|||||||
# 複製為 .env 後依需要調整。
|
# 複製為 .env 後依需要調整。
|
||||||
# R-2 階段會改為 postgresql 連線字串(例如 postgresql://user:password@localhost:5432/kokorone)。
|
# R-2 階段會改為 postgresql 連線字串(例如 postgresql://user:password@localhost:5432/kokorone)。
|
||||||
DATABASE_URL="file:./prisma/dev.db"
|
DATABASE_URL="file:./prisma/dev.db"
|
||||||
|
|
||||||
|
# R-2:情緒狀態即時讀寫/session 快取的加速層;連不到時自動退回原本行為(DB/行程記憶體),不是必要依賴。
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
|
||||||
|
# R-3:LLM_PROVIDER=mock(預設,冒煙測試固定用這個)|claude(真的呼叫 API)
|
||||||
|
LLM_PROVIDER=mock
|
||||||
|
# 這個部署走 CLIProxy(OpenAI 相容的分散式派工代理)而不是直連 api.anthropic.com,
|
||||||
|
# 底層仍是 Claude;若改直連官方 API,把 CLAUDE_BASE_URL 換成 https://api.anthropic.com/v1 即可。
|
||||||
|
CLAUDE_BASE_URL=http://localhost:3000/api/v1
|
||||||
|
CLAUDE_API_KEY=
|
||||||
|
CLAUDE_MODEL=claude-sonnet-4-5
|
||||||
|
|||||||
@@ -2,4 +2,68 @@
|
|||||||
|
|
||||||
以人腦「記憶/情緒/關係」架構為引擎基礎的戀愛陪伴系統。
|
以人腦「記憶/情緒/關係」架構為引擎基礎的戀愛陪伴系統。
|
||||||
|
|
||||||
> 目前使用 npm workspaces 開發(本機無 pnpm),待 R-1 階段會遷移至 pnpm + Turborepo,對外指令介面(`npm run restart` / `npm run smoke`)保持不變。
|
## 開發環境
|
||||||
|
|
||||||
|
Monorepo 採 pnpm workspace + Turborepo(`pnpm-workspace.yaml` / `turbo.json`)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # 安裝所有 workspace 套件
|
||||||
|
pnpm run build # turbo run build(依相依順序建置 shared/db/api,並建置 web 的 production bundle)
|
||||||
|
pnpm run restart # 停止舊的 api/web 行程、重建 TypeScript(tsc -b)、重新啟動並等待健康檢查
|
||||||
|
pnpm run smoke A # 執行 A 群組冒煙測試;all 表示依序執行 A~R 全部群組
|
||||||
|
```
|
||||||
|
|
||||||
|
對外指令介面(`restart` / `smoke`)維持與遷移前相同的腳本名稱;唯一差異是
|
||||||
|
**pnpm 不會像 npm 一樣吃掉 `--` 分隔符**,`pnpm run smoke -- A` 會把字面上的
|
||||||
|
`--` 一起傳給腳本而找不到群組,要執行子指令請直接接在腳本名稱後面
|
||||||
|
(`pnpm run smoke A`),不要加 `--`。
|
||||||
|
|
||||||
|
## 環境變數
|
||||||
|
|
||||||
|
複製 `.env.example` 為 `.env` 後依需要調整;`apps/web/.env.local` 另外可設定
|
||||||
|
`NEXT_PUBLIC_API_URL`(瀏覽器端呼叫 api 用,預設 `http://localhost:3001`)。
|
||||||
|
|
||||||
|
| 變數 | 預設值 | 說明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `DATABASE_URL` | `file:./prisma/dev.db` | Prisma 連線字串。R-5 若遷移到 PostgreSQL,改為 `postgresql://user:password@host:5432/db` 並將 `prisma/schema.prisma` 的 `provider` 由 `sqlite` 換成 `postgresql`。 |
|
||||||
|
| `PORT` | `3001` | api(NestJS)監聽埠。 |
|
||||||
|
| `PORT_API` / `PORT_WEB` | `3001` / `3100` | `pnpm run restart`(`scripts/dev-restart.mjs`)啟動 api/web 時使用的埠號。 |
|
||||||
|
| `WEB_ORIGIN` | `http://localhost:3100` | api 的 CORS 允許來源之一;`http://localhost:8081`(Expo web 開發伺服器)已固定加入,不受此變數影響。 |
|
||||||
|
| `NEXT_PUBLIC_API_URL` | `http://localhost:3001` | web(Next.js)呼叫 api 的位址,build time 注入,正式環境需在建置時設定。 |
|
||||||
|
| `LLM_PROVIDER` | `mock` | `mock`(模板庫,冒煙測試固定用這個)/`claude`(呼叫真實 API,見下)。 |
|
||||||
|
| `CLAUDE_BASE_URL` | `http://localhost:3000/api/v1` | OpenAI 相容的 chat completions 端點基底路徑。本專案的部署方式是透過 [CLIProxy](https://gitea.jsc.idv.tw/jiantw83/CLIProxy)(分散式派工代理,本機 3000 埠)轉發到真實 Claude,而非直連 `api.anthropic.com`;若要直連官方 API,把這個值換成 `https://api.anthropic.com/v1` 並確認金鑰/請求格式相容即可,`ClaudeProvider` 的呼叫端程式碼不需要改動。 |
|
||||||
|
| `CLAUDE_API_KEY` | (無) | 呼叫 `CLAUDE_BASE_URL` 用的金鑰,`LLM_PROVIDER=claude` 時必填。 |
|
||||||
|
| `CLAUDE_MODEL` | `claude-sonnet-4-5` | 要求 Provider 使用的模型名稱;CLIProxy 端需要有對應的 Worker 已註冊、能接這個模型的派工,否則會回報「沒有任何可用的 {模型}-{執行者}-{工具} 組合」。 |
|
||||||
|
| `TTS_PROVIDER` | `mock` | `mock`/`real`(P-4 空殼,尚未接上真實供應商,人工確認後才實作)。 |
|
||||||
|
| `PUSH_PROVIDER` | `mock` | `mock`/`expo`(Q-3,`expo` 直接可用,不需金鑰)。 |
|
||||||
|
|
||||||
|
## 部署路徑
|
||||||
|
|
||||||
|
開發環境:以上服務直接用 `node`/`next dev`/`expo start` 跑在同一台 Linux 主機上
|
||||||
|
(`pnpm run restart` 一鍵重啟 api/web),**不使用 Docker**——這是本專案的實際
|
||||||
|
部署選擇,取代原先技術選型草案中「Docker Compose(開發)」的規劃。
|
||||||
|
|
||||||
|
正式環境建議路徑:
|
||||||
|
|
||||||
|
1. **建置**:`pnpm install --frozen-lockfile && pnpm run build`(turbo 依序建置
|
||||||
|
`packages/shared`/`packages/db`/`apps/api`,並產出 `apps/web` 的 production
|
||||||
|
bundle)。
|
||||||
|
2. **資料庫遷移**:`npx prisma migrate deploy`(依 `DATABASE_URL` 指向的資料庫套用
|
||||||
|
遷移;R-5 遷移到 PostgreSQL 後流程相同,只是連線字串換成 postgresql)。
|
||||||
|
3. **啟動**:
|
||||||
|
- api:`node apps/api/dist/main.js`(讀取上表環境變數)。
|
||||||
|
- web:`apps/web` 下 `npx next start -p <PORT_WEB>`(讀取建置時注入的
|
||||||
|
`NEXT_PUBLIC_API_URL`)。
|
||||||
|
- mobile:`apps/mobile` 透過 `eas build` 產出實際的 App Store/Google Play
|
||||||
|
安裝檔,不隨 api/web 一起常駐部署。
|
||||||
|
4. **程序常駐與重啟**:用 systemd unit(或 pm2)分別管理 api/web 兩個
|
||||||
|
Node 行程,設定 `Restart=on-failure`;健康檢查沿用 `GET /health`
|
||||||
|
(api)與首頁 200(web),與 `scripts/dev-restart.mjs` 開發環境用的
|
||||||
|
檢查邏輯一致。
|
||||||
|
5. **反向代理/TLS**:nginx 或雲端負載平衡器終止 TLS 後轉發到 api/web
|
||||||
|
對應埠號,對外只曝露 443。
|
||||||
|
|
||||||
|
這個路徑刻意不假設 K8s 或容器化,只依賴「Node.js 行程 + systemd + nginx」,
|
||||||
|
因為這台開發主機本身就是照這個模式跑(`pnpm run restart` 正是這個模式的
|
||||||
|
開發期簡化版),移植到正式主機時只是把「用 node 直接跑」換成「用 systemd
|
||||||
|
管理同一組 node 指令」,中間不需要引入額外的容器化工具鏈。
|
||||||
|
|||||||
@@ -10,13 +10,14 @@
|
|||||||
"start": "node dist/main.js"
|
"start": "node dist/main.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kokorone/db": "*",
|
"@kokorone/db": "workspace:*",
|
||||||
"@kokorone/shared": "*",
|
"@kokorone/shared": "workspace:*",
|
||||||
"@nestjs/common": "^11.1.29",
|
"@nestjs/common": "^11.1.29",
|
||||||
"@nestjs/core": "^11.1.29",
|
"@nestjs/core": "^11.1.29",
|
||||||
"@nestjs/event-emitter": "^3.1.0",
|
"@nestjs/event-emitter": "^3.1.0",
|
||||||
"@nestjs/platform-express": "^11.1.29",
|
"@nestjs/platform-express": "^11.1.29",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
"node-cron": "^4.6.0",
|
"node-cron": "^4.6.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2"
|
||||||
|
|||||||
Vendored
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import { Redis } from "ioredis";
|
||||||
|
import { log } from "@kokorone/shared";
|
||||||
|
|
||||||
|
let client: Redis | null = null;
|
||||||
|
let loggedUnavailable = false;
|
||||||
|
|
||||||
|
// R-2:session 快取/情緒狀態即時讀寫改走 Redis。連線失敗時呼叫端一律要能安全退回原本的行為
|
||||||
|
// (工作記憶退回行程記憶體 Map、情緒狀態退回直接讀寫 Prisma),Redis 只是加速層,不是新的必要依賴,
|
||||||
|
// 這樣即使部署環境還沒有 Redis(例如這個沒有 Docker/sudo 的沙盒),既有行為也不會被打斷。
|
||||||
|
export function getRedisClient(): Redis {
|
||||||
|
if (!client) {
|
||||||
|
client = new Redis(process.env.REDIS_URL ?? "redis://127.0.0.1:6379", {
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
retryStrategy: () => null,
|
||||||
|
connectTimeout: 300,
|
||||||
|
});
|
||||||
|
client.on("error", () => {
|
||||||
|
if (!loggedUnavailable) {
|
||||||
|
loggedUnavailable = true;
|
||||||
|
log("啟動", "WRN", `Redis 無法連線(REDIS_URL=${process.env.REDIS_URL ?? "redis://127.0.0.1:6379"}),相關快取將退回原本行為`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ import { RuleBasedEmotionTagger } from "./emotion-tagger.js";
|
|||||||
import { nextEmotionState, type TransitionContext } from "./emotion-state-machine.js";
|
import { nextEmotionState, type TransitionContext } from "./emotion-state-machine.js";
|
||||||
import { toResponseStyle, type ResponseStyle } from "./response-style.js";
|
import { toResponseStyle, type ResponseStyle } from "./response-style.js";
|
||||||
import { getArchetypeParams } from "../personality/archetype-params.js";
|
import { getArchetypeParams } from "../personality/archetype-params.js";
|
||||||
|
import { getRedisClient } from "../cache/redis-client.js";
|
||||||
|
|
||||||
|
const REDIS_KEY_PREFIX = "kokorone:emotion-state:";
|
||||||
|
|
||||||
// D-2 時間衰減:預設半衰期 2 小時,實際速度由角色參數(G 群組)覆寫。
|
// D-2 時間衰減:預設半衰期 2 小時,實際速度由角色參數(G 群組)覆寫。
|
||||||
const DEFAULT_HALF_LIFE_MS = 2 * 60 * 60 * 1000;
|
const DEFAULT_HALF_LIFE_MS = 2 * 60 * 60 * 1000;
|
||||||
@@ -133,11 +136,45 @@ export class EmotionService {
|
|||||||
return { state: toDomain(characterId, updated, now), style: toResponseStyle(next) };
|
return { state: toDomain(characterId, updated, now), style: toResponseStyle(next) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// R-2:情緒狀態機的即時讀寫改走 Redis 當熱路徑快取,Prisma/SQLite(未來 Postgres)
|
||||||
|
// 仍是唯一的持久真實來源——快取只省掉命中時的資料庫往返,Redis 不可用或未命中都直接退回原本
|
||||||
|
// 讀 Prisma 的行為,因此「重啟 api 後情緒狀態不遺失」這件事完全不受快取層是否存在影響。
|
||||||
|
private async readCached(characterId: string): Promise<{ dims: Dimensions; updatedAt: Date } | null> {
|
||||||
|
try {
|
||||||
|
const raw = await getRedisClient().get(REDIS_KEY_PREFIX + characterId);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as Dimensions & { updatedAt: string };
|
||||||
|
const { updatedAt, ...dims } = parsed;
|
||||||
|
return { dims, updatedAt: new Date(updatedAt) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeCache(characterId: string, dims: Dimensions, updatedAt: Date): Promise<void> {
|
||||||
|
try {
|
||||||
|
await getRedisClient().set(
|
||||||
|
REDIS_KEY_PREFIX + characterId,
|
||||||
|
JSON.stringify({ ...dims, updatedAt: updatedAt.toISOString() }),
|
||||||
|
"EX",
|
||||||
|
60 * 60 * 24,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Redis 不可用時,Prisma 仍是持久真實來源,快取寫入失敗不影響正確性。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async decayedDimensions(
|
private async decayedDimensions(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
now: Date,
|
now: Date,
|
||||||
halfLifeMs = DEFAULT_HALF_LIFE_MS,
|
halfLifeMs = DEFAULT_HALF_LIFE_MS,
|
||||||
): Promise<{ dims: Dimensions; updatedAt: Date }> {
|
): Promise<{ dims: Dimensions; updatedAt: Date }> {
|
||||||
|
const cached = await this.readCached(characterId);
|
||||||
|
if (cached) {
|
||||||
|
const elapsedMs = now.getTime() - cached.updatedAt.getTime();
|
||||||
|
return { dims: decayDimensions(cached.dims, elapsedMs, halfLifeMs), updatedAt: cached.updatedAt };
|
||||||
|
}
|
||||||
|
|
||||||
const row = await this.prisma.client.emotionState.upsert({
|
const row = await this.prisma.client.emotionState.upsert({
|
||||||
where: { characterId },
|
where: { characterId },
|
||||||
update: {},
|
update: {},
|
||||||
@@ -154,6 +191,7 @@ export class EmotionService {
|
|||||||
where: { characterId },
|
where: { characterId },
|
||||||
data: { ...dims, updatedAt: now },
|
data: { ...dims, updatedAt: now },
|
||||||
});
|
});
|
||||||
|
await this.writeCache(characterId, dims, now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,101 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import type { LLMProvider } from "./llm-provider.js";
|
import type { LLMProvider } from "./llm-provider.js";
|
||||||
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
||||||
|
import { buildMessages } from "./prompt-builder.js";
|
||||||
|
import { extractActions } from "./action-markup.js";
|
||||||
|
|
||||||
const NOT_IMPLEMENTED_MESSAGE = "ClaudeProvider 尚未實作(R-3 會接上真實 Claude API)";
|
interface OpenAiCompatibleChoice {
|
||||||
|
message?: { content?: string };
|
||||||
|
delta?: { content?: string };
|
||||||
|
}
|
||||||
|
interface OpenAiCompatibleResponse {
|
||||||
|
choices?: OpenAiCompatibleChoice[];
|
||||||
|
}
|
||||||
|
|
||||||
// F-2 ClaudeProvider 空殼:R-3 才會實作真實呼叫,目前僅回報未實作。
|
function baseUrl(): string {
|
||||||
|
return process.env.CLAUDE_BASE_URL ?? "http://localhost:3000/api/v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiKey(): string {
|
||||||
|
const key = process.env.CLAUDE_API_KEY;
|
||||||
|
if (!key) {
|
||||||
|
throw new Error("CLAUDE_API_KEY 未設定,LLM_PROVIDER=claude 需要這個環境變數才能呼叫真實 API");
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function model(): string {
|
||||||
|
return process.env.CLAUDE_MODEL ?? "claude-sonnet-4-5";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestChatCompletion(body: Record<string, unknown>): Promise<Response> {
|
||||||
|
const res = await fetch(`${baseUrl()}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey()}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`ClaudeProvider 呼叫失敗(HTTP ${res.status}):${text.slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-3:把 F-2 的空殼換成真的呼叫。這個部署走 CLIProxy(分散式派工代理,見
|
||||||
|
// gitea.jsc.idv.tw/jiantw83/CLIProxy)提供的 OpenAI 相容端點,而不是直連
|
||||||
|
// api.anthropic.com——底層仍是 Claude,只是多一層派工代理,對這個介面
|
||||||
|
// (LLMProvider.generate/stream)完全透明,切換供應商不需要動任何呼叫端程式碼。
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClaudeProvider implements LLMProvider {
|
export class ClaudeProvider implements LLMProvider {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
async generate(context: GenerationContext): Promise<GeneratedResponse> {
|
||||||
async generate(_context: GenerationContext): Promise<GeneratedResponse> {
|
const res = await requestChatCompletion({
|
||||||
throw new Error(NOT_IMPLEMENTED_MESSAGE);
|
model: model(),
|
||||||
|
messages: buildMessages(context),
|
||||||
|
stream: false,
|
||||||
|
});
|
||||||
|
const json = (await res.json()) as OpenAiCompatibleResponse;
|
||||||
|
const text = json.choices?.[0]?.message?.content ?? "";
|
||||||
|
if (!text) {
|
||||||
|
throw new Error("ClaudeProvider 回應內容為空(choices[0].message.content 缺失)");
|
||||||
|
}
|
||||||
|
return { text, actions: extractActions(text) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, require-yield
|
async *stream(context: GenerationContext): AsyncIterable<string> {
|
||||||
async *stream(_context: GenerationContext): AsyncIterable<string> {
|
const res = await requestChatCompletion({
|
||||||
throw new Error(NOT_IMPLEMENTED_MESSAGE);
|
model: model(),
|
||||||
|
messages: buildMessages(context),
|
||||||
|
stream: true,
|
||||||
|
});
|
||||||
|
if (!res.body) {
|
||||||
|
throw new Error("ClaudeProvider 串流回應沒有 body");
|
||||||
|
}
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith("data:")) continue;
|
||||||
|
const payload = trimmed.slice("data:".length).trim();
|
||||||
|
if (payload === "[DONE]") return;
|
||||||
|
let chunk: OpenAiCompatibleResponse;
|
||||||
|
try {
|
||||||
|
chunk = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const delta = chunk.choices?.[0]?.delta?.content;
|
||||||
|
if (delta) yield delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export class ContextAssemblerService {
|
|||||||
? []
|
? []
|
||||||
: await this.retriever.retrieve(characterId, userInput, { relatedUserId: userId });
|
: await this.retriever.retrieve(characterId, userInput, { relatedUserId: userId });
|
||||||
|
|
||||||
const history = this.workingMemory.getContext(sessionId).map((entry) => ({
|
const history = (await this.workingMemory.getContext(sessionId)).map((entry) => ({
|
||||||
role: entry.role,
|
role: entry.role,
|
||||||
content: entry.content,
|
content: entry.content,
|
||||||
timestamp: entry.timestamp.toISOString(),
|
timestamp: entry.timestamp.toISOString(),
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export class DialogueService {
|
|||||||
options: HandleMessageOptions = {},
|
options: HandleMessageOptions = {},
|
||||||
): Promise<HandleMessageResult> {
|
): Promise<HandleMessageResult> {
|
||||||
const now = options.now ?? new Date();
|
const now = options.now ?? new Date();
|
||||||
this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now });
|
await this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now });
|
||||||
|
|
||||||
// I-6:按需生成離線事件(今天只生成一次),成為話題來源。
|
// I-6:按需生成離線事件(今天只生成一次),成為話題來源。
|
||||||
await this.offlineEvent.maybeGenerate(characterId, now);
|
await this.offlineEvent.maybeGenerate(characterId, now);
|
||||||
@@ -138,7 +138,7 @@ export class DialogueService {
|
|||||||
this.outputFilter.filter(text, forbiddenWords),
|
this.outputFilter.filter(text, forbiddenWords),
|
||||||
context.character.personalityArchetype,
|
context.character.personalityArchetype,
|
||||||
);
|
);
|
||||||
this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now });
|
await this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now });
|
||||||
|
|
||||||
return { text: filteredText, actions, isFastChannel, availability: effectiveAvailability, exceptionType, context };
|
return { text: filteredText, actions, isFastChannel, availability: effectiveAvailability, exceptionType, context };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { EmotionTag } from "@kokorone/shared";
|
||||||
|
import { pickTemplates } from "./template-library.js";
|
||||||
|
|
||||||
|
export interface FewShotExample {
|
||||||
|
emotionTag: EmotionTag;
|
||||||
|
tier: "low" | "high";
|
||||||
|
userInput: string;
|
||||||
|
response: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_USER_INPUT: Record<"low" | "high", string> = {
|
||||||
|
low: "今天天氣真好呢。",
|
||||||
|
high: "謝謝你,我今天好開心!",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAMPLE_EMOTIONS: EmotionTag[] = ["CALM", "JOY", "SAD", "ALERT", "SHY", "GRUMPY"];
|
||||||
|
|
||||||
|
// R-3:F-7 的 Mock 模板庫在真實 Provider 這邊轉為 few-shot 範例——同一份模板資料,
|
||||||
|
// 從「隨機挑一句直接當回應」變成「示範這個性格原型在各種情緒下該有的語氣」。
|
||||||
|
export function buildFewShotExamples(personalityArchetype: string): FewShotExample[] {
|
||||||
|
const examples: FewShotExample[] = [];
|
||||||
|
for (const tier of ["low", "high"] as const) {
|
||||||
|
for (const emotionTag of SAMPLE_EMOTIONS) {
|
||||||
|
const candidates = pickTemplates(personalityArchetype, emotionTag, tier);
|
||||||
|
if (candidates.length === 0) continue;
|
||||||
|
examples.push({ emotionTag, tier, userInput: SAMPLE_USER_INPUT[tier], response: candidates[0] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return examples;
|
||||||
|
}
|
||||||
@@ -16,10 +16,11 @@ import { DialogueService } from "./dialogue.service.js";
|
|||||||
import { DialogueController } from "./dialogue.controller.js";
|
import { DialogueController } from "./dialogue.controller.js";
|
||||||
|
|
||||||
// F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。
|
// F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。
|
||||||
|
// R-3:ClaudeProvider 已接上真實 API,這裡不再需要特別的「尚未實作」警告。
|
||||||
function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
|
function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
|
||||||
const providerName = process.env.LLM_PROVIDER ?? "mock";
|
const providerName = process.env.LLM_PROVIDER ?? "mock";
|
||||||
if (providerName === "claude") {
|
if (providerName === "claude") {
|
||||||
log("啟動", "ERR", "LLM_PROVIDER=claude 但 ClaudeProvider 尚未實作(R-3),對話生成呼叫時會拋出例外");
|
log("啟動", "INF", `LLM_PROVIDER=claude,對話生成將呼叫真實 API(CLAUDE_BASE_URL=${process.env.CLAUDE_BASE_URL ?? "http://localhost:3000/api/v1"})`);
|
||||||
return claude;
|
return claude;
|
||||||
}
|
}
|
||||||
return mock;
|
return mock;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { GenerationContext } from "./types.js";
|
||||||
|
import { buildFewShotExamples } from "./few-shot-examples.js";
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RELATIONSHIP_STAGE_LABEL: Record<string, string> = {
|
||||||
|
STRANGER: "陌生",
|
||||||
|
ACQUAINTANCE: "認識",
|
||||||
|
FRIEND: "朋友",
|
||||||
|
CLOSE_AMBIGUOUS: "摯友-曖昧",
|
||||||
|
BONDED: "羈絆",
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildSystemPrompt(context: GenerationContext): string {
|
||||||
|
const { character, emotion, relationship, retrievedMemories } = context;
|
||||||
|
const stageLabel = RELATIONSHIP_STAGE_LABEL[relationship.stage] ?? relationship.stage;
|
||||||
|
|
||||||
|
const memoryLines =
|
||||||
|
retrievedMemories.length > 0
|
||||||
|
? retrievedMemories.map((m) => `- ${m.content}`).join("\n")
|
||||||
|
: "(目前沒有可用的相關記憶)";
|
||||||
|
|
||||||
|
const fewShot = buildFewShotExamples(character.personalityArchetype);
|
||||||
|
const fewShotLines = fewShot
|
||||||
|
.map((ex) => `情境:情緒=${ex.emotionTag}、親密度=${ex.tier}\n使用者:${ex.userInput}\n角色:${ex.response}`)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
return [
|
||||||
|
`你正在扮演一個戀愛陪伴系統中的角色,必須完全以第一人稱、角色本人的口吻回應,不要說明你是 AI,不要跳出角色。`,
|
||||||
|
``,
|
||||||
|
`【角色設定】`,
|
||||||
|
`性格原型:${character.personalityArchetype}`,
|
||||||
|
`說話風格:${character.speechStyle}`,
|
||||||
|
`喜好/厭惡:${character.likesDislikes}`,
|
||||||
|
`基本資訊:${character.basicInfo}`,
|
||||||
|
``,
|
||||||
|
`【當前狀態】`,
|
||||||
|
`情緒:${emotion.dominant}(語氣:${emotion.style.tone},句長:${emotion.style.sentenceLength},主動性:${emotion.style.initiative})`,
|
||||||
|
`關係:親密度 ${relationship.intimacy}、信任度 ${relationship.trust}、階段:${stageLabel}`,
|
||||||
|
``,
|
||||||
|
`【相關記憶】`,
|
||||||
|
memoryLines,
|
||||||
|
``,
|
||||||
|
`【輸出格式】`,
|
||||||
|
`回應為純文字。若有動作/表情描寫,用全形星號包住,例如 *臉紅撇過頭*;不要用任何其他標記語法,不要加角色名稱前綴。`,
|
||||||
|
``,
|
||||||
|
`【語氣範例(僅供語氣參考,不要照抄內容)】`,
|
||||||
|
fewShotLines,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMessages(context: GenerationContext): ChatMessage[] {
|
||||||
|
const messages: ChatMessage[] = [{ role: "system", content: buildSystemPrompt(context) }];
|
||||||
|
for (const turn of context.history) {
|
||||||
|
messages.push({ role: turn.role === "character" ? "assistant" : "user", content: turn.content });
|
||||||
|
}
|
||||||
|
messages.push({ role: "user", content: context.userInput });
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ export class MemoryConsolidationService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async consolidate(characterId: string, sessionId: string): Promise<void> {
|
async consolidate(characterId: string, sessionId: string): Promise<void> {
|
||||||
const entries = this.workingMemory.getContext(sessionId);
|
const entries = await this.workingMemory.getContext(sessionId);
|
||||||
|
|
||||||
const contentCounts = new Map<string, number>();
|
const contentCounts = new Map<string, number>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -53,6 +53,6 @@ export class MemoryConsolidationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.workingMemory.clear(sessionId);
|
await this.workingMemory.clear(sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,20 +27,20 @@ export class MemoryController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post(":characterId/sessions/:sessionId/messages")
|
@Post(":characterId/sessions/:sessionId/messages")
|
||||||
appendMessage(@Param("sessionId") sessionId: string, @Body() body: AppendMessageBody) {
|
async appendMessage(@Param("sessionId") sessionId: string, @Body() body: AppendMessageBody) {
|
||||||
this.workingMemory.append(sessionId, {
|
await this.workingMemory.append(sessionId, {
|
||||||
role: body.role,
|
role: body.role,
|
||||||
content: body.content,
|
content: body.content,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
emotionTag: body.emotionTag,
|
emotionTag: body.emotionTag,
|
||||||
emotionIntensity: body.emotionIntensity,
|
emotionIntensity: body.emotionIntensity,
|
||||||
});
|
});
|
||||||
return { context: this.workingMemory.getContext(sessionId) };
|
return { context: await this.workingMemory.getContext(sessionId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":characterId/sessions/:sessionId/messages")
|
@Get(":characterId/sessions/:sessionId/messages")
|
||||||
getContext(@Param("sessionId") sessionId: string) {
|
async getContext(@Param("sessionId") sessionId: string) {
|
||||||
return { context: this.workingMemory.getContext(sessionId) };
|
return { context: await this.workingMemory.getContext(sessionId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":characterId/sessions/:sessionId/consolidate")
|
@Post(":characterId/sessions/:sessionId/consolidate")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import type { EmotionTag } from "@kokorone/shared";
|
import type { EmotionTag } from "@kokorone/shared";
|
||||||
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
|
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
|
||||||
|
import { getRedisClient } from "../cache/redis-client.js";
|
||||||
|
|
||||||
export interface WorkingMemoryEntry {
|
export interface WorkingMemoryEntry {
|
||||||
role: "user" | "character";
|
role: "user" | "character";
|
||||||
@@ -10,7 +11,14 @@ export interface WorkingMemoryEntry {
|
|||||||
emotionIntensity?: number; // 0~1,未標記情緒的訊息(如快速通道問候)可省略
|
emotionIntensity?: number; // 0~1,未標記情緒的訊息(如快速通道問候)可省略
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SerializedEntry extends Omit<WorkingMemoryEntry, "timestamp"> {
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_TOKEN_LIMIT = 200;
|
const DEFAULT_TOKEN_LIMIT = 200;
|
||||||
|
const REDIS_KEY_PREFIX = "kokorone:working-memory:";
|
||||||
|
// session 閒置這麼久沒有新訊息就任由 Redis 自然過期,避免無限累積殭屍 session。
|
||||||
|
const SESSION_TTL_SECONDS = 60 * 60 * 6;
|
||||||
|
|
||||||
// 粗略估算:Mock 階段不需要真實 tokenizer,先以字元數/2 近似。
|
// 粗略估算:Mock 階段不需要真實 tokenizer,先以字元數/2 近似。
|
||||||
function estimateTokens(text: string): number {
|
function estimateTokens(text: string): number {
|
||||||
@@ -21,32 +29,11 @@ function isHighEmotion(entry: WorkingMemoryEntry): boolean {
|
|||||||
return (entry.emotionIntensity ?? 0) >= HIGH_EMOTION_THRESHOLD;
|
return (entry.emotionIntensity ?? 0) >= HIGH_EMOTION_THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
// C-1 工作記憶:session 對話上下文緩衝,只存在於行程記憶體中(如同海馬迴暫存),
|
function evictOverflow(entries: WorkingMemoryEntry[], tokenLimit: number): WorkingMemoryEntry[] {
|
||||||
// 不落地到任何長期記憶表——長期寫入只能由 C-4 睡眠固化觸發。
|
|
||||||
@Injectable()
|
|
||||||
export class WorkingMemoryService {
|
|
||||||
private readonly sessions = new Map<string, WorkingMemoryEntry[]>();
|
|
||||||
private readonly tokenLimit = DEFAULT_TOKEN_LIMIT;
|
|
||||||
|
|
||||||
append(sessionId: string, entry: WorkingMemoryEntry): void {
|
|
||||||
const entries = this.sessions.get(sessionId) ?? [];
|
|
||||||
entries.push(entry);
|
|
||||||
this.sessions.set(sessionId, this.evictOverflow(entries));
|
|
||||||
}
|
|
||||||
|
|
||||||
getContext(sessionId: string): WorkingMemoryEntry[] {
|
|
||||||
return [...(this.sessions.get(sessionId) ?? [])];
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(sessionId: string): void {
|
|
||||||
this.sessions.delete(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private evictOverflow(entries: WorkingMemoryEntry[]): WorkingMemoryEntry[] {
|
|
||||||
let result = entries;
|
let result = entries;
|
||||||
let totalTokens = result.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
let totalTokens = result.reduce((sum, e) => sum + estimateTokens(e.content), 0);
|
||||||
|
|
||||||
while (totalTokens > this.tokenLimit && result.length > 0) {
|
while (totalTokens > tokenLimit && result.length > 0) {
|
||||||
// 最舊的低情緒段落先被裁掉;若全部都是高情緒,最後才犧牲最舊的一則以確保不超出上限。
|
// 最舊的低情緒段落先被裁掉;若全部都是高情緒,最後才犧牲最舊的一則以確保不超出上限。
|
||||||
let evictIndex = result.findIndex((e) => !isHighEmotion(e));
|
let evictIndex = result.findIndex((e) => !isHighEmotion(e));
|
||||||
if (evictIndex === -1) {
|
if (evictIndex === -1) {
|
||||||
@@ -57,5 +44,59 @@ export class WorkingMemoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(entries: WorkingMemoryEntry[]): string {
|
||||||
|
const payload: SerializedEntry[] = entries.map((e) => ({ ...e, timestamp: e.timestamp.toISOString() }));
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserialize(raw: string): WorkingMemoryEntry[] {
|
||||||
|
const payload = JSON.parse(raw) as SerializedEntry[];
|
||||||
|
return payload.map((e) => ({ ...e, timestamp: new Date(e.timestamp) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// C-1 工作記憶:session 對話上下文緩衝,如同海馬迴暫存,長期寫入只能由 C-4 睡眠固化觸發。
|
||||||
|
// R-2:改走 Redis(session 快取),Redis 不可用時退回行程記憶體 Map,行為(token 上限、高情緒優先保留)不變。
|
||||||
|
@Injectable()
|
||||||
|
export class WorkingMemoryService {
|
||||||
|
private readonly fallback = new Map<string, WorkingMemoryEntry[]>();
|
||||||
|
private readonly tokenLimit = DEFAULT_TOKEN_LIMIT;
|
||||||
|
|
||||||
|
async append(sessionId: string, entry: WorkingMemoryEntry): Promise<void> {
|
||||||
|
const entries = await this.getContext(sessionId);
|
||||||
|
entries.push(entry);
|
||||||
|
const evicted = evictOverflow(entries, this.tokenLimit);
|
||||||
|
|
||||||
|
const redis = getRedisClient();
|
||||||
|
try {
|
||||||
|
await redis.set(REDIS_KEY_PREFIX + sessionId, serialize(evicted), "EX", SESSION_TTL_SECONDS);
|
||||||
|
this.fallback.delete(sessionId);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
this.fallback.set(sessionId, evicted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContext(sessionId: string): Promise<WorkingMemoryEntry[]> {
|
||||||
|
const redis = getRedisClient();
|
||||||
|
try {
|
||||||
|
const raw = await redis.get(REDIS_KEY_PREFIX + sessionId);
|
||||||
|
if (raw !== null) return deserialize(raw);
|
||||||
|
if (this.fallback.has(sessionId)) return [...this.fallback.get(sessionId)!];
|
||||||
|
return [];
|
||||||
|
} catch {
|
||||||
|
return [...(this.fallback.get(sessionId) ?? [])];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(sessionId: string): Promise<void> {
|
||||||
|
this.fallback.delete(sessionId);
|
||||||
|
const redis = getRedisClient();
|
||||||
|
try {
|
||||||
|
await redis.del(REDIS_KEY_PREFIX + sessionId);
|
||||||
|
} catch {
|
||||||
|
// Redis 不可用時 fallback 已經在上面清掉,沒有需要額外處理的狀態。
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/metro-runtime": "~57.0.9",
|
"@expo/metro-runtime": "~57.0.9",
|
||||||
"@kokorone/shared": "*",
|
"@kokorone/shared": "workspace:*",
|
||||||
"expo": "~57.0.12",
|
"expo": "~57.0.12",
|
||||||
"expo-constants": "~57.0.10",
|
"expo-constants": "~57.0.10",
|
||||||
"expo-device": "~57.0.1",
|
"expo-device": "~57.0.1",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kokorone/shared": "*",
|
"@kokorone/shared": "workspace:*",
|
||||||
"next": "16.3.0",
|
"next": "16.3.0",
|
||||||
"pixi.js": "^8.19.0",
|
"pixi.js": "^8.19.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
|
|||||||
Generated
-17332
File diff suppressed because it is too large
Load Diff
+3
-5
@@ -3,12 +3,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "心音(Kokorone)— 戀愛陪伴系統 monorepo",
|
"description": "心音(Kokorone)— 戀愛陪伴系統 monorepo",
|
||||||
"workspaces": [
|
"packageManager": "pnpm@11.21.0",
|
||||||
"apps/*",
|
|
||||||
"packages/*"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build --workspaces --if-present",
|
"build": "turbo run build",
|
||||||
"restart": "node scripts/dev-restart.mjs",
|
"restart": "node scripts/dev-restart.mjs",
|
||||||
"smoke": "node scripts/smoke/run.mjs"
|
"smoke": "node scripts/smoke/run.mjs"
|
||||||
},
|
},
|
||||||
@@ -19,6 +16,7 @@
|
|||||||
"@types/node": "^26.2.0",
|
"@types/node": "^26.2.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
|
"turbo": "^2.5.8",
|
||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+11884
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
|
allowBuilds:
|
||||||
|
'@prisma/engines': true
|
||||||
|
better-sqlite3: true
|
||||||
|
prisma: true
|
||||||
|
unrs-resolver: true
|
||||||
+12
-7
@@ -136,14 +136,18 @@ export default async function smokeF() {
|
|||||||
// 清理本次測試建立的 ProceduralRule,避免重複執行時累積髒資料
|
// 清理本次測試建立的 ProceduralRule,避免重複執行時累積髒資料
|
||||||
await prisma.proceduralRule.deleteMany({ where: { characterId: CHARACTER_ID, situation } });
|
await prisma.proceduralRule.deleteMany({ where: { characterId: CHARACTER_ID, situation } });
|
||||||
|
|
||||||
// F-2 Provider 切換:LLM_PROVIDER=claude 時啟動即以 ERR log 明確告知未實作,呼叫時拋出例外
|
// R-3:ClaudeProvider 已經是真的呼叫,不再是空殼。這裡只驗證 Provider 切換的「配線」本身
|
||||||
await verifyClaudeProviderStub();
|
// (選對了 ClaudeProvider、缺金鑰時的錯誤處理正確),不對 CLIProxy 發真的請求——真正的
|
||||||
|
// 回歸測試基準仍是 Mock 模板庫(同 R-3 驗收註記),自動化冒煙測試不應依賴外部服務目前是否可用。
|
||||||
|
await verifyClaudeProviderWiring();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyClaudeProviderStub() {
|
async function verifyClaudeProviderWiring() {
|
||||||
const port = "3091";
|
const port = "3091";
|
||||||
|
const childEnv = { ...process.env, PORT: port, LLM_PROVIDER: "claude" };
|
||||||
|
delete childEnv.CLAUDE_API_KEY;
|
||||||
const child = spawn("node", ["apps/api/dist/main.js"], {
|
const child = spawn("node", ["apps/api/dist/main.js"], {
|
||||||
env: { ...process.env, PORT: port, LLM_PROVIDER: "claude" },
|
env: childEnv,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,8 +160,8 @@ async function verifyClaudeProviderStub() {
|
|||||||
while (!output.includes("啟動") && Date.now() < deadline) {
|
while (!output.includes("啟動") && Date.now() < deadline) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
}
|
}
|
||||||
if (!output.includes("ClaudeProvider 尚未實作")) {
|
if (!output.includes("LLM_PROVIDER=claude")) {
|
||||||
throw new Error("LLM_PROVIDER=claude 啟動時未輸出「尚未實作」的 ERR log");
|
throw new Error("LLM_PROVIDER=claude 啟動時未輸出對應的 INF log");
|
||||||
}
|
}
|
||||||
|
|
||||||
let healthOk = false;
|
let healthOk = false;
|
||||||
@@ -182,8 +186,9 @@ async function verifyClaudeProviderStub() {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ userId: USER_ID, text: "今天發生了很多事情想跟你分享" }),
|
body: JSON.stringify({ userId: USER_ID, text: "今天發生了很多事情想跟你分享" }),
|
||||||
});
|
});
|
||||||
|
// 沒有金鑰應該在呼叫 generate() 時就快速、確定性地失敗(不對 CLIProxy 發任何請求)。
|
||||||
if (res.status < 500) {
|
if (res.status < 500) {
|
||||||
throw new Error(`ClaudeProvider 空殼應在呼叫 generate() 時失敗,實際回傳 ${res.status}`);
|
throw new Error(`缺少 CLAUDE_API_KEY 時應該呼叫失敗,實際回傳 ${res.status}`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
child.kill("SIGKILL");
|
child.kill("SIGKILL");
|
||||||
|
|||||||
+1
-1
@@ -225,7 +225,7 @@ export default async function smokeJ() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { spawnSync } = await import("node:child_process");
|
const { spawnSync } = await import("node:child_process");
|
||||||
const restartResult = spawnSync("npm", ["run", "restart"], { cwd: process.cwd(), stdio: "pipe" });
|
const restartResult = spawnSync("pnpm", ["run", "restart"], { cwd: process.cwd(), stdio: "pipe" });
|
||||||
if (restartResult.status !== 0) {
|
if (restartResult.status !== 0) {
|
||||||
throw new Error(`重啟服務失敗:${restartResult.stderr?.toString()}`);
|
throw new Error(`重啟服務失敗:${restartResult.stderr?.toString()}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**", "*.tsbuildinfo", ".next/**", "!.next/cache/**"]
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user