整理 opencode 接入方式,embedded 和 client 模式

This commit is contained in:
2026-05-19 10:27:12 +08:00
parent 69a90de9a1
commit 8b02cae2af
3 changed files with 181 additions and 108 deletions
+23 -4
View File
@@ -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` CLIClient 模式不依赖本地 CLI。
根目录的 Bun scripts 已经封装 `.opencode` 依赖安装和类型检查,日常只需要在 `TJWaterAgent/` 根目录操作。 根目录的 Bun scripts 已经封装 `.opencode` 依赖安装和类型检查,日常只需要在 `TJWaterAgent/` 根目录操作。
@@ -175,9 +180,21 @@ opencode.json
因此修改 agent prompt、tools、skills、模型配置或本地环境变量后,不需要手动重启 `bun run dev` 因此修改 agent prompt、tools、skills、模型配置或本地环境变量后,不需要手动重启 `bun run dev`
本地开发可以在项目根目录的 `.local.env` 中配置环境变量 本地开发可以在项目根目录的 `.local.env` 中配置环境变量
Embedded 模式示例:
```bash ```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 DEEPSEEK_API_KEY=sk-xxx
TJWATER_API_BASE_URL=http://127.0.0.1:8000 TJWATER_API_BASE_URL=http://127.0.0.1:8000
``` ```
@@ -287,8 +304,10 @@ bun run start
如果需要连接外部独立运行的 opencode server,可以配置: 如果需要连接外部独立运行的 opencode server,可以配置:
```bash ```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。 配置后,`TJWaterAgent` 会连接该外部 opencode server,而不是自行启动 embedded opencode server。
>>>>>>> 414247d (新增 skills、README,指定 opencode 的启动行为)
兼容说明:历史环境变量 `OPENCODE_BASE_URL` 仍可使用,但建议迁移为 `OPENCODE_CLIENT_BASE_URL`,并显式设置 `OPENCODE_MODE=client`
+41 -10
View File
@@ -4,8 +4,21 @@ import { z } from "zod";
// 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。 // 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。
dotenv.config({ path: ".local.env", override: false }); 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({ const envSchema = z
.object({
// 运行环境标识,如 development / production。 // 运行环境标识,如 development / production。
NODE_ENV: z.string().default("development"), NODE_ENV: z.string().default("development"),
// HTTP 服务监听端口。 // HTTP 服务监听端口。
@@ -19,7 +32,9 @@ const envSchema = z.object({
.string() .string()
.default("./logs/llm-request-audit.log"), .default("./logs/llm-request-audit.log"),
// 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。 // 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。
AGENT_INTERNAL_TOKEN: z.string().optional(), AGENT_INTERNAL_TOKEN: optionalString(),
// opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
// embedded opencode server 的监听地址。 // embedded opencode server 的监听地址。
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"), OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
// embedded opencode server 的监听端口。 // embedded opencode server 的监听端口。
@@ -28,12 +43,8 @@ const envSchema = z.object({
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
// 默认使用的 opencode 模型标识。 // 默认使用的 opencode 模型标识。
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"), OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
// 外部 opencode server 的基础地址;配置后将跳过 embedded 模式 // client 模式下,目标 opencode server 的基础地址。
OPENCODE_BASE_URL: z.string().optional(), OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
// 外部 opencode server 的访问密码(预留)。
OPENCODE_SERVER_PASSWORD: z.string().optional(),
// 外部 opencode server 的访问用户名(预留)。
OPENCODE_SERVER_USERNAME: z.string().default("opencode"),
// chat session 在本地注册表中的保活时长(秒)。 // chat session 在本地注册表中的保活时长(秒)。
SESSION_TTL_SECONDS: z.coerce.number().int().positive().default(1800), SESSION_TTL_SECONDS: z.coerce.number().int().positive().default(1800),
// 提供给本地 opencode tools 读取的会话上下文目录。 // 提供给本地 opencode tools 读取的会话上下文目录。
@@ -94,8 +105,28 @@ const envSchema = z.object({
.int() .int()
.positive() .positive()
.default(50), .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 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);
+29 -6
View File
@@ -105,13 +105,16 @@ export class OpencodeRuntimeAdapter {
} }
private async bootstrapClient(): Promise<OpencodeClient> { private async bootstrapClient(): Promise<OpencodeClient> {
if (config.OPENCODE_BASE_URL) { if (config.OPENCODE_MODE === "client") {
logger.info( 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({ return createOpencodeClient({
baseUrl: config.OPENCODE_BASE_URL, baseUrl: config.OPENCODE_CLIENT_BASE_URL,
}); });
} }
@@ -128,11 +131,14 @@ export class OpencodeRuntimeAdapter {
hostname: config.OPENCODE_HOSTNAME, hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT, port: config.OPENCODE_PORT,
model: config.OPENCODE_MODEL, model: config.OPENCODE_MODEL,
mode: config.OPENCODE_MODE,
}, },
"starting embedded opencode server", "starting opencode server in embedded mode",
); );
const runtime = await createOpencode({ let runtime;
try {
runtime = await createOpencode({
hostname: config.OPENCODE_HOSTNAME, hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT, port: config.OPENCODE_PORT,
timeout: config.OPENCODE_TIMEOUT_MS, timeout: config.OPENCODE_TIMEOUT_MS,
@@ -140,6 +146,14 @@ export class OpencodeRuntimeAdapter {
model: config.OPENCODE_MODEL, 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 = () => { this.closeServer = () => {
runtime.server.close(); runtime.server.close();
@@ -151,6 +165,15 @@ export class OpencodeRuntimeAdapter {
export const opencodeRuntime = new 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 { function requireData<T>(data: T | undefined, operation: string): T {
if (data === undefined) { if (data === undefined) {
throw new Error(`${operation} returned no data`); throw new Error(`${operation} returned no data`);