183 lines
5.0 KiB
TypeScript
183 lines
5.0 KiB
TypeScript
import {
|
||
createOpencode,
|
||
createOpencodeClient,
|
||
type OpencodeClient,
|
||
} from "@opencode-ai/sdk/v2";
|
||
|
||
import { config } from "../config.js";
|
||
import { logger } from "../logger.js";
|
||
|
||
export type RuntimeHealth = {
|
||
healthy: boolean;
|
||
version: string;
|
||
};
|
||
|
||
type RuntimeModelOverride = {
|
||
providerID: string;
|
||
modelID: string;
|
||
};
|
||
|
||
export class OpencodeRuntimeAdapter {
|
||
private clientPromise: Promise<OpencodeClient> | null = null;
|
||
private closeServer: (() => void) | null = null;
|
||
|
||
async ensureClient(): Promise<OpencodeClient> {
|
||
if (!this.clientPromise) {
|
||
this.clientPromise = this.bootstrapClient();
|
||
}
|
||
return this.clientPromise;
|
||
}
|
||
|
||
async health(): Promise<RuntimeHealth> {
|
||
const client = await this.ensureClient();
|
||
const response = await client.global.health();
|
||
return requireData(response.data, "global.health");
|
||
}
|
||
|
||
async createSession(title?: string) {
|
||
const client = await this.ensureClient();
|
||
const response = await client.session.create({
|
||
title,
|
||
});
|
||
return requireData(response.data, "session.create");
|
||
}
|
||
|
||
async getSession(id: string) {
|
||
const client = await this.ensureClient();
|
||
const response = await client.session.get({
|
||
sessionID: id,
|
||
});
|
||
return requireData(response.data, "session.get");
|
||
}
|
||
|
||
async sendPrompt(sessionId: string, text: string) {
|
||
await this.prompt(sessionId, text);
|
||
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts,
|
||
// 所以这里紧跟一次 messages() 回读,给上层路由统一消费。
|
||
return this.messages(sessionId);
|
||
}
|
||
|
||
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
|
||
const client = await this.ensureClient();
|
||
await client.session.prompt({
|
||
sessionID: sessionId,
|
||
model,
|
||
parts: [{ type: "text", text }],
|
||
});
|
||
}
|
||
|
||
async messages(sessionId: string, limit = 20) {
|
||
const client = await this.ensureClient();
|
||
const messages = await client.session.messages({
|
||
sessionID: sessionId,
|
||
limit,
|
||
});
|
||
return requireData(messages.data, "session.messages");
|
||
}
|
||
|
||
async forkSession(sessionId: string, messageId?: string) {
|
||
const client = await this.ensureClient();
|
||
const response = await client.session.fork({
|
||
sessionID: sessionId,
|
||
messageID: messageId,
|
||
});
|
||
return requireData(response.data, "session.fork");
|
||
}
|
||
|
||
async abortSession(sessionId: string) {
|
||
const client = await this.ensureClient();
|
||
const response = await client.session.abort({
|
||
sessionID: sessionId,
|
||
});
|
||
return requireData(response.data, "session.abort");
|
||
}
|
||
|
||
async subscribeEvents() {
|
||
const client = await this.ensureClient();
|
||
const response = await client.event.subscribe();
|
||
return response.stream;
|
||
}
|
||
|
||
async dispose(): Promise<void> {
|
||
this.closeServer?.();
|
||
this.closeServer = null;
|
||
this.clientPromise = null;
|
||
}
|
||
|
||
private async bootstrapClient(): Promise<OpencodeClient> {
|
||
if (config.OPENCODE_MODE === "client") {
|
||
logger.info(
|
||
{
|
||
baseUrl: config.OPENCODE_CLIENT_BASE_URL,
|
||
mode: config.OPENCODE_MODE,
|
||
},
|
||
"connecting to opencode server in client mode",
|
||
);
|
||
return createOpencodeClient({
|
||
baseUrl: config.OPENCODE_CLIENT_BASE_URL,
|
||
});
|
||
}
|
||
|
||
// embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里,
|
||
// 这样 .opencode/tools 下的自定义工具可以回调本服务。
|
||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`;
|
||
process.env.TJWATER_AGENT_INTERNAL_TOKEN =
|
||
config.AGENT_INTERNAL_TOKEN ??
|
||
process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
|
||
"";
|
||
|
||
logger.info(
|
||
{
|
||
hostname: config.OPENCODE_HOSTNAME,
|
||
port: config.OPENCODE_PORT,
|
||
model: config.OPENCODE_MODEL,
|
||
mode: config.OPENCODE_MODE,
|
||
},
|
||
"starting opencode server in embedded mode",
|
||
);
|
||
|
||
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();
|
||
};
|
||
|
||
return runtime.client;
|
||
}
|
||
}
|
||
|
||
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`);
|
||
}
|
||
return data;
|
||
}
|