From 8b02cae2af36e27c8debd3a6798cf877fd67006b Mon Sep 17 00:00:00 2001 From: Huarch Date: Tue, 19 May 2026 10:27:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B4=E7=90=86=20opencode=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=96=B9=E5=BC=8F=EF=BC=8Cembedded=20=E5=92=8C=20clie?= =?UTF-8?q?nt=20=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 27 ++++- src/config.ts | 213 +++++++++++++++++++++++----------------- src/runtime/opencode.ts | 49 ++++++--- 3 files changed, 181 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 67adb44..e05847e 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,12 @@ typescript ## 启动与部署 -默认部署不需要全局安装 `opencode` CLI。服务会通过 `@opencode-ai/sdk` 的 embedded 模式启动 opencode server。 +支持两种 opencode 接入方式: + +1. Embedded 模式:服务通过 `@opencode-ai/sdk` 调用 `createOpencode`,启动本地 `opencode` CLI 子进程并自动创建 client。 +2. Client 模式:服务通过 `createOpencodeClient` 直接连接一个已经存在的 opencode server。 + +因此,只有 Embedded 模式要求运行环境已安装 `opencode` CLI;Client 模式不依赖本地 CLI。 根目录的 Bun scripts 已经封装 `.opencode` 依赖安装和类型检查,日常只需要在 `TJWaterAgent/` 根目录操作。 @@ -175,9 +180,21 @@ opencode.json 因此修改 agent prompt、tools、skills、模型配置或本地环境变量后,不需要手动重启 `bun run dev`。 -本地开发可以在项目根目录的 `.local.env` 中配置环境变量: +本地开发可以在项目根目录的 `.local.env` 中配置环境变量。 + +Embedded 模式示例: ```bash +OPENCODE_MODE=embedded +DEEPSEEK_API_KEY=sk-xxx +TJWATER_API_BASE_URL=http://127.0.0.1:8000 +``` + +Client 模式示例: + +```bash +OPENCODE_MODE=client +OPENCODE_CLIENT_BASE_URL=http://127.0.0.1:4096 DEEPSEEK_API_KEY=sk-xxx TJWATER_API_BASE_URL=http://127.0.0.1:8000 ``` @@ -287,8 +304,10 @@ bun run start 如果需要连接外部独立运行的 opencode server,可以配置: ```bash -OPENCODE_BASE_URL=http://127.0.0.1:4096 +OPENCODE_MODE=client +OPENCODE_CLIENT_BASE_URL=http://127.0.0.1:4096 ``` 配置后,`TJWaterAgent` 会连接该外部 opencode server,而不是自行启动 embedded opencode server。 ->>>>>>> 414247d (新增 skills、README,指定 opencode 的启动行为) + +兼容说明:历史环境变量 `OPENCODE_BASE_URL` 仍可使用,但建议迁移为 `OPENCODE_CLIENT_BASE_URL`,并显式设置 `OPENCODE_MODE=client`。 diff --git a/src/config.ts b/src/config.ts index 5621bb6..1696c08 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,98 +4,129 @@ import { z } from "zod"; // 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。 dotenv.config({ path: ".local.env", override: false }); +const optionalString = () => + z.preprocess( + (value) => { + if (typeof value !== "string") { + return value; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; + }, + z.string().optional(), + ); + // 统一在启动时解析环境变量,避免业务代码里散落字符串默认值。 -const envSchema = z.object({ - // 运行环境标识,如 development / production。 - NODE_ENV: z.string().default("development"), - // HTTP 服务监听端口。 - PORT: z.coerce.number().int().positive().default(8787), - // HTTP 服务监听地址。 - HOST: z.string().default("0.0.0.0"), - // Pino 日志级别。 - LOG_LEVEL: z.string().default("info"), - // LLM 工具/技能调用审计日志路径。 - LLM_REQUEST_AUDIT_LOG_PATH: z - .string() - .default("./logs/llm-request-audit.log"), - // 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。 - AGENT_INTERNAL_TOKEN: z.string().optional(), - // embedded opencode server 的监听地址。 - OPENCODE_HOSTNAME: z.string().default("127.0.0.1"), - // embedded opencode server 的监听端口。 - OPENCODE_PORT: z.coerce.number().int().positive().default(4096), - // opencode SDK 启动或连接运行时时的超时时间(毫秒)。 - OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), - // 默认使用的 opencode 模型标识。 - OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"), - // 外部 opencode server 的基础地址;配置后将跳过 embedded 模式。 - OPENCODE_BASE_URL: z.string().optional(), - // 外部 opencode server 的访问密码(预留)。 - OPENCODE_SERVER_PASSWORD: z.string().optional(), - // 外部 opencode server 的访问用户名(预留)。 - OPENCODE_SERVER_USERNAME: z.string().default("opencode"), - // chat session 在本地注册表中的保活时长(秒)。 - SESSION_TTL_SECONDS: z.coerce.number().int().positive().default(1800), - // 提供给本地 opencode tools 读取的会话上下文目录。 - SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"), - // TJWater 后端 API 的基础地址。 - TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"), - // 代理调用 TJWater 后端 API 的超时时间(毫秒)。 - TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000), - // 后端结果在直接内联返回给模型前允许的最大字节数。 - MAX_INLINE_RESULT_BYTES: z.coerce.number().int().positive().default(12000), - // 生成结果 preview 时最多抽样的条目数。 - MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3), - // memory 持久化存储目录。 - MEMORY_STORAGE_DIR: z.string().default("./data/memory"), - // 持久化文件写入前保留历史版本的目录。 - PERSISTENCE_HISTORY_DIR: z.string().default("./data/history"), - // 注入到 prompt 的 memory 快照最大字符数,避免上下文过大。 - MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800), - // session transcript 持久化目录。 - SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"), - // 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。 - SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce - .number() - .int() - .positive() - .default(120), - // session_search 工具默认返回的最大命中数。 - SESSION_SEARCH_MAX_RESULTS: z.coerce.number().int().positive().default(8), - // session_search 查询文本最大长度。 - SESSION_SEARCH_MAX_QUERY_CHARS: z.coerce.number().int().positive().default(240), - // learning review 会话状态目录。 - LEARNING_STATE_STORAGE_DIR: z.string().default("./data/learning-state"), - // learning audit 日志路径。 - LEARNING_AUDIT_LOG_PATH: z - .string() - .default("./logs/learning-audit.log"), - // learning gate 的最小 turn 冷却间隔;这是运行时节流,不参与内容判断。 - LEARNING_GATE_TURN_COOLDOWN: z.coerce.number().int().positive().default(2), - // gate 结果被提升为 review 前的最低置信度。 - LEARNING_GATE_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.65), - // review prompt 最多携带多少轮最近 transcript。 - LEARNING_REVIEW_MAX_RECENT_TURNS: z.coerce.number().int().positive().default(8), - // review proposal 的最低置信度阈值。 - LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8), - // result_ref 持久化存储目录。 - RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"), - // result_ref 保留时长(小时)。 - RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168), - // 定时清理过期 result_ref 的扫描周期(毫秒)。 - RESULT_REF_CLEANUP_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(3600000), - // fetch_result_ref 默认最多返回的顶层项/字段数量。 - RESULT_REF_MAX_RETRIEVAL_ITEMS: z.coerce - .number() - .int() - .positive() - .default(50), -}); +const envSchema = z + .object({ + // 运行环境标识,如 development / production。 + NODE_ENV: z.string().default("development"), + // HTTP 服务监听端口。 + PORT: z.coerce.number().int().positive().default(8787), + // HTTP 服务监听地址。 + HOST: z.string().default("0.0.0.0"), + // Pino 日志级别。 + LOG_LEVEL: z.string().default("info"), + // LLM 工具/技能调用审计日志路径。 + LLM_REQUEST_AUDIT_LOG_PATH: z + .string() + .default("./logs/llm-request-audit.log"), + // 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。 + AGENT_INTERNAL_TOKEN: optionalString(), + // opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。 + OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"), + // embedded opencode server 的监听地址。 + OPENCODE_HOSTNAME: z.string().default("127.0.0.1"), + // embedded opencode server 的监听端口。 + OPENCODE_PORT: z.coerce.number().int().positive().default(4096), + // opencode SDK 启动或连接运行时时的超时时间(毫秒)。 + OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), + // 默认使用的 opencode 模型标识。 + OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"), + // client 模式下,目标 opencode server 的基础地址。 + OPENCODE_CLIENT_BASE_URL: z.string().url().optional(), + // chat session 在本地注册表中的保活时长(秒)。 + SESSION_TTL_SECONDS: z.coerce.number().int().positive().default(1800), + // 提供给本地 opencode tools 读取的会话上下文目录。 + SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"), + // TJWater 后端 API 的基础地址。 + TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"), + // 代理调用 TJWater 后端 API 的超时时间(毫秒)。 + TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000), + // 后端结果在直接内联返回给模型前允许的最大字节数。 + MAX_INLINE_RESULT_BYTES: z.coerce.number().int().positive().default(12000), + // 生成结果 preview 时最多抽样的条目数。 + MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3), + // memory 持久化存储目录。 + MEMORY_STORAGE_DIR: z.string().default("./data/memory"), + // 持久化文件写入前保留历史版本的目录。 + PERSISTENCE_HISTORY_DIR: z.string().default("./data/history"), + // 注入到 prompt 的 memory 快照最大字符数,避免上下文过大。 + MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800), + // session transcript 持久化目录。 + SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"), + // 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。 + SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce + .number() + .int() + .positive() + .default(120), + // session_search 工具默认返回的最大命中数。 + SESSION_SEARCH_MAX_RESULTS: z.coerce.number().int().positive().default(8), + // session_search 查询文本最大长度。 + SESSION_SEARCH_MAX_QUERY_CHARS: z.coerce.number().int().positive().default(240), + // learning review 会话状态目录。 + LEARNING_STATE_STORAGE_DIR: z.string().default("./data/learning-state"), + // learning audit 日志路径。 + LEARNING_AUDIT_LOG_PATH: z + .string() + .default("./logs/learning-audit.log"), + // learning gate 的最小 turn 冷却间隔;这是运行时节流,不参与内容判断。 + LEARNING_GATE_TURN_COOLDOWN: z.coerce.number().int().positive().default(2), + // gate 结果被提升为 review 前的最低置信度。 + LEARNING_GATE_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.65), + // review prompt 最多携带多少轮最近 transcript。 + LEARNING_REVIEW_MAX_RECENT_TURNS: z.coerce.number().int().positive().default(8), + // review proposal 的最低置信度阈值。 + LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8), + // result_ref 持久化存储目录。 + RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"), + // result_ref 保留时长(小时)。 + RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168), + // 定时清理过期 result_ref 的扫描周期(毫秒)。 + RESULT_REF_CLEANUP_INTERVAL_MS: z.coerce + .number() + .int() + .positive() + .default(3600000), + // fetch_result_ref 默认最多返回的顶层项/字段数量。 + RESULT_REF_MAX_RETRIEVAL_ITEMS: z.coerce + .number() + .int() + .positive() + .default(50), + }) + .superRefine((env, ctx) => { + if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["OPENCODE_CLIENT_BASE_URL"], + message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client", + }); + } + }); export type AppConfig = z.infer; -export const config: AppConfig = envSchema.parse(process.env); +const normalizedEnv = { + ...process.env, + OPENCODE_MODE: + process.env.OPENCODE_MODE ?? + (process.env.OPENCODE_CLIENT_BASE_URL || process.env.OPENCODE_BASE_URL + ? "client" + : "embedded"), + OPENCODE_CLIENT_BASE_URL: + process.env.OPENCODE_CLIENT_BASE_URL ?? process.env.OPENCODE_BASE_URL, +}; + +export const config: AppConfig = envSchema.parse(normalizedEnv); diff --git a/src/runtime/opencode.ts b/src/runtime/opencode.ts index b4b17b3..11b77b0 100644 --- a/src/runtime/opencode.ts +++ b/src/runtime/opencode.ts @@ -105,13 +105,16 @@ export class OpencodeRuntimeAdapter { } private async bootstrapClient(): Promise { - if (config.OPENCODE_BASE_URL) { + if (config.OPENCODE_MODE === "client") { logger.info( - { baseUrl: config.OPENCODE_BASE_URL }, - "connecting to external opencode server", + { + baseUrl: config.OPENCODE_CLIENT_BASE_URL, + mode: config.OPENCODE_MODE, + }, + "connecting to opencode server in client mode", ); return createOpencodeClient({ - baseUrl: config.OPENCODE_BASE_URL, + baseUrl: config.OPENCODE_CLIENT_BASE_URL, }); } @@ -128,18 +131,29 @@ export class OpencodeRuntimeAdapter { hostname: config.OPENCODE_HOSTNAME, port: config.OPENCODE_PORT, model: config.OPENCODE_MODEL, + mode: config.OPENCODE_MODE, }, - "starting embedded opencode server", + "starting opencode server in embedded mode", ); - const runtime = await createOpencode({ - hostname: config.OPENCODE_HOSTNAME, - port: config.OPENCODE_PORT, - timeout: config.OPENCODE_TIMEOUT_MS, - config: { - model: config.OPENCODE_MODEL, - }, - }); + let runtime; + try { + runtime = await createOpencode({ + hostname: config.OPENCODE_HOSTNAME, + port: config.OPENCODE_PORT, + timeout: config.OPENCODE_TIMEOUT_MS, + config: { + model: config.OPENCODE_MODEL, + }, + }); + } catch (error) { + if (isMissingOpencodeCli(error)) { + throw new Error( + "embedded mode requires the opencode CLI to be installed and available in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL", + ); + } + throw error; + } this.closeServer = () => { runtime.server.close(); @@ -151,6 +165,15 @@ export class OpencodeRuntimeAdapter { export const opencodeRuntime = new OpencodeRuntimeAdapter(); +function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + function requireData(data: T | undefined, operation: string): T { if (data === undefined) { throw new Error(`${operation} returned no data`);