@whispering233/ai-editor-server 0.0.30 → 0.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client-dist/assets/{index-BBkKfUUn.css → index-oxaM6dHH.css} +1 -1
- package/client-dist/assets/index-tfvJfexp.js +523 -0
- package/client-dist/index.html +3 -3
- package/client-dist/provider-icons.svg +54 -0
- package/dist/backup.d.ts +8 -7
- package/dist/backup.d.ts.map +1 -1
- package/dist/backup.js +61 -45
- package/dist/backup.js.map +1 -1
- package/dist/chat-runtime.d.ts +44 -0
- package/dist/chat-runtime.d.ts.map +1 -0
- package/dist/chat-runtime.js +102 -0
- package/dist/chat-runtime.js.map +1 -0
- package/dist/debug.d.ts +1 -1
- package/dist/debug.d.ts.map +1 -1
- package/dist/debug.js +3 -3
- package/dist/debug.js.map +1 -1
- package/dist/http-dispatcher.d.ts +30 -0
- package/dist/http-dispatcher.d.ts.map +1 -0
- package/dist/http-dispatcher.js +101 -0
- package/dist/http-dispatcher.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -1
- package/dist/index.js.map +1 -1
- package/dist/last-project.d.ts +7 -0
- package/dist/last-project.d.ts.map +1 -0
- package/dist/last-project.js +48 -0
- package/dist/last-project.js.map +1 -0
- package/dist/middleware/project.d.ts.map +1 -1
- package/dist/middleware/project.js +4 -0
- package/dist/middleware/project.js.map +1 -1
- package/dist/model-runtime.d.ts +41 -0
- package/dist/model-runtime.d.ts.map +1 -0
- package/dist/model-runtime.js +81 -0
- package/dist/model-runtime.js.map +1 -0
- package/dist/routes/chat.d.ts +35 -72
- package/dist/routes/chat.d.ts.map +1 -1
- package/dist/routes/chat.js +390 -373
- package/dist/routes/chat.js.map +1 -1
- package/dist/routes/names.js +2 -2
- package/dist/routes/project.d.ts.map +1 -1
- package/dist/routes/project.js +6 -0
- package/dist/routes/project.js.map +1 -1
- package/dist/routes/settings.d.ts +0 -53
- package/dist/routes/settings.d.ts.map +1 -1
- package/dist/routes/settings.js +161 -194
- package/dist/routes/settings.js.map +1 -1
- package/package.json +8 -6
- package/client-dist/assets/index-ygCEt46m.js +0 -523
package/dist/routes/chat.js
CHANGED
|
@@ -1,126 +1,44 @@
|
|
|
1
|
-
//
|
|
2
|
-
// POST /api/v1/chat(POST + SSE 对话端点,切片 7 最后一张卡)
|
|
1
|
+
// 对话路由:POST /api/v1/chat(POST + SSE)、会话列表 / 消息历史 / 思维链全文 / 删除会话
|
|
3
2
|
//
|
|
3
|
+
// **本文件是 docs/api/80-api-chat.md 那份契约的服务端实现**——SSE 事件集与过滤约定见该文档,
|
|
4
|
+
// 事件投影的唯一实现在 agent 包 `runtime/events.ts`(本文件只做「取帧 → 写帧」与日志)。
|
|
4
5
|
//
|
|
5
|
-
//
|
|
6
|
-
// - 无当前项目 → 409 NO_PROJECT_OPEN
|
|
6
|
+
// 语义要点:
|
|
7
|
+
// - 无当前项目 → 409 NO_PROJECT_OPEN(与其他业务路由一致)
|
|
7
8
|
// - 请求体校验失败 → 400 VALIDATION_ERROR(JSON,非 SSE——校验在开流之前)
|
|
8
|
-
// -
|
|
9
|
-
// -
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// - 单项目单在途流 → 409 CHAT_BUSY;重复会话删除 → 404 SESSION_NOT_FOUND;在途会话 → 409 SESSION_BUSY
|
|
10
|
+
// - 模型/凭据缺失 → 400 LLM_API_KEY_MISSING(同样在开流之前,JSON)
|
|
11
|
+
// - 会话归属由项目目录表达:`session_id` 只经磁盘发现映射到文件(**不做路径拼接**),
|
|
12
|
+
// 缺省则新建会话(pi 生成 id,首帧 `session` 回给客户端)
|
|
13
|
+
// - 断开(刷新/断网)→ 三路检测 → `AgentSession.abort()`;未确认提案随取消作废
|
|
12
14
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
+
// 可测试性:`createChatRoutes(deps)` 注入运行时工厂 / 提案仓 / 心跳间隔,测试用 faux provider
|
|
16
|
+
// 离线跑通全链路(不触碰真实 provider 与全局单例)。
|
|
17
|
+
import { unlinkSync } from "node:fs";
|
|
15
18
|
import { Hono } from "hono";
|
|
16
19
|
import { streamSSE } from "hono/streaming";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import { findOutlineNode, getEntity, insertChatMessage, listMessageRows, listMessages, listSessions, nowIso, readAgentsFile, readOutlineFile, } from "@whispering233/ai-editor-db";
|
|
22
|
-
import { TOOL_PERMISSION, generateRuntimeId } from "@whispering233/ai-editor-shared";
|
|
23
|
-
import { chatMessagesResSchema, chatSendReqSchema, chatSessionsResSchema } from "@whispering233/ai-editor-shared/schemas";
|
|
20
|
+
import { createPingFrame, createSessionFrame, defaultProposalStore, findProjectSession, FOCUS_TITLE, lastVisibleSessionText, listProjectSessions, NoModelConfiguredError, projectSessionMessages, readProjectSession, readSessionThinking, toSseFrame, } from "@whispering233/ai-editor-agent";
|
|
21
|
+
import { findOutlineNode, getEntity, readOutlineFile } from "@whispering233/ai-editor-db";
|
|
22
|
+
import { truncate } from "@whispering233/ai-editor-shared";
|
|
23
|
+
import { chatMessagesResSchema, chatSendReqSchema, chatSessionDeleteResSchema, chatSessionsResSchema, chatThinkingResSchema, } from "@whispering233/ai-editor-shared/schemas";
|
|
24
24
|
import { HttpError, ok } from "../middleware/error.js";
|
|
25
25
|
import { requireCurrentProject } from "../middleware/project.js";
|
|
26
26
|
import { debugLog, isCategoryEnabled } from "../debug.js";
|
|
27
|
-
import {
|
|
27
|
+
import { acquireProjectRuntime, beginActiveChat, enterInFlightSession, hasActiveChat, isSessionInFlight, leaveInFlightSession, } from "../chat-runtime.js";
|
|
28
28
|
// ============ 常量 ============
|
|
29
29
|
/** 心跳间隔(15-30s 随机;测试经 deps.heartbeat 覆盖为毫秒级) */
|
|
30
30
|
export const DEFAULT_HEARTBEAT_MS = { minMs: 15_000, maxMs: 30_000 };
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
/** 会话列表 lastMessage 截断长度(与历史口径一致,见 docs/db/schema.md) */
|
|
32
|
+
export const SESSION_LAST_MESSAGE_MAX_LEN = 50;
|
|
33
|
+
/** 工具结果截断标记(agent 包 runtime/tool-result.ts 的同款文案;用于 TOOL_RESULT_TOO_LARGE 日志) */
|
|
34
|
+
const TOOL_RESULT_TRUNCATION_MARKER = "[工具结果已截断";
|
|
35
|
+
// ============ 聚焦上下文(本轮消息的一部分,见 docs/design/20-context.md §2) ============
|
|
35
36
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* 内置转换全覆盖且输出 draft 2020-12 合法 schema(`.strict` → additionalProperties:false、
|
|
40
|
-
* `.refine` 仅影响校验不改变类型 schema、`z.record` → propertyNames 形态)。
|
|
41
|
-
* 仅剥离顶层 `$schema` 关键字——OpenAI 兼容端点(含 DeepSeek)对 parameters 内未知关键字
|
|
42
|
-
* 存在严格模式拒绝风险,剥离后零风险(其余关键字为标准 JSON Schema 内容,兼容)。
|
|
37
|
+
* 聚焦上下文文本:focus_entity_id → 实体、focus_node_id → 大纲节点,拼成结构化文本。
|
|
38
|
+
* 查询不到(已软删/不存在/跨项目)→ 跳过该项(不报错):客户端可能携带过期 focus。
|
|
39
|
+
* 两项皆无 → undefined(不注入,消息原文保持干净)。
|
|
43
40
|
*/
|
|
44
|
-
export function
|
|
45
|
-
const js = toJSONSchema(schema);
|
|
46
|
-
delete js.$schema;
|
|
47
|
-
return js;
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* registry 工具定义 → LLM function calling 工具定义(决策点:只转换 AUTO + PROPOSAL 权限的
|
|
51
|
-
* 33 个工具——listTools 已不注册执行类(S6.7 核心设计原则「AI 只能提案不能直接写」),
|
|
52
|
-
* 此处权限过滤为双保险:未来误注册执行类工具也不会暴露给模型)。
|
|
53
|
-
*/
|
|
54
|
-
export function toLLMToolDefinitions(defs) {
|
|
55
|
-
return defs
|
|
56
|
-
.filter((d) => d.permission === TOOL_PERMISSION.AUTO || d.permission === TOOL_PERMISSION.PROPOSAL)
|
|
57
|
-
.map((d) => ({
|
|
58
|
-
name: d.name,
|
|
59
|
-
description: d.description,
|
|
60
|
-
parameters: zodArgsToJsonSchema(d.argsSchema),
|
|
61
|
-
}));
|
|
62
|
-
}
|
|
63
|
-
// ============ 真实 produce / dispatcher 构造 ============
|
|
64
|
-
/**
|
|
65
|
-
* 真实 produce 闭包(S7.3 把 onEvent 转发给 chatStream 的 onEvent——runAgent 依赖它
|
|
66
|
-
* 累积文本与收集工具调用;失败按 chatStream resolve 出 { ok:false, ... } 不 throw;
|
|
67
|
-
* signal 由 runAgent 注入 attempt 级独立控制器——超时 abort 与用户取消分离)。
|
|
68
|
-
*/
|
|
69
|
-
function createRealProduce(apiKey, provider, model, tools, debugStream, thinking = DEFAULT_THINKING_LEVEL) {
|
|
70
|
-
return (messages, signal, onEvent) =>
|
|
71
|
-
// debugStream 显式传布尔(含 false)——stream 类别关时压过 env,保证配置文件类别隔离语义
|
|
72
|
-
// reasoning(思考强度):off 不传(模型默认推理),low/medium/high 传 pi-ai 统一接口
|
|
73
|
-
// provider:模型只在 provider 目录内解析(llm 层兜底)
|
|
74
|
-
chatStream({ apiKey, provider, model, messages, tools, signal, onEvent, debugStream, reasoning: thinking });
|
|
75
|
-
}
|
|
76
|
-
// ============ [llm] 请求 / usage 调试日志装饰器(细粒度类别 request / usage) ============
|
|
77
|
-
/**
|
|
78
|
-
* [llm] 请求调试日志装饰器:给 produce 包一层(真实路径经
|
|
79
|
-
* `createLLMRequestLogger(createRealProduce(...), { model, tools })` 组装),对应类别开启时:
|
|
80
|
-
* - **request 日志**(类别 request):模型名 + 请求参数 + **完整 messages JSON(不截断——
|
|
81
|
-
* 用户核心诉求「最终组装的 prompt」)** + 工具名列表;每轮每次 attempt 各打一次(含重试)
|
|
82
|
-
* - **usage 日志**(类别 usage):onEvent 转发处捕获 finish 事件,打真实 token 数
|
|
83
|
-
* (需求 3,流内真实 usage)——与 request **分开判定**(categories: ["request","usage"]
|
|
84
|
-
* 时只显示请求与 tokens 统计)
|
|
85
|
-
* - **敏感红线**:只打印请求体(messages 本身无 key——key 走 fetch header),
|
|
86
|
-
* 绝不打印 apiKey / headers
|
|
87
|
-
* - 两类别全关(request + usage 均未开启)时**零开销直通**:不包装 onEvent、不拼接字符串
|
|
88
|
-
* 独立成装饰器而非塞进 createRealProduce:测试可用 mock produce 直测日志层,
|
|
89
|
-
* 不经真实 DeepSeek 网络调用(与 createChatEventLogger 的「工厂 + 组合」同款模式)。
|
|
90
|
-
* 注:produce (run.ts)不含 maxTokens/temperature(当前无对应配置),如实标注 <未设置>。
|
|
91
|
-
*/
|
|
92
|
-
export function createLLMRequestLogger(produce, ctx) {
|
|
93
|
-
const toolNames = ctx.tools.map((t) => t.name).join(", ");
|
|
94
|
-
return (messages, signal, onEvent) => {
|
|
95
|
-
// 两类别全关:零开销直通(不包装 onEvent、不拼接字符串)
|
|
96
|
-
if (!isCategoryEnabled("request") && !isCategoryEnabled("usage")) {
|
|
97
|
-
return produce(messages, signal, onEvent);
|
|
98
|
-
}
|
|
99
|
-
if (isCategoryEnabled("request")) {
|
|
100
|
-
debugLog("request", "llm", `request model=${ctx.model} max_tokens=<未设置> temperature=<未设置> tools=[${toolNames}]`);
|
|
101
|
-
debugLog("request", "llm", `request messages=${JSON.stringify(messages, null, 2)}`); // 完整打印不截断
|
|
102
|
-
}
|
|
103
|
-
// usage 类别开启时才需要包装 onEvent(捕获 finish 事件)
|
|
104
|
-
const loggedOnEvent = isCategoryEnabled("usage") && onEvent
|
|
105
|
-
? (event) => {
|
|
106
|
-
if (event.type === "finish") {
|
|
107
|
-
const u = event.usage;
|
|
108
|
-
debugLog("usage", "llm", `usage prompt_tokens=${u?.prompt_tokens ?? "?"} completion_tokens=${u?.completion_tokens ?? "?"} total=${u?.total_tokens ?? "?"} stop=${event.stopReason}`);
|
|
109
|
-
}
|
|
110
|
-
onEvent(event); // 原样转发(日志先于事件)
|
|
111
|
-
}
|
|
112
|
-
: onEvent;
|
|
113
|
-
return produce(messages, signal, loggedOnEvent);
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
// ============ 聚焦上下文拼装(S7.2 输入形态) ============
|
|
117
|
-
/**
|
|
118
|
-
* 聚焦上下文文本(聚焦层):focus_entity_* → getEntity 查询实体、focus_node_id →
|
|
119
|
-
* 大纲节点查询,拼成结构化文本注入 system 聚焦消息。
|
|
120
|
-
* 查询不到(已软删/不存在/跨项目)→ 跳过该项(不报错)——聚焦缺失不阻断对话,属防御性
|
|
121
|
-
* 正常路径(客户端可能携带过期 focus 发送);两项皆无 → undefined(无聚焦注入)。
|
|
122
|
-
*/
|
|
123
|
-
function buildFocusText(project, context) {
|
|
41
|
+
export function buildFocusText(project, context) {
|
|
124
42
|
const parts = [];
|
|
125
43
|
if (context?.focus_entity_id !== undefined) {
|
|
126
44
|
const row = getEntity(project.db, context.focus_entity_id);
|
|
@@ -136,38 +54,14 @@ function buildFocusText(project, context) {
|
|
|
136
54
|
}
|
|
137
55
|
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
|
138
56
|
}
|
|
139
|
-
// ============
|
|
140
|
-
/**
|
|
141
|
-
function sleepAbortable(ms, signals) {
|
|
142
|
-
return new Promise((resolve) => {
|
|
143
|
-
if (signals.some((s) => s.aborted)) {
|
|
144
|
-
resolve();
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
const onAbort = () => {
|
|
148
|
-
clearTimeout(timer);
|
|
149
|
-
resolve();
|
|
150
|
-
};
|
|
151
|
-
const timer = setTimeout(() => {
|
|
152
|
-
for (const s of signals)
|
|
153
|
-
s.removeEventListener("abort", onAbort);
|
|
154
|
-
resolve();
|
|
155
|
-
}, ms);
|
|
156
|
-
for (const s of signals)
|
|
157
|
-
s.addEventListener("abort", onAbort, { once: true });
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
// ============ [chat] 事件调试日志(配置文件 chat 类别) ============
|
|
161
|
-
/** 调试日志字段摘要长度上限(tool_call args / tool_result 长文本截断,防刷屏) */
|
|
57
|
+
// ============ 调试日志(配置文件 debug 段;关闭时零开销) ============
|
|
58
|
+
/** 调试日志字段摘要长度上限(工具参数/结果长文本截断,防刷屏) */
|
|
162
59
|
const DEBUG_FIELD_MAX = 200;
|
|
163
|
-
/**
|
|
164
|
-
* 调试日志字段摘要:字符串原样、对象 JSON 序列化,超长截断并标注原长。
|
|
165
|
-
* 仅在调试开启时被调用(createChatEventLogger 内部早退),关闭时零开销。
|
|
166
|
-
*/
|
|
60
|
+
/** 调试日志字段摘要:字符串原样、对象 JSON,超长截断并标注原长 */
|
|
167
61
|
function debugSummary(value, max = DEBUG_FIELD_MAX) {
|
|
168
62
|
let text;
|
|
169
63
|
try {
|
|
170
|
-
text = typeof value === "string" ? value : JSON.stringify(value) ?? "undefined";
|
|
64
|
+
text = typeof value === "string" ? value : (JSON.stringify(value) ?? "undefined");
|
|
171
65
|
}
|
|
172
66
|
catch {
|
|
173
67
|
text = String(value);
|
|
@@ -175,52 +69,145 @@ function debugSummary(value, max = DEBUG_FIELD_MAX) {
|
|
|
175
69
|
return text.length > max ? `${text.slice(0, max)}…(${text.length} 字符)` : text;
|
|
176
70
|
}
|
|
177
71
|
/**
|
|
178
|
-
*
|
|
179
|
-
* -
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* - 注:AgentEvent.tool_result (run.ts)不含 ok/isError 字段——工具成败已编码进 result
|
|
185
|
-
* 文本(失败为错误说明),日志只摘要该文本
|
|
72
|
+
* 会话事件调试日志器(配置文件 debug 段;类别全关时零开销早退):
|
|
73
|
+
* - chat:turn_start / 文本与思考增量(只打长度)/ 工具执行起止(参数与结果摘要)/ 压缩 / 重试 / 本轮结束
|
|
74
|
+
* - request:**每次模型请求前一次**(即 user / toolResult 消息落定之时——这两种消息之后紧跟一次模型请求)
|
|
75
|
+
* 打印模型名 + 工具名 + **完整 messages JSON(不截断——用户核心诉求「最终组装的 prompt」)**
|
|
76
|
+
* - usage:assistant 消息的真实 usage;工具结果被截断时的 TOOL_RESULT_TOO_LARGE 记录
|
|
77
|
+
* 注:旧 `stream` 类别(chatStream debugStream)随自研 LLM 层退场,见报告。
|
|
186
78
|
*/
|
|
187
|
-
export function
|
|
188
|
-
let round = 0; // 轮次计数器(turn_start 刷新;done 事件附带——AgentEvent.done 无轮次字段)
|
|
79
|
+
export function createChatDebugLogger(runtime) {
|
|
189
80
|
return (event) => {
|
|
190
|
-
|
|
191
|
-
|
|
81
|
+
const chatOn = isCategoryEnabled("chat");
|
|
82
|
+
const requestOn = isCategoryEnabled("request");
|
|
83
|
+
const usageOn = isCategoryEnabled("usage");
|
|
84
|
+
if (!chatOn && !requestOn && !usageOn)
|
|
85
|
+
return;
|
|
192
86
|
switch (event.type) {
|
|
193
|
-
case "turn_start":
|
|
194
|
-
|
|
195
|
-
|
|
87
|
+
case "turn_start": {
|
|
88
|
+
if (chatOn)
|
|
89
|
+
debugLog("chat", "chat", "turn_start");
|
|
196
90
|
return;
|
|
197
|
-
|
|
198
|
-
|
|
91
|
+
}
|
|
92
|
+
case "message_update": {
|
|
93
|
+
if (!chatOn)
|
|
94
|
+
return;
|
|
95
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
96
|
+
if (assistantEvent?.type === "text_delta" && typeof assistantEvent.delta === "string") {
|
|
97
|
+
debugLog("chat", "chat", `text delta=+${assistantEvent.delta.length} 字符`);
|
|
98
|
+
}
|
|
99
|
+
else if (assistantEvent?.type === "thinking_delta" && typeof assistantEvent.delta === "string") {
|
|
100
|
+
debugLog("chat", "chat", `thinking delta=+${assistantEvent.delta.length} 字符`);
|
|
101
|
+
}
|
|
199
102
|
return;
|
|
200
|
-
|
|
201
|
-
|
|
103
|
+
}
|
|
104
|
+
case "message_end": {
|
|
105
|
+
const message = event.message;
|
|
106
|
+
// 模型请求前的最后一个可观测点:user / toolResult 消息落定后紧跟一次请求
|
|
107
|
+
if (requestOn && (message?.role === "user" || message?.role === "toolResult")) {
|
|
108
|
+
const model = runtime.session.model;
|
|
109
|
+
debugLog("request", "llm", `request model=${model?.id ?? "?"} tools=[${runtime.session.getActiveToolNames().join(", ")}]`);
|
|
110
|
+
// 完整 messages(不截断):模型实际看到的历史 + 即将发出的那一轮输入
|
|
111
|
+
debugLog("request", "llm", `request messages=${JSON.stringify(runtime.session.messages, null, 2)}`);
|
|
112
|
+
}
|
|
113
|
+
if (!usageOn || message?.role !== "assistant" || message.usage === undefined)
|
|
114
|
+
return;
|
|
115
|
+
debugLog("usage", "llm", `usage input_tokens=${message.usage.input ?? "?"} output_tokens=${message.usage.output ?? "?"} total=${message.usage.totalTokens ?? "?"} stop=${message.stopReason ?? "?"}`);
|
|
202
116
|
return;
|
|
203
|
-
|
|
204
|
-
|
|
117
|
+
}
|
|
118
|
+
case "tool_execution_start": {
|
|
119
|
+
if (!chatOn)
|
|
120
|
+
return;
|
|
121
|
+
debugLog("chat", "chat", `tool_call tool=${String(event.toolName)} id=${String(event.toolCallId)} args=${debugSummary(event.args)}`);
|
|
205
122
|
return;
|
|
206
|
-
|
|
207
|
-
|
|
123
|
+
}
|
|
124
|
+
case "tool_execution_end": {
|
|
125
|
+
const result = event.result;
|
|
126
|
+
// 全量序列化(截断标记在尾部,不能拿截断后的摘要去做判定)
|
|
127
|
+
let fullText;
|
|
128
|
+
try {
|
|
129
|
+
fullText = JSON.stringify(result?.content ?? result ?? "") ?? "";
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
fullText = String(result?.content ?? "");
|
|
133
|
+
}
|
|
134
|
+
if (chatOn) {
|
|
135
|
+
debugLog("chat", "chat", `tool_result tool=${String(event.toolName)} id=${String(event.toolCallId)} isError=${String(event.isError)} result=${debugSummary(fullText)}`);
|
|
136
|
+
}
|
|
137
|
+
if (usageOn && fullText.includes(TOOL_RESULT_TRUNCATION_MARKER)) {
|
|
138
|
+
debugLog("usage", "agent", `TOOL_RESULT_TOO_LARGE 工具结果截断 tool=${String(event.toolName)}(数据不完整,模型已收到提示)`);
|
|
139
|
+
}
|
|
208
140
|
return;
|
|
209
|
-
|
|
210
|
-
|
|
141
|
+
}
|
|
142
|
+
case "compaction_start":
|
|
143
|
+
if (chatOn)
|
|
144
|
+
debugLog("chat", "chat", `compaction_start reason=${String(event.reason)}`);
|
|
211
145
|
return;
|
|
212
|
-
case "
|
|
213
|
-
|
|
146
|
+
case "compaction_end":
|
|
147
|
+
if (chatOn) {
|
|
148
|
+
debugLog("chat", "chat", `compaction_end reason=${String(event.reason)} aborted=${String(event.aborted)} willRetry=${String(event.willRetry)}`);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
case "auto_retry_start":
|
|
152
|
+
if (chatOn) {
|
|
153
|
+
debugLog("chat", "chat", `auto_retry_start attempt=${String(event.attempt)}/${String(event.maxAttempts)} delayMs=${String(event.delayMs)}`);
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
case "auto_retry_end":
|
|
157
|
+
if (chatOn)
|
|
158
|
+
debugLog("chat", "chat", `auto_retry_end success=${String(event.success)}`);
|
|
159
|
+
return;
|
|
160
|
+
case "agent_end": {
|
|
161
|
+
if (!chatOn)
|
|
162
|
+
return;
|
|
163
|
+
const messages = Array.isArray(event.messages) ? event.messages : [];
|
|
164
|
+
const last = [...messages].reverse().find((m) => m.role === "assistant");
|
|
165
|
+
if (last?.stopReason === "error" || last?.stopReason === "aborted") {
|
|
166
|
+
debugLog("chat", "chat", `error stopReason=${last.stopReason} message=${last.errorMessage ?? ""}`);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
debugLog("chat", "chat", "done");
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
default:
|
|
214
174
|
return;
|
|
215
175
|
}
|
|
216
176
|
};
|
|
217
177
|
}
|
|
218
|
-
// ============
|
|
178
|
+
// ============ 内部辅助 ============
|
|
179
|
+
/** 可中止 sleep:任一 signal 触发即提前返回(心跳停止 / 断连即时唤醒,不空等满间隔) */
|
|
180
|
+
function sleepAbortable(ms, signals) {
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
if (signals.some((s) => s.aborted)) {
|
|
183
|
+
resolve();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const onAbort = () => {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
resolve();
|
|
189
|
+
};
|
|
190
|
+
const timer = setTimeout(() => {
|
|
191
|
+
for (const s of signals)
|
|
192
|
+
s.removeEventListener("abort", onAbort);
|
|
193
|
+
resolve();
|
|
194
|
+
}, ms);
|
|
195
|
+
for (const s of signals)
|
|
196
|
+
s.addEventListener("abort", onAbort, { once: true });
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/** 占用条字段(与事件投影同口径:`AgentSession.getContextUsage()`) */
|
|
200
|
+
function contextUsageField(runtime) {
|
|
201
|
+
const usage = runtime.session.getContextUsage();
|
|
202
|
+
if (usage === undefined)
|
|
203
|
+
return {};
|
|
204
|
+
return { contextUsage: { percent: usage.percent, tokens: usage.tokens, contextWindow: usage.contextWindow } };
|
|
205
|
+
}
|
|
206
|
+
// ============ POST /api/v1/chat(POST + SSE) ============
|
|
219
207
|
/**
|
|
220
208
|
* POST /chat 处理器工厂(测试经 createChatRoutes 注入 deps)。
|
|
221
|
-
* 流程:项目/请求校验(JSON 错误,不开流)→
|
|
222
|
-
* →
|
|
223
|
-
* onEvent 转 SSE 帧、onMessages 落库)→ 取消作废提案(B2 取舍见下)→ 关流。
|
|
209
|
+
* 流程:项目/请求校验(JSON 错误,不开流)→ 会话解析(磁盘发现或新建)→ 运行时装配 + 凭据预检
|
|
210
|
+
* → 开流:首帧 session + 事件投影帧 + 心跳 → `session.prompt()` → 收尾(取消则作废提案)。
|
|
224
211
|
*/
|
|
225
212
|
export function chatSendHandler(deps = {}) {
|
|
226
213
|
return async (c) => {
|
|
@@ -228,255 +215,285 @@ export function chatSendHandler(deps = {}) {
|
|
|
228
215
|
// ---- 请求校验(开流之前:失败走统一 JSON 错误,非 SSE) ----
|
|
229
216
|
const raw = await c.req.json().catch(() => null); // 空 body / 非法 JSON → 校验失败
|
|
230
217
|
const parsed = chatSendReqSchema.safeParse(raw);
|
|
231
|
-
if (!parsed.success)
|
|
218
|
+
if (!parsed.success)
|
|
232
219
|
throw parsed.error; // → app.onError → 400 VALIDATION_ERROR(含 fields)
|
|
233
|
-
}
|
|
234
220
|
const { message, session_id, context } = parsed.data;
|
|
235
|
-
// ----
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const envKey = deps.apiKey ?? effectiveApiKey(provider).key;
|
|
239
|
-
if (envKey === null && deps.produce === undefined) {
|
|
240
|
-
const envVar = providerEnvVar(provider);
|
|
241
|
-
throw new HttpError(400, "LLM_API_KEY_MISSING", `未配置 ${providerDisplayName(provider)} API key:请设置环境变量 ${envVar ?? provider} 或在设置页配置`);
|
|
221
|
+
// ---- 单项目单在途流:判定与占位相邻且中间无 await(不留竞态窗口) ----
|
|
222
|
+
if (hasActiveChat(project.root)) {
|
|
223
|
+
throw new HttpError(409, "CHAT_BUSY", "当前项目已有在途对话流,请等待本轮结束或停止生成后再发送");
|
|
242
224
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
// ---- 3. 用户消息落库(assistant/tool 消息由 onMessages 落库) ----
|
|
262
|
-
insertChatMessage(project.db, {
|
|
263
|
-
session_id: sessionId,
|
|
264
|
-
project_id: project.config.id,
|
|
265
|
-
role: "user",
|
|
266
|
-
content: message,
|
|
267
|
-
created_at: now(),
|
|
268
|
-
});
|
|
269
|
-
// ---- 4. 取消信号 + 三路断开检测 ----
|
|
270
|
-
// controller.signal 即 runAgent 的 signal(全链路第 0 层——四层穿透的
|
|
271
|
-
// fetch/读循环/工具执行/重试 sleep 已由 llm/tools/agent 各层承担,本卡链路总装)
|
|
272
|
-
const controller = new AbortController();
|
|
273
|
-
const cancel = () => {
|
|
274
|
-
if (!controller.signal.aborted)
|
|
275
|
-
controller.abort();
|
|
276
|
-
};
|
|
277
|
-
// ① stream.onAbort:客户端断开 → 响应流 cancel → Hono StreamingApi.abort → 本回调
|
|
278
|
-
stream.onAbort(cancel);
|
|
279
|
-
// ② c.req.raw 的 close/error 监听:@hono/node-server v2 的 c.req.raw 是标准 Request
|
|
280
|
-
// (无 close 事件,signal 随请求中止触发)——两种形态按能力探测都挂上,双保险
|
|
281
|
-
c.req.raw.signal.addEventListener("abort", cancel, { once: true });
|
|
282
|
-
const rawReq = c.req.raw;
|
|
283
|
-
if (typeof rawReq.on === "function") {
|
|
284
|
-
rawReq.on("close", cancel);
|
|
285
|
-
rawReq.on("error", cancel);
|
|
225
|
+
const chat = beginActiveChat(project.root);
|
|
226
|
+
let released = false;
|
|
227
|
+
const release = () => {
|
|
228
|
+
if (released)
|
|
229
|
+
return;
|
|
230
|
+
released = true;
|
|
231
|
+
chat.release();
|
|
232
|
+
};
|
|
233
|
+
try {
|
|
234
|
+
const target = { root: project.root, projectId: project.config.id, db: project.db };
|
|
235
|
+
// ---- 目标会话:session_id 只经磁盘发现映射(未命中 → 404) ----
|
|
236
|
+
let sessionFile;
|
|
237
|
+
if (session_id !== undefined) {
|
|
238
|
+
const info = await findProjectSession(target.root, session_id);
|
|
239
|
+
if (info === null) {
|
|
240
|
+
throw new HttpError(404, "SESSION_NOT_FOUND", `会话不存在: ${session_id}(客户端应改为新会话重试)`);
|
|
241
|
+
}
|
|
242
|
+
sessionFile = info.path;
|
|
286
243
|
}
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
await stream.writeSSE({ event, data: JSON.stringify(data) });
|
|
244
|
+
// ---- 运行时装配(模型/凭据/会话/工具都由 pi 提供) + 凭据预检(开流前 JSON 错误) ----
|
|
245
|
+
let runtime;
|
|
246
|
+
try {
|
|
247
|
+
runtime = await acquireProjectRuntime(target, sessionFile, deps.runtimeFactory);
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
if (err instanceof NoModelConfiguredError) {
|
|
251
|
+
throw new HttpError(400, "LLM_API_KEY_MISSING", err.message);
|
|
296
252
|
}
|
|
297
|
-
|
|
298
|
-
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
const model = runtime.session.model;
|
|
256
|
+
if (model === undefined) {
|
|
257
|
+
throw new HttpError(400, "LLM_API_KEY_MISSING", "未配置可用模型:请先在设置页选择模型");
|
|
258
|
+
}
|
|
259
|
+
if (!runtime.modelRuntime.hasConfiguredAuth(model.provider)) {
|
|
260
|
+
throw new HttpError(400, "LLM_API_KEY_MISSING", `未配置 ${model.provider} 的凭据:请在设置页填写 API key,或设置对应环境变量`);
|
|
261
|
+
}
|
|
262
|
+
const sessionId = runtime.session.sessionId;
|
|
263
|
+
const store = deps.store ?? defaultProposalStore;
|
|
264
|
+
// 聚焦上下文注入为本轮用户消息的一部分(system 提示词只有内核提示词 + 项目 AGENTS.md 两层)
|
|
265
|
+
const focusText = buildFocusText(project, context);
|
|
266
|
+
const promptText = focusText === undefined ? message : `${FOCUS_TITLE}\n${focusText}\n\n${message}`;
|
|
267
|
+
const logEvent = createChatDebugLogger(runtime);
|
|
268
|
+
// 项目切换/关闭时由 chat-runtime 触发中止(占位已在,中止回调就绪即可被叫停)
|
|
269
|
+
chat.setAbort(() => {
|
|
270
|
+
void runtime.session.abort();
|
|
271
|
+
});
|
|
272
|
+
return streamSSE(c, async (stream) => {
|
|
273
|
+
// ---- 取消信号 + 三路断开检测 ----
|
|
274
|
+
const controller = new AbortController();
|
|
275
|
+
const cancel = () => {
|
|
276
|
+
if (!controller.signal.aborted)
|
|
277
|
+
controller.abort();
|
|
278
|
+
void runtime.session.abort(); // pi 侧中止:模型请求 / 工具执行 / 重试退避
|
|
279
|
+
};
|
|
280
|
+
// ① stream.onAbort:客户端断开 → 响应流 cancel → Hono StreamingApi.abort
|
|
281
|
+
stream.onAbort(cancel);
|
|
282
|
+
// ② 请求 signal(@hono/node-server v2 的 c.req.raw 是标准 Request);老形态的 close/error 也挂
|
|
283
|
+
c.req.raw.signal.addEventListener("abort", cancel, { once: true });
|
|
284
|
+
const rawReq = c.req.raw;
|
|
285
|
+
if (typeof rawReq.on === "function") {
|
|
286
|
+
rawReq.on("close", cancel);
|
|
287
|
+
rawReq.on("error", cancel);
|
|
299
288
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const heartbeatStop = new AbortController();
|
|
303
|
-
const heartbeat = (async () => {
|
|
304
|
-
const { minMs, maxMs } = deps.heartbeat ?? DEFAULT_HEARTBEAT_MS;
|
|
305
|
-
while (!heartbeatStop.signal.aborted) {
|
|
306
|
-
const ms = minMs + Math.floor(Math.random() * (maxMs - minMs + 1));
|
|
307
|
-
await sleepAbortable(ms, [heartbeatStop.signal, controller.signal]);
|
|
308
|
-
if (heartbeatStop.signal.aborted || controller.signal.aborted)
|
|
309
|
-
return;
|
|
310
|
-
await stream.writeSSE({ event: "ping", data: "{}" });
|
|
311
|
-
// ③ 写后复查:客户端断开时 onAbort 已置位 controller(见第 4 步注释)
|
|
289
|
+
// ---- 帧写入器(断连后不再写;写失败即视为断开) ----
|
|
290
|
+
const writeFrame = async (frame) => {
|
|
312
291
|
if (controller.signal.aborted)
|
|
313
292
|
return;
|
|
293
|
+
try {
|
|
294
|
+
await stream.writeSSE({ event: frame.event, data: JSON.stringify(frame.data) });
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
cancel(); // writeSSE 理论上吞错,防御路径
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
// ---- 心跳协程(15-30s 随机 ping;停止/断连即时唤醒退出)----
|
|
301
|
+
const heartbeatStop = new AbortController();
|
|
302
|
+
const heartbeat = (async () => {
|
|
303
|
+
const { minMs, maxMs } = deps.heartbeat ?? DEFAULT_HEARTBEAT_MS;
|
|
304
|
+
while (!heartbeatStop.signal.aborted) {
|
|
305
|
+
const ms = minMs + Math.floor(Math.random() * (maxMs - minMs + 1));
|
|
306
|
+
await sleepAbortable(ms, [heartbeatStop.signal, controller.signal]);
|
|
307
|
+
if (heartbeatStop.signal.aborted || controller.signal.aborted)
|
|
308
|
+
return;
|
|
309
|
+
await writeFrame(createPingFrame());
|
|
310
|
+
// ③ 心跳写后复查:onAbort 已置位 controller(写失败检测的最后一道防线)
|
|
311
|
+
if (controller.signal.aborted)
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
})();
|
|
315
|
+
let entered = false;
|
|
316
|
+
let unsubscribe;
|
|
317
|
+
try {
|
|
318
|
+
// ---- 首帧:会话(客户端据此持久化「当前会话」)----
|
|
319
|
+
await writeFrame(createSessionFrame(sessionId));
|
|
320
|
+
// ---- 事件订阅:pi 会话事件 → SSE 帧(投影唯一实现在 agent 包)----
|
|
321
|
+
enterInFlightSession(sessionId);
|
|
322
|
+
entered = true;
|
|
323
|
+
unsubscribe = runtime.session.subscribe((event) => {
|
|
324
|
+
logEvent(event);
|
|
325
|
+
const frame = toSseFrame(event, { getContextUsage: () => runtime.session.getContextUsage() });
|
|
326
|
+
if (frame === null)
|
|
327
|
+
return;
|
|
328
|
+
void writeFrame(frame); // 排入 writer(FIFO 保证顺序),不阻塞循环
|
|
329
|
+
});
|
|
330
|
+
await runtime.session.prompt(promptText);
|
|
331
|
+
await runtime.session.waitForIdle();
|
|
314
332
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
case "turn_start":
|
|
324
|
-
return; // 循环内部事件:不映射 SSE 帧(日志/调试用)
|
|
325
|
-
case "text":
|
|
326
|
-
void writeEvent("text", { delta: event.delta });
|
|
327
|
-
return;
|
|
328
|
-
case "tool_call":
|
|
329
|
-
void writeEvent("tool_call", { tool: event.tool, args: event.args, id: event.id });
|
|
330
|
-
return;
|
|
331
|
-
case "tool_result":
|
|
332
|
-
void writeEvent("tool_result", { tool: event.tool, result: event.result, id: event.id });
|
|
333
|
-
return;
|
|
334
|
-
case "proposal":
|
|
335
|
-
//:proposal 在对应 tool_result 之后、循环继续之前(runAgent 保证)
|
|
336
|
-
void writeEvent("proposal", {
|
|
337
|
-
proposal_id: event.proposal.proposal_id,
|
|
338
|
-
type: event.proposal.type,
|
|
339
|
-
preview: event.proposal.preview,
|
|
340
|
-
});
|
|
341
|
-
return;
|
|
342
|
-
case "done":
|
|
343
|
-
// 需求 3:附带本轮真实 usage,前端计算上下文占用
|
|
344
|
-
void writeEvent("done", { session_id: event.sessionId, ...(lastUsage !== null ? { usage: lastUsage } : {}) });
|
|
345
|
-
return;
|
|
346
|
-
case "error":
|
|
347
|
-
// 用户取消/断开(aborted=true):客户端已不可达,不写 error 帧(写了也失败)
|
|
348
|
-
if (!event.aborted)
|
|
349
|
-
void writeEvent("error", { code: event.code, message: event.message });
|
|
350
|
-
return;
|
|
333
|
+
catch (err) {
|
|
334
|
+
// 开流后的失败(如 prompt 前置校验):事件表没有独立 error 帧——以 agent_end 表达终止
|
|
335
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
336
|
+
debugLog("chat", "chat", `prompt 失败:${message}`);
|
|
337
|
+
await writeFrame({
|
|
338
|
+
event: "agent_end",
|
|
339
|
+
data: { stopReason: "error", errorMessage: message, ...contextUsageField(runtime) },
|
|
340
|
+
});
|
|
351
341
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
342
|
+
finally {
|
|
343
|
+
unsubscribe?.();
|
|
344
|
+
// 注销在途登记紧跟本轮结束(其后还有 heartbeat/close 两个 await):
|
|
345
|
+
// 客户端读到 agent_end 立刻删会话不能落进 SESSION_BUSY 窗口
|
|
346
|
+
if (entered)
|
|
347
|
+
leaveInFlightSession(sessionId);
|
|
348
|
+
release();
|
|
349
|
+
heartbeatStop.abort(); // sleep 即时唤醒
|
|
350
|
+
await heartbeat;
|
|
351
|
+
if (controller.signal.aborted)
|
|
352
|
+
store.clear(); // 未确认提案随取消作废
|
|
353
|
+
await stream.close();
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
// 防御路径 onError:cb 内部异常(agent 层错误已由事件表达)——记日志 + 尽力通知
|
|
357
|
+
async (err, stream) => {
|
|
358
|
+
console.error("[server] chat SSE 流异常:", err);
|
|
359
|
+
try {
|
|
360
|
+
await stream.writeSSE({
|
|
361
|
+
event: "agent_end",
|
|
362
|
+
data: JSON.stringify({
|
|
363
|
+
stopReason: "error",
|
|
364
|
+
errorMessage: err instanceof Error ? err.message : "SSE 流内部错误",
|
|
365
|
+
}),
|
|
367
366
|
});
|
|
368
367
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
// debugStream 按 stream 类别显式传入(false 也传——压过 env,保证类别隔离语义)
|
|
373
|
-
const produce = deps.produce ??
|
|
374
|
-
createLLMRequestLogger(createRealProduce(apiKey, provider, model, tools, isCategoryEnabled("stream"), thinking), {
|
|
375
|
-
model,
|
|
376
|
-
tools,
|
|
377
|
-
});
|
|
378
|
-
// S7.4 真实现:ToolContext { db, outlineDir, projectId } + 同仓提案(S7.5 消费)
|
|
379
|
-
const dispatcher = deps.dispatcher ?? createToolDispatcher({ db: project.db, outlineDir: project.root, projectId: project.config.id }, { store });
|
|
380
|
-
// ---- 9.5 上下文占用数据(需求 3):包裹 produce 捕获流内 finish 事件的真实 usage,
|
|
381
|
-
// 在 done SSE 帧附带——前端据此计算上下文占用(usage.total / 模型 contextWindow) ----
|
|
382
|
-
let lastUsage = null;
|
|
383
|
-
const produceWithUsage = async (messages, signal, onEvent) => {
|
|
384
|
-
const tee = (e) => {
|
|
385
|
-
if (e?.type === "finish")
|
|
386
|
-
lastUsage = e.usage;
|
|
387
|
-
onEvent?.(e);
|
|
388
|
-
};
|
|
389
|
-
return produce(messages, signal, tee);
|
|
390
|
-
};
|
|
391
|
-
const result = await runAgent({
|
|
392
|
-
sessionId,
|
|
393
|
-
userMessage: message, // runAgent 追加进会话(传入 session 不应已含本轮消息)
|
|
394
|
-
session,
|
|
395
|
-
focus,
|
|
396
|
-
// 项目规则唯一事实源 = 项目目录 (取代 project.config.prompt)——
|
|
397
|
-
// 每次对话实时读文件(外部编辑立即可见);文件不存在 → undefined(「## 项目设定」段跳过)
|
|
398
|
-
projectPrompt: readAgentsFile(project.root) ?? undefined,
|
|
399
|
-
deps: { produce: produceWithUsage, dispatcher, onEvent, onMessages },
|
|
400
|
-
signal: controller.signal, // 断开即取消(abort 永不重试)
|
|
368
|
+
catch {
|
|
369
|
+
// 客户端已断开:放弃通知
|
|
370
|
+
}
|
|
401
371
|
});
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
// 作废)记录为扩展点:多会话并发(backlog 多标签页)时按选项 a 演进——Proposal 增
|
|
409
|
-
// session_id 字段、ProposalStore 增 clearBySession(sessionId),调度时由 S7.6 透传会话。
|
|
410
|
-
if (result.aborted) {
|
|
411
|
-
store.clear();
|
|
412
|
-
}
|
|
413
|
-
// ---- 11. 停心跳 + 关流(error 后流立即关闭;done 自然结束——streamSSE run 的
|
|
414
|
-
// finally 亦兜底 close,此处显式确保顺序:帧排入 writer 后 close,FIFO 保证送达) ----
|
|
415
|
-
heartbeatStop.abort(); // sleep 即时唤醒(sleepAbortable 监听该 signal)
|
|
416
|
-
await heartbeat;
|
|
417
|
-
await stream.close();
|
|
418
|
-
},
|
|
419
|
-
// 防御路径 onError:cb 内部异常(正常流程 error 事件已由 runAgent 发出,理论不可达)——
|
|
420
|
-
// 转形态 JSON error 帧(客户端 use-sse 收到 error 即终止解析)
|
|
421
|
-
async (err, stream) => {
|
|
422
|
-
console.error("[server] chat SSE 流异常:", err);
|
|
423
|
-
try {
|
|
424
|
-
await stream.writeSSE({
|
|
425
|
-
event: "error",
|
|
426
|
-
data: JSON.stringify({
|
|
427
|
-
code: "INTERNAL_ERROR",
|
|
428
|
-
message: err instanceof Error ? err.message : "SSE 流内部错误",
|
|
429
|
-
}),
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
catch {
|
|
433
|
-
// 客户端已断开:放弃通知
|
|
434
|
-
}
|
|
435
|
-
});
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
// 开流前的任何失败(校验/会话解析/装配)都要把在途占位还回去
|
|
375
|
+
release();
|
|
376
|
+
throw err;
|
|
377
|
+
}
|
|
436
378
|
};
|
|
437
379
|
}
|
|
380
|
+
// ============ 会话端点辅助 ============
|
|
381
|
+
/** 会话末条可见文本(列表摘要用)+ 50 字截断 */
|
|
382
|
+
async function lastVisibleText(target, sessionId) {
|
|
383
|
+
const opened = await readProjectSession(target.root, sessionId);
|
|
384
|
+
return opened === null ? "" : lastVisibleSessionText(opened.entries);
|
|
385
|
+
}
|
|
438
386
|
// ============ 路由装配 ============
|
|
439
387
|
/**
|
|
440
|
-
* 创建对话路由(
|
|
441
|
-
* 独立实例挂载测试 app,避免真实 DeepSeek 调用;index.ts 挂载默认实例)。
|
|
388
|
+
* 创建对话路由(index.ts 挂载默认实例;测试注入 deps 构造独立实例)。
|
|
442
389
|
*/
|
|
443
390
|
export function createChatRoutes(deps = {}) {
|
|
444
391
|
const routes = new Hono();
|
|
445
|
-
|
|
392
|
+
// GET /api/v1/chat/sessions —— 会话列表(按最后活动倒序)
|
|
393
|
+
routes.get("/sessions", async (c) => {
|
|
446
394
|
const project = requireCurrentProject();
|
|
447
|
-
|
|
395
|
+
const target = { root: project.root, projectId: project.config.id, db: project.db };
|
|
396
|
+
const sessions = await listProjectSessions(target.root);
|
|
397
|
+
const items = await Promise.all(sessions.map(async (info) => ({
|
|
398
|
+
id: info.id,
|
|
399
|
+
lastMessage: truncate(await lastVisibleText(target, info.id), SESSION_LAST_MESSAGE_MAX_LEN),
|
|
400
|
+
messageCount: info.messageCount,
|
|
401
|
+
createdAt: info.created.toISOString(),
|
|
402
|
+
updatedAt: info.modified.toISOString(),
|
|
403
|
+
})));
|
|
404
|
+
return sessionsResponse(c, items);
|
|
448
405
|
});
|
|
449
|
-
|
|
406
|
+
// GET /api/v1/chat/sessions/:id/messages —— 消息历史(含思维链预览)
|
|
407
|
+
routes.get("/sessions/:id/messages", async (c) => {
|
|
450
408
|
const project = requireCurrentProject();
|
|
451
409
|
const sessionId = c.req.param("id");
|
|
452
|
-
|
|
410
|
+
const opened = await readProjectSession(project.root, sessionId);
|
|
411
|
+
if (opened === null) {
|
|
412
|
+
throw new HttpError(404, "SESSION_NOT_FOUND", `会话不存在: ${sessionId}`);
|
|
413
|
+
}
|
|
414
|
+
return messagesResponse(c, sessionId, projectSessionMessages(opened.entries));
|
|
453
415
|
});
|
|
454
|
-
//
|
|
416
|
+
// GET /api/v1/chat/sessions/:id/messages/:messageId/thinking —— 思维链全文(按块按需拉取)
|
|
417
|
+
routes.get("/sessions/:id/messages/:messageId/thinking", async (c) => {
|
|
418
|
+
const project = requireCurrentProject();
|
|
419
|
+
const sessionId = c.req.param("id");
|
|
420
|
+
const messageId = c.req.param("messageId");
|
|
421
|
+
const blockIndexParam = c.req.query("blockIndex");
|
|
422
|
+
const blockIndex = blockIndexParam === undefined ? Number.NaN : Number(blockIndexParam);
|
|
423
|
+
if (!Number.isSafeInteger(blockIndex) || blockIndex < 0) {
|
|
424
|
+
throw new HttpError(400, "VALIDATION_ERROR", "blockIndex 必填且为非负整数");
|
|
425
|
+
}
|
|
426
|
+
const opened = await readProjectSession(project.root, sessionId);
|
|
427
|
+
if (opened === null) {
|
|
428
|
+
throw new HttpError(404, "SESSION_NOT_FOUND", `会话不存在: ${sessionId}`);
|
|
429
|
+
}
|
|
430
|
+
const thinking = readSessionThinking(opened.entries, messageId, blockIndex);
|
|
431
|
+
if (thinking === null) {
|
|
432
|
+
throw new HttpError(404, "THINKING_NOT_FOUND", `该消息没有下标为 ${blockIndex} 的思维链块`);
|
|
433
|
+
}
|
|
434
|
+
return thinkingResponse(c, thinking);
|
|
435
|
+
});
|
|
436
|
+
// DELETE /api/v1/chat/sessions/:id —— 物理删除会话(无回收站;前端二次确认)
|
|
437
|
+
// 校验顺序:在途流(防流把文件原地重建)→ 文件存在性(磁盘发现未命中即 404)
|
|
438
|
+
routes.delete("/sessions/:id", async (c) => {
|
|
439
|
+
const project = requireCurrentProject();
|
|
440
|
+
const sessionId = c.req.param("id");
|
|
441
|
+
if (isSessionInFlight(sessionId)) {
|
|
442
|
+
throw new HttpError(409, "SESSION_BUSY", `会话有在途对话流,请先停止生成再删除: ${sessionId}`);
|
|
443
|
+
}
|
|
444
|
+
const info = await findProjectSession(project.root, sessionId);
|
|
445
|
+
if (info === null) {
|
|
446
|
+
throw new HttpError(404, "SESSION_NOT_FOUND", `会话不存在: ${sessionId}`);
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
unlinkSync(info.path);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// 发现与删除之间被外部删除:同样是「不存在」
|
|
453
|
+
throw new HttpError(404, "SESSION_NOT_FOUND", `会话不存在: ${sessionId}`);
|
|
454
|
+
}
|
|
455
|
+
return sessionDeleteResponse(c);
|
|
456
|
+
});
|
|
457
|
+
// POST /api/v1/chat —— POST + SSE 对话端点
|
|
455
458
|
routes.post("/", chatSendHandler(deps));
|
|
456
459
|
return routes;
|
|
457
460
|
}
|
|
458
461
|
/** 默认对话路由实例(index.ts 挂载于 /api/v1/chat) */
|
|
459
462
|
export const chatRoutes = createChatRoutes();
|
|
460
463
|
/**
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
* 不让 ZodError 冒泡——否则 errorHandler 会按入参语义误报 400 VALIDATION_ERROR。
|
|
464
|
+
* 响应自检出口(参照 project.ts listResponse):parse 失败 = 服务端构造的响应不符合 shared 契约,
|
|
465
|
+
* 转 500 INTERNAL_ERROR——不让 ZodError 冒泡(否则 errorHandler 按入参语义误报 400)。
|
|
464
466
|
*/
|
|
465
467
|
function sessionsResponse(c, sessions) {
|
|
466
468
|
try {
|
|
467
469
|
return c.json(ok(chatSessionsResSchema.parse({ sessions })));
|
|
468
470
|
}
|
|
469
471
|
catch (err) {
|
|
470
|
-
throw new HttpError(500, "INTERNAL_ERROR", `sessions
|
|
472
|
+
throw new HttpError(500, "INTERNAL_ERROR", `sessions 响应不符合契约: ${err instanceof Error ? err.message : String(err)}`);
|
|
471
473
|
}
|
|
472
474
|
}
|
|
473
|
-
/** messages 响应自检出口(同上;db 消息含 sessionId/projectId 附加字段,parse 按剥离) */
|
|
474
475
|
function messagesResponse(c, sessionId, messages) {
|
|
475
476
|
try {
|
|
476
477
|
return c.json(ok(chatMessagesResSchema.parse({ sessionId, messages })));
|
|
477
478
|
}
|
|
478
479
|
catch (err) {
|
|
479
|
-
throw new HttpError(500, "INTERNAL_ERROR", `messages
|
|
480
|
+
throw new HttpError(500, "INTERNAL_ERROR", `messages 响应不符合契约: ${err instanceof Error ? err.message : String(err)}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
function thinkingResponse(c, thinking) {
|
|
484
|
+
try {
|
|
485
|
+
return c.json(ok(chatThinkingResSchema.parse({ thinking })));
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
throw new HttpError(500, "INTERNAL_ERROR", `thinking 响应不符合契约: ${err instanceof Error ? err.message : String(err)}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function sessionDeleteResponse(c) {
|
|
492
|
+
try {
|
|
493
|
+
return c.json(ok(chatSessionDeleteResSchema.parse({ deleted: true })));
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
throw new HttpError(500, "INTERNAL_ERROR", `删除会话响应不符合契约: ${err instanceof Error ? err.message : String(err)}`);
|
|
480
497
|
}
|
|
481
498
|
}
|
|
482
499
|
//# sourceMappingURL=chat.js.map
|