Compare commits
10
Commits
cf6cada538
...
d782b7fa11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d782b7fa11 | ||
|
|
40c0395fb1 | ||
|
|
d75b98a61b | ||
|
|
ead76185b7 | ||
|
|
8857b18dc9 | ||
|
|
4b572d9264 | ||
|
|
fc7393fa2b | ||
|
|
c823e3935e | ||
|
|
366c05b752 | ||
|
|
fa2c28c1c0 |
@@ -1,7 +1,7 @@
|
||||
---
|
||||
description: TJWater Agent,用于供水网络分析和操作员工作流
|
||||
mode: primary
|
||||
model: deepseek/deepseek-v4-pro
|
||||
model: deepseek/deepseek-v4-flash
|
||||
temperature: 0.2
|
||||
---
|
||||
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
|
||||
|
||||
@@ -28,8 +28,11 @@ export default tool({
|
||||
.describe("Classification method."),
|
||||
segments: tool.schema
|
||||
.number()
|
||||
.int()
|
||||
.min(2)
|
||||
.max(10)
|
||||
.optional()
|
||||
.describe("Number of segments."),
|
||||
.describe("Number of rendered intervals, from 2 to 10."),
|
||||
min_size: tool.schema
|
||||
.number()
|
||||
.optional()
|
||||
@@ -54,9 +57,9 @@ export default tool({
|
||||
.enum(["single", "gradient", "rainbow", "custom"])
|
||||
.optional()
|
||||
.describe("Color strategy."),
|
||||
single_palette_index: tool.schema.number().optional(),
|
||||
gradient_palette_index: tool.schema.number().optional(),
|
||||
rainbow_palette_index: tool.schema.number().optional(),
|
||||
single_palette_index: tool.schema.number().int().min(0).max(6).optional(),
|
||||
gradient_palette_index: tool.schema.number().int().min(0).max(2).optional(),
|
||||
rainbow_palette_index: tool.schema.number().int().min(0).max(1).optional(),
|
||||
show_labels: tool.schema
|
||||
.boolean()
|
||||
.optional()
|
||||
@@ -65,7 +68,7 @@ export default tool({
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Whether to show ids."),
|
||||
opacity: tool.schema.number().optional().describe("Opacity in [0, 1]."),
|
||||
opacity: tool.schema.number().min(0).max(1).optional().describe("Opacity in [0, 1]."),
|
||||
adjust_width_by_property: tool.schema
|
||||
.boolean()
|
||||
.optional()
|
||||
@@ -73,11 +76,11 @@ export default tool({
|
||||
custom_breaks: tool.schema
|
||||
.array(tool.schema.number())
|
||||
.optional()
|
||||
.describe("Custom break values."),
|
||||
.describe("Strictly increasing boundaries. Length must equal segments + 1."),
|
||||
custom_colors: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.optional()
|
||||
.describe("Custom rgba colors."),
|
||||
.describe("Custom CSS colors. Length must equal segments."),
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
@@ -87,8 +90,24 @@ export default tool({
|
||||
async execute(args) {
|
||||
const layerLabel = args.layer_id === "junctions" ? "节点" : "管道";
|
||||
if (args.reset_to_default) {
|
||||
return `已将${layerLabel}图层重置为默认样式。`;
|
||||
return `已提交${layerLabel}图层默认样式重置请求。`;
|
||||
}
|
||||
return `已对${layerLabel}图层应用样式。`;
|
||||
const style = args.style_config;
|
||||
if (style?.custom_breaks) {
|
||||
if (
|
||||
style.custom_breaks.some(
|
||||
(value, index, values) => index > 0 && value <= values[index - 1],
|
||||
)
|
||||
) {
|
||||
throw new Error("custom_breaks must be strictly increasing");
|
||||
}
|
||||
if (style.segments && style.custom_breaks.length !== style.segments + 1) {
|
||||
throw new Error("custom_breaks length must equal segments + 1");
|
||||
}
|
||||
}
|
||||
if (style?.segments && style.custom_colors && style.custom_colors.length !== style.segments) {
|
||||
throw new Error("custom_colors length must equal segments");
|
||||
}
|
||||
return `已提交${layerLabel}图层样式请求。`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -100,7 +100,11 @@ export const createSkillManagerTool = (
|
||||
kind: "skill",
|
||||
decision: "accepted",
|
||||
detail: "skill listed",
|
||||
...result,
|
||||
references: result.references,
|
||||
scripts: result.scripts,
|
||||
skill_path: result.skillPath,
|
||||
target: result.target,
|
||||
patterns: result.patterns,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ export default tool({
|
||||
.describe("Why web search is required for the current user request."),
|
||||
query: tool.schema.string().describe("Search query text."),
|
||||
freshness: tool.schema
|
||||
.enum(["noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear"])
|
||||
.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"])
|
||||
.optional()
|
||||
.describe("Optional freshness filter. Defaults to noLimit."),
|
||||
.describe("Optional freshness filter. Defaults to no_limit."),
|
||||
summary: tool.schema
|
||||
.boolean()
|
||||
.optional()
|
||||
|
||||
@@ -30,7 +30,7 @@ TJWaterAgent/
|
||||
1. 启动 HTTP 服务。
|
||||
2. 通过 `@opencode-ai/sdk` 启动内嵌 opencode server,或连接外部 opencode server。
|
||||
3. 管理前端 `session_id -> opencode sessionId` 的映射。
|
||||
4. 保存并传递用户 `Authorization`、`x-user-id`、`x-project-id`、`x-trace-id`。
|
||||
4. 保存并传递后端认证后的用户 token、metadata 用户 ID、项目 ID 和 trace。
|
||||
5. 把 opencode 输出适配成前端需要的 SSE 事件。
|
||||
6. 为 `.opencode/tools/tjwater_cli.ts` 提供内部回调接口。
|
||||
7. 代理调用真实 TJWater 后端 API。
|
||||
@@ -50,6 +50,7 @@ POST /api/v1/agent/chat/stream
|
||||
| `tool_call` | 前端地图/面板/图表动作 |
|
||||
| `done` | 当前轮完成 |
|
||||
| `error` | 当前轮失败 |
|
||||
| `auth_required` | 运行中 access token 过期或被后端拒绝,需要前端刷新登录态后重试 |
|
||||
|
||||
主要目录和文件:
|
||||
|
||||
@@ -266,21 +267,42 @@ docker compose down
|
||||
默认 Agent 模型为:
|
||||
|
||||
```text
|
||||
deepseek/deepseek-v4-pro
|
||||
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 不写入代码,部署时通过环境变量设置:
|
||||
|
||||
@@ -13,7 +13,7 @@ function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const schemeName = resolveScheme(ctx, optionalString(values, "scheme"), true)!;
|
||||
return emitApi(ctx, "爆管分析执行成功", {
|
||||
method: "GET",
|
||||
path: "/burst_analysis/",
|
||||
path: "/burst-analysis",
|
||||
params: {
|
||||
network: requireNetwork(ctx),
|
||||
modify_pattern_start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||
@@ -50,7 +50,7 @@ function analysisValve(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "isolation mode requires at least one --element", 2);
|
||||
return emitApi(ctx, "阀门隔离分析执行成功", {
|
||||
method: "GET",
|
||||
path: "/valve_isolation_analysis/",
|
||||
path: "/valve-isolation-analysis",
|
||||
params: { network: requireNetwork(ctx), accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") },
|
||||
requireNetworkCtx: true,
|
||||
});
|
||||
@@ -61,7 +61,7 @@ function analysisFlushing(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
const [valves, openings] = parseValveSettingFile(requiredString(values, "valve-setting-file"));
|
||||
return emitApi(ctx, "冲洗分析执行成功", {
|
||||
method: "GET",
|
||||
path: "/flushing_analysis/",
|
||||
path: "/flushing-analysis",
|
||||
params: {
|
||||
network: requireNetwork(ctx),
|
||||
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||
@@ -98,7 +98,7 @@ function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void>
|
||||
};
|
||||
const pattern = optionalString(values, "pattern");
|
||||
if (pattern) params.pattern = pattern;
|
||||
return emitApi(ctx, "污染物模拟执行成功", { method: "GET", path: "/contaminant_simulation/", params, requireNetworkCtx: true });
|
||||
return emitApi(ctx, "污染物模拟执行成功", { method: "GET", path: "/contaminant-simulation", params, requireNetworkCtx: true });
|
||||
}
|
||||
|
||||
function sensorKmeans(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
|
||||
@@ -163,5 +163,5 @@ export const dataHandlers: HandlerMap = {
|
||||
"data scada list": dataScadaList,
|
||||
"data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/getschemeschema/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }),
|
||||
"data scheme get": dataSchemeGet,
|
||||
"data scheme list": (ctx) => emitApi(ctx, "读取方案列表成功", { method: "GET", path: "/getallschemes/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }),
|
||||
"data scheme list": (ctx) => emitApi(ctx, "读取方案列表成功", { method: "GET", path: "/schemes", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }),
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ function simulationRun(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||
return emitApi(
|
||||
ctx,
|
||||
"触发模拟成功",
|
||||
{ method: "POST", path: "/runsimulationmanuallybydate/", body: { name: network, start_time: start.replace(/\.\d+/, ""), duration }, requireNetworkCtx: true },
|
||||
{ method: "POST", path: "/simulations/run-by-date", body: { name: network, start_time: start.replace(/\.\d+/, ""), duration }, requireNetworkCtx: true },
|
||||
[
|
||||
`tjwater-cli data timeseries realtime links --start-time ${start} --end-time ${end}`,
|
||||
`tjwater-cli data timeseries realtime nodes --start-time ${start} --end-time ${end}`,
|
||||
|
||||
@@ -19,7 +19,6 @@ function headers(ctx: RuntimeContext, requireAuth: boolean, requireProject: bool
|
||||
if (!ctx.auth.projectId) throw new CliError("认证失败", "PROJECT_CONTEXT_REQUIRED", "missing project_id for agent context", 3, false, null, ["add project_id to auth context"]);
|
||||
out["X-Project-Id"] = ctx.auth.projectId;
|
||||
} else if (ctx.auth.projectId) out["X-Project-Id"] = ctx.auth.projectId;
|
||||
if (ctx.auth.userId) out["X-User-Id"] = ctx.auth.userId;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -36,12 +35,18 @@ function appendParams(url: URL, params: Record<string, unknown> = {}): void {
|
||||
}
|
||||
}
|
||||
|
||||
function withNetworkParam(params: Record<string, unknown> | undefined, network: string): Record<string, unknown> {
|
||||
if (params?.network !== undefined || params?.network_name !== undefined || params?.name !== undefined) return params ?? {};
|
||||
return { ...(params ?? {}), network };
|
||||
}
|
||||
|
||||
export async function requestJson(ctx: RuntimeContext, request: RequestOptions): Promise<[unknown, number]> {
|
||||
const { method, path, params, body, requireAuth = true, requireProject = false, requireNetworkCtx = false, requireUsernameCtx = false } = request;
|
||||
if (requireNetworkCtx) requireNetwork(ctx);
|
||||
const network = requireNetworkCtx ? requireNetwork(ctx) : null;
|
||||
if (requireUsernameCtx) requireUsername(ctx);
|
||||
const url = new URL(`/api/v1${path}`, ctx.server.replace(/\/+$/, ""));
|
||||
appendParams(url, params);
|
||||
const requestParams = network && (params !== undefined || body === undefined) ? withNetworkParam(params, network) : params;
|
||||
appendParams(url, requestParams);
|
||||
const started = performance.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ctx.timeout * 1000);
|
||||
@@ -93,4 +98,3 @@ export async function emitApi(ctx: RuntimeContext, summary: string, request: Req
|
||||
const [data, durationMs] = await requestJson(ctx, request);
|
||||
success(summary, data, ctx, durationMs, nextCommands);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ export async function loadAuthContext(authStdin: boolean): Promise<AuthContext>
|
||||
server: process.env.TJWATER_SERVER,
|
||||
access_token: process.env.TJWATER_ACCESS_TOKEN,
|
||||
project_id: process.env.TJWATER_PROJECT_ID,
|
||||
user_id: process.env.TJWATER_USER_ID,
|
||||
username: process.env.TJWATER_USERNAME,
|
||||
network: process.env.TJWATER_NETWORK,
|
||||
headers: process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {},
|
||||
@@ -44,7 +43,6 @@ export async function loadAuthContext(authStdin: boolean): Promise<AuthContext>
|
||||
server: pick(raw, "server", "base_url"),
|
||||
accessToken: pick(raw, "access_token", "token", "accessToken"),
|
||||
projectId: pick(raw, "project_id", "projectId", "x_project_id"),
|
||||
userId: pick(raw, "user_id", "userId", "x_user_id"),
|
||||
username: pick(raw, "username", "preferred_username"),
|
||||
network: pick(raw, "network", "project_code", "projectCode", "project"),
|
||||
headers: Object.fromEntries(Object.entries(headers as Record<string, unknown>).map(([key, value]) => [String(key), String(value)])),
|
||||
@@ -98,4 +96,3 @@ export function resolveScheme(ctx: RuntimeContext, explicit: string | undefined,
|
||||
if (must && !scheme) throw new CliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2);
|
||||
return scheme ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ export interface AuthContext {
|
||||
server: string | null;
|
||||
accessToken: string | null;
|
||||
projectId: string | null;
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
network: string | null;
|
||||
headers: Record<string, string>;
|
||||
@@ -86,4 +85,3 @@ export type HelpPayload =
|
||||
menu_level?: number;
|
||||
commands: Array<{ command: string; summary: string; usage?: string; example?: string }>;
|
||||
};
|
||||
|
||||
|
||||
@@ -83,7 +83,6 @@ function normalizeSeenRequest(request) {
|
||||
headers: {
|
||||
authorization: request.headers.authorization,
|
||||
"x-project-id": request.headers["x-project-id"],
|
||||
"x-user-id": request.headers["x-user-id"],
|
||||
},
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
@@ -213,7 +212,7 @@ test("sends auth headers and simulation body through the backend API contract",
|
||||
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
assert.equal(server.seen[0].method, "POST");
|
||||
assert.equal(server.seen[0].url, "/api/v1/runsimulationmanuallybydate/");
|
||||
assert.equal(server.seen[0].url, "/api/v1/simulations/run-by-date");
|
||||
assert.equal(server.seen[0].headers.authorization, "Bearer token-1");
|
||||
assert.equal(server.seen[0].headers["x-extra"], "extra");
|
||||
assert.deepEqual(server.seen[0].body, {
|
||||
@@ -280,7 +279,6 @@ test("matches Python CLI backend request shape for every command and key variant
|
||||
access_token: "token",
|
||||
network: "tjwater",
|
||||
project_id: "project-1",
|
||||
user_id: "user-1",
|
||||
username: "alice",
|
||||
headers: { "x-extra": "extra" },
|
||||
};
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"model": "deepseek/deepseek-v4-pro",
|
||||
"model": "deepseek/deepseek-v4-flash",
|
||||
"server": {
|
||||
"hostname": "127.0.0.1",
|
||||
"port": 4096
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dev": "bun --watch src/server.ts",
|
||||
"build": "bun run check",
|
||||
"check": "bun run typecheck && bun run typecheck:opencode",
|
||||
"migrate:session-identity": "bun scripts/migrate-session-identity.ts",
|
||||
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh",
|
||||
"start": "bun src/server.ts",
|
||||
"start:prod": "bun run check && bun src/server.ts"
|
||||
|
||||
+93
-3
@@ -3,6 +3,39 @@ import type { NextFunction, Request, Response } from "express";
|
||||
import { config } from "../config.js";
|
||||
import { logger } from "../logger.js";
|
||||
|
||||
export type AgentAuthContext = {
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
keycloakSub: string;
|
||||
username: string;
|
||||
role: string;
|
||||
isSuperuser: boolean;
|
||||
projectId: string;
|
||||
network: string;
|
||||
projectRole: string;
|
||||
tokenExpiresAt?: string;
|
||||
};
|
||||
|
||||
type AgentAuthContextResponse = {
|
||||
user_id?: unknown;
|
||||
keycloak_sub?: unknown;
|
||||
username?: unknown;
|
||||
role?: unknown;
|
||||
is_superuser?: unknown;
|
||||
project_id?: unknown;
|
||||
network?: unknown;
|
||||
project_role?: unknown;
|
||||
token_expires_at?: unknown;
|
||||
};
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
agentAuth?: AgentAuthContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const extractBearerToken = (authorization?: string) => {
|
||||
const value = authorization?.trim();
|
||||
if (!value) {
|
||||
@@ -11,8 +44,41 @@ export const extractBearerToken = (authorization?: string) => {
|
||||
return value.replace(/^Bearer\s+/i, "").trim();
|
||||
};
|
||||
|
||||
// Agent API 复用 TJWater 后端的登录态:每个请求都向 /auth/me 校验 Bearer token,
|
||||
// 成功后才允许进入会话路由,避免 Agent 服务维护第二套用户体系。
|
||||
const normalizeAgentAuthContext = (
|
||||
payload: AgentAuthContextResponse,
|
||||
accessToken: string,
|
||||
): AgentAuthContext | null => {
|
||||
if (
|
||||
typeof payload.user_id !== "string" ||
|
||||
typeof payload.keycloak_sub !== "string" ||
|
||||
typeof payload.username !== "string" ||
|
||||
typeof payload.role !== "string" ||
|
||||
typeof payload.is_superuser !== "boolean" ||
|
||||
typeof payload.project_id !== "string" ||
|
||||
typeof payload.network !== "string" ||
|
||||
typeof payload.project_role !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accessToken,
|
||||
userId: payload.user_id,
|
||||
keycloakSub: payload.keycloak_sub,
|
||||
username: payload.username,
|
||||
role: payload.role,
|
||||
isSuperuser: payload.is_superuser,
|
||||
projectId: payload.project_id,
|
||||
network: payload.network,
|
||||
projectRole: payload.project_role,
|
||||
tokenExpiresAt:
|
||||
typeof payload.token_expires_at === "string"
|
||||
? payload.token_expires_at
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// Agent API 使用 TJWater 后端专用认证上下文:后端负责校验 Keycloak token、
|
||||
// metadata 用户状态和项目 membership,Agent 只信任后端返回的 user/project。
|
||||
export const requireAgentAuth = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -24,20 +90,37 @@ export const requireAgentAuth = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = req.header("x-project-id")?.trim();
|
||||
if (!projectId) {
|
||||
res.status(400).json({ message: "x-project-id is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(new URL("/api/v1/auth/me", config.TJWATER_API_BASE_URL), {
|
||||
const response = await fetch(new URL("/api/v1/agent/auth/context", config.TJWATER_API_BASE_URL), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-Project-Id": projectId,
|
||||
...(req.header("x-trace-id") ? { "X-Trace-Id": req.header("x-trace-id") as string } : {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const context = normalizeAgentAuthContext(
|
||||
(await response.json()) as AgentAuthContextResponse,
|
||||
token,
|
||||
);
|
||||
if (!context) {
|
||||
res.status(502).json({ message: "invalid authentication context" });
|
||||
return;
|
||||
}
|
||||
req.agentAuth = context;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -58,3 +141,10 @@ export const requireAgentAuth = async (
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
export const getAgentAuthContext = (req: Request) => {
|
||||
if (!req.agentAuth) {
|
||||
throw new Error("agent auth context is missing");
|
||||
}
|
||||
return req.agentAuth;
|
||||
};
|
||||
|
||||
@@ -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<string, unknown> =>
|
||||
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<string>();
|
||||
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);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -17,7 +17,9 @@ export type SessionBinding = {
|
||||
export type SessionContext = {
|
||||
clientSessionId: string;
|
||||
accessToken?: string;
|
||||
network?: string;
|
||||
projectId?: string;
|
||||
tokenExpiresAt?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
@@ -35,8 +37,10 @@ export class ChatSessionBridge {
|
||||
async resolve(context: {
|
||||
sessionId?: string;
|
||||
accessToken?: string;
|
||||
network?: string;
|
||||
projectId?: string;
|
||||
traceId?: string;
|
||||
tokenExpiresAt?: string;
|
||||
userId?: string;
|
||||
}): Promise<{
|
||||
binding: SessionBinding;
|
||||
@@ -69,9 +73,11 @@ export class ChatSessionBridge {
|
||||
allowLearningWrite: true,
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
learningMode: "interactive",
|
||||
network: requestContext.network,
|
||||
projectId: requestContext.projectId,
|
||||
projectKey: requestContext.projectKey,
|
||||
sessionId,
|
||||
tokenExpiresAt: requestContext.tokenExpiresAt,
|
||||
traceId: requestContext.traceId,
|
||||
});
|
||||
|
||||
@@ -141,8 +147,10 @@ export class ChatSessionBridge {
|
||||
private buildRequestContext(context: {
|
||||
sessionId?: string;
|
||||
accessToken?: string;
|
||||
network?: string;
|
||||
projectId?: string;
|
||||
traceId?: string;
|
||||
tokenExpiresAt?: string;
|
||||
userId?: string;
|
||||
}): ChatRequestContext {
|
||||
const sessionId = context.sessionId?.trim();
|
||||
@@ -150,8 +158,10 @@ export class ChatSessionBridge {
|
||||
clientSessionId: sessionId || this.createClientSessionId(),
|
||||
accessToken: context.accessToken,
|
||||
actorKey: toActorKey(context.userId),
|
||||
network: context.network,
|
||||
projectId: context.projectId,
|
||||
projectKey: toProjectKey(context.projectId),
|
||||
tokenExpiresAt: context.tokenExpiresAt,
|
||||
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
||||
userId: context.userId?.trim(),
|
||||
};
|
||||
|
||||
+36
-2
@@ -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 });
|
||||
|
||||
@@ -33,7 +39,7 @@ const envSchema = z
|
||||
.default("./logs/llm-request-audit.log"),
|
||||
// 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。
|
||||
AGENT_INTERNAL_TOKEN: optionalString(),
|
||||
// Agent 前置认证调用后端 /api/v1/auth/me 的超时时间(毫秒)。
|
||||
// Agent 前置认证调用后端 /api/v1/agent/auth/context 的超时时间(毫秒)。
|
||||
AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
||||
// opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。
|
||||
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
|
||||
@@ -44,7 +50,9 @@ const envSchema = z
|
||||
// opencode SDK 启动或连接运行时时的超时时间(毫秒)。
|
||||
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
||||
// 默认使用的 opencode 模型标识。
|
||||
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
|
||||
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<typeof envSchema>;
|
||||
|
||||
@@ -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<typeof gateResultSchema>;
|
||||
type ReviewResult = z.infer<typeof reviewResultSchema>;
|
||||
|
||||
type SupportedModel = "deepseek/deepseek-v4-flash" | "deepseek/deepseek-v4-pro";
|
||||
|
||||
type TurnReviewInput = {
|
||||
assistantMessage: string;
|
||||
model?: SupportedModel;
|
||||
|
||||
+128
-108
@@ -1,6 +1,14 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getAgentAuthContext } from "../auth/agentAuth.js";
|
||||
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";
|
||||
@@ -11,6 +19,7 @@ import { type ResultReferenceResolver } from "../results/resolver.js";
|
||||
import {
|
||||
type OpencodeRuntimeAdapter,
|
||||
} from "../runtime/opencode.js";
|
||||
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||
import { type SessionRecord } from "../sessions/metadataStore.js";
|
||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||
@@ -28,14 +37,13 @@ import {
|
||||
type PermissionRequestPayload,
|
||||
type QuestionRequestPayload,
|
||||
streamPromptResponse,
|
||||
supportedModels,
|
||||
type SupportedModel,
|
||||
type TodoUpdatePayload,
|
||||
} from "./chatStream.js";
|
||||
import {
|
||||
type ActiveRun,
|
||||
type RunStatus,
|
||||
type StreamSubscriber,
|
||||
appendBackendToolArtifact,
|
||||
cancelBackendTodos,
|
||||
completeBackendProgress,
|
||||
createInitialStreamingMessages,
|
||||
@@ -53,7 +61,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"),
|
||||
});
|
||||
|
||||
@@ -67,12 +77,6 @@ const forkPayloadSchema = z.object({
|
||||
keep_message_count: z.coerce.number().int().min(0),
|
||||
});
|
||||
|
||||
const sessionStateSchema = z.object({
|
||||
title: z.string().max(120).optional(),
|
||||
is_title_manually_edited: z.boolean().optional(),
|
||||
messages: z.array(z.unknown()).default([]),
|
||||
});
|
||||
|
||||
const activeRuns = new Map<string, ActiveRun>();
|
||||
const lastRunStatuses = new Map<string, RunStatus>();
|
||||
|
||||
@@ -80,6 +84,20 @@ const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
|
||||
sessionId: sessionRecord.sessionId,
|
||||
});
|
||||
|
||||
export const buildForkedSessionUiState = (
|
||||
sourceState: { messages?: unknown[] } | null | undefined,
|
||||
input: {
|
||||
keepMessageCount: number;
|
||||
targetSessionId: string;
|
||||
},
|
||||
) => ({
|
||||
sessionId: input.targetSessionId,
|
||||
isTitleManuallyEdited: false,
|
||||
messages: Array.isArray(sourceState?.messages)
|
||||
? sourceState.messages.slice(0, input.keepMessageCount)
|
||||
: [],
|
||||
});
|
||||
|
||||
const getSessionRunStatus = (sessionId: string) =>
|
||||
activeRuns.get(sessionId)?.status ?? lastRunStatuses.get(sessionId);
|
||||
|
||||
@@ -106,6 +124,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) {
|
||||
@@ -116,8 +141,9 @@ export const buildChatRouter = (
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const requestedSessionId = parsed.data.session_id?.trim();
|
||||
@@ -143,8 +169,9 @@ export const buildChatRouter = (
|
||||
});
|
||||
|
||||
chatRouter.get("/sessions", async (req, res) => {
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const records = await sessionMetadataStore.list({
|
||||
@@ -167,10 +194,11 @@ export const buildChatRouter = (
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.get("/session/:sessionId", async (req, res) => {
|
||||
const sessionId = req.params.sessionId?.trim();
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
chatRouter.get("/session/:session_id", async (req, res) => {
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
if (!sessionId) {
|
||||
@@ -210,10 +238,11 @@ export const buildChatRouter = (
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.get("/session/:sessionId/stream", async (req, res) => {
|
||||
const sessionId = req.params.sessionId?.trim();
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
chatRouter.get("/session/:session_id/stream", async (req, res) => {
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
if (!sessionId) {
|
||||
@@ -274,81 +303,17 @@ export const buildChatRouter = (
|
||||
res.on("close", cleanup);
|
||||
});
|
||||
|
||||
chatRouter.put("/session/:sessionId", async (req, res) => {
|
||||
const sessionId = req.params.sessionId?.trim();
|
||||
const parsed = sessionStateSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
detail: parsed.error.flatten(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ message: "session_id is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { record } = await sessionMetadataStore.ensure({
|
||||
actorKey,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
const nextRecord = await sessionMetadataStore.touch(record, {
|
||||
...(parsed.data.title ? { title: parsed.data.title } : {}),
|
||||
});
|
||||
await sessionUiStateStore.write(toSessionUiStateContext(nextRecord), {
|
||||
sessionId: nextRecord.sessionId,
|
||||
isTitleManuallyEdited: parsed.data.is_title_manually_edited,
|
||||
messages: parsed.data.messages,
|
||||
});
|
||||
const latestTurn = extractLatestFrontendTurn(parsed.data.messages);
|
||||
if (latestTurn) {
|
||||
void learningOrchestrator.onTurnCompleted({
|
||||
...latestTurn,
|
||||
requestContext: {
|
||||
actorKey,
|
||||
clientSessionId: nextRecord.sessionId,
|
||||
projectId,
|
||||
projectKey,
|
||||
traceId: req.header("x-trace-id") ?? `save-${nextRecord.sessionId}`,
|
||||
userId,
|
||||
},
|
||||
sessionId: nextRecord.sessionId,
|
||||
}).catch((error) => {
|
||||
logger.warn(
|
||||
{ err: error, sessionId: nextRecord.sessionId },
|
||||
"post-save learning failed",
|
||||
);
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
id: nextRecord.sessionId,
|
||||
title: nextRecord.title ?? "新对话",
|
||||
created_at: nextRecord.createdAt,
|
||||
updated_at: nextRecord.updatedAt,
|
||||
status: nextRecord.status,
|
||||
session_id: nextRecord.sessionId,
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.patch("/session/:sessionId/title", async (req, res) => {
|
||||
const sessionId = req.params.sessionId?.trim();
|
||||
chatRouter.patch("/session/:session_id/title", async (req, res) => {
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const title =
|
||||
typeof req.body?.title === "string" ? req.body.title.trim() : "";
|
||||
const isTitleManuallyEdited =
|
||||
typeof req.body?.is_title_manually_edited === "boolean"
|
||||
? req.body.is_title_manually_edited
|
||||
: undefined;
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
if (!sessionId || !title) {
|
||||
@@ -384,10 +349,11 @@ export const buildChatRouter = (
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.delete("/session/:sessionId", async (req, res) => {
|
||||
const sessionId = req.params.sessionId?.trim();
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
chatRouter.delete("/session/:session_id", async (req, res) => {
|
||||
const sessionId = req.params.session_id?.trim();
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
if (!sessionId) {
|
||||
@@ -440,9 +406,10 @@ export const buildChatRouter = (
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const traceId = req.header("x-trace-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const userId = authContext.userId;
|
||||
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
@@ -469,7 +436,7 @@ export const buildChatRouter = (
|
||||
});
|
||||
const nextSessionId = targetSessionRecord.sessionId;
|
||||
|
||||
if (sourceSessionId && parsed.data.keep_message_count > 0) {
|
||||
if (sourceSessionId) {
|
||||
await sessionTranscriptStore.cloneThread(
|
||||
{
|
||||
actorKey,
|
||||
@@ -485,12 +452,24 @@ export const buildChatRouter = (
|
||||
},
|
||||
parsed.data.keep_message_count,
|
||||
);
|
||||
if (sourceSessionRecord?.title) {
|
||||
await sessionMetadataStore.touch(targetSessionRecord, {
|
||||
title: sourceSessionRecord.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
const sourceState = sourceSessionRecord
|
||||
? await sessionUiStateStore.read(toSessionUiStateContext(sourceSessionRecord))
|
||||
: null;
|
||||
const forkTitle = sourceSessionRecord?.title
|
||||
? `${sourceSessionRecord.title} 副本`
|
||||
: "新对话副本";
|
||||
const titledTargetSessionRecord = await sessionMetadataStore.touch(
|
||||
targetSessionRecord,
|
||||
{ title: forkTitle },
|
||||
);
|
||||
await sessionUiStateStore.write(
|
||||
toSessionUiStateContext(titledTargetSessionRecord),
|
||||
buildForkedSessionUiState(sourceState, {
|
||||
keepMessageCount: parsed.data.keep_message_count,
|
||||
targetSessionId: nextSessionId,
|
||||
}),
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
@@ -527,13 +506,11 @@ export const buildChatRouter = (
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.header("authorization");
|
||||
const accessToken = authHeader?.startsWith("Bearer ")
|
||||
? authHeader.slice("Bearer ".length)
|
||||
: authHeader;
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const accessToken = authContext.accessToken;
|
||||
const projectId = authContext.projectId;
|
||||
const traceId = req.header("x-trace-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const requestedSessionId = parsed.data.session_id?.trim();
|
||||
@@ -548,8 +525,10 @@ export const buildChatRouter = (
|
||||
const { binding, requestContext, created } = await sessionBridge.resolve({
|
||||
sessionId: requestedSessionId,
|
||||
accessToken,
|
||||
network: authContext.network,
|
||||
projectId,
|
||||
traceId,
|
||||
tokenExpiresAt: authContext.tokenExpiresAt,
|
||||
userId,
|
||||
});
|
||||
const { record: ensuredSessionRecord, created: sessionCreated } =
|
||||
@@ -706,6 +685,19 @@ export const buildChatRouter = (
|
||||
progress: completeBackendProgress(message.progress),
|
||||
todos: cancelBackendTodos(message.todos),
|
||||
}));
|
||||
} else if (event === "auth_required") {
|
||||
activeRun.status = "error";
|
||||
lastRunStatuses.set(clientSessionId, "error");
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
content:
|
||||
typeof data.message === "string"
|
||||
? `⚠️ **${data.message}**`
|
||||
: "⚠️ **登录态已过期,请刷新登录后重试**",
|
||||
isError: true,
|
||||
progress: completeBackendProgress(message.progress),
|
||||
todos: cancelBackendTodos(message.todos),
|
||||
}));
|
||||
} else if (event === "permission_request") {
|
||||
const payload = data as PermissionRequestPayload;
|
||||
activeRun.pendingPermissions.set(payload.request_id, payload);
|
||||
@@ -789,6 +781,11 @@ export const buildChatRouter = (
|
||||
...message,
|
||||
todos: upsertBackendTodoUpdate(message.todos, payload),
|
||||
}));
|
||||
} else if (event === "tool_call") {
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
artifacts: appendBackendToolArtifact(message.artifacts, data),
|
||||
}));
|
||||
}
|
||||
|
||||
for (const subscriber of activeRun.subscribers) {
|
||||
@@ -829,6 +826,16 @@ export const buildChatRouter = (
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||
});
|
||||
|
||||
const latestRuntimeContext = getRuntimeSessionContext(binding.sessionId);
|
||||
if (latestRuntimeContext?.authExpired) {
|
||||
publish("auth_required", {
|
||||
session_id: clientSessionId,
|
||||
reason: latestRuntimeContext.authExpired.reason,
|
||||
message: latestRuntimeContext.authExpired.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!streamResult.aborted && !streamResult.failed) {
|
||||
const messages = await runtime.messages(binding.sessionId, 60);
|
||||
const assistantMessage = [...messages]
|
||||
@@ -876,6 +883,19 @@ export const buildChatRouter = (
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||
});
|
||||
}
|
||||
const latestTurn = extractLatestFrontendTurn(activeRun.messages);
|
||||
if (latestTurn) {
|
||||
void learningOrchestrator.onTurnCompleted({
|
||||
...latestTurn,
|
||||
requestContext,
|
||||
sessionId: clientSessionId,
|
||||
}).catch((error) => {
|
||||
logger.warn(
|
||||
{ err: error, sessionId: clientSessionId },
|
||||
"stream-completed learning failed",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (abortController.signal.aborted) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getAgentAuthContext } from "../auth/agentAuth.js";
|
||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { type ResultReferenceResolver } from "../results/resolver.js";
|
||||
@@ -44,22 +45,16 @@ export const registerChatAuxiliaryRoutes = (
|
||||
sessionUiStateStore,
|
||||
}: RegisterAuxiliaryRoutesOptions,
|
||||
) => {
|
||||
chatRouter.get("/render-ref/:renderRef", async (req, res) => {
|
||||
const renderRef = req.params.renderRef?.trim();
|
||||
const userId = req.header("x-user-id")?.trim();
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
chatRouter.get("/render-ref/:render_ref", async (req, res) => {
|
||||
const renderRef = req.params.render_ref?.trim();
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const userId = authContext.userId;
|
||||
const projectId = authContext.projectId;
|
||||
const clientSessionId =
|
||||
typeof req.query.session_id === "string"
|
||||
? req.query.session_id.trim()
|
||||
: undefined;
|
||||
|
||||
if (!userId) {
|
||||
res.status(400).json({
|
||||
message: "x-user-id is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!renderRef) {
|
||||
res.status(400).json({
|
||||
message: "render_ref is required",
|
||||
@@ -98,8 +93,9 @@ export const registerChatAuxiliaryRoutes = (
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getAgentAuthContext } from "../auth/agentAuth.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||
@@ -48,8 +49,8 @@ export const registerChatInteractionRoutes = (
|
||||
sessionUiStateStore,
|
||||
}: RegisterInteractionRoutesOptions,
|
||||
) => {
|
||||
chatRouter.post("/permission/:requestId/reply", async (req, res) => {
|
||||
const requestId = req.params.requestId?.trim();
|
||||
chatRouter.post("/permission/:request_id/reply", async (req, res) => {
|
||||
const requestId = req.params.request_id?.trim();
|
||||
const parsed = permissionReplyPayloadSchema.safeParse(req.body);
|
||||
if (!requestId) {
|
||||
res.status(400).json({ message: "request_id is required" });
|
||||
@@ -64,8 +65,9 @@ export const registerChatInteractionRoutes = (
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
@@ -172,8 +174,8 @@ export const registerChatInteractionRoutes = (
|
||||
}
|
||||
});
|
||||
|
||||
chatRouter.post("/question/:requestId/reply", async (req, res) => {
|
||||
const requestId = req.params.requestId?.trim();
|
||||
chatRouter.post("/question/:request_id/reply", async (req, res) => {
|
||||
const requestId = req.params.request_id?.trim();
|
||||
const parsed = questionReplyPayloadSchema.safeParse(req.body);
|
||||
if (!requestId) {
|
||||
res.status(400).json({ message: "request_id is required" });
|
||||
@@ -188,8 +190,9 @@ export const registerChatInteractionRoutes = (
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
@@ -303,8 +306,8 @@ export const registerChatInteractionRoutes = (
|
||||
}
|
||||
});
|
||||
|
||||
chatRouter.post("/question/:requestId/reject", async (req, res) => {
|
||||
const requestId = req.params.requestId?.trim();
|
||||
chatRouter.post("/question/:request_id/reject", async (req, res) => {
|
||||
const requestId = req.params.request_id?.trim();
|
||||
const parsed = questionRejectPayloadSchema.safeParse(req.body);
|
||||
if (!requestId) {
|
||||
res.status(400).json({ message: "request_id is required" });
|
||||
@@ -319,8 +322,9 @@ export const registerChatInteractionRoutes = (
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = req.header("x-project-id") ?? undefined;
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const authContext = getAgentAuthContext(req);
|
||||
const projectId = authContext.projectId;
|
||||
const userId = authContext.userId;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -22,6 +22,15 @@ export type ActiveRun = {
|
||||
subscribers: Set<StreamSubscriber>;
|
||||
};
|
||||
|
||||
type ToolArtifactKind = "chart" | "map" | "panel" | "tool";
|
||||
|
||||
type ToolCallPayload = {
|
||||
session_id?: string;
|
||||
tool?: string;
|
||||
params?: unknown;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
@@ -196,6 +205,59 @@ export const updateLastAssistantQuestion = (
|
||||
};
|
||||
});
|
||||
|
||||
const getToolArtifactKind = (tool: string): ToolArtifactKind => {
|
||||
if (tool === "show_chart" || tool === "chart") return "chart";
|
||||
if (
|
||||
tool === "locate_features" ||
|
||||
tool === "zoom_to_map" ||
|
||||
tool === "render_junctions" ||
|
||||
tool === "apply_layer_style" ||
|
||||
tool.startsWith("locate_")
|
||||
) {
|
||||
return "map";
|
||||
}
|
||||
if (tool === "view_history" || tool === "view_scada") return "panel";
|
||||
return "tool";
|
||||
};
|
||||
|
||||
const getToolArtifactTitle = (tool: string, params: Record<string, unknown>) => {
|
||||
if (typeof params.title === "string" && params.title.trim()) {
|
||||
return params.title.trim();
|
||||
}
|
||||
if (tool === "show_chart" || tool === "chart") return "生成图表";
|
||||
if (tool === "zoom_to_map") return "缩放到地图坐标";
|
||||
if (tool === "render_junctions") return "渲染节点分区";
|
||||
if (tool === "view_history") return "打开计算结果曲线";
|
||||
if (tool === "view_scada") return "打开 SCADA 数据面板";
|
||||
if (tool === "apply_layer_style") return "应用图层样式";
|
||||
if (tool === "locate_features" || tool.startsWith("locate_")) return "地图定位";
|
||||
return tool || "工具调用";
|
||||
};
|
||||
|
||||
export const appendBackendToolArtifact = (
|
||||
artifacts: unknown,
|
||||
payload: ToolCallPayload,
|
||||
) => {
|
||||
const tool = typeof payload.tool === "string" ? payload.tool.trim() : "";
|
||||
if (!tool) {
|
||||
return artifacts;
|
||||
}
|
||||
const params = isObjectRecord(payload.params) ? payload.params : {};
|
||||
const next = Array.isArray(artifacts) ? [...artifacts] : [];
|
||||
next.push({
|
||||
id: `${tool}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
tool,
|
||||
kind: getToolArtifactKind(tool),
|
||||
title: getToolArtifactTitle(tool, params),
|
||||
description:
|
||||
typeof payload.reason === "string" && payload.reason.trim()
|
||||
? payload.reason.trim()
|
||||
: undefined,
|
||||
params,
|
||||
});
|
||||
return next;
|
||||
};
|
||||
|
||||
export const toFrontendPermission = (
|
||||
payload: PermissionRequestPayload,
|
||||
status: "pending" | "approved_once" | "approved_always" | "rejected" | "error" = "pending",
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
export type RuntimeSessionContext = {
|
||||
accessToken?: string;
|
||||
actorKey: string;
|
||||
authExpired?: {
|
||||
message: string;
|
||||
reason: "access_token_expired" | "access_token_rejected";
|
||||
};
|
||||
allowLearningWrite?: boolean;
|
||||
clientSessionId: string;
|
||||
learningMode?: "interactive" | "review";
|
||||
memoryListReadScopes?: Partial<Record<"user" | "workspace", boolean>>;
|
||||
network?: string;
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
sessionId: string;
|
||||
tokenExpiresAt?: string;
|
||||
traceId: string;
|
||||
};
|
||||
|
||||
@@ -22,6 +28,23 @@ export const getRuntimeSessionContext = (sessionId: string) => {
|
||||
return context ? { ...context } : null;
|
||||
};
|
||||
|
||||
export const markRuntimeSessionAuthExpired = (
|
||||
sessionId: string,
|
||||
reason: NonNullable<RuntimeSessionContext["authExpired"]>["reason"],
|
||||
message = "登录态已过期,请刷新登录后重试",
|
||||
) => {
|
||||
const context = contexts.get(sessionId);
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
const nextContext: RuntimeSessionContext = {
|
||||
...context,
|
||||
authExpired: { message, reason },
|
||||
};
|
||||
contexts.set(sessionId, nextContext);
|
||||
return { ...nextContext };
|
||||
};
|
||||
|
||||
export const removeRuntimeSessionContext = (sessionId: string) => {
|
||||
contexts.delete(sessionId);
|
||||
};
|
||||
|
||||
+85
-9
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
|
||||
import { requireAgentAuth } from "./auth/agentAuth.js";
|
||||
import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
|
||||
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
||||
import { config } from "./config.js";
|
||||
@@ -18,7 +19,11 @@ import {
|
||||
} from "./results/store.js";
|
||||
import { buildChatRouter } from "./routes/chat.js";
|
||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||
import { getRuntimeSessionContext } from "./runtime/sessionContext.js";
|
||||
import {
|
||||
getRuntimeSessionContext,
|
||||
markRuntimeSessionAuthExpired,
|
||||
type RuntimeSessionContext,
|
||||
} from "./runtime/sessionContext.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -78,6 +83,14 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isRuntimeAuthExpired(context)) {
|
||||
markAuthExpired(context, "access_token_expired");
|
||||
res.status(401).json({
|
||||
message: "access token expired; refresh chat context",
|
||||
detail: sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const command = typeof req.body?.command === "string" ? req.body.command.trim() : "";
|
||||
if (!command) {
|
||||
@@ -88,11 +101,19 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
||||
const timeoutSec =
|
||||
typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120;
|
||||
|
||||
if (!context.network) {
|
||||
res.status(400).json({
|
||||
message: "runtime network missing; refresh chat context",
|
||||
detail: sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const authJson = JSON.stringify({
|
||||
server: config.TJWATER_API_BASE_URL,
|
||||
access_token: context.accessToken,
|
||||
project_id: context.projectId,
|
||||
network:"tjwater",
|
||||
network: context.network,
|
||||
});
|
||||
|
||||
const cliArgs = ["--auth-stdin", ...command.split(/\s+/).filter(Boolean)];
|
||||
@@ -252,9 +273,20 @@ app.post("/internal/tools/session-search", async (req, res) => {
|
||||
|
||||
const callBackendJson = async (
|
||||
path: string,
|
||||
accessToken: string | undefined,
|
||||
context: RuntimeSessionContext,
|
||||
payload: unknown,
|
||||
) => {
|
||||
if (isRuntimeAuthExpired(context)) {
|
||||
markAuthExpired(context, "access_token_expired");
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: JSON.stringify({
|
||||
message: "access token expired; refresh chat context",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
|
||||
try {
|
||||
@@ -262,8 +294,8 @@ const callBackendJson = async (
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`;
|
||||
if (context.accessToken) {
|
||||
headers.Authorization = `Bearer ${context.accessToken}`;
|
||||
}
|
||||
const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), {
|
||||
method: "POST",
|
||||
@@ -272,6 +304,9 @@ const callBackendJson = async (
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
if (response.status === 401) {
|
||||
markAuthExpired(context, "access_token_rejected");
|
||||
}
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
@@ -287,6 +322,47 @@ const parseStringArray = (value: unknown) =>
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: undefined;
|
||||
|
||||
const webSearchFreshnessMap: Record<string, string> = {
|
||||
no_limit: "noLimit",
|
||||
one_day: "oneDay",
|
||||
one_week: "oneWeek",
|
||||
one_month: "oneMonth",
|
||||
one_year: "oneYear",
|
||||
};
|
||||
|
||||
const normalizeWebSearchFreshness = (value: unknown) => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return webSearchFreshnessMap[value] ?? value;
|
||||
};
|
||||
|
||||
const AUTH_EXPIRY_SKEW_MS = 30_000;
|
||||
|
||||
function isRuntimeAuthExpired(context: RuntimeSessionContext) {
|
||||
if (!context.tokenExpiresAt) {
|
||||
return false;
|
||||
}
|
||||
const expiresAt = Date.parse(context.tokenExpiresAt);
|
||||
if (!Number.isFinite(expiresAt)) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() >= expiresAt - AUTH_EXPIRY_SKEW_MS;
|
||||
}
|
||||
|
||||
function markAuthExpired(
|
||||
context: RuntimeSessionContext,
|
||||
reason: NonNullable<RuntimeSessionContext["authExpired"]>["reason"],
|
||||
) {
|
||||
markRuntimeSessionAuthExpired(context.sessionId, reason);
|
||||
void opencodeRuntime.abortSession(context.sessionId).catch((error) => {
|
||||
logger.warn(
|
||||
{ err: error, sessionId: context.sessionId },
|
||||
"failed to abort runtime after auth expired",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
app.post("/internal/tools/web-search", async (req, res) => {
|
||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||
res.status(403).json({ message: "forbidden" });
|
||||
@@ -316,8 +392,7 @@ app.post("/internal/tools/web-search", async (req, res) => {
|
||||
: undefined;
|
||||
const payload = {
|
||||
query,
|
||||
freshness:
|
||||
typeof req.body?.freshness === "string" ? req.body.freshness : undefined,
|
||||
freshness: normalizeWebSearchFreshness(req.body?.freshness),
|
||||
summary:
|
||||
typeof req.body?.summary === "boolean" ? req.body.summary : undefined,
|
||||
count,
|
||||
@@ -328,7 +403,7 @@ app.post("/internal/tools/web-search", async (req, res) => {
|
||||
try {
|
||||
const response = await callBackendJson(
|
||||
"/api/v1/web-search",
|
||||
context.accessToken,
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
res
|
||||
@@ -371,7 +446,7 @@ app.post("/internal/tools/geocode", async (req, res) => {
|
||||
try {
|
||||
const response = await callBackendJson(
|
||||
"/api/v1/tianditu/geocode",
|
||||
context.accessToken,
|
||||
context,
|
||||
{ keyword },
|
||||
);
|
||||
res
|
||||
@@ -389,6 +464,7 @@ app.post("/internal/tools/geocode", async (req, res) => {
|
||||
|
||||
app.use(
|
||||
"/api/v1/agent/chat",
|
||||
requireAgentAuth,
|
||||
buildChatRouter(
|
||||
sessionBridge,
|
||||
opencodeRuntime,
|
||||
|
||||
@@ -147,29 +147,6 @@ export class SessionTranscriptStore {
|
||||
return nextTranscript;
|
||||
}
|
||||
|
||||
async truncateThread(
|
||||
context: SessionTranscriptContext,
|
||||
keepMessageCount: number,
|
||||
) {
|
||||
const key = this.filePath(context);
|
||||
return this.serializeWrite(key, async () => {
|
||||
const transcript = await this.readTranscript(context);
|
||||
if (!transcript) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextTranscript: SessionTranscriptRecord = {
|
||||
...transcript,
|
||||
clientSessionId: context.clientSessionId ?? transcript.clientSessionId,
|
||||
sessionId: context.sessionId,
|
||||
turns: projectTurnsForFork(transcript.turns, keepMessageCount),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await atomicWriteJson(key, nextTranscript);
|
||||
return nextTranscript;
|
||||
});
|
||||
}
|
||||
|
||||
async search(
|
||||
context: Pick<SessionTranscriptContext, "actorKey" | "projectKey">,
|
||||
query: string,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
buildForkedSessionUiState,
|
||||
} from "../../src/routes/chat.js";
|
||||
import {
|
||||
buildPromptWithLearningContext,
|
||||
extractLatestFrontendTurn,
|
||||
@@ -199,3 +202,79 @@ describe("extractLatestFrontendTurn", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildForkedSessionUiState", () => {
|
||||
it("copies truncated source messages and preserves tool artifacts", () => {
|
||||
const forked = buildForkedSessionUiState(
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "画压力曲线" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "已生成图表",
|
||||
artifacts: [
|
||||
{
|
||||
id: "chart-1",
|
||||
tool: "show_chart",
|
||||
kind: "chart",
|
||||
params: { chart_type: "line" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "继续分析" },
|
||||
],
|
||||
},
|
||||
{
|
||||
keepMessageCount: 2,
|
||||
targetSessionId: "forked-session",
|
||||
},
|
||||
);
|
||||
|
||||
expect(forked).toEqual({
|
||||
sessionId: "forked-session",
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [
|
||||
{ role: "user", content: "画压力曲线" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "已生成图表",
|
||||
artifacts: [
|
||||
{
|
||||
id: "chart-1",
|
||||
tool: "show_chart",
|
||||
kind: "chart",
|
||||
params: { chart_type: "line" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an empty branch state when source UI state is missing or keep count is zero", () => {
|
||||
expect(
|
||||
buildForkedSessionUiState(null, {
|
||||
keepMessageCount: 3,
|
||||
targetSessionId: "forked-without-source",
|
||||
}),
|
||||
).toEqual({
|
||||
sessionId: "forked-without-source",
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildForkedSessionUiState(
|
||||
{ messages: [{ role: "user", content: "不保留" }] },
|
||||
{
|
||||
keepMessageCount: 0,
|
||||
targetSessionId: "forked-empty",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
sessionId: "forked-empty",
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
appendBackendToolArtifact,
|
||||
cancelBackendTodos,
|
||||
upsertBackendQuestion,
|
||||
} from "../../src/routes/chatUiState.js";
|
||||
|
||||
describe("appendBackendToolArtifact", () => {
|
||||
it("persists show_chart tool calls as chart artifacts", () => {
|
||||
const artifacts = appendBackendToolArtifact([], {
|
||||
session_id: "session-1",
|
||||
tool: "show_chart",
|
||||
reason: "测试折线图渲染",
|
||||
params: {
|
||||
title: "压力曲线",
|
||||
chart_type: "line",
|
||||
x_data: ["00:00", "01:00"],
|
||||
series: [{ name: "P-101", data: [0.42, 0.41] }],
|
||||
},
|
||||
}) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(artifacts).toHaveLength(1);
|
||||
expect(artifacts[0]).toMatchObject({
|
||||
tool: "show_chart",
|
||||
kind: "chart",
|
||||
title: "压力曲线",
|
||||
description: "测试折线图渲染",
|
||||
params: {
|
||||
chart_type: "line",
|
||||
x_data: ["00:00", "01:00"],
|
||||
series: [{ name: "P-101", data: [0.42, 0.41] }],
|
||||
},
|
||||
});
|
||||
expect(artifacts[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^show_chart-/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertBackendQuestion", () => {
|
||||
it("replaces a tool-call placeholder with the actionable question request", () => {
|
||||
const questions = upsertBackendQuestion(
|
||||
|
||||
@@ -14,6 +14,7 @@ describe("runtime session context", () => {
|
||||
allowLearningWrite: true,
|
||||
clientSessionId: "chat-session-1",
|
||||
learningMode: "interactive",
|
||||
network: "fengyang",
|
||||
projectId: "project-id-1",
|
||||
projectKey: "project-1",
|
||||
sessionId: "runtime-session-1",
|
||||
@@ -24,6 +25,7 @@ describe("runtime session context", () => {
|
||||
|
||||
expect(runtimeContext?.accessToken).toBe("token-1");
|
||||
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
|
||||
expect(runtimeContext?.network).toBe("fengyang");
|
||||
expect(runtimeContext?.sessionId).toBe("runtime-session-1");
|
||||
|
||||
removeRuntimeSessionContext("runtime-session-1");
|
||||
|
||||
Reference in New Issue
Block a user