diff --git a/README.md b/README.md index d78ff6a..c61c7bb 100644 --- a/README.md +++ b/README.md @@ -269,18 +269,39 @@ docker compose down deepseek/deepseek-v4-flash ``` +默认聊天模型配置组为: + +```json +[ + { + "id": "deepseek/deepseek-v4-flash", + "label": "快速", + "description": "快速回答和任务执行", + "icon": "bolt" + }, + { + "id": "deepseek/deepseek-v4-pro", + "label": "专家", + "description": "探索、解决复杂任务", + "icon": "sparkle" + } +] +``` + 涉及位置: ```text -opencode.json -.opencode/agents/tjwater-assistant.md -src/config.ts 的 OPENCODE_MODEL 默认值 +src/chat/modelConfig.ts 的默认模型配置组 +src/config.ts 的 OPENCODE_MODEL 与 OPENCODE_MODEL_OPTIONS 默认值 +opencode.json 的 opencode 运行时默认模型 ``` -如果需要临时覆盖模型,可以在启动时设置: +如果需要临时覆盖默认模型和模型配置组,可以在启动时设置: ```bash -OPENCODE_MODEL=deepseek/deepseek-v4-pro bun run start +OPENCODE_MODEL=deepseek/deepseek-v4-pro \ +OPENCODE_MODEL_OPTIONS='[{"id":"deepseek/deepseek-v4-flash","label":"快速","description":"快速回答和任务执行","icon":"bolt"},{"id":"deepseek/deepseek-v4-pro","label":"专家","description":"探索、解决复杂任务","icon":"sparkle"}]' \ +bun run start ``` DeepSeek API key 不写入代码,部署时通过环境变量设置: diff --git a/src/chat/modelConfig.ts b/src/chat/modelConfig.ts new file mode 100644 index 0000000..e2797e9 --- /dev/null +++ b/src/chat/modelConfig.ts @@ -0,0 +1,78 @@ +export type SupportedModel = string; +export type AgentModelIcon = "bolt" | "sparkle"; + +export type AgentModelOption = { + id: SupportedModel; + label: string; + description: string; + icon: AgentModelIcon; +}; + +export const defaultAgentModelOptions: AgentModelOption[] = [ + { + id: "deepseek/deepseek-v4-flash", + label: "快速", + description: "快速回答和任务执行", + icon: "bolt", + }, + { + id: "deepseek/deepseek-v4-pro", + label: "专家", + description: "探索、解决复杂任务", + icon: "sparkle", + }, +]; + +export const defaultAgentModelOptionsJson = JSON.stringify(defaultAgentModelOptions); + +const isObjectRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const parseAgentModelOptions = (value: string): AgentModelOption[] => { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error("OPENCODE_MODEL_OPTIONS must be valid JSON"); + } + if (!Array.isArray(parsed)) { + throw new Error("OPENCODE_MODEL_OPTIONS must be a JSON array"); + } + + const seen = new Set(); + return parsed.map((item, index) => { + if (!isObjectRecord(item)) { + throw new Error(`OPENCODE_MODEL_OPTIONS[${index}] must be an object`); + } + const id = typeof item.id === "string" ? item.id.trim() : ""; + if (!id) { + throw new Error(`OPENCODE_MODEL_OPTIONS[${index}].id is required`); + } + if (seen.has(id)) { + throw new Error(`duplicate OPENCODE_MODEL_OPTIONS id: ${id}`); + } + seen.add(id); + + const label = + typeof item.label === "string" && item.label.trim() + ? item.label.trim() + : id; + const description = + typeof item.description === "string" && item.description.trim() + ? item.description.trim() + : label; + const icon = item.icon === "bolt" || item.icon === "sparkle" + ? item.icon + : "sparkle"; + + return { + id, + label, + description, + icon, + }; + }); +}; + +export const getAgentModelIds = (options: AgentModelOption[]) => + options.map((option) => option.id); diff --git a/src/chat/models.ts b/src/chat/models.ts new file mode 100644 index 0000000..2cc66c2 --- /dev/null +++ b/src/chat/models.ts @@ -0,0 +1,28 @@ +import { config } from "../config.js"; +import { + getAgentModelIds, + parseAgentModelOptions, + type SupportedModel, +} from "./modelConfig.js"; + +export { + type AgentModelIcon, + type AgentModelOption, + type SupportedModel, +} from "./modelConfig.js"; + +export const agentModelOptions = parseAgentModelOptions( + config.OPENCODE_MODEL_OPTIONS, +); + +export const supportedModels = getAgentModelIds(agentModelOptions); + +export const isSupportedModel = (model: string): model is SupportedModel => + supportedModels.includes(model); + +export const resolveDefaultModel = (model: string): SupportedModel => { + if (!isSupportedModel(model)) { + throw new Error(`unsupported default agent model: ${model}`); + } + return model; +}; diff --git a/src/config.ts b/src/config.ts index 855ff91..c196d65 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,12 @@ import dotenv from "dotenv"; import { z } from "zod"; +import { + defaultAgentModelOptionsJson, + getAgentModelIds, + parseAgentModelOptions, +} from "./chat/modelConfig.js"; + // 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。 dotenv.config({ path: ".local.env", override: false }); @@ -45,6 +51,8 @@ const envSchema = z OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), // 默认使用的 opencode 模型标识。 OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-flash"), + // 聊天 UI 和 /stream 允许选择的 opencode 模型完整配置,JSON 数组。 + OPENCODE_MODEL_OPTIONS: z.string().default(defaultAgentModelOptionsJson), // opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。 OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"), // client 模式下,目标 opencode server 的基础地址。 @@ -116,6 +124,32 @@ const envSchema = z message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client", }); } + let modelOptions; + try { + modelOptions = parseAgentModelOptions(env.OPENCODE_MODEL_OPTIONS); + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["OPENCODE_MODEL_OPTIONS"], + message: error instanceof Error ? error.message : String(error), + }); + return; + } + const supportedModels = getAgentModelIds(modelOptions); + if (supportedModels.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["OPENCODE_MODEL_OPTIONS"], + message: "OPENCODE_MODEL_OPTIONS must include at least one model", + }); + } + if (!supportedModels.includes(env.OPENCODE_MODEL)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["OPENCODE_MODEL"], + message: "OPENCODE_MODEL must be included in OPENCODE_MODEL_OPTIONS", + }); + } }); export type AppConfig = z.infer; diff --git a/src/learning/orchestrator.ts b/src/learning/orchestrator.ts index 8b03074..34771d0 100644 --- a/src/learning/orchestrator.ts +++ b/src/learning/orchestrator.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { writeLearningAuditLog } from "../audit/learningAudit.js"; +import { type SupportedModel } from "../chat/models.js"; import { type ChatRequestContext } from "../chat/sessionBridge.js"; import { config } from "../config.js"; import { type SessionTurnRecord, SessionTranscriptStore } from "../sessions/transcriptStore.js"; @@ -64,8 +65,6 @@ const reviewResultSchema = z.object({ type GateResult = z.infer; type ReviewResult = z.infer; -type SupportedModel = "deepseek/deepseek-v4-flash" | "deepseek/deepseek-v4-pro"; - type TurnReviewInput = { assistantMessage: string; model?: SupportedModel; diff --git a/src/routes/chat.ts b/src/routes/chat.ts index a70d016..c75db30 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -1,6 +1,13 @@ import { Router } from "express"; import { z } from "zod"; +import { + agentModelOptions, + isSupportedModel, + resolveDefaultModel, + type SupportedModel, +} from "../chat/models.js"; +import { config } from "../config.js"; import { type LearningOrchestrator } from "../learning/orchestrator.js"; import { type SessionTranscriptStore } from "../sessions/transcriptStore.js"; import { logger } from "../logger.js"; @@ -28,8 +35,6 @@ import { type PermissionRequestPayload, type QuestionRequestPayload, streamPromptResponse, - supportedModels, - type SupportedModel, type TodoUpdatePayload, } from "./chatStream.js"; import { @@ -54,7 +59,9 @@ import { const payloadSchema = z.object({ message: z.string().min(1).max(10000), session_id: z.string().max(128).optional(), - model: z.enum(supportedModels).optional(), + model: z.string().refine(isSupportedModel, { + message: "unsupported model", + }).optional(), approval_mode: z.enum(["request", "always"]).optional().default("request"), }); @@ -115,6 +122,13 @@ export const buildChatRouter = ( ) => { const chatRouter = Router(); + chatRouter.get("/models", (_req, res) => { + res.json({ + default_model: resolveDefaultModel(config.OPENCODE_MODEL), + models: agentModelOptions, + }); + }); + chatRouter.post("/session", async (req, res) => { const parsed = createSessionPayloadSchema.safeParse(req.body ?? {}); if (!parsed.success) { diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index d85fe2f..8bb75f4 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -1,6 +1,7 @@ import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2"; import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js"; +import { type SupportedModel } from "../chat/models.js"; import { logger } from "../logger.js"; import { type PermissionReply, @@ -54,12 +55,6 @@ export { type TodoUpdatePayload, } from "./chatStreamEvents.js"; -export const supportedModels = [ - "deepseek/deepseek-v4-flash", - "deepseek/deepseek-v4-pro", -] as const; - -export type SupportedModel = (typeof supportedModels)[number]; export type ApprovalMode = "request" | "always"; type StreamPromptOptions = { diff --git a/tests/chat/models.test.ts b/tests/chat/models.test.ts new file mode 100644 index 0000000..2580c2b --- /dev/null +++ b/tests/chat/models.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; + +import { + agentModelOptions, + isSupportedModel, + resolveDefaultModel, + supportedModels, +} from "../../src/chat/models.js"; +import { parseAgentModelOptions } from "../../src/chat/modelConfig.js"; + +describe("agent model config", () => { + it("keeps every exposed option in the supported model list", () => { + expect(agentModelOptions.map((model) => model.id)).toEqual(supportedModels); + expect( + agentModelOptions.every( + (model) => model.label && model.description && model.icon, + ), + ).toBe(true); + }); + + it("validates supported model ids", () => { + expect(isSupportedModel("deepseek/deepseek-v4-flash")).toBe(true); + expect(isSupportedModel("unknown/model")).toBe(false); + }); + + it("rejects unsupported default models", () => { + expect(resolveDefaultModel("deepseek/deepseek-v4-pro")).toBe( + "deepseek/deepseek-v4-pro", + ); + expect(() => resolveDefaultModel("unknown/model")).toThrow( + "unsupported default agent model", + ); + }); + + it("parses full model option config from JSON", () => { + expect( + parseAgentModelOptions( + JSON.stringify([ + { + id: "provider/model", + label: "自定义", + description: "自定义模型", + icon: "bolt", + }, + ]), + ), + ).toEqual([ + { + id: "provider/model", + label: "自定义", + description: "自定义模型", + icon: "bolt", + }, + ]); + }); + + it("rejects invalid model option config", () => { + expect(() => parseAgentModelOptions("not-json")).toThrow( + "OPENCODE_MODEL_OPTIONS must be valid JSON", + ); + expect(() => + parseAgentModelOptions( + JSON.stringify([ + { id: "provider/model", label: "模型" }, + { id: "provider/model", label: "重复模型" }, + ]), + ), + ).toThrow("duplicate OPENCODE_MODEL_OPTIONS id"); + }); +});