This commit is contained in:
2026-07-30 08:42:55 +08:00
parent 48b7391365
commit 1867649527
19 changed files with 1877 additions and 486 deletions

View File

@ -5,17 +5,21 @@
## 已实现 ## 已实现
- 单会话聊天、SQLite 持久化与首轮 AI 主题标题 - 单会话聊天、SQLite 持久化与首轮 AI 主题标题
- 首屏由 Server Component 直接读取 SQLite客户端 hydration 前即可显示工作台
- 标题编辑、会话重置(重置后标题清空) - 标题编辑、会话重置(重置后标题清空)
- DeepSeek V4 Flash 流式回答 - DeepSeek V4 Flash 流式回答
- Skill 多选、拖拽排序、标签移除与 `/` 快速提示 - Skill 多选、拖拽排序、标签移除与 `/` 快速提示
- 每轮由 AI 独立判断实际使用哪些已选 Skill - 勾选集合绑定当前需求生命周期,直到需求完成或明确放弃
- 每轮由 AI 独立判断实际使用哪些已勾选 Skill
- 候选 Skill 与实际使用 Skill 的差异化高亮反馈 - 候选 Skill 与实际使用 Skill 的差异化高亮反馈
- 回答中止;中止作为本轮终态处理,并清空本轮 Skill - Skill 编辑自动递增版本;进行中的需求固定使用开始时的完整快照
- 回答中止采用 SQLite 原子终态;只结束 Run不清空需求的 Skill
- SSE 事件持久化、序号去重与断线续传 - SSE 事件持久化、序号去重与断线续传
- Skill 新建、AI 对话编辑、删除与列表自动刷新 - Skill 新建、AI 对话编辑、删除与列表自动刷新
- 五节点严格顺序:触发条件、输入参数、执行步骤、输出格式、约束与测试 - 五节点严格顺序:触发条件、输入参数、执行步骤、输出格式、约束与测试
- 已完成节点可回改,未解锁节点不可跳过 - 已完成节点可回改,未解锁节点不可跳过
- 右侧节点内容完全只读,只能通过 AI 对话修改 - 右侧节点内容完全只读,只能通过 AI 对话修改
- AI 先在聊天中展示待写入提案,用户确认后才更新节点
- 所有节点完成后才允许保存 - 所有节点完成后才允许保存
- AI JSON 输出经过严格结构校验、纠错重试和服务端二次约束 - AI JSON 输出经过严格结构校验、纠错重试和服务端二次约束
- 无 API Key 时自动进入可操作的本地演示模式 - 无 API Key 时自动进入可操作的本地演示模式
@ -59,12 +63,26 @@ SQLite 默认写入 `data/skillloom.db`,包含:
- `conversations` / `messages` - `conversations` / `messages`
- `chat_runs` / `chat_run_events` - `chat_runs` / `chat_run_events`
`skills.version` 记录可编辑版本;`messages.skill_snapshots_json` 保存每轮
审计快照;`conversations.task_state`
`retained_skill_snapshots` 保存进行中需求的生命周期和固定版本。
主聊天采用“两步式 Run” 主聊天采用“两步式 Run”
1. `POST /api/chat` 幂等创建回答任务。 1. `POST /api/chat` 幂等创建回答任务。
2. `GET /api/chat/runs/:id/stream?after=:seq` 订阅 SSE。 2. `GET /api/chat/runs/:id/stream?after=:seq` 订阅 SSE。
每个事件先写入 SQLite 并获得单调递增的 `seq`,浏览器断线后携带最后序号重连,因此不会重复拼接 token。显式中止使用独立接口不会把普通网络断开误判为用户中止 每个事件先写入 SQLite 并获得单调递增的 `seq`,浏览器断线后携带最后序号重连,因此不会重复拼接 token。显式中止使用独立接口并在同一事务中写入可见内容、用量、Skill 保留状态、终态事件和 Run 状态,避免停止请求与后台流式写入相互覆盖
Skill 有三组彼此独立的状态:
1. 用户勾选集合:当前需求允许使用哪些 Skill
2. 当轮使用集合:路由器从勾选集合中实际选择的子集;
3. 需求状态:`active``awaiting_input``offer_pending`
`complete``abandoned`
只有 `complete``abandoned` 会清空勾选集合。回答失败、中止或服务重启
只结束本次 Run需求和已固定的 Skill 快照仍保留。
结构化 AI 输出经过四层保护: 结构化 AI 输出经过四层保护:
@ -88,7 +106,9 @@ npm run test:acceptance
- 删除 Skill 与列表刷新 - 删除 Skill 与列表刷新
- 聊天选择和实际调用 Skill - 聊天选择和实际调用 Skill
- 返回是否调用及具体 Skill - 返回是否调用及具体 Skill
- 中止作为终态 - 多选 Skill 的需求级保留与单轮子集路由
- Skill 编辑版本递增与跨轮快照固定
- 中止内容、事件与需求状态的原子终态
- 首轮 AI 主题标题、标题编辑与会话重置 - 首轮 AI 主题标题、标题编辑与会话重置
- SSE 断线重连、事件去重和继续生成 - SSE 断线重连、事件去重和继续生成

View File

@ -152,6 +152,7 @@ let nodes = definitions.map(([key, title, description]) => ({
let skillName = "未命名 Skill"; let skillName = "未命名 Skill";
let skillDescription = ""; let skillDescription = "";
let builderMessages = []; let builderMessages = [];
let pendingProposal = null;
async function builderTurn(message) { async function builderTurn(message) {
const clientRequestId = `acceptance_builder_${crypto.randomUUID()}`; const clientRequestId = `acceptance_builder_${crypto.randomUUID()}`;
@ -163,6 +164,7 @@ async function builderTurn(message) {
message, message,
skillName, skillName,
skillDescription, skillDescription,
pendingProposal,
nodes, nodes,
messages: builderMessages, messages: builderMessages,
}); });
@ -216,6 +218,7 @@ async function builderTurn(message) {
nodes = update.evaluation.nodes; nodes = update.evaluation.nodes;
skillName = update.evaluation.skillName; skillName = update.evaluation.skillName;
skillDescription = update.evaluation.skillDescription; skillDescription = update.evaluation.skillDescription;
pendingProposal = update.evaluation.proposal;
const reply = events const reply = events
.filter((event) => event.type === "token") .filter((event) => event.type === "token")
.map((event) => event.token) .map((event) => event.token)
@ -231,6 +234,21 @@ async function runAcceptance() {
await waitForServer(); await waitForServer();
console.log("✓ 测试服务与独立 SQLite 已启动"); console.log("✓ 测试服务与独立 SQLite 已启动");
const initialPageResponse = await fetch(baseUrl);
const initialPageHtml = await initialPageResponse.text();
assert.equal(initialPageResponse.ok, true, "工作台首屏必须可以直接访问");
assert.doesNotMatch(
initialPageHtml,
/正在打开工作台/,
"首屏不得依赖客户端 effect 才能离开加载状态",
);
assert.match(
initialPageHtml,
/有什么我可以帮你/,
"服务端首屏必须直接输出可用的工作台内容",
);
console.log("✓ 工作台首屏由服务端数据直接渲染,不会卡在加载状态");
const migratedChat = await jsonRequest("/api/chat"); const migratedChat = await jsonRequest("/api/chat");
assert.equal(migratedChat.continuation.state, "awaiting_input"); assert.equal(migratedChat.continuation.state, "awaiting_input");
assert.deepEqual(migratedChat.continuation.skillIds, ["skill_sql_guard"]); assert.deepEqual(migratedChat.continuation.skillIds, ["skill_sql_guard"]);
@ -433,12 +451,37 @@ async function runAcceptance() {
console.log("✓ Builder 拒绝脚本/API 执行能力并给出可行替代"); console.log("✓ Builder 拒绝脚本/API 执行能力并给出可行替代");
const incompleteTurn = await builderTurn("做一套清单"); const incompleteTurn = await builderTurn("做一套清单");
assert.equal(
incompleteTurn.evaluation.action,
"proposed",
"规范内容必须先作为待确认提案返回",
);
assert.equal(
nodes.every((node) => node.content === ""),
true,
"用户确认前不得写入任何节点内容",
);
assert.match(
incompleteTurn.reply,
/待确认|确认写入/,
"聊天回复必须明确展示待确认状态",
);
const confirmedIncompleteTurn = await builderTurn("确认写入");
assert.equal(
confirmedIncompleteTurn.evaluation.action,
"applied",
"用户确认后才允许写入提案",
);
assert.equal( assert.equal(
nodes.filter((node) => node.completed).length, nodes.filter((node) => node.completed).length,
0, 0,
"信息不足时节点不能提前完成", "信息不足时节点不能提前完成",
); );
assert.match(incompleteTurn.reply, /[?]/, "信息不足时 AI 必须继续追问"); assert.match(
confirmedIncompleteTurn.reply,
/[?]/,
"确认写入的信息仍不足时 AI 必须继续追问",
);
const multiNodeTurn = await builderTurn( const multiNodeTurn = await builderTurn(
[ [
@ -449,9 +492,46 @@ async function runAcceptance() {
].join(""), ].join(""),
); );
assert.deepEqual( assert.deepEqual(
multiNodeTurn.evaluation.updatedNodeKeys, multiNodeTurn.evaluation.proposedNodeKeys,
["trigger", "inputs", "steps", "constraints"], ["trigger", "inputs", "steps", "constraints"],
"一条连贯消息涵盖多个节点时AI 必须一次归类全部内容", "一条连贯消息涵盖多个节点时AI 必须一次归类全部提案内容",
);
assert.deepEqual(
multiNodeTurn.evaluation.updatedNodeKeys,
[],
"多节点提案确认前不得产生实际更新",
);
assert.equal(
nodes.filter((node) => node.completed).length,
0,
"多节点提案确认前进度不得变化",
);
const revisedMultiNodeTurn = await builderTurn(
"输入参数改为:必填产品需求、目标用户和验收环境,可选优先级与发布日期。",
);
assert.equal(
revisedMultiNodeTurn.evaluation.action,
"proposed",
"修改意见必须生成新版提案,而不是直接写入",
);
assert.match(
pendingProposal.updates.find((update) => update.nodeKey === "inputs")
?.content ?? "",
/验收环境/,
"新版提案必须吸收用户的修改意见",
);
assert.equal(
nodes.filter((node) => node.completed).length,
0,
"修改待确认提案时节点仍不得变化",
);
const confirmedMultiNodeTurn = await builderTurn("确认写入");
assert.deepEqual(
confirmedMultiNodeTurn.evaluation.updatedNodeKeys,
["trigger", "inputs", "steps", "constraints"],
"确认后必须一次写入完整的多节点提案",
); );
assert.equal( assert.equal(
nodes.filter((node) => node.completed).length, nodes.filter((node) => node.completed).length,
@ -473,9 +553,20 @@ async function runAcceptance() {
"输出格式:输出 Markdown 表格,字段为编号、前置条件、操作、预期结果。", "输出格式:输出 Markdown 表格,字段为编号、前置条件、操作、预期结果。",
); );
assert.deepEqual( assert.deepEqual(
filledGap.evaluation.updatedNodeKeys, filledGap.evaluation.proposedNodeKeys,
["output"], ["output"],
"补充缺口时 AI 应更新第 4 节点", "补充缺口时 AI 应先提议更新第 4 节点",
);
assert.equal(
nodes.filter((node) => node.completed).length,
3,
"补充缺口的提案确认前不得改变进度",
);
const confirmedGap = await builderTurn("确认写入");
assert.deepEqual(
confirmedGap.evaluation.updatedNodeKeys,
["output"],
"确认后才应更新第 4 节点",
); );
assert.equal( assert.equal(
nodes.filter((node) => node.completed).length, nodes.filter((node) => node.completed).length,
@ -483,11 +574,13 @@ async function runAcceptance() {
"第 4 节点补齐后,已准备好的第 5 节点必须自动完成", "第 4 节点补齐后,已准备好的第 5 节点必须自动完成",
); );
assert.doesNotMatch( assert.doesNotMatch(
filledGap.reply, confirmedGap.reply,
/约束与测试[^。]*[?]/, /约束与测试[^。]*[?]/,
"全部节点自动完成后不得继续追问已经解锁的节点", "全部节点自动完成后不得继续追问已经解锁的节点",
); );
console.log("✓ 一轮可完成多个连续节点,缺口补齐后自动解锁后续节点"); console.log(
"✓ 节点内容先展示提案,支持修改,确认后再写入并按顺序解锁",
);
assert.deepEqual( assert.deepEqual(
getSkillNodeQualityIssues(nodes), getSkillNodeQualityIssues(nodes),
@ -594,8 +687,24 @@ async function runAcceptance() {
"补充规则:当需求只有一句话时,先追问目标用户再生成清单。", "补充规则:当需求只有一句话时,先追问目标用户再生成清单。",
); );
assert.ok( assert.ok(
editedTurn.evaluation.updatedNodeKeys.includes("trigger"), editedTurn.evaluation.proposedNodeKeys.includes("trigger"),
"编辑时也必须由 AI 自动判断目标节点", "编辑时也必须由 AI 自动判断待修改的目标节点",
);
const nodesBeforeDiscard = structuredClone(nodes);
const discardedTurn = await builderTurn("放弃提案");
assert.equal(discardedTurn.evaluation.action, "discarded");
assert.deepEqual(nodes, nodesBeforeDiscard, "放弃提案不得改变现有节点");
const confirmedEditProposal = await builderTurn(
"补充规则:当需求只有一句话时,先追问目标用户再生成清单。",
);
assert.ok(
confirmedEditProposal.evaluation.proposedNodeKeys.includes("trigger"),
);
const confirmedEdit = await builderTurn("确认写入");
assert.ok(
confirmedEdit.evaluation.updatedNodeKeys.includes("trigger"),
"编辑提案也必须在确认后才写入目标节点",
); );
await jsonRequest(`/api/skills/${skillId}`, { await jsonRequest(`/api/skills/${skillId}`, {
method: "PUT", method: "PUT",
@ -828,7 +937,11 @@ async function runAcceptance() {
const sqlReviewSkill = seededSkills.skills.find( const sqlReviewSkill = seededSkills.skills.find(
(skill) => skill.name === "SQL 安全审阅", (skill) => skill.name === "SQL 安全审阅",
); );
const decisionNoteSkill = seededSkills.skills.find(
(skill) => skill.name === "决策备忘录",
);
assert.ok(sqlReviewSkill, "预置的 SQL 安全审阅 Skill 必须存在"); assert.ok(sqlReviewSkill, "预置的 SQL 安全审阅 Skill 必须存在");
assert.ok(decisionNoteSkill, "预置的决策备忘录 Skill 必须存在");
const explainSkillPayload = await jsonRequest("/api/chat", { const explainSkillPayload = await jsonRequest("/api/chat", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -866,6 +979,79 @@ async function runAcceptance() {
); );
console.log("✓ 询问 Skill 用途时严格返回已选 Skill 的真实定义"); console.log("✓ 询问 Skill 用途时严格返回已选 Skill 的真实定义");
const stagedSkillPayload = await jsonRequest("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
conversationId: chat.conversation.id,
message: "请分别介绍这些 Skill 的用途",
selectedSkillIds: [decisionNoteSkill.id, sqlReviewSkill.id],
clientRequestId: `acceptance_staged_skills_${crypto.randomUUID()}`,
}),
});
const stagedSkillEvents = await readSse(
await fetch(
`${baseUrl}/api/chat/runs/${stagedSkillPayload.run.id}/stream?after=0`,
),
);
assert.deepEqual(
stagedSkillEvents
.filter(
(event) =>
event.type === "skill_started" ||
event.type === "skill_completed",
)
.map((event) => [event.type, event.skillId]),
[
["skill_started", decisionNoteSkill.id],
["skill_completed", decisionNoteSkill.id],
["skill_started", sqlReviewSkill.id],
["skill_completed", sqlReviewSkill.id],
],
"多个 Skill 必须按用户选择顺序逐个发送开始与完成事件",
);
const stagedStartedEvents = stagedSkillEvents.filter(
(event) => event.type === "skill_started",
);
const stagedCompletedEvents = stagedSkillEvents.filter(
(event) => event.type === "skill_completed",
);
assert.equal(
stagedStartedEvents[0]?.input?.request,
"请分别介绍这些 Skill 的用途",
"Skill 开始事件必须包含实际用户请求",
);
assert.match(
stagedCompletedEvents[0]?.result ?? "",
/复杂选项整理成有依据、可复盘的决策建议/,
"Skill 完成事件必须包含真实阶段产物",
);
assert.doesNotMatch(
stagedCompletedEvents[0]?.result ?? "",
/\"used\"\s*:\s*true/,
"Skill 结果不得再使用 used=true 占位",
);
const chatWithStagedExecutions = await jsonRequest("/api/chat");
const persistedStagedMessage = chatWithStagedExecutions.messages.find(
(message) => message.id === stagedSkillPayload.run.assistantMessageId,
);
assert.equal(
persistedStagedMessage?.skillExecutions.length,
2,
"助手消息必须持久化每个 Skill 的执行记录",
);
assert.ok(
persistedStagedMessage?.skillExecutions.every(
(execution) =>
execution.status === "completed" &&
execution.input.request === "请分别介绍这些 Skill 的用途" &&
typeof execution.result === "string" &&
execution.result.length > 0,
),
"刷新后仍应读取真实 Skill 输入与结果",
);
console.log("✓ 多 Skill 按顺序执行并持久化真实输入与阶段结果");
const followUpPayload = await jsonRequest("/api/chat", { const followUpPayload = await jsonRequest("/api/chat", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -873,7 +1059,7 @@ async function runAcceptance() {
conversationId: chat.conversation.id, conversationId: chat.conversation.id,
message: message:
"产品需求 验收清单:我还没提供具体内容,请先询问我需要补充的信息。", "产品需求 验收清单:我还没提供具体内容,请先询问我需要补充的信息。",
selectedSkillIds: [skillId], selectedSkillIds: [skillId, sqlReviewSkill.id],
clientRequestId: `acceptance_follow_up_${crypto.randomUUID()}`, clientRequestId: `acceptance_follow_up_${crypto.randomUUID()}`,
}), }),
}); });
@ -885,6 +1071,20 @@ async function runAcceptance() {
const followUpRetention = followUpEvents.find( const followUpRetention = followUpEvents.find(
(event) => event.type === "skill_retention", (event) => event.type === "skill_retention",
); );
const followUpUsage = followUpEvents.find(
(event) => event.type === "skill_usage",
);
assert.equal(
followUpUsage?.skillIds.length,
1,
"路由器应只使用两个已勾选 Skill 中的相关子集",
);
assert.ok(
followUpUsage.skillIds.every((id) =>
[skillId, sqlReviewSkill.id].includes(id),
),
"实际使用项不得超出用户勾选集合",
);
assert.equal( assert.equal(
followUpRetention?.state, followUpRetention?.state,
"awaiting_input", "awaiting_input",
@ -892,13 +1092,13 @@ async function runAcceptance() {
); );
assert.deepEqual( assert.deepEqual(
followUpRetention?.skillIds, followUpRetention?.skillIds,
[skillId], [skillId, sqlReviewSkill.id],
"未完成的多轮任务必须保留实际使用的 Skill", "未完成的需求必须保留全部勾选 Skill不能丢弃当轮未使用项",
); );
const chatWithRetainedSkill = await jsonRequest("/api/chat"); const chatWithRetainedSkill = await jsonRequest("/api/chat");
assert.deepEqual( assert.deepEqual(
chatWithRetainedSkill.retainedSkillIds, chatWithRetainedSkill.retainedSkillIds,
[skillId], [skillId, sqlReviewSkill.id],
"刷新页面后仍必须恢复待继续任务的 Skill", "刷新页面后仍必须恢复待继续任务的 Skill",
); );
assert.equal( assert.equal(
@ -915,7 +1115,40 @@ async function runAcceptance() {
"deterministic", "deterministic",
"明确追问应由确定性规则识别,不依赖 AI 猜测", "明确追问应由确定性规则识别,不依赖 AI 猜测",
); );
console.log("✓ AI 追问时跨轮保留 Skill并持久化到 SQLite"); const followUpAssistantMessage = chatWithRetainedSkill.messages.find(
(message) => message.id === followUpPayload.run.assistantMessageId,
);
const pinnedSnapshot = followUpAssistantMessage?.skillSnapshots.find(
(snapshot) => snapshot.id === skillId,
);
assert.ok(pinnedSnapshot?.version >= 1, "消息必须保存 Skill 版本快照");
const latestSkillBeforeEdit = (
await jsonRequest(`/api/skills/${skillId}`)
).skill;
assert.equal(
pinnedSnapshot.version,
latestSkillBeforeEdit.version,
"首次进入需求时必须固定当时的 Skill 版本",
);
const updatedDuringTask = await jsonRequest(`/api/skills/${skillId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: latestSkillBeforeEdit.name,
description: `${latestSkillBeforeEdit.description}(新版)`,
nodes: latestSkillBeforeEdit.nodes,
status: "active",
}),
});
assert.equal(
updatedDuringTask.skill.version,
pinnedSnapshot.version + 1,
"每次编辑 Skill 必须递增版本号",
);
console.log(
"✓ AI 追问时保留全部勾选 Skill并固定版本快照到 SQLite",
);
const continuedPayload = await jsonRequest("/api/chat", { const continuedPayload = await jsonRequest("/api/chat", {
method: "POST", method: "POST",
@ -923,7 +1156,7 @@ async function runAcceptance() {
body: JSON.stringify({ body: JSON.stringify({
conversationId: chat.conversation.id, conversationId: chat.conversation.id,
message: "需要", message: "需要",
selectedSkillIds: [skillId], selectedSkillIds: [skillId, sqlReviewSkill.id],
clientRequestId: `acceptance_continued_skill_${crypto.randomUUID()}`, clientRequestId: `acceptance_continued_skill_${crypto.randomUUID()}`,
}), }),
}); });
@ -937,7 +1170,7 @@ async function runAcceptance() {
); );
assert.deepEqual( assert.deepEqual(
continuedUsage?.skillIds, continuedUsage?.skillIds,
[skillId], [skillId, sqlReviewSkill.id],
"用户用“需要”等短回复接受上一轮提议时必须继续使用保留的 Skill", "用户用“需要”等短回复接受上一轮提议时必须继续使用保留的 Skill",
); );
assert.equal( assert.equal(
@ -945,7 +1178,18 @@ async function runAcceptance() {
"continuation", "continuation",
"省略回复必须通过显式续接状态恢复 Skill", "省略回复必须通过显式续接状态恢复 Skill",
); );
console.log("✓ “需要”等省略回复会继续使用上一轮保留的 Skill"); const chatAfterContinuation = await jsonRequest("/api/chat");
const continuedAssistantMessage = chatAfterContinuation.messages.find(
(message) => message.id === continuedPayload.run.assistantMessageId,
);
assert.equal(
continuedAssistantMessage?.skillSnapshots.find(
(snapshot) => snapshot.id === skillId,
)?.version,
pinnedSnapshot.version,
"进行中的需求必须继续使用旧快照,不能混入刚编辑的新版本",
);
console.log("✓ 省略回复续接所选 Skill并沿用需求开始时的版本快照");
const stopPayload = await jsonRequest("/api/chat", { const stopPayload = await jsonRequest("/api/chat", {
method: "POST", method: "POST",
@ -953,7 +1197,7 @@ async function runAcceptance() {
body: JSON.stringify({ body: JSON.stringify({
conversationId: chat.conversation.id, conversationId: chat.conversation.id,
message: "请生成一份很长的补充清单。", message: "请生成一份很长的补充清单。",
selectedSkillIds: [], selectedSkillIds: [skillId],
clientRequestId: `acceptance_stop_${crypto.randomUUID()}`, clientRequestId: `acceptance_stop_${crypto.randomUUID()}`,
}), }),
}); });
@ -1001,6 +1245,23 @@ async function runAcceptance() {
stoppedMessage?.usage?.durationMs >= 0, stoppedMessage?.usage?.durationMs >= 0,
"中止回答也必须记录端到端用时", "中止回答也必须记录端到端用时",
); );
assert.equal(
chatAfterStop.continuation.state,
"active",
"中止回答只结束 Run不能把当前需求误判为完成",
);
assert.deepEqual(
chatAfterStop.retainedSkillIds,
[skillId],
"中止后必须维持用户勾选的 Skill",
);
assert.equal(
stoppedMessage?.skillSnapshots.find(
(snapshot) => snapshot.id === skillId,
)?.version,
updatedDuringTask.skill.version,
"上一个需求完成后,新需求必须获取 Skill 的最新版本快照",
);
assert.equal( assert.equal(
chatAfterStop.messages.some( chatAfterStop.messages.some(
(message) => message.content === "这条并发冲突消息不应写入数据库。", (message) => message.content === "这条并发冲突消息不应写入数据库。",
@ -1008,7 +1269,7 @@ async function runAcceptance() {
false, false,
"并发冲突事务不得留下孤立消息", "并发冲突事务不得留下孤立消息",
); );
console.log("✓ 中止被视为一轮完成"); console.log("✓ 中止原子落库且维持当前需求的 Skill 选择");
const renamed = await jsonRequest("/api/conversation", { const renamed = await jsonRequest("/api/conversation", {
method: "PATCH", method: "PATCH",
@ -1037,7 +1298,7 @@ async function runAcceptance() {
try { try {
await runAcceptance(); await runAcceptance();
console.log("\n验收通过创建、编辑、删除、选择、调用反馈、中止、标题与重连均正常。"); console.log("\n验收通过生命周期、版本快照、中止竞态、创建编辑与重连均正常。");
} finally { } finally {
server.kill("SIGTERM"); server.kill("SIGTERM");
await new Promise((resolve) => { await new Promise((resolve) => {

View File

@ -10,6 +10,11 @@ import {
listMessages, listMessages,
} from "@/lib/db"; } from "@/lib/db";
import { hasDeepSeekApiKey } from "@/lib/deepseek"; import { hasDeepSeekApiKey } from "@/lib/deepseek";
import {
pinSkillsToSnapshots,
snapshotsForSkills,
} from "@/lib/skill-snapshots";
import { classifyContinuationReply } from "@/lib/skill-continuation";
import type { ChatMessage, Skill } from "@/lib/types"; import type { ChatMessage, Skill } from "@/lib/types";
import { createId } from "@/lib/utils"; import { createId } from "@/lib/utils";
import { chatRequestSchema } from "@/lib/validation"; import { chatRequestSchema } from "@/lib/validation";
@ -44,11 +49,27 @@ export async function POST(request: Request) {
const continuation = getConversationContinuation( const continuation = getConversationContinuation(
input.conversationId, input.conversationId,
); );
const selectedSkills = [...new Set(input.selectedSkillIds)] const currentSelectedSkills = [...new Set(input.selectedSkillIds)]
.map((id) => getSkill(id)) .map((id) => getSkill(id))
.filter( .filter(
(skill): skill is Skill => skill?.status === "active", (skill): skill is Skill => skill?.status === "active",
); );
const replyKind = classifyContinuationReply(input.message);
const continuesExistingTask =
!["complete", "abandoned"].includes(continuation.state) &&
replyKind !== "new_topic" &&
replyKind !== "decline";
const retainedSnapshots = continuesExistingTask
? continuation.skillSnapshots
: [];
const selectedSkills = pinSkillsToSnapshots(
currentSelectedSkills,
retainedSnapshots,
);
const skillSnapshots = snapshotsForSkills(
selectedSkills,
retainedSnapshots,
);
const userMessageId = createId("message"); const userMessageId = createId("message");
const assistantMessageId = createId("message"); const assistantMessageId = createId("message");
@ -63,6 +84,8 @@ export async function POST(request: Request) {
content: input.message, content: input.message,
selectedSkillIds: selectedSkills.map((skill) => skill.id), selectedSkillIds: selectedSkills.map((skill) => skill.id),
usedSkillIds: [], usedSkillIds: [],
skillSnapshots,
skillExecutions: [],
status: "complete", status: "complete",
createdAt: now, createdAt: now,
}, },
@ -72,6 +95,8 @@ export async function POST(request: Request) {
content: "", content: "",
selectedSkillIds: selectedSkills.map((skill) => skill.id), selectedSkillIds: selectedSkills.map((skill) => skill.id),
usedSkillIds: [], usedSkillIds: [],
skillSnapshots,
skillExecutions: [],
status: "streaming", status: "streaming",
createdAt: new Date(Date.now() + 1).toISOString(), createdAt: new Date(Date.now() + 1).toISOString(),
}, },
@ -109,6 +134,7 @@ export async function POST(request: Request) {
conversationId: input.conversationId, conversationId: input.conversationId,
userMessage: input.message, userMessage: input.message,
selectedSkills, selectedSkills,
skillSnapshots,
continuation, continuation,
history, history,
}); });

View File

@ -1,6 +1,30 @@
import { ChatWorkspace } from "@/components/chat-workspace"; import { ChatWorkspace } from "@/components/chat-workspace";
import {
getActiveChatRun,
getConversationContinuation,
getDefaultConversation,
listMessages,
listSkills,
} from "@/lib/db";
import { hasDeepSeekApiKey } from "@/lib/deepseek";
import { connection } from "next/server";
export default function Home() { export default async function Home() {
return <ChatWorkspace />; await connection();
const conversation = getDefaultConversation();
const activeRun = getActiveChatRun(conversation.id);
const continuation = getConversationContinuation(conversation.id);
return (
<ChatWorkspace
initialData={{
skills: listSkills(),
conversation,
messages: listMessages(conversation.id),
activeRun,
continuation,
apiConfigured: hasDeepSeekApiKey(),
}}
/>
);
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { LoaderCircle, Sparkles } from "lucide-react"; import { Check, LoaderCircle, Sparkles, X } from "lucide-react";
import { import {
AssistantRuntimeProvider, AssistantRuntimeProvider,
MessagePrimitive, MessagePrimitive,
@ -17,8 +17,10 @@ import { StreamingMarkdown } from "@/components/streaming-markdown";
import { useSmoothFollow } from "@/components/use-smooth-follow"; import { useSmoothFollow } from "@/components/use-smooth-follow";
import { import {
type BuilderMessage, type BuilderMessage,
type BuilderProposal,
type BuilderSuggestionSource, type BuilderSuggestionSource,
type SkillNodeKey, type SkillNodeKey,
SKILL_NODE_DEFINITIONS,
} from "@/lib/types"; } from "@/lib/types";
import { formatRelativeTime } from "@/lib/utils"; import { formatRelativeTime } from "@/lib/utils";
@ -189,6 +191,53 @@ function BuilderSuggestions({
); );
} }
function BuilderProposalActions({
proposal,
onSend,
}: {
proposal: BuilderProposal;
onSend: (message: string) => Promise<void>;
}) {
const titles = proposal.updates
.map(
(update) =>
SKILL_NODE_DEFINITIONS.find(
(definition) => definition.key === update.nodeKey,
)?.title,
)
.filter(Boolean)
.join("、");
return (
<div className="rounded-lg border border-amber-200 bg-amber-50/80 p-2.5 shadow-sm">
<div className="text-[10px] font-semibold text-amber-900">
· {titles}
</div>
<p className="mt-0.5 text-[9px] leading-4 text-amber-800/80">
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
<button
type="button"
className="inline-flex h-7 items-center gap-1 rounded-md bg-[#07C160] px-2.5 text-[10px] font-semibold text-white transition-colors hover:bg-[#06AD56] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/40"
onClick={() => void onSend("确认写入")}
>
<Check className="size-3" />
</button>
<button
type="button"
className="inline-flex h-7 items-center gap-1 rounded-md border border-amber-300 bg-white/80 px-2.5 text-[10px] font-medium text-amber-900 transition-colors hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30"
onClick={() => void onSend("放弃提案")}
>
<X className="size-3" />
</button>
</div>
</div>
);
}
export function AssistantBuilderChat({ export function AssistantBuilderChat({
messages, messages,
loading, loading,
@ -196,6 +245,7 @@ export function AssistantBuilderChat({
generationStatus, generationStatus,
suggestions, suggestions,
suggestionSource, suggestionSource,
pendingProposal,
sendFailure, sendFailure,
onSend, onSend,
onStop, onStop,
@ -208,6 +258,7 @@ export function AssistantBuilderChat({
generationStatus: string; generationStatus: string;
suggestions: string[]; suggestions: string[];
suggestionSource: BuilderSuggestionSource; suggestionSource: BuilderSuggestionSource;
pendingProposal: BuilderProposal | null;
sendFailure: { content: string; detail: string } | null; sendFailure: { content: string; detail: string } | null;
onSend: (message: string) => Promise<void>; onSend: (message: string) => Promise<void>;
onStop: () => void; onStop: () => void;
@ -264,11 +315,18 @@ export function AssistantBuilderChat({
}} }}
/> />
{!streaming && ( {!streaming && (
<BuilderSuggestions pendingProposal ? (
suggestions={suggestions} <BuilderProposalActions
source={suggestionSource} proposal={pendingProposal}
onSend={onSend} onSend={onSend}
/> />
) : (
<BuilderSuggestions
suggestions={suggestions}
source={suggestionSource}
onSend={onSend}
/>
)
)} )}
</div> </div>
</ThreadPrimitive.Viewport> </ThreadPrimitive.Viewport>

View File

@ -108,6 +108,7 @@ function toAssistantMessage(
skills: Skill[], skills: Skill[],
generationStatus: string, generationStatus: string,
resolvedSkillIds: string[] | null, resolvedSkillIds: string[] | null,
completedSkillIds: string[],
): ThreadMessageLike { ): ThreadMessageLike {
const usedSkills = message.usedSkillIds const usedSkills = message.usedSkillIds
.map((id) => skills.find((skill) => skill.id === id)) .map((id) => skills.find((skill) => skill.id === id))
@ -120,22 +121,33 @@ function toAssistantMessage(
const content: AssistantContentPart[] = []; const content: AssistantContentPart[] = [];
for (const [position, skill] of usedSkills.entries()) { for (const [position, skill] of usedSkills.entries()) {
const execution = message.skillExecutions.find(
(item) => item.skillId === skill.id,
);
const executionCompleted =
execution?.status === "completed" ||
completedSkillIds.includes(skill.id);
content.push({ content.push({
type: "tool-call", type: "tool-call",
toolCallId: `skill_${message.id}_${skill.id}`, toolCallId: `skill_${message.id}_${skill.id}`,
toolName: skill.name, toolName: skill.name,
args: { args: {
skillName: skill.name, skill: {
description: skill.description, name: skill.name,
position: position + 1, description: skill.description,
position: position + 1,
},
request:
execution?.input.request ??
"该历史记录创建时尚未保存 Skill 的实际输入。",
upstreamResults: execution?.input.upstreamResults ?? [],
}, },
...(isRunning ...(isRunning && !executionCompleted
? {} ? {}
: { : {
result: { result:
used: true, execution?.result ??
skillName: skill.name, "该历史记录创建时尚未保存 Skill 的具体执行结果。",
},
}), }),
}); });
} }
@ -185,6 +197,7 @@ function convertMessage(
skills: Skill[], skills: Skill[],
generationStatus: string, generationStatus: string,
resolvedSkillIds: string[] | null, resolvedSkillIds: string[] | null,
completedSkillIds: string[],
): ThreadMessageLike { ): ThreadMessageLike {
if (message.role === "assistant") { if (message.role === "assistant") {
return toAssistantMessage( return toAssistantMessage(
@ -192,6 +205,7 @@ function convertMessage(
skills, skills,
generationStatus, generationStatus,
resolvedSkillIds, resolvedSkillIds,
completedSkillIds,
); );
} }
@ -501,7 +515,7 @@ function Welcome({
className="inline-flex h-9 items-center gap-2 rounded-lg border border-border bg-background px-3.5 text-xs font-medium text-foreground shadow-xs transition-colors hover:bg-accent" className="inline-flex h-9 items-center gap-2 rounded-lg border border-border bg-background px-3.5 text-xs font-medium text-foreground shadow-xs transition-colors hover:bg-accent"
> >
<Plus className="size-3.5" /> <Plus className="size-3.5" />
Skill Skill
</button> </button>
<Link <Link
href="/skills/new" href="/skills/new"
@ -521,7 +535,8 @@ export function AssistantChat({
messages, messages,
selectedIds, selectedIds,
resolvedSkillIds, resolvedSkillIds,
loading, activeSkillId,
completedSkillIds,
streaming, streaming,
stopping, stopping,
generationStatus, generationStatus,
@ -538,7 +553,8 @@ export function AssistantChat({
messages: ChatMessage[]; messages: ChatMessage[];
selectedIds: string[]; selectedIds: string[];
resolvedSkillIds: string[] | null; resolvedSkillIds: string[] | null;
loading: boolean; activeSkillId: string | null;
completedSkillIds: string[];
streaming: boolean; streaming: boolean;
stopping: boolean; stopping: boolean;
generationStatus: string; generationStatus: string;
@ -553,7 +569,7 @@ export function AssistantChat({
}) { }) {
const runtime = useExternalStoreRuntime<ChatMessage>({ const runtime = useExternalStoreRuntime<ChatMessage>({
messages, messages,
isLoading: loading, isLoading: false,
isRunning: streaming, isRunning: streaming,
isSendDisabled: !canSend, isSendDisabled: !canSend,
convertMessage: (message) => convertMessage: (message) =>
@ -562,6 +578,7 @@ export function AssistantChat({
skills, skills,
generationStatus, generationStatus,
resolvedSkillIds, resolvedSkillIds,
completedSkillIds,
), ),
onNew: async (message) => { onNew: async (message) => {
const text = getText(message); const text = getText(message);
@ -578,57 +595,48 @@ export function AssistantChat({
return ( return (
<AssistantRuntimeProvider runtime={runtime}> <AssistantRuntimeProvider runtime={runtime}>
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col"> <ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col">
{loading ? ( <ThreadPrimitive.Viewport
<div className="grid h-full place-items-center"> {...smoothFollow}
<div className="flex items-center gap-2 text-sm text-slate-500"> autoScroll={false}
<LoaderCircle className="size-4 animate-spin text-cobalt" /> scrollToBottomOnRunStart={false}
className="min-h-0 flex-1 overflow-y-auto px-4 [scrollbar-gutter:stable] sm:px-8"
>
{messages.length === 0 ? (
<Welcome onOpenSkillPicker={onOpenSkillPicker} />
) : (
<div className="mx-auto max-w-[704px] space-y-7 py-6">
<ThreadPrimitive.Messages
components={{
UserMessage,
AssistantMessage,
}}
/>
</div> </div>
</div> )}
) : ( </ThreadPrimitive.Viewport>
<>
<ThreadPrimitive.Viewport
{...smoothFollow}
autoScroll={false}
scrollToBottomOnRunStart={false}
className="min-h-0 flex-1 overflow-y-auto px-4 [scrollbar-gutter:stable] sm:px-8"
>
{messages.length === 0 ? (
<Welcome onOpenSkillPicker={onOpenSkillPicker} />
) : (
<div className="mx-auto max-w-[704px] space-y-7 py-6">
<ThreadPrimitive.Messages
components={{
UserMessage,
AssistantMessage,
}}
/>
</div>
)}
</ThreadPrimitive.Viewport>
<div className="relative z-10 shrink-0 bg-background px-4 pb-4 pt-3 sm:px-8 sm:pb-6"> <div className="relative z-10 shrink-0 bg-background px-4 pb-4 pt-3 sm:px-8 sm:pb-6">
<div className="mx-auto max-w-[704px]"> <div className="mx-auto max-w-[704px]">
{sendFailure && ( {sendFailure && (
<ChatErrorNotice <ChatErrorNotice
detail={sendFailure.detail} detail={sendFailure.detail}
onRetry={onRetry} onRetry={onRetry}
onDismiss={onDismissFailure} onDismiss={onDismissFailure}
/> />
)} )}
<ChatComposer <ChatComposer
skills={skills} skills={skills}
selectedIds={selectedIds} selectedIds={selectedIds}
resolvedSkillIds={resolvedSkillIds} resolvedSkillIds={resolvedSkillIds}
onSelectedChange={onSelectedChange} activeSkillId={activeSkillId}
onOpenSkillPicker={onOpenSkillPicker} completedSkillIds={completedSkillIds}
isStreaming={streaming} onSelectedChange={onSelectedChange}
isStopping={stopping} onOpenSkillPicker={onOpenSkillPicker}
/> isStreaming={streaming}
</div> isStopping={stopping}
</div> />
</> </div>
)} </div>
</ThreadPrimitive.Root> </ThreadPrimitive.Root>
</AssistantRuntimeProvider> </AssistantRuntimeProvider>
); );

View File

@ -32,6 +32,8 @@ export function ChatComposer({
skills, skills,
selectedIds, selectedIds,
resolvedSkillIds, resolvedSkillIds,
activeSkillId,
completedSkillIds,
onSelectedChange, onSelectedChange,
onOpenSkillPicker, onOpenSkillPicker,
isStreaming, isStreaming,
@ -40,6 +42,8 @@ export function ChatComposer({
skills: Skill[]; skills: Skill[];
selectedIds: string[]; selectedIds: string[];
resolvedSkillIds: string[] | null; resolvedSkillIds: string[] | null;
activeSkillId: string | null;
completedSkillIds: string[];
onSelectedChange: (ids: string[]) => void; onSelectedChange: (ids: string[]) => void;
onOpenSkillPicker: () => void; onOpenSkillPicker: () => void;
isStreaming: boolean; isStreaming: boolean;
@ -202,9 +206,13 @@ export function ChatComposer({
? "selected" ? "selected"
: !routeResolved : !routeResolved
? "checking" ? "checking"
: resolvedSkillIds.includes(skill.id) : !resolvedSkillIds.includes(skill.id)
? "used" ? "unused"
: "unused"; : activeSkillId === skill.id
? "running"
: completedSkillIds.includes(skill.id)
? "completed"
: "queued";
return ( return (
<Fragment key={skill.id}> <Fragment key={skill.id}>
{index > 0 && ( {index > 0 && (
@ -216,8 +224,10 @@ export function ChatComposer({
!routeResolved && !routeResolved &&
"animate-pulse text-amber-500/70", "animate-pulse text-amber-500/70",
routeResolved && routeResolved &&
resolvedSkillIds?.includes(skill.id) && completedSkillIds.includes(skill.id) &&
"text-emerald-500/80", "text-emerald-500/80",
activeSkillId === skill.id &&
"animate-pulse text-blue-500/90",
)} )}
/> />
)} )}

View File

@ -11,6 +11,7 @@ import type {
ChatMessage, ChatMessage,
ChatRun, ChatRun,
Conversation, Conversation,
ConversationContinuation,
Skill, Skill,
SseEvent, SseEvent,
} from "@/lib/types"; } from "@/lib/types";
@ -33,22 +34,71 @@ function wait(ms: number, signal: AbortSignal) {
}); });
} }
export function ChatWorkspace() { type ChatWorkspaceInitialData = {
const [skills, setSkills] = useState<Skill[]>([]); skills: Skill[];
const [messages, setMessages] = useState<ChatMessage[]>([]); messages: ChatMessage[];
const [conversation, setConversation] = useState<Conversation | null>(null); conversation: Conversation;
const [selectedIds, setSelectedIds] = useState<string[]>([]); activeRun: ChatRun | null;
continuation: ConversationContinuation;
apiConfigured: boolean;
};
export function ChatWorkspace({
initialData,
}: {
initialData: ChatWorkspaceInitialData;
}) {
const [skills, setSkills] = useState<Skill[]>(initialData.skills);
const [messages, setMessages] = useState<ChatMessage[]>(() =>
initialData.activeRun
? initialData.messages.map((message) =>
message.id === initialData.activeRun?.assistantMessageId
? {
...message,
content: "",
usedSkillIds: [],
skillExecutions: [],
status: "streaming" as const,
}
: message,
)
: initialData.messages,
);
const [conversation, setConversation] = useState<Conversation | null>(
initialData.conversation,
);
const [selectedIds, setSelectedIds] = useState<string[]>(() => {
const activeMessage = initialData.activeRun
? initialData.messages.find(
(message) =>
message.id === initialData.activeRun?.assistantMessageId,
)
: null;
const selected = activeMessage
? activeMessage.selectedSkillIds
: initialData.continuation.skillIds;
return selected.filter((id) =>
initialData.skills.some((skill) => skill.id === id),
);
});
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [loading, setLoading] = useState(true); const [streaming, setStreaming] = useState(
const [streaming, setStreaming] = useState(false); Boolean(initialData.activeRun),
const [generationStatus, setGenerationStatus] = useState(""); );
const [apiConfigured, setApiConfigured] = useState(false); const [generationStatus, setGenerationStatus] = useState(
const [activeRun, setActiveRun] = useState<ChatRun | null>(null); initialData.activeRun ? "正在恢复本轮回答" : "",
);
const [apiConfigured] = useState(initialData.apiConfigured);
const [activeRun, setActiveRun] = useState<ChatRun | null>(
initialData.activeRun,
);
const [stopping, setStopping] = useState(false); const [stopping, setStopping] = useState(false);
const [resolvedSkillIds, setResolvedSkillIds] = useState<string[] | null>( const [resolvedSkillIds, setResolvedSkillIds] = useState<string[] | null>(
null, null,
); );
const activeRunRef = useRef<ChatRun | null>(null); const [activeSkillId, setActiveSkillId] = useState<string | null>(null);
const [completedSkillIds, setCompletedSkillIds] = useState<string[]>([]);
const activeRunRef = useRef<ChatRun | null>(initialData.activeRun);
const stopRequestedRef = useRef(false); const stopRequestedRef = useRef(false);
const visibleAssistantContentRef = useRef(""); const visibleAssistantContentRef = useRef("");
const [sendFailure, setSendFailure] = useState<{ const [sendFailure, setSendFailure] = useState<{
@ -67,84 +117,6 @@ export function ChatWorkspace() {
); );
}, []); }, []);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const [skillsResponse, chatResponse] = await Promise.all([
fetch("/api/skills", { cache: "no-store" }),
fetch("/api/chat", { cache: "no-store" }),
]);
if (!skillsResponse.ok || !chatResponse.ok) {
throw new Error("工作台加载失败");
}
const skillPayload = (await skillsResponse.json()) as {
skills: Skill[];
};
const chatPayload = (await chatResponse.json()) as {
conversation: Conversation;
messages: ChatMessage[];
activeRun: ChatRun | null;
retainedSkillIds: string[];
apiConfigured: boolean;
};
if (cancelled) return;
const hydratedMessages = chatPayload.activeRun
? chatPayload.messages.map((message) =>
message.id === chatPayload.activeRun?.assistantMessageId
? {
...message,
content: "",
usedSkillIds: [],
status: "streaming" as const,
}
: message,
)
: chatPayload.messages;
setSkills(skillPayload.skills);
setConversation(chatPayload.conversation);
setMessages(hydratedMessages);
if (chatPayload.activeRun) {
const activeMessage = chatPayload.messages.find(
(message) =>
message.id === chatPayload.activeRun?.assistantMessageId,
);
setSelectedIds(
(activeMessage?.selectedSkillIds ?? []).filter((id) =>
skillPayload.skills.some((skill) => skill.id === id),
),
);
setResolvedSkillIds(null);
} else {
setSelectedIds(
(chatPayload.retainedSkillIds ?? []).filter((id) =>
skillPayload.skills.some((skill) => skill.id === id),
),
);
}
setApiConfigured(chatPayload.apiConfigured);
activeRunRef.current = chatPayload.activeRun;
setActiveRun(chatPayload.activeRun);
setStreaming(Boolean(chatPayload.activeRun));
if (chatPayload.activeRun) setGenerationStatus("正在恢复本轮回答");
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "工作台加载失败");
}
} finally {
if (!cancelled) setLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, []);
useEffect(() => { useEffect(() => {
const refresh = () => void loadSkills().catch(() => undefined); const refresh = () => void loadSkills().catch(() => undefined);
window.addEventListener("focus", refresh); window.addEventListener("focus", refresh);
@ -201,6 +173,55 @@ export function ChatWorkspace() {
usedSkillIds: event.skillIds, usedSkillIds: event.skillIds,
})); }));
} }
if (event.type === "skill_started") {
setActiveSkillId(event.skillId);
updateAssistant((message) => ({
...message,
skillExecutions: [
...message.skillExecutions.filter(
(item) =>
item.skillId !== event.skillId ||
item.position !== event.position,
),
{
skillId: event.skillId,
skillName: event.skillName,
position: event.position,
total: event.total,
status: "running" as const,
input: event.input,
startedAt: event.startedAt,
},
].sort((a, b) => a.position - b.position),
}));
}
if (event.type === "skill_completed") {
setCompletedSkillIds((ids) =>
ids.includes(event.skillId) ? ids : [...ids, event.skillId],
);
setActiveSkillId((id) => (id === event.skillId ? null : id));
updateAssistant((message) => ({
...message,
skillExecutions: message.skillExecutions
.map((item) =>
item.skillId === event.skillId &&
item.position === event.position
? {
skillId: event.skillId,
skillName: event.skillName,
position: event.position,
total: event.total,
status: "completed" as const,
input: event.input,
result: event.result,
startedAt: event.startedAt,
completedAt: event.completedAt,
}
: item,
)
.sort((a, b) => a.position - b.position),
}));
}
if (event.type === "skill_retention") { if (event.type === "skill_retention") {
retainedForNextTurn = event.skillIds; retainedForNextTurn = event.skillIds;
setSelectedIds(event.skillIds); setSelectedIds(event.skillIds);
@ -294,6 +315,8 @@ export function ChatWorkspace() {
setGenerationStatus(""); setGenerationStatus("");
setSelectedIds(retainedForNextTurn); setSelectedIds(retainedForNextTurn);
setResolvedSkillIds(null); setResolvedSkillIds(null);
setActiveSkillId(null);
setCompletedSkillIds([]);
setActiveRun(null); setActiveRun(null);
} }
} }
@ -310,6 +333,8 @@ export function ChatWorkspace() {
stopRequestedRef.current = false; stopRequestedRef.current = false;
visibleAssistantContentRef.current = ""; visibleAssistantContentRef.current = "";
setStopping(false); setStopping(false);
setActiveSkillId(null);
setCompletedSkillIds([]);
const selectedForTurn = [...(selectedOverride ?? selectedIds)]; const selectedForTurn = [...(selectedOverride ?? selectedIds)];
const userLocalId = `local_user_${crypto.randomUUID()}`; const userLocalId = `local_user_${crypto.randomUUID()}`;
const assistantLocalId = `local_assistant_${crypto.randomUUID()}`; const assistantLocalId = `local_assistant_${crypto.randomUUID()}`;
@ -321,6 +346,8 @@ export function ChatWorkspace() {
content, content,
selectedSkillIds: selectedForTurn, selectedSkillIds: selectedForTurn,
usedSkillIds: [], usedSkillIds: [],
skillSnapshots: [],
skillExecutions: [],
status: "complete", status: "complete",
createdAt: now, createdAt: now,
}; };
@ -330,6 +357,8 @@ export function ChatWorkspace() {
content: "", content: "",
selectedSkillIds: selectedForTurn, selectedSkillIds: selectedForTurn,
usedSkillIds: [], usedSkillIds: [],
skillSnapshots: [],
skillExecutions: [],
status: "streaming", status: "streaming",
createdAt: new Date(Date.now() + 1).toISOString(), createdAt: new Date(Date.now() + 1).toISOString(),
}; };
@ -491,6 +520,8 @@ export function ChatWorkspace() {
setMessages([]); setMessages([]);
setSelectedIds([]); setSelectedIds([]);
setResolvedSkillIds(null); setResolvedSkillIds(null);
setActiveSkillId(null);
setCompletedSkillIds([]);
setSendFailure(null); setSendFailure(null);
stopRequestedRef.current = false; stopRequestedRef.current = false;
activeRunRef.current = null; activeRunRef.current = null;
@ -508,15 +539,20 @@ export function ChatWorkspace() {
messages={messages} messages={messages}
selectedIds={selectedIds} selectedIds={selectedIds}
resolvedSkillIds={resolvedSkillIds} resolvedSkillIds={resolvedSkillIds}
loading={loading} activeSkillId={activeSkillId}
completedSkillIds={completedSkillIds}
streaming={streaming} streaming={streaming}
stopping={stopping} stopping={stopping}
generationStatus={generationStatus} generationStatus={generationStatus}
sendFailure={sendFailure} sendFailure={sendFailure}
canSend={Boolean(conversation) && !loading} canSend={Boolean(conversation)}
onSelectedChange={(ids) => { onSelectedChange={(ids) => {
setSelectedIds(ids); setSelectedIds(ids);
if (!streaming) setResolvedSkillIds(null); if (!streaming) {
setResolvedSkillIds(null);
setActiveSkillId(null);
setCompletedSkillIds([]);
}
}} }}
onOpenSkillPicker={() => setPickerOpen(true)} onOpenSkillPicker={() => setPickerOpen(true)}
onSend={sendMessage} onSend={sendMessage}
@ -539,6 +575,8 @@ export function ChatWorkspace() {
onConfirm={(ids) => { onConfirm={(ids) => {
setSelectedIds(ids); setSelectedIds(ids);
setResolvedSkillIds(null); setResolvedSkillIds(null);
setActiveSkillId(null);
setCompletedSkillIds([]);
}} }}
onSkillsChange={setSkills} onSkillsChange={setSkills}
/> />

View File

@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button";
import { consumeSse } from "@/lib/client-sse"; import { consumeSse } from "@/lib/client-sse";
import type { import type {
BuilderMessage, BuilderMessage,
BuilderProposal,
BuilderSuggestionSource, BuilderSuggestionSource,
Skill, Skill,
SkillNode, SkillNode,
@ -58,6 +59,8 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [nodes, setNodes] = useState<SkillNode[]>(createEmptyNodes); const [nodes, setNodes] = useState<SkillNode[]>(createEmptyNodes);
const [messages, setMessages] = useState<BuilderMessage[]>([]); const [messages, setMessages] = useState<BuilderMessage[]>([]);
const [pendingProposal, setPendingProposal] =
useState<BuilderProposal | null>(null);
const [loading, setLoading] = useState(Boolean(skillId)); const [loading, setLoading] = useState(Boolean(skillId));
const [streaming, setStreaming] = useState(false); const [streaming, setStreaming] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -164,7 +167,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
setMessages([ setMessages([
createBuilderMessage( createBuilderMessage(
"assistant", "assistant",
`已载入「${skillPayload.skill.name}」。直接告诉我想补充或修改什么,我会判断应该更新哪些节点。`, `已载入「${skillPayload.skill.name}」。直接告诉我想补充或修改什么,我会先整理成提案,等你确认后再更新节点。`,
"constraints", "constraints",
SKILL_NODE_DEFINITIONS.map((node) => node.key), SKILL_NODE_DEFINITIONS.map((node) => node.key),
), ),
@ -248,6 +251,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
message: content, message: content,
skillName: name, skillName: name,
skillDescription: description, skillDescription: description,
pendingProposal,
nodes, nodes,
messages: previousMessages.map((message) => ({ messages: previousMessages.map((message) => ({
role: message.role, role: message.role,
@ -262,9 +266,12 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
} }
if (event.type === "status") setStatus(event.label); if (event.type === "status") setStatus(event.label);
if (event.type === "builder_update") { if (event.type === "builder_update") {
setName(event.evaluation.skillName); setPendingProposal(event.evaluation.proposal);
setDescription(event.evaluation.skillDescription); if (event.evaluation.action === "applied") {
setNodes(event.evaluation.nodes); setName(event.evaluation.skillName);
setDescription(event.evaluation.skillDescription);
setNodes(event.evaluation.nodes);
}
const suggestionsMatchNode = const suggestionsMatchNode =
event.evaluation.suggestionNodeKey === event.evaluation.suggestionNodeKey ===
event.evaluation.activeNode; event.evaluation.activeNode;
@ -292,16 +299,15 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
? { ? {
...message, ...message,
nodeKey: nodeKey:
event.evaluation.proposedNodeKeys[0] ??
event.evaluation.updatedNodeKeys[0] ?? event.evaluation.updatedNodeKeys[0] ??
event.evaluation.activeNode, event.evaluation.activeNode,
updatedNodeKeys: event.evaluation.updatedNodeKeys, updatedNodeKeys:
event.evaluation.action === "proposed"
? event.evaluation.proposedNodeKeys
: event.evaluation.updatedNodeKeys,
} }
: message, : message,
)
.filter(
(message) =>
event.evaluation.activeNode === activeNode ||
message.id === assistantMessage.id,
), ),
); );
} }
@ -394,7 +400,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
} }
async function save() { async function save() {
if (!allComplete || saving) return; if (!allComplete || saving || pendingProposal) return;
setSaving(true); setSaving(true);
try { try {
const response = await fetch( const response = await fetch(
@ -462,8 +468,14 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
size="sm" size="sm"
className="bg-[#07C160] text-white hover:bg-[#06AD56] disabled:bg-muted-foreground/35 lg:hidden" className="bg-[#07C160] text-white hover:bg-[#06AD56] disabled:bg-muted-foreground/35 lg:hidden"
onClick={() => void save()} onClick={() => void save()}
disabled={!allComplete || saving} disabled={!allComplete || saving || Boolean(pendingProposal)}
title={!allComplete ? "完成全部节点后才能保存" : undefined} title={
pendingProposal
? "请先确认或放弃当前提案"
: !allComplete
? "完成全部节点后才能保存"
: undefined
}
> >
{saving ? ( {saving ? (
<LoaderCircle className="size-3.5 animate-spin" /> <LoaderCircle className="size-3.5 animate-spin" />
@ -526,6 +538,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
generationStatus={status} generationStatus={status}
suggestions={allComplete ? [] : suggestions} suggestions={allComplete ? [] : suggestions}
suggestionSource={suggestionSource} suggestionSource={suggestionSource}
pendingProposal={pendingProposal}
sendFailure={sendFailure} sendFailure={sendFailure}
onSend={sendBuilderMessage} onSend={sendBuilderMessage}
onStop={stop} onStop={stop}
@ -573,7 +586,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
<Check className="size-3" /> <Check className="size-3" />
</span> </span>
<span> <span>
AI AI
</span> </span>
</div> </div>
) : null} ) : null}
@ -590,7 +603,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
<Button <Button
size="sm" size="sm"
className="h-8 flex-1 bg-[#07C160] text-xs text-white hover:bg-[#06AD56] disabled:bg-muted-foreground/35" className="h-8 flex-1 bg-[#07C160] text-xs text-white hover:bg-[#06AD56] disabled:bg-muted-foreground/35"
disabled={!allComplete || saving} disabled={!allComplete || saving || Boolean(pendingProposal)}
onClick={() => void save()} onClick={() => void save()}
> >
{saving ? ( {saving ? (

View File

@ -194,10 +194,10 @@ export function SkillPicker({
<div className="flex items-center justify-between border-b border-border px-5 py-4 sm:px-6"> <div className="flex items-center justify-between border-b border-border px-5 py-4 sm:px-6">
<div> <div>
<DialogTitle className="font-display text-lg font-semibold tracking-[-0.03em] text-foreground"> <DialogTitle className="font-display text-lg font-semibold tracking-[-0.03em] text-foreground">
Skill Skill
</DialogTitle> </DialogTitle>
<DialogDescription className="mt-1 text-xs"> <DialogDescription className="mt-1 text-xs">
Skill AI AI 使 Skill
</DialogDescription> </DialogDescription>
</div> </div>
<DialogClose asChild> <DialogClose asChild>
@ -366,7 +366,7 @@ export function SkillPicker({
</div> </div>
<p className="mt-1 text-xs leading-5 text-muted-foreground"> <p className="mt-1 text-xs leading-5 text-muted-foreground">
Skill Skill
</p> </p>
</div> </div>
</div> </div>

View File

@ -27,12 +27,29 @@ export function SkillTag({
active?: boolean; active?: boolean;
muted?: boolean; muted?: boolean;
draggable?: boolean; draggable?: boolean;
status?: "selected" | "checking" | "used" | "unused"; status?:
| "selected"
| "checking"
| "queued"
| "running"
| "completed"
| "unused";
showStatusLabel?: boolean; showStatusLabel?: boolean;
className?: string; className?: string;
}) { }) {
const statusDescription = {
selected: "已选择",
checking: "判断中",
queued: "待执行",
running: "执行中",
completed: "已完成",
unused: "未触发",
}[status];
return ( return (
<span <span
aria-label={`${name}${statusDescription}`}
aria-current={status === "running" ? "step" : undefined}
className={cn( className={cn(
"inline-flex h-7 max-w-full items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-medium transition-colors", "inline-flex h-7 max-w-full items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-medium transition-colors",
active active
@ -40,7 +57,11 @@ export function SkillTag({
: "border-border bg-background text-foreground/75", : "border-border bg-background text-foreground/75",
status === "checking" && status === "checking" &&
"border-foreground/15 bg-background text-foreground", "border-foreground/15 bg-background text-foreground",
status === "used" && status === "queued" &&
"border-border bg-muted/40 text-muted-foreground",
status === "running" &&
"border-blue-300 bg-blue-50 text-blue-700 shadow-[0_0_0_3px_rgba(59,130,246,0.08)]",
status === "completed" &&
"border-emerald-200 bg-emerald-50 text-emerald-700", "border-emerald-200 bg-emerald-50 text-emerald-700",
status === "unused" && status === "unused" &&
"border-border bg-background text-muted-foreground opacity-65", "border-border bg-background text-muted-foreground opacity-65",
@ -70,10 +91,19 @@ export function SkillTag({
</span> </span>
)} )}
{showStatusLabel && status === "used" && ( {showStatusLabel && status === "queued" && (
<span className="ml-0.5 text-[9px] font-medium"></span>
)}
{showStatusLabel && status === "running" && (
<span className="ml-0.5 inline-flex items-center gap-1 text-[9px] font-medium">
<LoaderCircle className="size-3 animate-spin" />
</span>
)}
{showStatusLabel && status === "completed" && (
<span className="ml-0.5 inline-flex items-center gap-1 text-[9px] font-medium"> <span className="ml-0.5 inline-flex items-center gap-1 text-[9px] font-medium">
<CheckCircle2 className="size-3" /> <CheckCircle2 className="size-3" />
</span> </span>
)} )}
{showStatusLabel && status === "unused" && ( {showStatusLabel && status === "unused" && (

View File

@ -249,6 +249,9 @@ function ToolFallbackArgs({
className={cn("aui-tool-fallback-args", className)} className={cn("aui-tool-fallback-args", className)}
{...props} {...props}
> >
<p className="aui-tool-fallback-args-header text-muted-foreground mb-1 text-xs font-medium">
</p>
<pre className="aui-tool-fallback-args-value bg-muted/50 text-foreground/90 rounded-md p-2.5 text-xs whitespace-pre-wrap"> <pre className="aui-tool-fallback-args-value bg-muted/50 text-foreground/90 rounded-md p-2.5 text-xs whitespace-pre-wrap">
{argsText} {argsText}
</pre> </pre>

View File

@ -1,6 +1,7 @@
import { evaluateBuilderTurn, hasDeepSeekApiKey } from "@/lib/deepseek"; import { evaluateBuilderTurn, hasDeepSeekApiKey } from "@/lib/deepseek";
import type { import type {
BuilderEvaluation, BuilderEvaluation,
BuilderProposal,
SkillNode, SkillNode,
SseEvent, SseEvent,
} from "@/lib/types"; } from "@/lib/types";
@ -11,6 +12,7 @@ type BuilderRunInput = {
nodes: SkillNode[]; nodes: SkillNode[];
skillName: string; skillName: string;
skillDescription: string; skillDescription: string;
pendingProposal: BuilderProposal | null;
messages: Array<{ role: "user" | "assistant"; content: string }>; messages: Array<{ role: "user" | "assistant"; content: string }>;
}; };
@ -65,6 +67,7 @@ export function getOrStartBuilderRun(input: BuilderRunInput) {
nodes: input.nodes, nodes: input.nodes,
skillName: input.skillName, skillName: input.skillName,
skillDescription: input.skillDescription, skillDescription: input.skillDescription,
pendingProposal: input.pendingProposal,
messages: input.messages, messages: input.messages,
signal: run.controller.signal, signal: run.controller.signal,
}); });

View File

@ -1,6 +1,7 @@
import { import {
buildSkillExplanationResponse, buildSkillExplanationResponse,
buildChatSystemPrompt, buildChatSystemPrompt,
completeTextCompletion,
decideSkillContinuation, decideSkillContinuation,
decideSkillUsage, decideSkillUsage,
demoChatResponse, demoChatResponse,
@ -13,15 +14,16 @@ import {
import { import {
appendChatRunEvent, appendChatRunEvent,
completeChatRunIfNotCancelled, completeChatRunIfNotCancelled,
finalizeChatRunAsStopped,
getConversation, getConversation,
getChatRun, getChatRun,
getMessage, getMessage,
isChatRunStopRequested, isChatRunStopRequested,
requestChatRunStop,
setConversationContinuation, setConversationContinuation,
setConversationTitleIfEmpty, setConversationTitleIfEmpty,
updateChatRunStatus, updateChatRunStatus,
updateMessage, updateMessage,
updateMessageSkillExecutions,
updateMessageUsage, updateMessageUsage,
} from "@/lib/db"; } from "@/lib/db";
import { import {
@ -35,12 +37,14 @@ import type {
ConversationContinuation, ConversationContinuation,
ModelCallUsage, ModelCallUsage,
Skill, Skill,
SkillExecution,
SkillExecutionInput,
SkillSnapshot,
} from "@/lib/types"; } from "@/lib/types";
type RunningChat = { type RunningChat = {
controller: AbortController; controller: AbortController;
timedOut: boolean; timedOut: boolean;
stopContent?: string;
}; };
const globalForRuns = globalThis as unknown as { const globalForRuns = globalThis as unknown as {
@ -63,6 +67,48 @@ async function streamDemo(
} }
} }
function formatStageArtifacts(
artifacts: Array<{ skillName: string; content: string }>,
) {
if (artifacts.length === 0) return "无前序阶段结果。";
return artifacts
.map(
(artifact, index) =>
`### 前序阶段 ${index + 1}${artifact.skillName}\n${artifact.content}`,
)
.join("\n\n");
}
function buildIntermediateStagePrompt(
skill: Skill,
position: number,
total: number,
) {
return `${buildChatSystemPrompt([skill])}
你正在执行有序工作流的第 ${position}/${total} 阶段。本阶段只应用「${skill.name}」,产出供后续 Skill 使用的中间成果。
- 不要写寒暄、总结性结束语或可选的后续邀请;
- 保留后续阶段需要的事实、结构化数据、判断依据和待确认项;
- 不得编造缺失信息,涉及数量或金额时逐项复核计算;
- 只输出中间成果本身,不要解释内部编排过程。`;
}
function buildFinalStagePrompt(
skill: Skill,
orderedSkills: Skill[],
) {
return `${buildChatSystemPrompt([skill])}
你正在执行有序工作流的最终阶段。工作流顺序为:${orderedSkills
.map((item, index) => `${index + 1}. ${item.name}`)
.join("")}
请以「${skill.name}」作为当前阶段规范,吸收前序阶段成果,直接交付一份完整、连贯的最终回答。
- 不要向用户泄露中间产物、阶段编号或内部编排过程;
- 用户的明确要求优先,前序成果中的事实和结构化数据应继续保留;
- 发现前序结果冲突或计算错误时应在最终回答中纠正;
- 涉及汇总、比例或排序时,完成一次独立复核后再输出。`;
}
async function completeTitle( async function completeTitle(
runId: string, runId: string,
titlePromise: Promise<string>, titlePromise: Promise<string>,
@ -106,6 +152,7 @@ export function startChatRun(input: {
conversationId: string; conversationId: string;
userMessage: string; userMessage: string;
selectedSkills: Skill[]; selectedSkills: Skill[];
skillSnapshots: SkillSnapshot[];
continuation: ConversationContinuation; continuation: ConversationContinuation;
history: Array<{ role: ChatMessage["role"]; content: string }>; history: Array<{ role: ChatMessage["role"]; content: string }>;
}) { }) {
@ -142,6 +189,7 @@ export function startChatRun(input: {
void (async () => { void (async () => {
let content = ""; let content = "";
let usedSkillIds: string[] = []; let usedSkillIds: string[] = [];
let skillExecutions: SkillExecution[] = [];
let lastPersistedAt = 0; let lastPersistedAt = 0;
let titlePromise: Promise<string> | null = null; let titlePromise: Promise<string> | null = null;
let usage = createMessageUsage( let usage = createMessageUsage(
@ -153,6 +201,7 @@ export function startChatRun(input: {
let terminalDurationMs: number | undefined; let terminalDurationMs: number | undefined;
const persistUsage = () => { const persistUsage = () => {
if (getChatRun(input.runId)?.status === "stopped") return;
if (usageTerminalStatus) { if (usageTerminalStatus) {
usage = finalizeMessageUsage( usage = finalizeMessageUsage(
usage, usage,
@ -203,10 +252,11 @@ export function startChatRun(input: {
onUsage, onUsage,
}, },
); );
usedSkillIds = skillDecision.skillIds; const decidedSkillIds: string[] = skillDecision.skillIds;
const usedSkills = input.selectedSkills.filter((skill) => const usedSkills = input.selectedSkills.filter((skill) =>
usedSkillIds.includes(skill.id), decidedSkillIds.includes(skill.id),
); );
usedSkillIds = usedSkills.map((skill) => skill.id);
if (!getConversation(input.conversationId)?.title) { if (!getConversation(input.conversationId)?.title) {
titlePromise = generateConversationTitle({ titlePromise = generateConversationTitle({
userMessage: input.userMessage, userMessage: input.userMessage,
@ -225,7 +275,7 @@ export function startChatRun(input: {
type: "status", type: "status",
label: label:
usedSkills.length > 0 usedSkills.length > 0
? `正在应用 ${usedSkills.map((skill) => skill.name).join("、")}` ? `已规划 ${usedSkills.length} 个 Skill准备开始`
: "正在思考", : "正在思考",
}); });
@ -248,32 +298,244 @@ export function startChatRun(input: {
} }
}; };
if (isExplanationRequest) { const startSkill = (
await streamDemo( skill: Skill,
buildSkillExplanationResponse(usedSkills), position: number,
controller.signal, stageInput: SkillExecutionInput,
onToken, ) => {
const startedAt = new Date().toISOString();
const execution: SkillExecution = {
skillId: skill.id,
skillName: skill.name,
position,
total: usedSkills.length,
status: "running",
input: stageInput,
startedAt,
};
skillExecutions = [
...skillExecutions.filter(
(item) =>
item.skillId !== skill.id || item.position !== position,
),
execution,
].sort((a, b) => a.position - b.position);
updateMessageSkillExecutions(
run.assistantMessageId,
skillExecutions,
); );
} else if (hasDeepSeekApiKey()) { appendChatRunEvent(input.runId, {
await streamTextCompletion({ type: "skill_started",
messages: [ skillId: skill.id,
{ skillName: skill.name,
role: "system", position,
content: buildChatSystemPrompt(usedSkills), total: usedSkills.length,
}, input: stageInput,
...input.history, startedAt,
{ role: "user", content: input.userMessage },
],
signal: controller.signal,
onToken,
onUsage,
}); });
} else { appendChatRunEvent(input.runId, {
await streamDemo( type: "status",
demoChatResponse(input.userMessage, usedSkills), label: `正在执行 ${skill.name}${position}/${usedSkills.length}`,
controller.signal, });
onToken, };
const completeSkill = (
skill: Skill,
position: number,
stageInput: SkillExecutionInput,
result: string,
) => {
const startedAt =
skillExecutions.find(
(item) =>
item.skillId === skill.id && item.position === position,
)?.startedAt ?? new Date().toISOString();
const completedAt = new Date().toISOString();
const execution: SkillExecution = {
skillId: skill.id,
skillName: skill.name,
position,
total: usedSkills.length,
status: "completed",
input: stageInput,
result,
startedAt,
completedAt,
};
skillExecutions = skillExecutions
.map((item) =>
item.skillId === skill.id && item.position === position
? execution
: item,
)
.sort((a, b) => a.position - b.position);
updateMessageSkillExecutions(
run.assistantMessageId,
skillExecutions,
); );
appendChatRunEvent(input.runId, {
type: "skill_completed",
skillId: skill.id,
skillName: skill.name,
position,
total: usedSkills.length,
input: stageInput,
result,
startedAt,
completedAt,
});
};
if (isExplanationRequest) {
if (usedSkills.length === 0) {
await streamDemo(
buildSkillExplanationResponse([]),
controller.signal,
onToken,
);
} else {
for (const [index, skill] of usedSkills.entries()) {
const position = index + 1;
const stageInput: SkillExecutionInput = {
request: input.userMessage,
upstreamResults: [],
};
startSkill(skill, position, stageInput);
if (index > 0) onToken("\n\n");
const stageResult = buildSkillExplanationResponse([skill]);
await streamDemo(
stageResult,
controller.signal,
onToken,
);
throwIfStopRequested();
completeSkill(skill, position, stageInput, stageResult);
}
}
} else if (hasDeepSeekApiKey()) {
const stageArtifacts: Array<{
skillName: string;
content: string;
}> = [];
if (usedSkills.length === 0) {
await streamTextCompletion({
messages: [
{
role: "system",
content: buildChatSystemPrompt([]),
},
...input.history,
{ role: "user", content: input.userMessage },
],
signal: controller.signal,
onToken,
onUsage,
});
} else {
for (const [index, skill] of usedSkills.entries()) {
const position = index + 1;
const isFinalStage = index === usedSkills.length - 1;
const stageInput: SkillExecutionInput = {
request: input.userMessage,
upstreamResults: stageArtifacts.map((artifact) => ({
skillName: artifact.skillName,
result: artifact.content,
})),
};
startSkill(skill, position, stageInput);
throwIfStopRequested();
if (isFinalStage) {
await streamTextCompletion({
messages: [
{
role: "system",
content: buildFinalStagePrompt(skill, usedSkills),
},
...input.history,
{
role: "user",
content: `${input.userMessage}
以下是已经完成并复核过的前序 Skill 成果,请将其作为本阶段输入:
${formatStageArtifacts(stageArtifacts)}`,
},
],
signal: controller.signal,
onToken,
onUsage,
});
} else {
const stageContent = await completeTextCompletion({
messages: [
{
role: "system",
content: buildIntermediateStagePrompt(
skill,
position,
usedSkills.length,
),
},
...input.history,
{
role: "user",
content: `${input.userMessage}
可用的前序 Skill 成果:
${formatStageArtifacts(stageArtifacts)}`,
},
],
signal: controller.signal,
onUsage,
maxTokens: 1_800,
});
stageArtifacts.push({
skillName: skill.name,
content: stageContent.slice(0, 8_000),
});
}
throwIfStopRequested();
completeSkill(
skill,
position,
stageInput,
isFinalStage
? content
: stageArtifacts.at(-1)?.content ??
"该阶段没有生成结果。",
);
}
}
} else {
for (const [index, skill] of usedSkills.entries()) {
const position = index + 1;
const stageInput: SkillExecutionInput = {
request: input.userMessage,
upstreamResults: [],
};
startSkill(skill, position, stageInput);
const isFinalStage = index === usedSkills.length - 1;
const stageResult = isFinalStage
? demoChatResponse(input.userMessage, usedSkills)
: "本地演示模式未调用模型,因此没有生成独立的中间阶段产物。";
if (isFinalStage) {
await streamDemo(stageResult, controller.signal, onToken);
} else {
await new Promise((resolve) => setTimeout(resolve, 45));
}
throwIfStopRequested();
completeSkill(skill, position, stageInput, stageResult);
}
if (usedSkills.length === 0) {
await streamDemo(
demoChatResponse(input.userMessage, []),
controller.signal,
onToken,
);
}
} }
throwIfStopRequested(); throwIfStopRequested();
@ -288,13 +550,23 @@ export function startChatRun(input: {
const continuation = await decideSkillContinuation({ const continuation = await decideSkillContinuation({
userMessage: input.userMessage, userMessage: input.userMessage,
assistantResponse: content, assistantResponse: content,
selectedSkills: input.selectedSkills,
usedSkills, usedSkills,
continuation: input.continuation,
history: input.history,
onUsage, onUsage,
}); });
throwIfStopRequested(); throwIfStopRequested();
const persistedContinuation = setConversationContinuation( const persistedContinuation = setConversationContinuation(
input.conversationId, input.conversationId,
continuation, {
...continuation,
skillSnapshots:
continuation.state === "complete" ||
continuation.state === "abandoned"
? []
: input.skillSnapshots,
},
); );
appendChatRunEvent(input.runId, { appendChatRunEvent(input.runId, {
type: "skill_retention", type: "skill_retention",
@ -334,6 +606,9 @@ export function startChatRun(input: {
throw new DOMException("Aborted", "AbortError"); throw new DOMException("Aborted", "AbortError");
} }
} catch (error) { } catch (error) {
if (getChatRun(input.runId)?.status === "stopped") {
return;
}
const stopped = const stopped =
!state.timedOut && !state.timedOut &&
(controller.signal.aborted || (controller.signal.aborted ||
@ -341,66 +616,58 @@ export function startChatRun(input: {
(error instanceof Error && error.name === "AbortError")); (error instanceof Error && error.name === "AbortError"));
if (stopped) { if (stopped) {
const stoppedContent = const stoppedUsage = finalizeMessageUsage(
(state.stopContent ?? content) || "本轮回答已中止。"; usage,
setConversationContinuation(input.conversationId, { "stopped",
state: "complete", Math.max(0, Date.now() - runStartedAt),
skillIds: [], );
expectedInput: "", finalizeChatRunAsStopped({
reason: "本轮回答已中止,不保留 Skill 续接状态。", runId: input.runId,
source: "deterministic", content: content || "本轮回答已中止。",
});
appendChatRunEvent(input.runId, {
type: "skill_retention",
state: "complete",
skillIds: [],
expectedInput: "",
reason: "本轮回答已中止,不保留 Skill 续接状态。",
source: "deterministic",
});
updateMessage(run.assistantMessageId, {
content: stoppedContent,
usedSkillIds, usedSkillIds,
status: "stopped", usage: stoppedUsage,
}); continuation: {
const stoppedSkills = input.selectedSkills.filter((skill) => state:
usedSkillIds.includes(skill.id), input.selectedSkills.length > 0 ? "active" : "complete",
); skillIds: input.selectedSkills.map((skill) => skill.id),
const fallbackTitle = fallbackConversationTitle( skillSnapshots: input.skillSnapshots,
input.userMessage, expectedInput: "",
stoppedSkills, reason:
); input.selectedSkills.length > 0
await completeTitle( ? "回答已中止,当前需求仍未结束,维持 Skill 选择。"
input.runId, : "回答已中止,当前需求没有启用 Skill。",
titleWithin( source: "deterministic",
titlePromise ?? },
generateConversationTitle({ title: fallbackConversationTitle(
userMessage: input.userMessage, input.userMessage,
usedSkills: stoppedSkills, input.selectedSkills.filter((skill) =>
onUsage, usedSkillIds.includes(skill.id),
}), ),
fallbackTitle,
400,
), ),
);
completeUsage("stopped");
appendChatRunEvent(input.runId, { type: "aborted" });
updateChatRunStatus(input.runId, "stopped");
} else {
setConversationContinuation(input.conversationId, {
state: "complete",
skillIds: [],
expectedInput: "",
reason: "本轮回答失败,不保留 Skill 续接状态。",
source: "deterministic",
}); });
} else {
const errorContinuation = setConversationContinuation(
input.conversationId,
{
state:
input.selectedSkills.length > 0 ? "active" : "complete",
skillIds: input.selectedSkills.map((skill) => skill.id),
skillSnapshots: input.skillSnapshots,
expectedInput: "",
reason:
input.selectedSkills.length > 0
? "本轮回答失败,当前需求仍未结束,维持 Skill 选择。"
: "本轮回答失败,当前需求没有启用 Skill。",
source: "deterministic",
},
);
appendChatRunEvent(input.runId, { appendChatRunEvent(input.runId, {
type: "skill_retention", type: "skill_retention",
state: "complete", state: errorContinuation.state,
skillIds: [], skillIds: errorContinuation.skillIds,
expectedInput: "", expectedInput: errorContinuation.expectedInput,
reason: "本轮回答失败,不保留 Skill 续接状态。", reason: errorContinuation.reason,
source: "deterministic", source: errorContinuation.source,
}); });
const message = state.timedOut const message = state.timedOut
? "回答超时,已安全结束本轮" ? "回答超时,已安全结束本轮"
@ -431,7 +698,7 @@ export async function stopChatRun(
const run = getChatRun(id); const run = getChatRun(id);
if (!run) return { found: false, stopped: false, title: "" }; if (!run) return { found: false, stopped: false, title: "" };
const requestedContent = options?.visibleContent; const requestedContent = options?.visibleContent;
const persistStoppedUsage = (message: ChatMessage | null) => { const stoppedUsage = (message: ChatMessage | null) => {
const createdAtMs = Date.parse(run.createdAt); const createdAtMs = Date.parse(run.createdAt);
const durationMs = Math.max( const durationMs = Math.max(
0, 0,
@ -445,36 +712,30 @@ export async function stopChatRun(
"stopped", "stopped",
durationMs, durationMs,
); );
updateMessageUsage(run.assistantMessageId, usage); return usage;
appendChatRunEvent(id, { type: "usage", usage });
}; };
if (!["pending", "running"].includes(run.status)) { if (!["pending", "running"].includes(run.status)) {
if (run.status === "complete" && requestedContent !== undefined) { if (run.status === "complete" && requestedContent !== undefined) {
const assistantMessage = getMessage(run.assistantMessageId); const assistantMessage = getMessage(run.assistantMessageId);
updateMessage(run.assistantMessageId, { const selectedIds = assistantMessage?.selectedSkillIds ?? [];
finalizeChatRunAsStopped({
runId: id,
content: requestedContent || "本轮回答已中止。", content: requestedContent || "本轮回答已中止。",
usedSkillIds: assistantMessage?.usedSkillIds ?? [], usedSkillIds: assistantMessage?.usedSkillIds ?? [],
status: "stopped", usage: stoppedUsage(assistantMessage),
continuation: {
state: selectedIds.length > 0 ? "active" : "complete",
skillIds: selectedIds,
skillSnapshots: assistantMessage?.skillSnapshots ?? [],
expectedInput: "",
reason:
selectedIds.length > 0
? "用户在回答展示完成前请求中止,当前需求仍未结束。"
: "用户在回答展示完成前请求中止。",
source: "deterministic",
},
}); });
setConversationContinuation(run.conversationId, {
state: "complete",
skillIds: [],
expectedInput: "",
reason: "用户在回答展示完成前请求中止,不保留 Skill 续接状态。",
source: "deterministic",
});
persistStoppedUsage(assistantMessage);
appendChatRunEvent(id, {
type: "skill_retention",
state: "complete",
skillIds: [],
expectedInput: "",
reason: "用户在回答展示完成前请求中止,不保留 Skill 续接状态。",
source: "deterministic",
});
appendChatRunEvent(id, { type: "aborted" });
updateChatRunStatus(id, "stopped");
return { found: true, stopped: true, title: "" }; return { found: true, stopped: true, title: "" };
} }
return { return {
@ -484,47 +745,40 @@ export async function stopChatRun(
}; };
} }
requestChatRunStop(id);
const active = runningChats.get(id);
if (active) {
active.stopContent = requestedContent;
active.controller.abort();
return { found: true, stopped: true, title: "" };
}
const userMessage = getMessage(run.userMessageId); const userMessage = getMessage(run.userMessageId);
const assistantMessage = getMessage(run.assistantMessageId); const assistantMessage = getMessage(run.assistantMessageId);
updateMessage(run.assistantMessageId, { const selectedIds = assistantMessage?.selectedSkillIds ?? [];
const result = finalizeChatRunAsStopped({
runId: id,
content: content:
requestedContent || requestedContent ||
assistantMessage?.content || assistantMessage?.content ||
"本轮回答已中止。", "本轮回答已中止。",
usedSkillIds: assistantMessage?.usedSkillIds ?? [], usedSkillIds: assistantMessage?.usedSkillIds ?? [],
status: "stopped", usage: stoppedUsage(assistantMessage),
}); continuation: {
setConversationContinuation(run.conversationId, { state: selectedIds.length > 0 ? "active" : "complete",
state: "complete", skillIds: selectedIds,
skillIds: [], skillSnapshots: assistantMessage?.skillSnapshots ?? [],
expectedInput: "", expectedInput: "",
reason: "本轮回答已中止,不保留 Skill 续接状态。", reason:
source: "deterministic", selectedIds.length > 0
}); ? "回答已中止,当前需求仍未结束,维持 Skill 选择。"
const title = await completeTitle( : "回答已中止,当前需求没有启用 Skill。",
id, source: "deterministic",
Promise.resolve( },
fallbackConversationTitle(userMessage?.content ?? "", []), title: fallbackConversationTitle(
userMessage?.content ?? "",
[],
), ),
);
persistStoppedUsage(assistantMessage);
appendChatRunEvent(id, {
type: "skill_retention",
state: "complete",
skillIds: [],
expectedInput: "",
reason: "本轮回答已中止,不保留 Skill 续接状态。",
source: "deterministic",
}); });
appendChatRunEvent(id, { type: "aborted" }); const active = runningChats.get(id);
updateChatRunStatus(id, "stopped"); if (active) {
return { found: true, stopped: true, title }; active.controller.abort();
}
return {
found: true,
stopped: result.changed || getChatRun(id)?.status === "stopped",
title: result.title,
};
} }

View File

@ -7,18 +7,22 @@ import type {
ChatRunStatus, ChatRunStatus,
Conversation, Conversation,
ConversationContinuation, ConversationContinuation,
MessageUsage,
Skill, Skill,
SkillExecution,
SkillNode, SkillNode,
SseEvent, SseEvent,
} from "@/lib/types"; } from "@/lib/types";
import { SKILL_NODE_DEFINITIONS } from "@/lib/types"; import { SKILL_NODE_DEFINITIONS } from "@/lib/types";
import { parseMessageUsage } from "@/lib/model-usage"; import { parseMessageUsage } from "@/lib/model-usage";
import { parseSkillSnapshots } from "@/lib/skill-snapshots";
import { createId, safeJsonArray } from "@/lib/utils"; import { createId, safeJsonArray } from "@/lib/utils";
type SkillRow = { type SkillRow = {
id: string; id: string;
name: string; name: string;
description: string; description: string;
version: number;
status: "draft" | "active"; status: "draft" | "active";
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@ -38,6 +42,8 @@ type MessageRow = {
content: string; content: string;
selected_skill_ids: string; selected_skill_ids: string;
used_skill_ids: string; used_skill_ids: string;
skill_snapshots_json: string;
skill_executions_json: string;
status: ChatMessage["status"]; status: ChatMessage["status"];
error_message: string; error_message: string;
usage_json: string; usage_json: string;
@ -48,7 +54,9 @@ type ConversationRow = {
id: string; id: string;
title: string; title: string;
retained_skill_ids: string; retained_skill_ids: string;
retained_skill_snapshots: string;
continuation_state: ConversationContinuation["state"]; continuation_state: ConversationContinuation["state"];
task_state: ConversationContinuation["state"];
continuation_expected_input: string; continuation_expected_input: string;
continuation_reason: string; continuation_reason: string;
continuation_source: ConversationContinuation["source"]; continuation_source: ConversationContinuation["source"];
@ -78,7 +86,7 @@ const globalForDb = globalThis as unknown as {
skillLoomDbSchemaVersion?: number; skillLoomDbSchemaVersion?: number;
}; };
const DATABASE_SCHEMA_VERSION = 3; const DATABASE_SCHEMA_VERSION = 5;
function initializeDatabase(database: DatabaseSync) { function initializeDatabase(database: DatabaseSync) {
database.exec("PRAGMA journal_mode = WAL;"); database.exec("PRAGMA journal_mode = WAL;");
@ -90,6 +98,7 @@ function initializeDatabase(database: DatabaseSync) {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active')), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active')),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
@ -111,8 +120,10 @@ function initializeDatabase(database: DatabaseSync) {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT NOT NULL, title TEXT NOT NULL,
retained_skill_ids TEXT NOT NULL DEFAULT '[]', retained_skill_ids TEXT NOT NULL DEFAULT '[]',
retained_skill_snapshots TEXT NOT NULL DEFAULT '[]',
continuation_state TEXT NOT NULL DEFAULT 'complete' continuation_state TEXT NOT NULL DEFAULT 'complete'
CHECK (continuation_state IN ('complete', 'awaiting_input', 'offer_pending')), CHECK (continuation_state IN ('complete', 'awaiting_input', 'offer_pending')),
task_state TEXT NOT NULL DEFAULT 'complete',
continuation_expected_input TEXT NOT NULL DEFAULT '', continuation_expected_input TEXT NOT NULL DEFAULT '',
continuation_reason TEXT NOT NULL DEFAULT '', continuation_reason TEXT NOT NULL DEFAULT '',
continuation_source TEXT NOT NULL DEFAULT 'none' continuation_source TEXT NOT NULL DEFAULT 'none'
@ -128,6 +139,8 @@ function initializeDatabase(database: DatabaseSync) {
content TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
selected_skill_ids TEXT NOT NULL DEFAULT '[]', selected_skill_ids TEXT NOT NULL DEFAULT '[]',
used_skill_ids TEXT NOT NULL DEFAULT '[]', used_skill_ids TEXT NOT NULL DEFAULT '[]',
skill_snapshots_json TEXT NOT NULL DEFAULT '[]',
skill_executions_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'complete', status TEXT NOT NULL DEFAULT 'complete',
error_message TEXT NOT NULL DEFAULT '', error_message TEXT NOT NULL DEFAULT '',
usage_json TEXT NOT NULL DEFAULT '', usage_json TEXT NOT NULL DEFAULT '',
@ -215,6 +228,24 @@ function initializeDatabase(database: DatabaseSync) {
"ALTER TABLE messages ADD COLUMN usage_json TEXT NOT NULL DEFAULT '';", "ALTER TABLE messages ADD COLUMN usage_json TEXT NOT NULL DEFAULT '';",
); );
} }
if (
!messageColumns.some(
(column) => column.name === "skill_snapshots_json",
)
) {
database.exec(
"ALTER TABLE messages ADD COLUMN skill_snapshots_json TEXT NOT NULL DEFAULT '[]';",
);
}
if (
!messageColumns.some(
(column) => column.name === "skill_executions_json",
)
) {
database.exec(
"ALTER TABLE messages ADD COLUMN skill_executions_json TEXT NOT NULL DEFAULT '[]';",
);
}
const conversationColumns = database const conversationColumns = database
.prepare("PRAGMA table_info(conversations)") .prepare("PRAGMA table_info(conversations)")
@ -228,6 +259,15 @@ function initializeDatabase(database: DatabaseSync) {
"ALTER TABLE conversations ADD COLUMN retained_skill_ids TEXT NOT NULL DEFAULT '[]';", "ALTER TABLE conversations ADD COLUMN retained_skill_ids TEXT NOT NULL DEFAULT '[]';",
); );
} }
if (
!conversationColumns.some(
(column) => column.name === "retained_skill_snapshots",
)
) {
database.exec(
"ALTER TABLE conversations ADD COLUMN retained_skill_snapshots TEXT NOT NULL DEFAULT '[]';",
);
}
if ( if (
!conversationColumns.some( !conversationColumns.some(
(column) => column.name === "continuation_state", (column) => column.name === "continuation_state",
@ -240,6 +280,20 @@ function initializeDatabase(database: DatabaseSync) {
"UPDATE conversations SET continuation_state = 'awaiting_input' WHERE retained_skill_ids <> '[]';", "UPDATE conversations SET continuation_state = 'awaiting_input' WHERE retained_skill_ids <> '[]';",
); );
} }
if (!conversationColumns.some((column) => column.name === "task_state")) {
database.exec(
"ALTER TABLE conversations ADD COLUMN task_state TEXT NOT NULL DEFAULT 'complete';",
);
database.exec(
`UPDATE conversations
SET task_state = CASE
WHEN continuation_state IN ('awaiting_input', 'offer_pending')
THEN continuation_state
WHEN retained_skill_ids <> '[]' THEN 'active'
ELSE 'complete'
END`,
);
}
if ( if (
!conversationColumns.some( !conversationColumns.some(
(column) => column.name === "continuation_expected_input", (column) => column.name === "continuation_expected_input",
@ -281,6 +335,15 @@ function initializeDatabase(database: DatabaseSync) {
database.exec("UPDATE skill_nodes SET ready = completed;"); database.exec("UPDATE skill_nodes SET ready = completed;");
} }
const skillColumns = database
.prepare("PRAGMA table_info(skills)")
.all() as unknown as Array<{ name: string }>;
if (!skillColumns.some((column) => column.name === "version")) {
database.exec(
"ALTER TABLE skills ADD COLUMN version INTEGER NOT NULL DEFAULT 1;",
);
}
database.exec(` database.exec(`
UPDATE conversations UPDATE conversations
SET title = '' SET title = ''
@ -423,6 +486,7 @@ function rowsToSkill(row: SkillRow, nodeRows: SkillNodeRow[]): Skill {
id: row.id, id: row.id,
name: row.name, name: row.name,
description: row.description, description: row.description,
version: row.version,
status: row.status, status: row.status,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
@ -494,7 +558,8 @@ export function saveSkill(input: {
if (existing) { if (existing) {
db.prepare( db.prepare(
`UPDATE skills `UPDATE skills
SET name = ?, description = ?, status = ?, updated_at = ? SET name = ?, description = ?, status = ?,
version = version + 1, updated_at = ?
WHERE id = ?`, WHERE id = ?`,
).run( ).run(
input.name, input.name,
@ -506,8 +571,8 @@ export function saveSkill(input: {
} else { } else {
db.prepare( db.prepare(
`INSERT INTO skills `INSERT INTO skills
(id, name, description, status, created_at, updated_at) (id, name, description, version, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, 1, ?, ?, ?)`,
).run( ).run(
id, id,
input.name, input.name,
@ -569,7 +634,9 @@ export function getDefaultConversation(): Conversation {
id: createId("conversation"), id: createId("conversation"),
title: "", title: "",
retained_skill_ids: "[]", retained_skill_ids: "[]",
retained_skill_snapshots: "[]",
continuation_state: "complete", continuation_state: "complete",
task_state: "complete",
continuation_expected_input: "", continuation_expected_input: "",
continuation_reason: "", continuation_reason: "",
continuation_source: "none", continuation_source: "none",
@ -642,6 +709,7 @@ export function setConversationRetainedSkillIds(
return setConversationContinuation(id, { return setConversationContinuation(id, {
state: skillIds.length > 0 ? "awaiting_input" : "complete", state: skillIds.length > 0 ? "awaiting_input" : "complete",
skillIds, skillIds,
skillSnapshots: [],
expectedInput: "", expectedInput: "",
reason: skillIds.length > 0 ? "legacy_retention" : "", reason: skillIds.length > 0 ? "legacy_retention" : "",
source: skillIds.length > 0 ? "fallback" : "none", source: skillIds.length > 0 ? "fallback" : "none",
@ -654,6 +722,7 @@ export function getConversationContinuation(
const row = db const row = db
.prepare( .prepare(
`SELECT retained_skill_ids, continuation_state, `SELECT retained_skill_ids, continuation_state,
retained_skill_snapshots, task_state,
continuation_expected_input, continuation_reason, continuation_expected_input, continuation_reason,
continuation_source, updated_at continuation_source, updated_at
FROM conversations WHERE id = ?`, FROM conversations WHERE id = ?`,
@ -662,7 +731,9 @@ export function getConversationContinuation(
| Pick< | Pick<
ConversationRow, ConversationRow,
| "retained_skill_ids" | "retained_skill_ids"
| "retained_skill_snapshots"
| "continuation_state" | "continuation_state"
| "task_state"
| "continuation_expected_input" | "continuation_expected_input"
| "continuation_reason" | "continuation_reason"
| "continuation_source" | "continuation_source"
@ -671,13 +742,15 @@ export function getConversationContinuation(
| undefined; | undefined;
const validStates = new Set([ const validStates = new Set([
"active",
"complete", "complete",
"awaiting_input", "awaiting_input",
"offer_pending", "offer_pending",
"abandoned",
]); ]);
const validSources = new Set(["none", "deterministic", "ai", "fallback"]); const validSources = new Set(["none", "deterministic", "ai", "fallback"]);
const state = validStates.has(row?.continuation_state ?? "") const state = validStates.has(row?.task_state ?? "")
? row!.continuation_state ? row!.task_state
: "complete"; : "complete";
const source = validSources.has(row?.continuation_source ?? "") const source = validSources.has(row?.continuation_source ?? "")
? row!.continuation_source ? row!.continuation_source
@ -686,9 +759,13 @@ export function getConversationContinuation(
return { return {
state, state,
skillIds: skillIds:
state === "complete" state === "complete" || state === "abandoned"
? [] ? []
: safeJsonArray(row?.retained_skill_ids ?? "[]"), : safeJsonArray(row?.retained_skill_ids ?? "[]"),
skillSnapshots:
state === "complete" || state === "abandoned"
? []
: parseSkillSnapshots(row?.retained_skill_snapshots),
expectedInput: row?.continuation_expected_input ?? "", expectedInput: row?.continuation_expected_input ?? "",
reason: row?.continuation_reason ?? "", reason: row?.continuation_reason ?? "",
source, source,
@ -701,22 +778,46 @@ export function setConversationContinuation(
continuation: Omit<ConversationContinuation, "updatedAt">, continuation: Omit<ConversationContinuation, "updatedAt">,
) { ) {
const requestedSkillIds = [...new Set(continuation.skillIds)].slice(0, 12); const requestedSkillIds = [...new Set(continuation.skillIds)].slice(0, 12);
const terminal =
continuation.state === "complete" ||
continuation.state === "abandoned";
const state = const state =
continuation.state === "complete" || requestedSkillIds.length === 0 terminal || requestedSkillIds.length > 0
? ("complete" as const) ? continuation.state
: continuation.state; : ("complete" as const);
const uniqueIds = state === "complete" ? [] : requestedSkillIds; const uniqueIds =
state === "complete" || state === "abandoned"
? []
: requestedSkillIds;
const allowedIds = new Set(uniqueIds);
const snapshots =
uniqueIds.length === 0
? []
: continuation.skillSnapshots.filter((snapshot) =>
allowedIds.has(snapshot.id),
);
const legacyState =
state === "active"
? "awaiting_input"
: state === "abandoned"
? "complete"
: state;
const now = new Date().toISOString(); const now = new Date().toISOString();
db.prepare( db.prepare(
`UPDATE conversations `UPDATE conversations
SET retained_skill_ids = ?, continuation_state = ?, SET retained_skill_ids = ?, retained_skill_snapshots = ?,
continuation_state = ?, task_state = ?,
continuation_expected_input = ?, continuation_reason = ?, continuation_expected_input = ?, continuation_reason = ?,
continuation_source = ?, updated_at = ? continuation_source = ?, updated_at = ?
WHERE id = ?`, WHERE id = ?`,
).run( ).run(
JSON.stringify(uniqueIds), JSON.stringify(uniqueIds),
JSON.stringify(snapshots),
legacyState,
state, state,
state === "complete" ? "" : continuation.expectedInput.slice(0, 240), state === "complete" || state === "abandoned"
? ""
: continuation.expectedInput.slice(0, 240),
continuation.reason.slice(0, 400), continuation.reason.slice(0, 400),
continuation.source, continuation.source,
now, now,
@ -725,8 +826,11 @@ export function setConversationContinuation(
return { return {
state, state,
skillIds: uniqueIds, skillIds: uniqueIds,
skillSnapshots: snapshots,
expectedInput: expectedInput:
state === "complete" ? "" : continuation.expectedInput.slice(0, 240), state === "complete" || state === "abandoned"
? ""
: continuation.expectedInput.slice(0, 240),
reason: continuation.reason.slice(0, 400), reason: continuation.reason.slice(0, 400),
source: continuation.source, source: continuation.source,
updatedAt: now, updatedAt: now,
@ -756,7 +860,9 @@ export function resetConversation(id: string) {
db.prepare( db.prepare(
`UPDATE conversations `UPDATE conversations
SET title = '', retained_skill_ids = '[]', SET title = '', retained_skill_ids = '[]',
retained_skill_snapshots = '[]',
continuation_state = 'complete', continuation_state = 'complete',
task_state = 'complete',
continuation_expected_input = '', continuation_expected_input = '',
continuation_reason = '', continuation_reason = '',
continuation_source = 'none', continuation_source = 'none',
@ -772,6 +878,71 @@ export function resetConversation(id: string) {
return getConversation(id); return getConversation(id);
} }
function parseSkillExecutions(value: string): SkillExecution[] {
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === "object",
)
.filter(
(item) =>
typeof item.skillId === "string" &&
typeof item.skillName === "string" &&
typeof item.position === "number" &&
typeof item.total === "number" &&
(item.status === "running" || item.status === "completed") &&
typeof item.startedAt === "string" &&
Boolean(item.input) &&
typeof item.input === "object",
)
.map((item) => {
const input = item.input as Record<string, unknown>;
const upstreamResults = Array.isArray(input.upstreamResults)
? input.upstreamResults
.filter(
(entry): entry is Record<string, unknown> =>
Boolean(entry) && typeof entry === "object",
)
.filter(
(entry) =>
typeof entry.skillName === "string" &&
typeof entry.result === "string",
)
.map((entry) => ({
skillName: entry.skillName as string,
result: entry.result as string,
}))
: [];
return {
skillId: item.skillId as string,
skillName: item.skillName as string,
position: item.position as number,
total: item.total as number,
status: item.status as SkillExecution["status"],
input: {
request:
typeof input.request === "string" ? input.request : "",
upstreamResults,
},
result: typeof item.result === "string" ? item.result : undefined,
startedAt: item.startedAt as string,
completedAt:
typeof item.completedAt === "string"
? item.completedAt
: undefined,
};
})
.slice(0, 12);
} catch {
return [];
}
}
export function listMessages(conversationId: string): ChatMessage[] { export function listMessages(conversationId: string): ChatMessage[] {
const rows = db const rows = db
.prepare( .prepare(
@ -785,6 +956,8 @@ export function listMessages(conversationId: string): ChatMessage[] {
content: row.content, content: row.content,
selectedSkillIds: safeJsonArray(row.selected_skill_ids), selectedSkillIds: safeJsonArray(row.selected_skill_ids),
usedSkillIds: safeJsonArray(row.used_skill_ids), usedSkillIds: safeJsonArray(row.used_skill_ids),
skillSnapshots: parseSkillSnapshots(row.skill_snapshots_json),
skillExecutions: parseSkillExecutions(row.skill_executions_json),
status: row.status, status: row.status,
errorMessage: row.error_message || undefined, errorMessage: row.error_message || undefined,
usage: parseMessageUsage(row.usage_json), usage: parseMessageUsage(row.usage_json),
@ -804,6 +977,8 @@ export function getMessage(id: string): ChatMessage | null {
content: row.content, content: row.content,
selectedSkillIds: safeJsonArray(row.selected_skill_ids), selectedSkillIds: safeJsonArray(row.selected_skill_ids),
usedSkillIds: safeJsonArray(row.used_skill_ids), usedSkillIds: safeJsonArray(row.used_skill_ids),
skillSnapshots: parseSkillSnapshots(row.skill_snapshots_json),
skillExecutions: parseSkillExecutions(row.skill_executions_json),
status: row.status, status: row.status,
errorMessage: row.error_message || undefined, errorMessage: row.error_message || undefined,
usage: parseMessageUsage(row.usage_json), usage: parseMessageUsage(row.usage_json),
@ -819,8 +994,10 @@ export function insertMessage(
db.prepare( db.prepare(
`INSERT INTO messages `INSERT INTO messages
(id, conversation_id, role, content, selected_skill_ids, used_skill_ids, status, error_message, usage_json, created_at) (id, conversation_id, role, content, selected_skill_ids, used_skill_ids,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, skill_snapshots_json, skill_executions_json, status, error_message,
usage_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(
message.id, message.id,
conversationId, conversationId,
@ -828,6 +1005,8 @@ export function insertMessage(
message.content, message.content,
JSON.stringify(message.selectedSkillIds), JSON.stringify(message.selectedSkillIds),
JSON.stringify(message.usedSkillIds), JSON.stringify(message.usedSkillIds),
JSON.stringify(message.skillSnapshots),
JSON.stringify(message.skillExecutions),
message.status, message.status,
message.errorMessage ?? "", message.errorMessage ?? "",
message.usage ? JSON.stringify(message.usage) : "", message.usage ? JSON.stringify(message.usage) : "",
@ -866,6 +1045,15 @@ export function updateMessageUsage(
); );
} }
export function updateMessageSkillExecutions(
id: string,
executions: SkillExecution[],
) {
db.prepare(
"UPDATE messages SET skill_executions_json = ? WHERE id = ?",
).run(JSON.stringify(executions), id);
}
function rowToChatRun(row: ChatRunRow): ChatRun { function rowToChatRun(row: ChatRunRow): ChatRun {
return { return {
id: row.id, id: row.id,
@ -931,6 +1119,20 @@ export function createChatRunWithMessages(input: {
insertMessage(input.conversationId, input.userMessage); insertMessage(input.conversationId, input.userMessage);
insertMessage(input.conversationId, input.assistantMessage); insertMessage(input.conversationId, input.assistantMessage);
setConversationContinuation(input.conversationId, {
state:
input.assistantMessage.selectedSkillIds.length > 0
? "active"
: "complete",
skillIds: input.assistantMessage.selectedSkillIds,
skillSnapshots: input.assistantMessage.skillSnapshots,
expectedInput: "",
reason:
input.assistantMessage.selectedSkillIds.length > 0
? "用户已开始一个启用所选 Skill 的需求。"
: "当前需求没有启用 Skill。",
source: "deterministic",
});
const run = createChatRun({ const run = createChatRun({
...input.run, ...input.run,
conversationId: input.conversationId, conversationId: input.conversationId,
@ -1007,16 +1209,6 @@ export function expireStaleChatRun(id: string, maxAgeMs = 180_000) {
error_message = '回答任务因服务重启或超时而中断' error_message = '回答任务因服务重启或超时而中断'
WHERE id = ? AND status = 'streaming'`, WHERE id = ? AND status = 'streaming'`,
).run(row.assistant_message_id); ).run(row.assistant_message_id);
db.prepare(
`UPDATE conversations
SET retained_skill_ids = '[]',
continuation_state = 'complete',
continuation_expected_input = '',
continuation_reason = '活动 Run 超时回收',
continuation_source = 'deterministic',
updated_at = ?
WHERE id = ?`,
).run(now, row.conversation_id);
appendChatRunEvent(id, { appendChatRunEvent(id, {
type: "error", type: "error",
message: "回答任务因服务重启或超时而中断,请重新发送", message: "回答任务因服务重启或超时而中断,请重新发送",
@ -1045,16 +1237,87 @@ export function completeChatRunIfNotCancelled(id: string) {
return Number(result.changes) > 0; return Number(result.changes) > 0;
} }
export function requestChatRunStop(id: string) { export function finalizeChatRunAsStopped(input: {
const now = new Date().toISOString(); runId: string;
const result = db content: string;
.prepare( usedSkillIds: string[];
usage: MessageUsage;
continuation: Omit<ConversationContinuation, "updatedAt">;
title?: string;
}) {
db.exec("BEGIN IMMEDIATE");
try {
const run = db
.prepare("SELECT * FROM chat_runs WHERE id = ?")
.get(input.runId) as ChatRunRow | undefined;
if (
!run ||
!["pending", "running", "complete"].includes(run.status)
) {
db.exec("COMMIT");
return { changed: false, title: "" };
}
const now = new Date().toISOString();
db.prepare(
`UPDATE messages
SET content = ?, used_skill_ids = ?, status = 'stopped',
error_message = '', usage_json = ?
WHERE id = ?`,
).run(
input.content,
JSON.stringify(input.usedSkillIds),
JSON.stringify(input.usage),
run.assistant_message_id,
);
const persistedContinuation = setConversationContinuation(
run.conversation_id,
input.continuation,
);
let title = "";
if (input.title?.trim()) {
const normalizedTitle = input.title.trim().slice(0, 40);
const titleResult = db
.prepare(
`UPDATE conversations
SET title = ?, updated_at = ?
WHERE id = ? AND title = ''`,
)
.run(normalizedTitle, now, run.conversation_id);
if (Number(titleResult.changes) > 0) title = normalizedTitle;
}
appendChatRunEvent(input.runId, {
type: "usage",
usage: input.usage,
});
appendChatRunEvent(input.runId, {
type: "skill_retention",
state: persistedContinuation.state,
skillIds: persistedContinuation.skillIds,
expectedInput: persistedContinuation.expectedInput,
reason: persistedContinuation.reason,
source: persistedContinuation.source,
});
if (title) {
appendChatRunEvent(input.runId, {
type: "conversation_title",
title,
});
}
appendChatRunEvent(input.runId, { type: "aborted" });
db.prepare(
`UPDATE chat_runs `UPDATE chat_runs
SET cancel_requested = 1, updated_at = ? SET status = 'stopped', cancel_requested = 1, updated_at = ?
WHERE id = ? AND status IN ('pending', 'running')`, WHERE id = ?`,
) ).run(now, input.runId);
.run(now, id); db.exec("COMMIT");
return Number(result.changes) > 0; return { changed: true, title };
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
} }
export function isChatRunStopRequested(id: string) { export function isChatRunStopRequested(id: string) {

View File

@ -1,5 +1,6 @@
import type { import type {
BuilderEvaluation, BuilderEvaluation,
BuilderProposal,
ConversationContinuation, ConversationContinuation,
ModelCallUsage, ModelCallUsage,
Skill, Skill,
@ -356,6 +357,34 @@ export async function streamTextCompletion(input: {
} }
} }
export async function completeTextCompletion(input: {
messages: AiMessage[];
signal: AbortSignal;
onUsage?: (usage: ModelCallUsage) => void;
maxTokens?: number;
}) {
const response = await requestDeepSeek(
{
messages: input.messages,
temperature: 0.35,
max_tokens: input.maxTokens ?? 2_000,
stream: false,
},
input.signal,
);
const payload = (await response.json()) as {
model?: string;
choices?: Array<{ message?: { content?: string } }>;
usage?: DeepSeekUsagePayload;
};
reportDeepSeekUsage(payload.model, payload.usage, input.onUsage);
const content = payload.choices?.[0]?.message?.content?.trim();
if (!content) {
throw new Error("DeepSeek 返回了空内容");
}
return content;
}
function skillSearchText(skill: Skill) { function skillSearchText(skill: Skill) {
return [ return [
skill.name, skill.name,
@ -412,16 +441,6 @@ export async function decideSkillUsage(
const recentConversation = (options?.history ?? []).slice(-6); const recentConversation = (options?.history ?? []).slice(-6);
const continuationReply = classifyContinuationReply(message); const continuationReply = classifyContinuationReply(message);
if (
retainedSkillIds.length > 0 &&
(continuationReply === "accept" || continuationReply === "answer")
) {
return {
skillIds: retainedSkillIds,
source: "continuation" as const,
reason: `用户正在回应上一轮的${options?.continuation?.state === "offer_pending" ? "具体提议" : "必要追问"}`,
};
}
if (retainedSkillIds.length > 0 && continuationReply === "decline") { if (retainedSkillIds.length > 0 && continuationReply === "decline") {
return { return {
skillIds: [], skillIds: [],
@ -453,13 +472,15 @@ export async function decideSkillUsage(
[ [
{ {
role: "system", role: "system",
content: `你是 Skill 路由器。你必须完全独立判断候选 Skill 是否真正有助于回答当前问题 content: `你是 Skill 编排器。候选 Skill 都是用户主动勾选、希望本轮采用的工作规范,因此默认全部使用
只选择与任务直接相关、会实质改变回答过程或格式的 Skill不要因为用户选中了就使用 先把用户请求拆成所有明确或可合理推导的子任务,再判断每个候选 Skill 能否帮助其中至少一个子任务
Skill 的触发条件是适用性指南而非死板关键词门槛;只要能力实质匹配,就应保留。一个 Skill 可以使用前序 Skill 产生的结构化结果,所需输入也可以从用户材料或前序结果中推导。
只有候选 Skill 与所有子任务明显无关、能力不可用,或与用户明确要求存在不可调和冲突时,才能排除。不得仅因为用户没有显式列出该 Skill 的全部输入而排除。
结合最近对话理解“需要”“可以”“1 小时”等省略回复;如果它是在回答上一轮的具体提议或必要追问,应继续选择相关的候选 Skill。 结合最近对话理解“需要”“可以”“1 小时”等省略回复;如果它是在回答上一轮的具体提议或必要追问,应继续选择相关的候选 Skill。
如果用户已经切换到新话题,不要沿用上一轮保留的 Skill。 如果用户已经切换到新话题,不要沿用上一轮保留的 Skill。
逐个判断所有候选项;可以一个都不选、只选一个或选择多个 逐个检查所有候选项;excludedSkillIds 只填写有充分理由排除的候选 ID通常应为空数组
reason 用一句话解释本轮选择依据 reason 用一句话说明保留的 Skill 如何覆盖本轮子任务,以及必要时为何排除个别 Skill
输出 JSON格式{"usedSkillIds":["skill_id"],"reason":"选择依据"}。只能返回候选 ID不得使用关键词匹配等外部规则代替判断。`, 输出 JSON格式{"excludedSkillIds":[],"reason":"编排依据"}。只能返回候选 ID不得使用关键词匹配等外部规则代替判断。`,
}, },
{ {
role: "user", role: "user",
@ -491,9 +512,18 @@ reason 用一句话解释本轮选择依据。
options?.onUsage, options?.onUsage,
); );
const excludedSkillIds = new Set(
(result.excludedSkillIds ?? []).filter((id) => allowed.has(id)),
);
const routedSkillIds = skills
.map((skill) => skill.id)
.filter((id) => !excludedSkillIds.has(id));
const isContinuation =
retainedSkillIds.length > 0 &&
isLikelySkillContinuationReply(message, recentConversation);
return { return {
skillIds: (result.usedSkillIds ?? []).filter((id) => allowed.has(id)), skillIds: routedSkillIds,
source: "ai" as const, source: isContinuation ? ("continuation" as const) : ("ai" as const),
reason: result.reason, reason: result.reason,
}; };
} }
@ -536,46 +566,46 @@ ${details}`;
.join("\n\n"); .join("\n\n");
} }
function fallbackSkillContinuation(
assistantResponse: string,
usedSkills: Skill[],
) {
const detected =
usedSkills.length > 0
? detectConversationContinuation(assistantResponse)
: null;
return {
detected,
skillIds: detected ? usedSkills.map((skill) => skill.id) : [],
};
}
export async function decideSkillContinuation(input: { export async function decideSkillContinuation(input: {
userMessage: string; userMessage: string;
assistantResponse: string; assistantResponse: string;
selectedSkills: Skill[];
usedSkills: Skill[]; usedSkills: Skill[];
continuation: ConversationContinuation;
history: ConversationTurn[];
onUsage?: (usage: ModelCallUsage) => void; onUsage?: (usage: ModelCallUsage) => void;
}) { }) {
const fallback = fallbackSkillContinuation( const selectedSkillIds = input.selectedSkills.map((skill) => skill.id);
input.assistantResponse, if (selectedSkillIds.length === 0) {
input.usedSkills,
);
const fallbackIds = fallback.skillIds;
if (input.usedSkills.length === 0) {
return { return {
state: "complete" as const, state: "complete" as const,
skillIds: [], skillIds: [],
expectedInput: "", expectedInput: "",
reason: "本轮未使用 Skill不需要保留续接状态。", reason: "当前需求没有启用 Skill。",
source: "deterministic" as const, source: "deterministic" as const,
}; };
} }
if (fallback.detected) { if (
input.continuation.state !== "complete" &&
classifyContinuationReply(input.userMessage) === "decline"
) {
return { return {
state: fallback.detected.state, state: "abandoned" as const,
skillIds: fallbackIds, skillIds: [],
expectedInput: fallback.detected.expectedInput, expectedInput: "",
reason: fallback.detected.reason, reason: "用户明确取消了当前需求的后续动作。",
source: "deterministic" as const,
};
}
const detected = detectConversationContinuation(
input.assistantResponse,
);
if (detected) {
return {
state: detected.state,
skillIds: selectedSkillIds,
expectedInput: detected.expectedInput,
reason: detected.reason,
source: "deterministic" as const, source: "deterministic" as const,
}; };
} }
@ -595,28 +625,35 @@ export async function decideSkillContinuation(input: {
[ [
{ {
role: "system", role: "system",
content: `你是多轮任务状态判断器。判断助手本轮回复后,当前任务是否明确停在“等待用户提供必要信息、确认选择或补充材料”的状态。 content: `你是多轮需求生命周期判断器。判断助手本轮回复后,当前用户需求处于什么状态。
规则: 规则:
- active需求仍在推进中但本轮既没有明确要求必要输入也没有等待确认具体提议
- awaiting_input助手明确要求用户补充完成任务所必需的信息 - awaiting_input助手明确要求用户补充完成任务所必需的信息
- offer_pending助手提出了一个具体、可执行的后续动作并正在等待用户接受或拒绝 - offer_pending助手提出了一个具体、可执行的后续动作并正在等待用户接受或拒绝
- complete本轮已经交付完成且没有等待用户回应。泛泛的“如果需要我可以继续”“还有什么可以帮你”等礼貌性邀请属于 complete - complete本轮已经交付完成且没有等待用户回应。泛泛的“如果需要我可以继续”“还有什么可以帮你”等礼貌性邀请属于 complete
- abandoned用户明确取消、放弃或结束了原需求
- “需要我根据你的时间调整计划吗”“要不要我继续生成迁移步骤”等有明确动作和对象的提议属于 offer_pending - “需要我根据你的时间调整计划吗”“要不要我继续生成迁移步骤”等有明确动作和对象的提议属于 offer_pending
- 只能保留本轮 actuallyUsedSkills 中下一轮仍必需的 Skill - selectedSkills 是用户为整个需求勾选的候选集合actuallyUsedSkills 只是本轮实际应用的子集;不要决定保留哪些 Skill
- 未实际使用的 Skill 永远不能保留 - expectedInput 简洁描述下一轮期待用户回答的内容complete 和 abandoned 时必须为空
- 如果 state 是 completekeepSkillIds 必须为空;
- expectedInput 简洁描述下一轮期待用户回答的内容complete 时必须为空;
- reason 用一句话说明状态依据; - reason 用一句话说明状态依据;
- 不要根据问号单独判断,要区分必要追问与可选邀请。 - 不要根据问号单独判断,要区分必要追问与可选邀请。
只输出 JSON 只输出 JSON
{"state":"awaiting_input","keepSkillIds":["skill_id"],"expectedInput":"需要用户补充的内容","reason":"判断理由"}`, {"state":"awaiting_input","expectedInput":"需要用户补充的内容","reason":"判断理由"}`,
}, },
{ {
role: "user", role: "user",
content: JSON.stringify({ content: JSON.stringify({
userMessage: input.userMessage.slice(0, 4_000), userMessage: input.userMessage.slice(0, 4_000),
assistantResponse: input.assistantResponse.slice(-8_000), assistantResponse: input.assistantResponse.slice(-8_000),
recentConversation: input.history.slice(-6),
previousState: input.continuation.state,
selectedSkills: input.selectedSkills.map((skill) => ({
id: skill.id,
name: skill.name,
version: skill.version,
})),
actuallyUsedSkills: input.usedSkills.map((skill) => ({ actuallyUsedSkills: input.usedSkills.map((skill) => ({
id: skill.id, id: skill.id,
name: skill.name, name: skill.name,
@ -629,29 +666,21 @@ export async function decideSkillContinuation(input: {
input.onUsage, input.onUsage,
); );
const allowedIds = new Set(input.usedSkills.map((skill) => skill.id)); const terminal =
const modelSkillIds = result.state === "complete" || result.state === "abandoned";
result.state !== "complete"
? result.keepSkillIds.filter((id) => allowedIds.has(id))
: [];
const state =
modelSkillIds.length > 0 ? result.state : ("complete" as const);
return { return {
state, state: result.state,
skillIds: state === "complete" ? [] : modelSkillIds, skillIds: terminal ? [] : selectedSkillIds,
expectedInput: state === "complete" ? "" : result.expectedInput, expectedInput: terminal ? "" : result.expectedInput,
reason: reason: result.reason,
state === "complete" && result.state !== "complete"
? "模型未返回有效的待续接 Skill。"
: result.reason,
source: "ai" as const, source: "ai" as const,
}; };
} catch { } catch {
return { return {
state: "complete" as const, state: "active" as const,
skillIds: [], skillIds: selectedSkillIds,
expectedInput: "", expectedInput: "",
reason: "状态判断失败,按完成处理以避免 Skill 意外粘连。", reason: "状态判断失败,暂时维持当前需求的 Skill 选择。",
source: "fallback" as const, source: "fallback" as const,
}; };
} }
@ -734,6 +763,30 @@ function fallbackBuilderSuggestions(nodes: SkillNode[]) {
return [...SKILL_NODE_DEFINITIONS[activeIndex].suggestions]; return [...SKILL_NODE_DEFINITIONS[activeIndex].suggestions];
} }
function formatBuilderProposal(proposal: BuilderProposal) {
const sections = proposal.updates.map((update) => {
const definition = SKILL_NODE_DEFINITIONS.find(
(item) => item.key === update.nodeKey,
);
const completeness = update.completed
? "内容已足够完整"
: "会先保存内容,但仍需继续补充";
return `### ${definition?.title ?? update.nodeKey}\n${update.content}\n\n_${completeness}_`;
});
return [
"我先整理成一份待确认提案,右侧节点尚未改变:",
`**Skill 名称**${proposal.skillName}`,
proposal.skillDescription
? `**一句话说明**${proposal.skillDescription}`
: "",
...sections,
"回复“确认写入”后我才会更新节点。你也可以直接告诉我要修改哪一处,或回复“放弃提案”。",
]
.filter(Boolean)
.join("\n\n");
}
export async function generateBuilderSuggestions(input: { export async function generateBuilderSuggestions(input: {
nodes: SkillNode[]; nodes: SkillNode[];
skillName: string; skillName: string;
@ -761,7 +814,7 @@ export async function generateBuilderSuggestions(input: {
content: `你是 Skill 构建器的快捷答案生成器。根据当前 Skill 上下文,为正在完善的唯一节点生成 2-4 条中文候选答案。 content: `你是 Skill 构建器的快捷答案生成器。根据当前 Skill 上下文,为正在完善的唯一节点生成 2-4 条中文候选答案。
要求: 要求:
- 每条都是用户点击后可直接发送并写入节点的完整答案,不是问题、标题、操作说明或空泛提示; - 每条都是用户点击后可直接发送并生成待确认节点提案的完整答案,不是问题、标题、操作说明或空泛提示;
- 候选答案要覆盖不同但合理的方向,结合已完成节点避免前后冲突; - 候选答案要覆盖不同但合理的方向,结合已完成节点避免前后冲突;
- 每条不超过 80 个字符,不使用 Markdown不要写“你可以”“请补充”等引导语 - 每条不超过 80 个字符,不使用 Markdown不要写“你可以”“请补充”等引导语
- 只为 currentNode 生成,不能提前替用户完成后续节点; - 只为 currentNode 生成,不能提前替用户完成后续节点;
@ -843,6 +896,8 @@ function normalizeBuilderResult(input: {
nodes: SkillNode[]; nodes: SkillNode[];
skillName: string; skillName: string;
skillDescription: string; skillDescription: string;
baseProposal?: BuilderProposal | null;
mode?: "propose" | "apply";
}) { }) {
const allowedKeys = new Set<SkillNodeKey>( const allowedKeys = new Set<SkillNodeKey>(
SKILL_NODE_DEFINITIONS.map((node) => node.key), SKILL_NODE_DEFINITIONS.map((node) => node.key),
@ -854,6 +909,12 @@ function normalizeBuilderResult(input: {
const intent = input.result.intent ?? "provide_spec"; const intent = input.result.intent ?? "provide_spec";
if (intent === "provide_spec") { if (intent === "provide_spec") {
for (const update of input.baseProposal?.updates ?? []) {
updates.set(update.nodeKey, {
content: update.content,
completed: update.completed,
});
}
for (const update of input.result.updates ?? []) { for (const update of input.result.updates ?? []) {
if (!allowedKeys.has(update.nodeKey)) continue; if (!allowedKeys.has(update.nodeKey)) continue;
const content = update.content.trim(); const content = update.content.trim();
@ -867,9 +928,38 @@ function normalizeBuilderResult(input: {
} }
} }
const proposedSkillName =
input.result.skillName?.trim().slice(0, 40) ||
input.baseProposal?.skillName ||
input.skillName ||
"未命名 Skill";
const proposedSkillDescription =
input.result.skillDescription?.trim().slice(0, 180) ||
input.baseProposal?.skillDescription ||
input.skillDescription;
const proposal =
input.mode !== "apply" &&
intent === "provide_spec" &&
updates.size > 0
? {
skillName: proposedSkillName,
skillDescription: proposedSkillDescription,
updates: [...updates.entries()].map(([nodeKey, update]) => ({
nodeKey,
...update,
})),
}
: null;
const appliedUpdates =
input.mode === "apply"
? updates
: new Map<
SkillNodeKey,
{ content: string; completed: boolean }
>();
const now = new Date().toISOString(); const now = new Date().toISOString();
const nodes = input.nodes.map((node) => { const nodes = input.nodes.map((node) => {
const update = updates.get(node.key); const update = appliedUpdates.get(node.key);
if (!update) { if (!update) {
return { return {
...node, ...node,
@ -905,28 +995,32 @@ function normalizeBuilderResult(input: {
(node, index) => (node, index) =>
node.completed && node.completed &&
!input.nodes[index].completed && !input.nodes[index].completed &&
!updates.has(node.key), !appliedUpdates.has(node.key),
); );
const defaultReply = allComplete const defaultReply = allComplete
? "五个节点都已经形成可执行规范。你可以继续描述想修改的内容,或保存这个 Skill。" ? "五个节点都已经形成可执行规范。你可以继续描述想修改的内容,或保存这个 Skill。"
: SKILL_NODE_DEFINITIONS[activeIndex].prompt; : SKILL_NODE_DEFINITIONS[activeIndex].prompt;
let reply = input.result.reply?.trim() || defaultReply; let reply = input.result.reply?.trim() || defaultReply;
const qualityFailures = [...updates.entries()].flatMap(([key, update]) => { const qualityFailures = [...appliedUpdates.entries()].flatMap(
if (update.completed) return []; ([key, update]) => {
const quality = evaluateSkillNodeQuality(key, update.content); if (update.completed) return [];
if (quality.complete) return []; const quality = evaluateSkillNodeQuality(key, update.content);
const title = SKILL_NODE_DEFINITIONS.find( if (quality.complete) return [];
(definition) => definition.key === key, const title = SKILL_NODE_DEFINITIONS.find(
)?.title; (definition) => definition.key === key,
return quality.missing.map( )?.title;
(missing) => `${title ?? key}还缺少:${missing}`, return quality.missing.map(
); (missing) => `${title ?? key}还缺少:${missing}`,
}); );
},
);
if (qualityFailures.length > 0) { if (proposal) {
reply = `已保存本轮提供的内容,但还不能标记为完成:${qualityFailures.join("")}\n\n${SKILL_NODE_DEFINITIONS[activeIndex].prompt}`; reply = formatBuilderProposal(proposal);
} else if (qualityFailures.length > 0) {
reply = `已确认写入本轮内容,但还不能标记为完成:${qualityFailures.join("")}\n\n${SKILL_NODE_DEFINITIONS[activeIndex].prompt}`;
} else if (allComplete) { } else if (allComplete) {
const updatedTitles = [...updates.keys()] const updatedTitles = [...appliedUpdates.keys()]
.map( .map(
(key) => (key) =>
SKILL_NODE_DEFINITIONS.find((definition) => definition.key === key) SKILL_NODE_DEFINITIONS.find((definition) => definition.key === key)
@ -938,7 +1032,7 @@ function normalizeBuilderResult(input: {
.map((node) => node.title) .map((node) => node.title)
.join("、"); .join("、");
reply = [ reply = [
updatedTitles ? `更新${updatedTitles}」。` : "", updatedTitles ? `确认并写入${updatedTitles}」。` : "",
unlockedTitles ? `之前准备好的「${unlockedTitles}」也已自动解锁。` : "", unlockedTitles ? `之前准备好的「${unlockedTitles}」也已自动解锁。` : "",
"五个节点都已经形成可执行规范,你可以继续描述修改内容,或保存这个 Skill。", "五个节点都已经形成可执行规范,你可以继续描述修改内容,或保存这个 Skill。",
].join(""); ].join("");
@ -949,15 +1043,21 @@ function normalizeBuilderResult(input: {
return { return {
reply, reply,
intent, intent,
action: proposal
? ("proposed" as const)
: appliedUpdates.size > 0
? ("applied" as const)
: ("none" as const),
skillName: skillName:
input.result.skillName?.trim().slice(0, 40) || appliedUpdates.size > 0 ? proposedSkillName : input.skillName,
input.skillName ||
"未命名 Skill",
skillDescription: skillDescription:
input.result.skillDescription?.trim().slice(0, 180) || appliedUpdates.size > 0
input.skillDescription, ? proposedSkillDescription
: input.skillDescription,
activeNode: SKILL_NODE_DEFINITIONS[activeIndex].key, activeNode: SKILL_NODE_DEFINITIONS[activeIndex].key,
updatedNodeKeys: [...updates.keys()], updatedNodeKeys: [...appliedUpdates.keys()],
proposedNodeKeys: proposal?.updates.map((update) => update.nodeKey) ?? [],
proposal,
suggestionNodeKey: SKILL_NODE_DEFINITIONS[activeIndex].key, suggestionNodeKey: SKILL_NODE_DEFINITIONS[activeIndex].key,
nodeQuality: sequentialNodes.map((node) => { nodeQuality: sequentialNodes.map((node) => {
const quality = evaluateSkillNodeQuality(node.key, node.content); const quality = evaluateSkillNodeQuality(node.key, node.content);
@ -998,6 +1098,7 @@ function demoBuilderEvaluation(input: {
nodes: SkillNode[]; nodes: SkillNode[];
skillName: string; skillName: string;
skillDescription: string; skillDescription: string;
pendingProposal: BuilderProposal | null;
}) { }) {
const currentDefinition = const currentDefinition =
SKILL_NODE_DEFINITIONS[activeNodeIndex(input.nodes)]; SKILL_NODE_DEFINITIONS[activeNodeIndex(input.nodes)];
@ -1044,6 +1145,17 @@ function demoBuilderEvaluation(input: {
}); });
} }
const proposalUpdates = new Map(
input.pendingProposal?.updates.map((update) => [
update.nodeKey,
update,
]) ?? [],
);
const draftNodes = input.nodes.map((node) => {
const update = proposalUpdates.get(node.key);
return update ? { ...node, content: update.content } : node;
});
const routeSignals: Array<[SkillNodeKey, RegExp[]]> = [ const routeSignals: Array<[SkillNodeKey, RegExp[]]> = [
[ [
"trigger", "trigger",
@ -1095,7 +1207,7 @@ function demoBuilderEvaluation(input: {
const routedUpdates = SKILL_NODE_DEFINITIONS.flatMap((definition, index) => { const routedUpdates = SKILL_NODE_DEFINITIONS.flatMap((definition, index) => {
const additions = routedSegments.get(definition.key); const additions = routedSegments.get(definition.key);
if (!additions) return []; if (!additions) return [];
const existing = input.nodes[index]; const existing = draftNodes[index];
const addition = additions.join("\n"); const addition = additions.join("\n");
return [ return [
{ {
@ -1142,17 +1254,82 @@ function demoBuilderEvaluation(input: {
nodes: input.nodes, nodes: input.nodes,
skillName: input.skillName, skillName: input.skillName,
skillDescription: input.skillDescription, skillDescription: input.skillDescription,
baseProposal: input.pendingProposal,
}); });
} }
function classifyBuilderProposalReply(message: string) {
const normalized = message
.trim()
.replace(/[。!!,\s]+$/g, "")
.toLowerCase();
if (
/^(?:确认|确认写入|确认应用|应用提案|按这个写入|就按这个|可以写入|没问题|同意)$/.test(
normalized,
)
) {
return "confirm" as const;
}
if (
/^(?:放弃|放弃提案|取消|取消提案|不要了|不写入|算了)$/.test(
normalized,
)
) {
return "discard" as const;
}
return "revise" as const;
}
export async function evaluateBuilderTurn(input: { export async function evaluateBuilderTurn(input: {
message: string; message: string;
nodes: SkillNode[]; nodes: SkillNode[];
skillName: string; skillName: string;
skillDescription: string; skillDescription: string;
pendingProposal: BuilderProposal | null;
messages: Array<{ role: "user" | "assistant"; content: string }>; messages: Array<{ role: "user" | "assistant"; content: string }>;
signal?: AbortSignal; signal?: AbortSignal;
}) { }) {
const proposalReply = input.pendingProposal
? classifyBuilderProposalReply(input.message)
: null;
if (input.pendingProposal && proposalReply === "confirm") {
return normalizeBuilderResult({
result: {
intent: "provide_spec",
skillName: input.pendingProposal.skillName,
skillDescription: input.pendingProposal.skillDescription,
updates: input.pendingProposal.updates,
suggestions: fallbackBuilderSuggestions(input.nodes),
suggestionNodeKey:
SKILL_NODE_DEFINITIONS[activeNodeIndex(input.nodes)].key,
reply: "",
},
nodes: input.nodes,
skillName: input.skillName,
skillDescription: input.skillDescription,
mode: "apply",
});
}
if (input.pendingProposal && proposalReply === "discard") {
const evaluation = normalizeBuilderResult({
result: {
intent: "request_guidance",
updates: [],
suggestions: fallbackBuilderSuggestions(input.nodes),
suggestionNodeKey:
SKILL_NODE_DEFINITIONS[activeNodeIndex(input.nodes)].key,
reply: `已放弃这次提案,节点没有发生变化。\n\n${SKILL_NODE_DEFINITIONS[activeNodeIndex(input.nodes)].prompt}`,
},
nodes: input.nodes,
skillName: input.skillName,
skillDescription: input.skillDescription,
});
return {
...evaluation,
action: "discarded" as const,
} satisfies BuilderEvaluation;
}
const unsupportedCapabilities = findUnsupportedSkillCapabilities( const unsupportedCapabilities = findUnsupportedSkillCapabilities(
input.message, input.message,
); );
@ -1185,6 +1362,8 @@ export async function evaluateBuilderTurn(input: {
五个节点必须严格按顺序完成:触发条件 → 输入参数 → 执行步骤 → 输出格式 → 约束与测试。 五个节点必须严格按顺序完成:触发条件 → 输入参数 → 执行步骤 → 输出格式 → 约束与测试。
用户不会手动指定要编辑哪个节点。你必须根据最新消息的语义,自主判断它应该更新一个或多个节点。 用户不会手动指定要编辑哪个节点。你必须根据最新消息的语义,自主判断它应该更新一个或多个节点。
你输出的 updates 是“待用户确认的完整提案”,服务端不会立即写入节点。用户明确确认后,服务端才会应用提案。
如果输入中存在 pendingProposal说明用户正在修改上一版提案。请在保留未被否定内容的基础上生成完整的新提案不要声称已经保存、更新或完成节点。
如果一条消息同时涵盖多个节点updates 必须包含所有真正被覆盖的节点,不能只更新当前节点。 如果一条消息同时涵盖多个节点updates 必须包含所有真正被覆盖的节点,不能只更新当前节点。
可以修改已经完成的节点也可以提前评估后续节点的内容。completed 表示该节点内容本身是否已经具体、完整、可执行;即使前置节点尚未完成,只要后续节点内容本身合格,也应返回 completed: true。服务端会保存这个判断并控制界面只能按顺序解锁。 可以修改已经完成的节点也可以提前评估后续节点的内容。completed 表示该节点内容本身是否已经具体、完整、可执行;即使前置节点尚未完成,只要后续节点内容本身合格,也应返回 completed: true。服务端会保存这个判断并控制界面只能按顺序解锁。
例如用户一次提供了第 1、2、3、5 节点的合格内容,应为这四个节点都返回 completed: true界面只会先完成 1、2、3。用户之后补齐第 4 节点时,之前准备好的第 5 节点会自动完成。 例如用户一次提供了第 1、2、3、5 节点的合格内容,应为这四个节点都返回 completed: true界面只会先完成 1、2、3。用户之后补齐第 4 节点时,之前准备好的第 5 节点会自动完成。
@ -1209,7 +1388,7 @@ ${SKILL_CAPABILITY_BOUNDARY}
- 信息足够时将对应节点 completed 设为 true - 信息足够时将对应节点 completed 设为 true
- 仍有任一未完成节点时reply 必须继续追问一个最关键、可直接回答的问题; - 仍有任一未完成节点时reply 必须继续追问一个最关键、可直接回答的问题;
- 如果 intent 为 provide_spec 且最近两次已经问过近似问题可以采用合理默认值、明确告知用户并将当前节点完成避免卡住request_guidance 永远不能因此完成节点; - 如果 intent 为 provide_spec 且最近两次已经问过近似问题可以采用合理默认值、明确告知用户并将当前节点完成避免卡住request_guidance 永远不能因此完成节点;
- reply 先说明 AI 判断更新了哪些节点,再提出唯一的下一问题;全部完成时才不再追问 - reply 简要说明提案判断,不得声称已经写入或保存节点;服务端会统一展示完整提案与确认提示
- suggestions 必须给出 2-4 个针对当前待完善节点、用户可直接采用或继续补充的短建议;不能重复用户已经确认的内容; - suggestions 必须给出 2-4 个针对当前待完善节点、用户可直接采用或继续补充的短建议;不能重复用户已经确认的内容;
- suggestionNodeKey 必须等于应用本轮 updates 后的第一个未完成节点;全部节点完成时仍填写 constraints - suggestionNodeKey 必须等于应用本轮 updates 后的第一个未完成节点;全部节点完成时仍填写 constraints
- 根据已有内容持续改进 skillName2-12 个汉字为佳)与 skillDescription。 - 根据已有内容持续改进 skillName2-12 个汉字为佳)与 skillDescription。
@ -1245,6 +1424,7 @@ ${SKILL_CAPABILITY_BOUNDARY}
completed: node.completed, completed: node.completed,
})), })),
}, },
pendingProposal: input.pendingProposal,
recentConversation: input.messages.slice(-10), recentConversation: input.messages.slice(-10),
firstIncompleteNode: firstIncompleteNode:
SKILL_NODE_DEFINITIONS[activeNodeIndex(input.nodes)].key, SKILL_NODE_DEFINITIONS[activeNodeIndex(input.nodes)].key,
@ -1260,5 +1440,6 @@ ${SKILL_CAPABILITY_BOUNDARY}
nodes: input.nodes, nodes: input.nodes,
skillName: input.skillName, skillName: input.skillName,
skillDescription: input.skillDescription, skillDescription: input.skillDescription,
baseProposal: input.pendingProposal,
}); });
} }

View File

@ -0,0 +1,99 @@
import type { Skill, SkillNode, SkillSnapshot } from "@/lib/types";
function isSkillNode(value: unknown): value is SkillNode {
if (!value || typeof value !== "object") return false;
const node = value as Partial<SkillNode>;
return (
typeof node.key === "string" &&
typeof node.title === "string" &&
typeof node.description === "string" &&
typeof node.content === "string" &&
typeof node.completed === "boolean"
);
}
function isSkillSnapshot(value: unknown): value is SkillSnapshot {
if (!value || typeof value !== "object") return false;
const snapshot = value as Partial<SkillSnapshot>;
return (
typeof snapshot.id === "string" &&
typeof snapshot.name === "string" &&
typeof snapshot.description === "string" &&
typeof snapshot.version === "number" &&
Number.isInteger(snapshot.version) &&
snapshot.version > 0 &&
Array.isArray(snapshot.nodes) &&
snapshot.nodes.every(isSkillNode) &&
typeof snapshot.createdAt === "string" &&
typeof snapshot.updatedAt === "string" &&
typeof snapshot.capturedAt === "string"
);
}
export function captureSkillSnapshots(
skills: Skill[],
capturedAt = new Date().toISOString(),
): SkillSnapshot[] {
return skills.map((skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
version: skill.version,
nodes: skill.nodes.map((node) => ({ ...node })),
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
capturedAt,
}));
}
export function parseSkillSnapshots(value: string | null | undefined) {
if (!value) return [];
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed) ? parsed.filter(isSkillSnapshot) : [];
} catch {
return [];
}
}
export function skillFromSnapshot(snapshot: SkillSnapshot): Skill {
return {
id: snapshot.id,
name: snapshot.name,
description: snapshot.description,
version: snapshot.version,
status: "active",
nodes: snapshot.nodes.map((node) => ({ ...node })),
createdAt: snapshot.createdAt,
updatedAt: snapshot.updatedAt,
};
}
export function pinSkillsToSnapshots(
currentSkills: Skill[],
snapshots: SkillSnapshot[],
) {
const byId = new Map(snapshots.map((snapshot) => [snapshot.id, snapshot]));
return currentSkills.map((skill) => {
const snapshot = byId.get(skill.id);
return snapshot ? skillFromSnapshot(snapshot) : skill;
});
}
export function snapshotsForSkills(
skills: Skill[],
existingSnapshots: SkillSnapshot[] = [],
) {
const existingById = new Map(
existingSnapshots.map((snapshot) => [snapshot.id, snapshot]),
);
const captured = captureSkillSnapshots(
skills.filter((skill) => !existingById.has(skill.id)),
);
const capturedById = new Map(
captured.map((snapshot) => [snapshot.id, snapshot]),
);
return skills.map(
(skill) => existingById.get(skill.id) ?? capturedById.get(skill.id)!,
);
}

View File

@ -78,12 +78,24 @@ export interface Skill {
id: string; id: string;
name: string; name: string;
description: string; description: string;
version: number;
status: "draft" | "active"; status: "draft" | "active";
nodes: SkillNode[]; nodes: SkillNode[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface SkillSnapshot {
id: string;
name: string;
description: string;
version: number;
nodes: SkillNode[];
createdAt: string;
updatedAt: string;
capturedAt: string;
}
export interface TokenUsage { export interface TokenUsage {
promptTokens: number; promptTokens: number;
promptCacheHitTokens: number; promptCacheHitTokens: number;
@ -111,12 +123,34 @@ export interface MessageUsage extends TokenUsage {
pricingVersion?: string; pricingVersion?: string;
} }
export interface SkillExecutionInput {
request: string;
upstreamResults: Array<{
skillName: string;
result: string;
}>;
}
export interface SkillExecution {
skillId: string;
skillName: string;
position: number;
total: number;
status: "running" | "completed";
input: SkillExecutionInput;
result?: string;
startedAt: string;
completedAt?: string;
}
export interface ChatMessage { export interface ChatMessage {
id: string; id: string;
role: "user" | "assistant"; role: "user" | "assistant";
content: string; content: string;
selectedSkillIds: string[]; selectedSkillIds: string[];
usedSkillIds: string[]; usedSkillIds: string[];
skillSnapshots: SkillSnapshot[];
skillExecutions: SkillExecution[];
status: "complete" | "streaming" | "stopped" | "error"; status: "complete" | "streaming" | "stopped" | "error";
errorMessage?: string; errorMessage?: string;
usage?: MessageUsage; usage?: MessageUsage;
@ -131,9 +165,11 @@ export interface Conversation {
} }
export type ConversationContinuationState = export type ConversationContinuationState =
| "active"
| "complete" | "complete"
| "awaiting_input" | "awaiting_input"
| "offer_pending"; | "offer_pending"
| "abandoned";
export type ConversationContinuationSource = export type ConversationContinuationSource =
| "none" | "none"
@ -144,6 +180,7 @@ export type ConversationContinuationSource =
export interface ConversationContinuation { export interface ConversationContinuation {
state: ConversationContinuationState; state: ConversationContinuationState;
skillIds: string[]; skillIds: string[];
skillSnapshots: SkillSnapshot[];
expectedInput: string; expectedInput: string;
reason: string; reason: string;
source: ConversationContinuationSource; source: ConversationContinuationSource;
@ -159,13 +196,28 @@ export interface BuilderMessage {
createdAt: string; createdAt: string;
} }
export interface BuilderProposalUpdate {
nodeKey: SkillNodeKey;
content: string;
completed: boolean;
}
export interface BuilderProposal {
skillName: string;
skillDescription: string;
updates: BuilderProposalUpdate[];
}
export interface BuilderEvaluation { export interface BuilderEvaluation {
reply: string; reply: string;
intent: "provide_spec" | "request_guidance"; intent: "provide_spec" | "request_guidance";
action: "none" | "proposed" | "applied" | "discarded";
skillName: string; skillName: string;
skillDescription: string; skillDescription: string;
activeNode: SkillNodeKey; activeNode: SkillNodeKey;
updatedNodeKeys: SkillNodeKey[]; updatedNodeKeys: SkillNodeKey[];
proposedNodeKeys: SkillNodeKey[];
proposal: BuilderProposal | null;
suggestionNodeKey: SkillNodeKey; suggestionNodeKey: SkillNodeKey;
nodeQuality: Array<{ nodeQuality: Array<{
key: SkillNodeKey; key: SkillNodeKey;
@ -222,6 +274,26 @@ export type SseEvent = SequencedEvent &
| "keyword_fallback"; | "keyword_fallback";
reason: string; reason: string;
} }
| {
type: "skill_started";
skillId: string;
skillName: string;
position: number;
total: number;
input: SkillExecutionInput;
startedAt: string;
}
| {
type: "skill_completed";
skillId: string;
skillName: string;
position: number;
total: number;
input: SkillExecutionInput;
result: string;
startedAt: string;
completedAt: string;
}
| { | {
type: "skill_retention"; type: "skill_retention";
state: ConversationContinuationState; state: ConversationContinuationState;

View File

@ -38,6 +38,25 @@ const skillNodesSchema = z
} }
}); });
const builderProposalSchema = z
.object({
skillName: z.string().trim().min(2).max(40),
skillDescription: z.string().trim().max(180),
updates: z
.array(
z
.object({
nodeKey: z.enum(nodeKeys),
content: z.string().trim().min(1).max(8_000),
completed: z.boolean(),
})
.strict(),
)
.min(1)
.max(nodeKeys.length),
})
.strict();
export const saveSkillSchema = z.object({ export const saveSkillSchema = z.object({
id: z.string().min(1).optional(), id: z.string().min(1).optional(),
name: z.string().trim().min(2).max(40), name: z.string().trim().min(2).max(40),
@ -60,6 +79,7 @@ export const builderRequestSchema = z.object({
message: z.string().trim().min(1).max(8_000), message: z.string().trim().min(1).max(8_000),
skillName: z.string().max(40).default("未命名 Skill"), skillName: z.string().max(40).default("未命名 Skill"),
skillDescription: z.string().max(180).default(""), skillDescription: z.string().max(180).default(""),
pendingProposal: builderProposalSchema.nullable().default(null),
nodes: skillNodesSchema, nodes: skillNodesSchema,
messages: z messages: z
.array( .array(
@ -73,15 +93,20 @@ export const builderRequestSchema = z.object({
export const skillRouterResultSchema = z export const skillRouterResultSchema = z
.object({ .object({
usedSkillIds: z.array(z.string()).max(12), excludedSkillIds: z.array(z.string()).max(12),
reason: z.string().trim().min(1).max(400), reason: z.string().trim().min(1).max(400),
}) })
.strict(); .strict();
export const skillContinuationResultSchema = z export const skillContinuationResultSchema = z
.object({ .object({
state: z.enum(["awaiting_input", "offer_pending", "complete"]), state: z.enum([
keepSkillIds: z.array(z.string()).max(12), "active",
"awaiting_input",
"offer_pending",
"complete",
"abandoned",
]),
expectedInput: z.string().trim().max(240), expectedInput: z.string().trim().max(240),
reason: z.string().trim().min(1).max(400), reason: z.string().trim().min(1).max(400),
}) })
@ -97,7 +122,10 @@ export const builderModelResultSchema = z
.object({ .object({
intent: z.enum(["provide_spec", "request_guidance"]), intent: z.enum(["provide_spec", "request_guidance"]),
skillName: z.string().min(2).max(40), skillName: z.string().min(2).max(40),
skillDescription: z.string().min(2).max(180), // A new Skill legitimately starts without a description. Treat the
// model-generated description as progressive metadata so an empty or
// omitted value cannot discard an otherwise valid builder turn.
skillDescription: z.string().trim().max(180).optional(),
updates: z updates: z
.array( .array(
z z