整理 opencode 接入方式,embedded 和 client 模式
This commit is contained in:
@@ -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`。
|
||||
|
||||
+122
-91
@@ -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<typeof envSchema>;
|
||||
|
||||
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);
|
||||
|
||||
+36
-13
@@ -105,13 +105,16 @@ export class OpencodeRuntimeAdapter {
|
||||
}
|
||||
|
||||
private async bootstrapClient(): Promise<OpencodeClient> {
|
||||
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<T>(data: T | undefined, operation: string): T {
|
||||
if (data === undefined) {
|
||||
throw new Error(`${operation} returned no data`);
|
||||
|
||||
Reference in New Issue
Block a user