update
This commit is contained in:
28
README.md
28
README.md
@ -5,17 +5,21 @@
|
||||
## 已实现
|
||||
|
||||
- 单会话聊天、SQLite 持久化与首轮 AI 主题标题
|
||||
- 首屏由 Server Component 直接读取 SQLite,客户端 hydration 前即可显示工作台
|
||||
- 标题编辑、会话重置(重置后标题清空)
|
||||
- DeepSeek V4 Flash 流式回答
|
||||
- Skill 多选、拖拽排序、标签移除与 `/` 快速提示
|
||||
- 每轮由 AI 独立判断实际使用哪些已选 Skill
|
||||
- 勾选集合绑定当前需求生命周期,直到需求完成或明确放弃
|
||||
- 每轮由 AI 独立判断实际使用哪些已勾选 Skill
|
||||
- 候选 Skill 与实际使用 Skill 的差异化高亮反馈
|
||||
- 回答中止;中止作为本轮终态处理,并清空本轮 Skill
|
||||
- Skill 编辑自动递增版本;进行中的需求固定使用开始时的完整快照
|
||||
- 回答中止采用 SQLite 原子终态;只结束 Run,不清空需求的 Skill
|
||||
- SSE 事件持久化、序号去重与断线续传
|
||||
- Skill 新建、AI 对话编辑、删除与列表自动刷新
|
||||
- 五节点严格顺序:触发条件、输入参数、执行步骤、输出格式、约束与测试
|
||||
- 已完成节点可回改,未解锁节点不可跳过
|
||||
- 右侧节点内容完全只读,只能通过 AI 对话修改
|
||||
- AI 先在聊天中展示待写入提案,用户确认后才更新节点
|
||||
- 所有节点完成后才允许保存
|
||||
- AI JSON 输出经过严格结构校验、纠错重试和服务端二次约束
|
||||
- 无 API Key 时自动进入可操作的本地演示模式
|
||||
@ -59,12 +63,26 @@ SQLite 默认写入 `data/skillloom.db`,包含:
|
||||
- `conversations` / `messages`
|
||||
- `chat_runs` / `chat_run_events`
|
||||
|
||||
`skills.version` 记录可编辑版本;`messages.skill_snapshots_json` 保存每轮
|
||||
审计快照;`conversations.task_state` 与
|
||||
`retained_skill_snapshots` 保存进行中需求的生命周期和固定版本。
|
||||
|
||||
主聊天采用“两步式 Run”:
|
||||
|
||||
1. `POST /api/chat` 幂等创建回答任务。
|
||||
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 输出经过四层保护:
|
||||
|
||||
@ -88,7 +106,9 @@ npm run test:acceptance
|
||||
- 删除 Skill 与列表刷新
|
||||
- 聊天选择和实际调用 Skill
|
||||
- 返回是否调用及具体 Skill
|
||||
- 中止作为终态
|
||||
- 多选 Skill 的需求级保留与单轮子集路由
|
||||
- Skill 编辑版本递增与跨轮快照固定
|
||||
- 中止内容、事件与需求状态的原子终态
|
||||
- 首轮 AI 主题标题、标题编辑与会话重置
|
||||
- SSE 断线重连、事件去重和继续生成
|
||||
|
||||
|
||||
@ -152,6 +152,7 @@ let nodes = definitions.map(([key, title, description]) => ({
|
||||
let skillName = "未命名 Skill";
|
||||
let skillDescription = "";
|
||||
let builderMessages = [];
|
||||
let pendingProposal = null;
|
||||
|
||||
async function builderTurn(message) {
|
||||
const clientRequestId = `acceptance_builder_${crypto.randomUUID()}`;
|
||||
@ -163,6 +164,7 @@ async function builderTurn(message) {
|
||||
message,
|
||||
skillName,
|
||||
skillDescription,
|
||||
pendingProposal,
|
||||
nodes,
|
||||
messages: builderMessages,
|
||||
});
|
||||
@ -216,6 +218,7 @@ async function builderTurn(message) {
|
||||
nodes = update.evaluation.nodes;
|
||||
skillName = update.evaluation.skillName;
|
||||
skillDescription = update.evaluation.skillDescription;
|
||||
pendingProposal = update.evaluation.proposal;
|
||||
const reply = events
|
||||
.filter((event) => event.type === "token")
|
||||
.map((event) => event.token)
|
||||
@ -231,6 +234,21 @@ async function runAcceptance() {
|
||||
await waitForServer();
|
||||
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");
|
||||
assert.equal(migratedChat.continuation.state, "awaiting_input");
|
||||
assert.deepEqual(migratedChat.continuation.skillIds, ["skill_sql_guard"]);
|
||||
@ -433,12 +451,37 @@ async function runAcceptance() {
|
||||
console.log("✓ Builder 拒绝脚本/API 执行能力并给出可行替代");
|
||||
|
||||
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(
|
||||
nodes.filter((node) => node.completed).length,
|
||||
0,
|
||||
"信息不足时节点不能提前完成",
|
||||
);
|
||||
assert.match(incompleteTurn.reply, /[??]/, "信息不足时 AI 必须继续追问");
|
||||
assert.match(
|
||||
confirmedIncompleteTurn.reply,
|
||||
/[??]/,
|
||||
"确认写入的信息仍不足时 AI 必须继续追问",
|
||||
);
|
||||
|
||||
const multiNodeTurn = await builderTurn(
|
||||
[
|
||||
@ -449,9 +492,46 @@ async function runAcceptance() {
|
||||
].join(";"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
multiNodeTurn.evaluation.updatedNodeKeys,
|
||||
multiNodeTurn.evaluation.proposedNodeKeys,
|
||||
["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(
|
||||
nodes.filter((node) => node.completed).length,
|
||||
@ -473,9 +553,20 @@ async function runAcceptance() {
|
||||
"输出格式:输出 Markdown 表格,字段为编号、前置条件、操作、预期结果。",
|
||||
);
|
||||
assert.deepEqual(
|
||||
filledGap.evaluation.updatedNodeKeys,
|
||||
filledGap.evaluation.proposedNodeKeys,
|
||||
["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(
|
||||
nodes.filter((node) => node.completed).length,
|
||||
@ -483,11 +574,13 @@ async function runAcceptance() {
|
||||
"第 4 节点补齐后,已准备好的第 5 节点必须自动完成",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
filledGap.reply,
|
||||
confirmedGap.reply,
|
||||
/约束与测试[^。]*[??]/,
|
||||
"全部节点自动完成后不得继续追问已经解锁的节点",
|
||||
);
|
||||
console.log("✓ 一轮可完成多个连续节点,缺口补齐后自动解锁后续节点");
|
||||
console.log(
|
||||
"✓ 节点内容先展示提案,支持修改,确认后再写入并按顺序解锁",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
getSkillNodeQualityIssues(nodes),
|
||||
@ -594,8 +687,24 @@ async function runAcceptance() {
|
||||
"补充规则:当需求只有一句话时,先追问目标用户再生成清单。",
|
||||
);
|
||||
assert.ok(
|
||||
editedTurn.evaluation.updatedNodeKeys.includes("trigger"),
|
||||
"编辑时也必须由 AI 自动判断目标节点",
|
||||
editedTurn.evaluation.proposedNodeKeys.includes("trigger"),
|
||||
"编辑时也必须由 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}`, {
|
||||
method: "PUT",
|
||||
@ -828,7 +937,11 @@ async function runAcceptance() {
|
||||
const sqlReviewSkill = seededSkills.skills.find(
|
||||
(skill) => skill.name === "SQL 安全审阅",
|
||||
);
|
||||
const decisionNoteSkill = seededSkills.skills.find(
|
||||
(skill) => skill.name === "决策备忘录",
|
||||
);
|
||||
assert.ok(sqlReviewSkill, "预置的 SQL 安全审阅 Skill 必须存在");
|
||||
assert.ok(decisionNoteSkill, "预置的决策备忘录 Skill 必须存在");
|
||||
const explainSkillPayload = await jsonRequest("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@ -866,6 +979,79 @@ async function runAcceptance() {
|
||||
);
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@ -873,7 +1059,7 @@ async function runAcceptance() {
|
||||
conversationId: chat.conversation.id,
|
||||
message:
|
||||
"产品需求 验收清单:我还没提供具体内容,请先询问我需要补充的信息。",
|
||||
selectedSkillIds: [skillId],
|
||||
selectedSkillIds: [skillId, sqlReviewSkill.id],
|
||||
clientRequestId: `acceptance_follow_up_${crypto.randomUUID()}`,
|
||||
}),
|
||||
});
|
||||
@ -885,6 +1071,20 @@ async function runAcceptance() {
|
||||
const followUpRetention = followUpEvents.find(
|
||||
(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(
|
||||
followUpRetention?.state,
|
||||
"awaiting_input",
|
||||
@ -892,13 +1092,13 @@ async function runAcceptance() {
|
||||
);
|
||||
assert.deepEqual(
|
||||
followUpRetention?.skillIds,
|
||||
[skillId],
|
||||
"未完成的多轮任务必须保留实际使用的 Skill",
|
||||
[skillId, sqlReviewSkill.id],
|
||||
"未完成的需求必须保留全部勾选 Skill,不能丢弃当轮未使用项",
|
||||
);
|
||||
const chatWithRetainedSkill = await jsonRequest("/api/chat");
|
||||
assert.deepEqual(
|
||||
chatWithRetainedSkill.retainedSkillIds,
|
||||
[skillId],
|
||||
[skillId, sqlReviewSkill.id],
|
||||
"刷新页面后仍必须恢复待继续任务的 Skill",
|
||||
);
|
||||
assert.equal(
|
||||
@ -915,7 +1115,40 @@ async function runAcceptance() {
|
||||
"deterministic",
|
||||
"明确追问应由确定性规则识别,不依赖 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", {
|
||||
method: "POST",
|
||||
@ -923,7 +1156,7 @@ async function runAcceptance() {
|
||||
body: JSON.stringify({
|
||||
conversationId: chat.conversation.id,
|
||||
message: "需要",
|
||||
selectedSkillIds: [skillId],
|
||||
selectedSkillIds: [skillId, sqlReviewSkill.id],
|
||||
clientRequestId: `acceptance_continued_skill_${crypto.randomUUID()}`,
|
||||
}),
|
||||
});
|
||||
@ -937,7 +1170,7 @@ async function runAcceptance() {
|
||||
);
|
||||
assert.deepEqual(
|
||||
continuedUsage?.skillIds,
|
||||
[skillId],
|
||||
[skillId, sqlReviewSkill.id],
|
||||
"用户用“需要”等短回复接受上一轮提议时必须继续使用保留的 Skill",
|
||||
);
|
||||
assert.equal(
|
||||
@ -945,7 +1178,18 @@ async function runAcceptance() {
|
||||
"continuation",
|
||||
"省略回复必须通过显式续接状态恢复 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", {
|
||||
method: "POST",
|
||||
@ -953,7 +1197,7 @@ async function runAcceptance() {
|
||||
body: JSON.stringify({
|
||||
conversationId: chat.conversation.id,
|
||||
message: "请生成一份很长的补充清单。",
|
||||
selectedSkillIds: [],
|
||||
selectedSkillIds: [skillId],
|
||||
clientRequestId: `acceptance_stop_${crypto.randomUUID()}`,
|
||||
}),
|
||||
});
|
||||
@ -1001,6 +1245,23 @@ async function runAcceptance() {
|
||||
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(
|
||||
chatAfterStop.messages.some(
|
||||
(message) => message.content === "这条并发冲突消息不应写入数据库。",
|
||||
@ -1008,7 +1269,7 @@ async function runAcceptance() {
|
||||
false,
|
||||
"并发冲突事务不得留下孤立消息",
|
||||
);
|
||||
console.log("✓ 中止被视为一轮完成");
|
||||
console.log("✓ 中止原子落库且维持当前需求的 Skill 选择");
|
||||
|
||||
const renamed = await jsonRequest("/api/conversation", {
|
||||
method: "PATCH",
|
||||
@ -1037,7 +1298,7 @@ async function runAcceptance() {
|
||||
|
||||
try {
|
||||
await runAcceptance();
|
||||
console.log("\n验收通过:创建、编辑、删除、选择、调用反馈、中止、标题与重连均正常。");
|
||||
console.log("\n验收通过:生命周期、版本快照、中止竞态、创建编辑与重连均正常。");
|
||||
} finally {
|
||||
server.kill("SIGTERM");
|
||||
await new Promise((resolve) => {
|
||||
|
||||
@ -10,6 +10,11 @@ import {
|
||||
listMessages,
|
||||
} from "@/lib/db";
|
||||
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 { createId } from "@/lib/utils";
|
||||
import { chatRequestSchema } from "@/lib/validation";
|
||||
@ -44,11 +49,27 @@ export async function POST(request: Request) {
|
||||
const continuation = getConversationContinuation(
|
||||
input.conversationId,
|
||||
);
|
||||
const selectedSkills = [...new Set(input.selectedSkillIds)]
|
||||
const currentSelectedSkills = [...new Set(input.selectedSkillIds)]
|
||||
.map((id) => getSkill(id))
|
||||
.filter(
|
||||
(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 assistantMessageId = createId("message");
|
||||
@ -63,6 +84,8 @@ export async function POST(request: Request) {
|
||||
content: input.message,
|
||||
selectedSkillIds: selectedSkills.map((skill) => skill.id),
|
||||
usedSkillIds: [],
|
||||
skillSnapshots,
|
||||
skillExecutions: [],
|
||||
status: "complete",
|
||||
createdAt: now,
|
||||
},
|
||||
@ -72,6 +95,8 @@ export async function POST(request: Request) {
|
||||
content: "",
|
||||
selectedSkillIds: selectedSkills.map((skill) => skill.id),
|
||||
usedSkillIds: [],
|
||||
skillSnapshots,
|
||||
skillExecutions: [],
|
||||
status: "streaming",
|
||||
createdAt: new Date(Date.now() + 1).toISOString(),
|
||||
},
|
||||
@ -109,6 +134,7 @@ export async function POST(request: Request) {
|
||||
conversationId: input.conversationId,
|
||||
userMessage: input.message,
|
||||
selectedSkills,
|
||||
skillSnapshots,
|
||||
continuation,
|
||||
history,
|
||||
});
|
||||
|
||||
@ -1,6 +1,30 @@
|
||||
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() {
|
||||
return <ChatWorkspace />;
|
||||
export default async function Home() {
|
||||
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(),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { LoaderCircle, Sparkles } from "lucide-react";
|
||||
import { Check, LoaderCircle, Sparkles, X } from "lucide-react";
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
MessagePrimitive,
|
||||
@ -17,8 +17,10 @@ import { StreamingMarkdown } from "@/components/streaming-markdown";
|
||||
import { useSmoothFollow } from "@/components/use-smooth-follow";
|
||||
import {
|
||||
type BuilderMessage,
|
||||
type BuilderProposal,
|
||||
type BuilderSuggestionSource,
|
||||
type SkillNodeKey,
|
||||
SKILL_NODE_DEFINITIONS,
|
||||
} from "@/lib/types";
|
||||
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({
|
||||
messages,
|
||||
loading,
|
||||
@ -196,6 +245,7 @@ export function AssistantBuilderChat({
|
||||
generationStatus,
|
||||
suggestions,
|
||||
suggestionSource,
|
||||
pendingProposal,
|
||||
sendFailure,
|
||||
onSend,
|
||||
onStop,
|
||||
@ -208,6 +258,7 @@ export function AssistantBuilderChat({
|
||||
generationStatus: string;
|
||||
suggestions: string[];
|
||||
suggestionSource: BuilderSuggestionSource;
|
||||
pendingProposal: BuilderProposal | null;
|
||||
sendFailure: { content: string; detail: string } | null;
|
||||
onSend: (message: string) => Promise<void>;
|
||||
onStop: () => void;
|
||||
@ -264,11 +315,18 @@ export function AssistantBuilderChat({
|
||||
}}
|
||||
/>
|
||||
{!streaming && (
|
||||
pendingProposal ? (
|
||||
<BuilderProposalActions
|
||||
proposal={pendingProposal}
|
||||
onSend={onSend}
|
||||
/>
|
||||
) : (
|
||||
<BuilderSuggestions
|
||||
suggestions={suggestions}
|
||||
source={suggestionSource}
|
||||
onSend={onSend}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ThreadPrimitive.Viewport>
|
||||
|
||||
@ -108,6 +108,7 @@ function toAssistantMessage(
|
||||
skills: Skill[],
|
||||
generationStatus: string,
|
||||
resolvedSkillIds: string[] | null,
|
||||
completedSkillIds: string[],
|
||||
): ThreadMessageLike {
|
||||
const usedSkills = message.usedSkillIds
|
||||
.map((id) => skills.find((skill) => skill.id === id))
|
||||
@ -120,22 +121,33 @@ function toAssistantMessage(
|
||||
const content: AssistantContentPart[] = [];
|
||||
|
||||
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({
|
||||
type: "tool-call",
|
||||
toolCallId: `skill_${message.id}_${skill.id}`,
|
||||
toolName: skill.name,
|
||||
args: {
|
||||
skillName: skill.name,
|
||||
skill: {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
position: position + 1,
|
||||
},
|
||||
...(isRunning
|
||||
request:
|
||||
execution?.input.request ??
|
||||
"该历史记录创建时尚未保存 Skill 的实际输入。",
|
||||
upstreamResults: execution?.input.upstreamResults ?? [],
|
||||
},
|
||||
...(isRunning && !executionCompleted
|
||||
? {}
|
||||
: {
|
||||
result: {
|
||||
used: true,
|
||||
skillName: skill.name,
|
||||
},
|
||||
result:
|
||||
execution?.result ??
|
||||
"该历史记录创建时尚未保存 Skill 的具体执行结果。",
|
||||
}),
|
||||
});
|
||||
}
|
||||
@ -185,6 +197,7 @@ function convertMessage(
|
||||
skills: Skill[],
|
||||
generationStatus: string,
|
||||
resolvedSkillIds: string[] | null,
|
||||
completedSkillIds: string[],
|
||||
): ThreadMessageLike {
|
||||
if (message.role === "assistant") {
|
||||
return toAssistantMessage(
|
||||
@ -192,6 +205,7 @@ function convertMessage(
|
||||
skills,
|
||||
generationStatus,
|
||||
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"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
组合本轮 Skill
|
||||
组合当前需求 Skill
|
||||
</button>
|
||||
<Link
|
||||
href="/skills/new"
|
||||
@ -521,7 +535,8 @@ export function AssistantChat({
|
||||
messages,
|
||||
selectedIds,
|
||||
resolvedSkillIds,
|
||||
loading,
|
||||
activeSkillId,
|
||||
completedSkillIds,
|
||||
streaming,
|
||||
stopping,
|
||||
generationStatus,
|
||||
@ -538,7 +553,8 @@ export function AssistantChat({
|
||||
messages: ChatMessage[];
|
||||
selectedIds: string[];
|
||||
resolvedSkillIds: string[] | null;
|
||||
loading: boolean;
|
||||
activeSkillId: string | null;
|
||||
completedSkillIds: string[];
|
||||
streaming: boolean;
|
||||
stopping: boolean;
|
||||
generationStatus: string;
|
||||
@ -553,7 +569,7 @@ export function AssistantChat({
|
||||
}) {
|
||||
const runtime = useExternalStoreRuntime<ChatMessage>({
|
||||
messages,
|
||||
isLoading: loading,
|
||||
isLoading: false,
|
||||
isRunning: streaming,
|
||||
isSendDisabled: !canSend,
|
||||
convertMessage: (message) =>
|
||||
@ -562,6 +578,7 @@ export function AssistantChat({
|
||||
skills,
|
||||
generationStatus,
|
||||
resolvedSkillIds,
|
||||
completedSkillIds,
|
||||
),
|
||||
onNew: async (message) => {
|
||||
const text = getText(message);
|
||||
@ -578,15 +595,6 @@ export function AssistantChat({
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col">
|
||||
{loading ? (
|
||||
<div className="grid h-full place-items-center">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<LoaderCircle className="size-4 animate-spin text-cobalt" />
|
||||
正在打开工作台
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ThreadPrimitive.Viewport
|
||||
{...smoothFollow}
|
||||
autoScroll={false}
|
||||
@ -620,6 +628,8 @@ export function AssistantChat({
|
||||
skills={skills}
|
||||
selectedIds={selectedIds}
|
||||
resolvedSkillIds={resolvedSkillIds}
|
||||
activeSkillId={activeSkillId}
|
||||
completedSkillIds={completedSkillIds}
|
||||
onSelectedChange={onSelectedChange}
|
||||
onOpenSkillPicker={onOpenSkillPicker}
|
||||
isStreaming={streaming}
|
||||
@ -627,8 +637,6 @@ export function AssistantChat({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ThreadPrimitive.Root>
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
|
||||
@ -32,6 +32,8 @@ export function ChatComposer({
|
||||
skills,
|
||||
selectedIds,
|
||||
resolvedSkillIds,
|
||||
activeSkillId,
|
||||
completedSkillIds,
|
||||
onSelectedChange,
|
||||
onOpenSkillPicker,
|
||||
isStreaming,
|
||||
@ -40,6 +42,8 @@ export function ChatComposer({
|
||||
skills: Skill[];
|
||||
selectedIds: string[];
|
||||
resolvedSkillIds: string[] | null;
|
||||
activeSkillId: string | null;
|
||||
completedSkillIds: string[];
|
||||
onSelectedChange: (ids: string[]) => void;
|
||||
onOpenSkillPicker: () => void;
|
||||
isStreaming: boolean;
|
||||
@ -202,9 +206,13 @@ export function ChatComposer({
|
||||
? "selected"
|
||||
: !routeResolved
|
||||
? "checking"
|
||||
: resolvedSkillIds.includes(skill.id)
|
||||
? "used"
|
||||
: "unused";
|
||||
: !resolvedSkillIds.includes(skill.id)
|
||||
? "unused"
|
||||
: activeSkillId === skill.id
|
||||
? "running"
|
||||
: completedSkillIds.includes(skill.id)
|
||||
? "completed"
|
||||
: "queued";
|
||||
return (
|
||||
<Fragment key={skill.id}>
|
||||
{index > 0 && (
|
||||
@ -216,8 +224,10 @@ export function ChatComposer({
|
||||
!routeResolved &&
|
||||
"animate-pulse text-amber-500/70",
|
||||
routeResolved &&
|
||||
resolvedSkillIds?.includes(skill.id) &&
|
||||
completedSkillIds.includes(skill.id) &&
|
||||
"text-emerald-500/80",
|
||||
activeSkillId === skill.id &&
|
||||
"animate-pulse text-blue-500/90",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -11,6 +11,7 @@ import type {
|
||||
ChatMessage,
|
||||
ChatRun,
|
||||
Conversation,
|
||||
ConversationContinuation,
|
||||
Skill,
|
||||
SseEvent,
|
||||
} from "@/lib/types";
|
||||
@ -33,22 +34,71 @@ function wait(ms: number, signal: AbortSignal) {
|
||||
});
|
||||
}
|
||||
|
||||
export function ChatWorkspace() {
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [conversation, setConversation] = useState<Conversation | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
type ChatWorkspaceInitialData = {
|
||||
skills: Skill[];
|
||||
messages: ChatMessage[];
|
||||
conversation: Conversation;
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [generationStatus, setGenerationStatus] = useState("");
|
||||
const [apiConfigured, setApiConfigured] = useState(false);
|
||||
const [activeRun, setActiveRun] = useState<ChatRun | null>(null);
|
||||
const [streaming, setStreaming] = useState(
|
||||
Boolean(initialData.activeRun),
|
||||
);
|
||||
const [generationStatus, setGenerationStatus] = useState(
|
||||
initialData.activeRun ? "正在恢复本轮回答" : "",
|
||||
);
|
||||
const [apiConfigured] = useState(initialData.apiConfigured);
|
||||
const [activeRun, setActiveRun] = useState<ChatRun | null>(
|
||||
initialData.activeRun,
|
||||
);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [resolvedSkillIds, setResolvedSkillIds] = useState<string[] | 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 visibleAssistantContentRef = useRef("");
|
||||
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(() => {
|
||||
const refresh = () => void loadSkills().catch(() => undefined);
|
||||
window.addEventListener("focus", refresh);
|
||||
@ -201,6 +173,55 @@ export function ChatWorkspace() {
|
||||
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") {
|
||||
retainedForNextTurn = event.skillIds;
|
||||
setSelectedIds(event.skillIds);
|
||||
@ -294,6 +315,8 @@ export function ChatWorkspace() {
|
||||
setGenerationStatus("");
|
||||
setSelectedIds(retainedForNextTurn);
|
||||
setResolvedSkillIds(null);
|
||||
setActiveSkillId(null);
|
||||
setCompletedSkillIds([]);
|
||||
setActiveRun(null);
|
||||
}
|
||||
}
|
||||
@ -310,6 +333,8 @@ export function ChatWorkspace() {
|
||||
stopRequestedRef.current = false;
|
||||
visibleAssistantContentRef.current = "";
|
||||
setStopping(false);
|
||||
setActiveSkillId(null);
|
||||
setCompletedSkillIds([]);
|
||||
const selectedForTurn = [...(selectedOverride ?? selectedIds)];
|
||||
const userLocalId = `local_user_${crypto.randomUUID()}`;
|
||||
const assistantLocalId = `local_assistant_${crypto.randomUUID()}`;
|
||||
@ -321,6 +346,8 @@ export function ChatWorkspace() {
|
||||
content,
|
||||
selectedSkillIds: selectedForTurn,
|
||||
usedSkillIds: [],
|
||||
skillSnapshots: [],
|
||||
skillExecutions: [],
|
||||
status: "complete",
|
||||
createdAt: now,
|
||||
};
|
||||
@ -330,6 +357,8 @@ export function ChatWorkspace() {
|
||||
content: "",
|
||||
selectedSkillIds: selectedForTurn,
|
||||
usedSkillIds: [],
|
||||
skillSnapshots: [],
|
||||
skillExecutions: [],
|
||||
status: "streaming",
|
||||
createdAt: new Date(Date.now() + 1).toISOString(),
|
||||
};
|
||||
@ -491,6 +520,8 @@ export function ChatWorkspace() {
|
||||
setMessages([]);
|
||||
setSelectedIds([]);
|
||||
setResolvedSkillIds(null);
|
||||
setActiveSkillId(null);
|
||||
setCompletedSkillIds([]);
|
||||
setSendFailure(null);
|
||||
stopRequestedRef.current = false;
|
||||
activeRunRef.current = null;
|
||||
@ -508,15 +539,20 @@ export function ChatWorkspace() {
|
||||
messages={messages}
|
||||
selectedIds={selectedIds}
|
||||
resolvedSkillIds={resolvedSkillIds}
|
||||
loading={loading}
|
||||
activeSkillId={activeSkillId}
|
||||
completedSkillIds={completedSkillIds}
|
||||
streaming={streaming}
|
||||
stopping={stopping}
|
||||
generationStatus={generationStatus}
|
||||
sendFailure={sendFailure}
|
||||
canSend={Boolean(conversation) && !loading}
|
||||
canSend={Boolean(conversation)}
|
||||
onSelectedChange={(ids) => {
|
||||
setSelectedIds(ids);
|
||||
if (!streaming) setResolvedSkillIds(null);
|
||||
if (!streaming) {
|
||||
setResolvedSkillIds(null);
|
||||
setActiveSkillId(null);
|
||||
setCompletedSkillIds([]);
|
||||
}
|
||||
}}
|
||||
onOpenSkillPicker={() => setPickerOpen(true)}
|
||||
onSend={sendMessage}
|
||||
@ -539,6 +575,8 @@ export function ChatWorkspace() {
|
||||
onConfirm={(ids) => {
|
||||
setSelectedIds(ids);
|
||||
setResolvedSkillIds(null);
|
||||
setActiveSkillId(null);
|
||||
setCompletedSkillIds([]);
|
||||
}}
|
||||
onSkillsChange={setSkills}
|
||||
/>
|
||||
|
||||
@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { consumeSse } from "@/lib/client-sse";
|
||||
import type {
|
||||
BuilderMessage,
|
||||
BuilderProposal,
|
||||
BuilderSuggestionSource,
|
||||
Skill,
|
||||
SkillNode,
|
||||
@ -58,6 +59,8 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
|
||||
const [description, setDescription] = useState("");
|
||||
const [nodes, setNodes] = useState<SkillNode[]>(createEmptyNodes);
|
||||
const [messages, setMessages] = useState<BuilderMessage[]>([]);
|
||||
const [pendingProposal, setPendingProposal] =
|
||||
useState<BuilderProposal | null>(null);
|
||||
const [loading, setLoading] = useState(Boolean(skillId));
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@ -164,7 +167,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
|
||||
setMessages([
|
||||
createBuilderMessage(
|
||||
"assistant",
|
||||
`已载入「${skillPayload.skill.name}」。直接告诉我想补充或修改什么,我会判断应该更新哪些节点。`,
|
||||
`已载入「${skillPayload.skill.name}」。直接告诉我想补充或修改什么,我会先整理成提案,等你确认后再更新节点。`,
|
||||
"constraints",
|
||||
SKILL_NODE_DEFINITIONS.map((node) => node.key),
|
||||
),
|
||||
@ -248,6 +251,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
|
||||
message: content,
|
||||
skillName: name,
|
||||
skillDescription: description,
|
||||
pendingProposal,
|
||||
nodes,
|
||||
messages: previousMessages.map((message) => ({
|
||||
role: message.role,
|
||||
@ -262,9 +266,12 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
|
||||
}
|
||||
if (event.type === "status") setStatus(event.label);
|
||||
if (event.type === "builder_update") {
|
||||
setPendingProposal(event.evaluation.proposal);
|
||||
if (event.evaluation.action === "applied") {
|
||||
setName(event.evaluation.skillName);
|
||||
setDescription(event.evaluation.skillDescription);
|
||||
setNodes(event.evaluation.nodes);
|
||||
}
|
||||
const suggestionsMatchNode =
|
||||
event.evaluation.suggestionNodeKey ===
|
||||
event.evaluation.activeNode;
|
||||
@ -292,16 +299,15 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
|
||||
? {
|
||||
...message,
|
||||
nodeKey:
|
||||
event.evaluation.proposedNodeKeys[0] ??
|
||||
event.evaluation.updatedNodeKeys[0] ??
|
||||
event.evaluation.activeNode,
|
||||
updatedNodeKeys: event.evaluation.updatedNodeKeys,
|
||||
updatedNodeKeys:
|
||||
event.evaluation.action === "proposed"
|
||||
? event.evaluation.proposedNodeKeys
|
||||
: event.evaluation.updatedNodeKeys,
|
||||
}
|
||||
: message,
|
||||
)
|
||||
.filter(
|
||||
(message) =>
|
||||
event.evaluation.activeNode === activeNode ||
|
||||
message.id === assistantMessage.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -394,7 +400,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!allComplete || saving) return;
|
||||
if (!allComplete || saving || pendingProposal) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
@ -462,8 +468,14 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
|
||||
size="sm"
|
||||
className="bg-[#07C160] text-white hover:bg-[#06AD56] disabled:bg-muted-foreground/35 lg:hidden"
|
||||
onClick={() => void save()}
|
||||
disabled={!allComplete || saving}
|
||||
title={!allComplete ? "完成全部节点后才能保存" : undefined}
|
||||
disabled={!allComplete || saving || Boolean(pendingProposal)}
|
||||
title={
|
||||
pendingProposal
|
||||
? "请先确认或放弃当前提案"
|
||||
: !allComplete
|
||||
? "完成全部节点后才能保存"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{saving ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
@ -526,6 +538,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
|
||||
generationStatus={status}
|
||||
suggestions={allComplete ? [] : suggestions}
|
||||
suggestionSource={suggestionSource}
|
||||
pendingProposal={pendingProposal}
|
||||
sendFailure={sendFailure}
|
||||
onSend={sendBuilderMessage}
|
||||
onStop={stop}
|
||||
@ -573,7 +586,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
|
||||
<Check className="size-3" />
|
||||
</span>
|
||||
<span>
|
||||
五个节点已完成。仍可继续描述修改内容,由 AI 自动更新对应节点。
|
||||
五个节点已完成。仍可继续描述修改内容,AI 会先给出提案,确认后再更新。
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
@ -590,7 +603,7 @@ export function SkillBuilder({ skillId }: { skillId?: string }) {
|
||||
<Button
|
||||
size="sm"
|
||||
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()}
|
||||
>
|
||||
{saving ? (
|
||||
|
||||
@ -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>
|
||||
<DialogTitle className="font-display text-lg font-semibold tracking-[-0.03em] text-foreground">
|
||||
选择本轮 Skill
|
||||
选择当前需求 Skill
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 text-xs">
|
||||
只有与问题相关的 Skill 才会被 AI 实际调用。
|
||||
AI 会优先使用全部已选 Skill,仅在明显无关或冲突时跳过。
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
@ -366,7 +366,7 @@ export function SkillPicker({
|
||||
还没有选择
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
从左侧添加 Skill。顺序会显示在输入框上方。
|
||||
从左侧添加 Skill。拖拽顺序就是默认执行顺序。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -27,12 +27,29 @@ export function SkillTag({
|
||||
active?: boolean;
|
||||
muted?: boolean;
|
||||
draggable?: boolean;
|
||||
status?: "selected" | "checking" | "used" | "unused";
|
||||
status?:
|
||||
| "selected"
|
||||
| "checking"
|
||||
| "queued"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "unused";
|
||||
showStatusLabel?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const statusDescription = {
|
||||
selected: "已选择",
|
||||
checking: "判断中",
|
||||
queued: "待执行",
|
||||
running: "执行中",
|
||||
completed: "已完成",
|
||||
unused: "未触发",
|
||||
}[status];
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={`${name}:${statusDescription}`}
|
||||
aria-current={status === "running" ? "step" : undefined}
|
||||
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",
|
||||
active
|
||||
@ -40,7 +57,11 @@ export function SkillTag({
|
||||
: "border-border bg-background text-foreground/75",
|
||||
status === "checking" &&
|
||||
"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",
|
||||
status === "unused" &&
|
||||
"border-border bg-background text-muted-foreground opacity-65",
|
||||
@ -70,10 +91,19 @@ export function SkillTag({
|
||||
判断中
|
||||
</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">
|
||||
<CheckCircle2 className="size-3" />
|
||||
已触发
|
||||
已完成
|
||||
</span>
|
||||
)}
|
||||
{showStatusLabel && status === "unused" && (
|
||||
|
||||
@ -249,6 +249,9 @@ function ToolFallbackArgs({
|
||||
className={cn("aui-tool-fallback-args", className)}
|
||||
{...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">
|
||||
{argsText}
|
||||
</pre>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { evaluateBuilderTurn, hasDeepSeekApiKey } from "@/lib/deepseek";
|
||||
import type {
|
||||
BuilderEvaluation,
|
||||
BuilderProposal,
|
||||
SkillNode,
|
||||
SseEvent,
|
||||
} from "@/lib/types";
|
||||
@ -11,6 +12,7 @@ type BuilderRunInput = {
|
||||
nodes: SkillNode[];
|
||||
skillName: string;
|
||||
skillDescription: string;
|
||||
pendingProposal: BuilderProposal | null;
|
||||
messages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
};
|
||||
|
||||
@ -65,6 +67,7 @@ export function getOrStartBuilderRun(input: BuilderRunInput) {
|
||||
nodes: input.nodes,
|
||||
skillName: input.skillName,
|
||||
skillDescription: input.skillDescription,
|
||||
pendingProposal: input.pendingProposal,
|
||||
messages: input.messages,
|
||||
signal: run.controller.signal,
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
buildSkillExplanationResponse,
|
||||
buildChatSystemPrompt,
|
||||
completeTextCompletion,
|
||||
decideSkillContinuation,
|
||||
decideSkillUsage,
|
||||
demoChatResponse,
|
||||
@ -13,15 +14,16 @@ import {
|
||||
import {
|
||||
appendChatRunEvent,
|
||||
completeChatRunIfNotCancelled,
|
||||
finalizeChatRunAsStopped,
|
||||
getConversation,
|
||||
getChatRun,
|
||||
getMessage,
|
||||
isChatRunStopRequested,
|
||||
requestChatRunStop,
|
||||
setConversationContinuation,
|
||||
setConversationTitleIfEmpty,
|
||||
updateChatRunStatus,
|
||||
updateMessage,
|
||||
updateMessageSkillExecutions,
|
||||
updateMessageUsage,
|
||||
} from "@/lib/db";
|
||||
import {
|
||||
@ -35,12 +37,14 @@ import type {
|
||||
ConversationContinuation,
|
||||
ModelCallUsage,
|
||||
Skill,
|
||||
SkillExecution,
|
||||
SkillExecutionInput,
|
||||
SkillSnapshot,
|
||||
} from "@/lib/types";
|
||||
|
||||
type RunningChat = {
|
||||
controller: AbortController;
|
||||
timedOut: boolean;
|
||||
stopContent?: string;
|
||||
};
|
||||
|
||||
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(
|
||||
runId: string,
|
||||
titlePromise: Promise<string>,
|
||||
@ -106,6 +152,7 @@ export function startChatRun(input: {
|
||||
conversationId: string;
|
||||
userMessage: string;
|
||||
selectedSkills: Skill[];
|
||||
skillSnapshots: SkillSnapshot[];
|
||||
continuation: ConversationContinuation;
|
||||
history: Array<{ role: ChatMessage["role"]; content: string }>;
|
||||
}) {
|
||||
@ -142,6 +189,7 @@ export function startChatRun(input: {
|
||||
void (async () => {
|
||||
let content = "";
|
||||
let usedSkillIds: string[] = [];
|
||||
let skillExecutions: SkillExecution[] = [];
|
||||
let lastPersistedAt = 0;
|
||||
let titlePromise: Promise<string> | null = null;
|
||||
let usage = createMessageUsage(
|
||||
@ -153,6 +201,7 @@ export function startChatRun(input: {
|
||||
let terminalDurationMs: number | undefined;
|
||||
|
||||
const persistUsage = () => {
|
||||
if (getChatRun(input.runId)?.status === "stopped") return;
|
||||
if (usageTerminalStatus) {
|
||||
usage = finalizeMessageUsage(
|
||||
usage,
|
||||
@ -203,10 +252,11 @@ export function startChatRun(input: {
|
||||
onUsage,
|
||||
},
|
||||
);
|
||||
usedSkillIds = skillDecision.skillIds;
|
||||
const decidedSkillIds: string[] = skillDecision.skillIds;
|
||||
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) {
|
||||
titlePromise = generateConversationTitle({
|
||||
userMessage: input.userMessage,
|
||||
@ -225,7 +275,7 @@ export function startChatRun(input: {
|
||||
type: "status",
|
||||
label:
|
||||
usedSkills.length > 0
|
||||
? `正在应用 ${usedSkills.map((skill) => skill.name).join("、")}`
|
||||
? `已规划 ${usedSkills.length} 个 Skill,准备开始`
|
||||
: "正在思考",
|
||||
});
|
||||
|
||||
@ -248,18 +298,131 @@ export function startChatRun(input: {
|
||||
}
|
||||
};
|
||||
|
||||
const startSkill = (
|
||||
skill: Skill,
|
||||
position: number,
|
||||
stageInput: SkillExecutionInput,
|
||||
) => {
|
||||
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,
|
||||
);
|
||||
appendChatRunEvent(input.runId, {
|
||||
type: "skill_started",
|
||||
skillId: skill.id,
|
||||
skillName: skill.name,
|
||||
position,
|
||||
total: usedSkills.length,
|
||||
input: stageInput,
|
||||
startedAt,
|
||||
});
|
||||
appendChatRunEvent(input.runId, {
|
||||
type: "status",
|
||||
label: `正在执行 ${skill.name}(${position}/${usedSkills.length})`,
|
||||
});
|
||||
};
|
||||
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(usedSkills),
|
||||
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(usedSkills),
|
||||
content: buildChatSystemPrompt([]),
|
||||
},
|
||||
...input.history,
|
||||
{ role: "user", content: input.userMessage },
|
||||
@ -269,12 +432,111 @@ export function startChatRun(input: {
|
||||
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, usedSkills),
|
||||
demoChatResponse(input.userMessage, []),
|
||||
controller.signal,
|
||||
onToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
throwIfStopRequested();
|
||||
|
||||
if (!content.trim()) {
|
||||
@ -288,13 +550,23 @@ export function startChatRun(input: {
|
||||
const continuation = await decideSkillContinuation({
|
||||
userMessage: input.userMessage,
|
||||
assistantResponse: content,
|
||||
selectedSkills: input.selectedSkills,
|
||||
usedSkills,
|
||||
continuation: input.continuation,
|
||||
history: input.history,
|
||||
onUsage,
|
||||
});
|
||||
throwIfStopRequested();
|
||||
const persistedContinuation = setConversationContinuation(
|
||||
input.conversationId,
|
||||
continuation,
|
||||
{
|
||||
...continuation,
|
||||
skillSnapshots:
|
||||
continuation.state === "complete" ||
|
||||
continuation.state === "abandoned"
|
||||
? []
|
||||
: input.skillSnapshots,
|
||||
},
|
||||
);
|
||||
appendChatRunEvent(input.runId, {
|
||||
type: "skill_retention",
|
||||
@ -334,6 +606,9 @@ export function startChatRun(input: {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
} catch (error) {
|
||||
if (getChatRun(input.runId)?.status === "stopped") {
|
||||
return;
|
||||
}
|
||||
const stopped =
|
||||
!state.timedOut &&
|
||||
(controller.signal.aborted ||
|
||||
@ -341,66 +616,58 @@ export function startChatRun(input: {
|
||||
(error instanceof Error && error.name === "AbortError"));
|
||||
|
||||
if (stopped) {
|
||||
const stoppedContent =
|
||||
(state.stopContent ?? content) || "本轮回答已中止。";
|
||||
setConversationContinuation(input.conversationId, {
|
||||
state: "complete",
|
||||
skillIds: [],
|
||||
expectedInput: "",
|
||||
reason: "本轮回答已中止,不保留 Skill 续接状态。",
|
||||
source: "deterministic",
|
||||
});
|
||||
appendChatRunEvent(input.runId, {
|
||||
type: "skill_retention",
|
||||
state: "complete",
|
||||
skillIds: [],
|
||||
expectedInput: "",
|
||||
reason: "本轮回答已中止,不保留 Skill 续接状态。",
|
||||
source: "deterministic",
|
||||
});
|
||||
updateMessage(run.assistantMessageId, {
|
||||
content: stoppedContent,
|
||||
const stoppedUsage = finalizeMessageUsage(
|
||||
usage,
|
||||
"stopped",
|
||||
Math.max(0, Date.now() - runStartedAt),
|
||||
);
|
||||
finalizeChatRunAsStopped({
|
||||
runId: input.runId,
|
||||
content: content || "本轮回答已中止。",
|
||||
usedSkillIds,
|
||||
status: "stopped",
|
||||
});
|
||||
const stoppedSkills = input.selectedSkills.filter((skill) =>
|
||||
usedSkillIds.includes(skill.id),
|
||||
);
|
||||
const fallbackTitle = fallbackConversationTitle(
|
||||
input.userMessage,
|
||||
stoppedSkills,
|
||||
);
|
||||
await completeTitle(
|
||||
input.runId,
|
||||
titleWithin(
|
||||
titlePromise ??
|
||||
generateConversationTitle({
|
||||
userMessage: input.userMessage,
|
||||
usedSkills: stoppedSkills,
|
||||
onUsage,
|
||||
}),
|
||||
fallbackTitle,
|
||||
400,
|
||||
),
|
||||
);
|
||||
completeUsage("stopped");
|
||||
appendChatRunEvent(input.runId, { type: "aborted" });
|
||||
updateChatRunStatus(input.runId, "stopped");
|
||||
} else {
|
||||
setConversationContinuation(input.conversationId, {
|
||||
state: "complete",
|
||||
skillIds: [],
|
||||
usage: stoppedUsage,
|
||||
continuation: {
|
||||
state:
|
||||
input.selectedSkills.length > 0 ? "active" : "complete",
|
||||
skillIds: input.selectedSkills.map((skill) => skill.id),
|
||||
skillSnapshots: input.skillSnapshots,
|
||||
expectedInput: "",
|
||||
reason: "本轮回答失败,不保留 Skill 续接状态。",
|
||||
reason:
|
||||
input.selectedSkills.length > 0
|
||||
? "回答已中止,当前需求仍未结束,维持 Skill 选择。"
|
||||
: "回答已中止,当前需求没有启用 Skill。",
|
||||
source: "deterministic",
|
||||
},
|
||||
title: fallbackConversationTitle(
|
||||
input.userMessage,
|
||||
input.selectedSkills.filter((skill) =>
|
||||
usedSkillIds.includes(skill.id),
|
||||
),
|
||||
),
|
||||
});
|
||||
} 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, {
|
||||
type: "skill_retention",
|
||||
state: "complete",
|
||||
skillIds: [],
|
||||
expectedInput: "",
|
||||
reason: "本轮回答失败,不保留 Skill 续接状态。",
|
||||
source: "deterministic",
|
||||
state: errorContinuation.state,
|
||||
skillIds: errorContinuation.skillIds,
|
||||
expectedInput: errorContinuation.expectedInput,
|
||||
reason: errorContinuation.reason,
|
||||
source: errorContinuation.source,
|
||||
});
|
||||
const message = state.timedOut
|
||||
? "回答超时,已安全结束本轮"
|
||||
@ -431,7 +698,7 @@ export async function stopChatRun(
|
||||
const run = getChatRun(id);
|
||||
if (!run) return { found: false, stopped: false, title: "" };
|
||||
const requestedContent = options?.visibleContent;
|
||||
const persistStoppedUsage = (message: ChatMessage | null) => {
|
||||
const stoppedUsage = (message: ChatMessage | null) => {
|
||||
const createdAtMs = Date.parse(run.createdAt);
|
||||
const durationMs = Math.max(
|
||||
0,
|
||||
@ -445,36 +712,30 @@ export async function stopChatRun(
|
||||
"stopped",
|
||||
durationMs,
|
||||
);
|
||||
updateMessageUsage(run.assistantMessageId, usage);
|
||||
appendChatRunEvent(id, { type: "usage", usage });
|
||||
return usage;
|
||||
};
|
||||
|
||||
if (!["pending", "running"].includes(run.status)) {
|
||||
if (run.status === "complete" && requestedContent !== undefined) {
|
||||
const assistantMessage = getMessage(run.assistantMessageId);
|
||||
updateMessage(run.assistantMessageId, {
|
||||
const selectedIds = assistantMessage?.selectedSkillIds ?? [];
|
||||
finalizeChatRunAsStopped({
|
||||
runId: id,
|
||||
content: requestedContent || "本轮回答已中止。",
|
||||
usedSkillIds: assistantMessage?.usedSkillIds ?? [],
|
||||
status: "stopped",
|
||||
});
|
||||
setConversationContinuation(run.conversationId, {
|
||||
state: "complete",
|
||||
skillIds: [],
|
||||
usage: stoppedUsage(assistantMessage),
|
||||
continuation: {
|
||||
state: selectedIds.length > 0 ? "active" : "complete",
|
||||
skillIds: selectedIds,
|
||||
skillSnapshots: assistantMessage?.skillSnapshots ?? [],
|
||||
expectedInput: "",
|
||||
reason: "用户在回答展示完成前请求中止,不保留 Skill 续接状态。",
|
||||
reason:
|
||||
selectedIds.length > 0
|
||||
? "用户在回答展示完成前请求中止,当前需求仍未结束。"
|
||||
: "用户在回答展示完成前请求中止。",
|
||||
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 {
|
||||
@ -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 assistantMessage = getMessage(run.assistantMessageId);
|
||||
updateMessage(run.assistantMessageId, {
|
||||
const selectedIds = assistantMessage?.selectedSkillIds ?? [];
|
||||
const result = finalizeChatRunAsStopped({
|
||||
runId: id,
|
||||
content:
|
||||
requestedContent ||
|
||||
assistantMessage?.content ||
|
||||
"本轮回答已中止。",
|
||||
usedSkillIds: assistantMessage?.usedSkillIds ?? [],
|
||||
status: "stopped",
|
||||
});
|
||||
setConversationContinuation(run.conversationId, {
|
||||
state: "complete",
|
||||
skillIds: [],
|
||||
usage: stoppedUsage(assistantMessage),
|
||||
continuation: {
|
||||
state: selectedIds.length > 0 ? "active" : "complete",
|
||||
skillIds: selectedIds,
|
||||
skillSnapshots: assistantMessage?.skillSnapshots ?? [],
|
||||
expectedInput: "",
|
||||
reason: "本轮回答已中止,不保留 Skill 续接状态。",
|
||||
reason:
|
||||
selectedIds.length > 0
|
||||
? "回答已中止,当前需求仍未结束,维持 Skill 选择。"
|
||||
: "回答已中止,当前需求没有启用 Skill。",
|
||||
source: "deterministic",
|
||||
});
|
||||
const title = await completeTitle(
|
||||
id,
|
||||
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" });
|
||||
updateChatRunStatus(id, "stopped");
|
||||
return { found: true, stopped: true, title };
|
||||
const active = runningChats.get(id);
|
||||
if (active) {
|
||||
active.controller.abort();
|
||||
}
|
||||
return {
|
||||
found: true,
|
||||
stopped: result.changed || getChatRun(id)?.status === "stopped",
|
||||
title: result.title,
|
||||
};
|
||||
}
|
||||
|
||||
329
src/lib/db.ts
329
src/lib/db.ts
@ -7,18 +7,22 @@ import type {
|
||||
ChatRunStatus,
|
||||
Conversation,
|
||||
ConversationContinuation,
|
||||
MessageUsage,
|
||||
Skill,
|
||||
SkillExecution,
|
||||
SkillNode,
|
||||
SseEvent,
|
||||
} from "@/lib/types";
|
||||
import { SKILL_NODE_DEFINITIONS } from "@/lib/types";
|
||||
import { parseMessageUsage } from "@/lib/model-usage";
|
||||
import { parseSkillSnapshots } from "@/lib/skill-snapshots";
|
||||
import { createId, safeJsonArray } from "@/lib/utils";
|
||||
|
||||
type SkillRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: number;
|
||||
status: "draft" | "active";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@ -38,6 +42,8 @@ type MessageRow = {
|
||||
content: string;
|
||||
selected_skill_ids: string;
|
||||
used_skill_ids: string;
|
||||
skill_snapshots_json: string;
|
||||
skill_executions_json: string;
|
||||
status: ChatMessage["status"];
|
||||
error_message: string;
|
||||
usage_json: string;
|
||||
@ -48,7 +54,9 @@ type ConversationRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
retained_skill_ids: string;
|
||||
retained_skill_snapshots: string;
|
||||
continuation_state: ConversationContinuation["state"];
|
||||
task_state: ConversationContinuation["state"];
|
||||
continuation_expected_input: string;
|
||||
continuation_reason: string;
|
||||
continuation_source: ConversationContinuation["source"];
|
||||
@ -78,7 +86,7 @@ const globalForDb = globalThis as unknown as {
|
||||
skillLoomDbSchemaVersion?: number;
|
||||
};
|
||||
|
||||
const DATABASE_SCHEMA_VERSION = 3;
|
||||
const DATABASE_SCHEMA_VERSION = 5;
|
||||
|
||||
function initializeDatabase(database: DatabaseSync) {
|
||||
database.exec("PRAGMA journal_mode = WAL;");
|
||||
@ -90,6 +98,7 @@ function initializeDatabase(database: DatabaseSync) {
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active')),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
@ -111,8 +120,10 @@ function initializeDatabase(database: DatabaseSync) {
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
retained_skill_ids TEXT NOT NULL DEFAULT '[]',
|
||||
retained_skill_snapshots TEXT NOT NULL DEFAULT '[]',
|
||||
continuation_state TEXT NOT NULL DEFAULT 'complete'
|
||||
CHECK (continuation_state IN ('complete', 'awaiting_input', 'offer_pending')),
|
||||
task_state TEXT NOT NULL DEFAULT 'complete',
|
||||
continuation_expected_input TEXT NOT NULL DEFAULT '',
|
||||
continuation_reason TEXT NOT NULL DEFAULT '',
|
||||
continuation_source TEXT NOT NULL DEFAULT 'none'
|
||||
@ -128,6 +139,8 @@ function initializeDatabase(database: DatabaseSync) {
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
selected_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',
|
||||
error_message 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 '';",
|
||||
);
|
||||
}
|
||||
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
|
||||
.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 '[]';",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!conversationColumns.some(
|
||||
(column) => column.name === "retained_skill_snapshots",
|
||||
)
|
||||
) {
|
||||
database.exec(
|
||||
"ALTER TABLE conversations ADD COLUMN retained_skill_snapshots TEXT NOT NULL DEFAULT '[]';",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!conversationColumns.some(
|
||||
(column) => column.name === "continuation_state",
|
||||
@ -240,6 +280,20 @@ function initializeDatabase(database: DatabaseSync) {
|
||||
"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 (
|
||||
!conversationColumns.some(
|
||||
(column) => column.name === "continuation_expected_input",
|
||||
@ -281,6 +335,15 @@ function initializeDatabase(database: DatabaseSync) {
|
||||
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(`
|
||||
UPDATE conversations
|
||||
SET title = ''
|
||||
@ -423,6 +486,7 @@ function rowsToSkill(row: SkillRow, nodeRows: SkillNodeRow[]): Skill {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
version: row.version,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
@ -494,7 +558,8 @@ export function saveSkill(input: {
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE skills
|
||||
SET name = ?, description = ?, status = ?, updated_at = ?
|
||||
SET name = ?, description = ?, status = ?,
|
||||
version = version + 1, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
input.name,
|
||||
@ -506,8 +571,8 @@ export function saveSkill(input: {
|
||||
} else {
|
||||
db.prepare(
|
||||
`INSERT INTO skills
|
||||
(id, name, description, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
(id, name, description, version, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?)`,
|
||||
).run(
|
||||
id,
|
||||
input.name,
|
||||
@ -569,7 +634,9 @@ export function getDefaultConversation(): Conversation {
|
||||
id: createId("conversation"),
|
||||
title: "",
|
||||
retained_skill_ids: "[]",
|
||||
retained_skill_snapshots: "[]",
|
||||
continuation_state: "complete",
|
||||
task_state: "complete",
|
||||
continuation_expected_input: "",
|
||||
continuation_reason: "",
|
||||
continuation_source: "none",
|
||||
@ -642,6 +709,7 @@ export function setConversationRetainedSkillIds(
|
||||
return setConversationContinuation(id, {
|
||||
state: skillIds.length > 0 ? "awaiting_input" : "complete",
|
||||
skillIds,
|
||||
skillSnapshots: [],
|
||||
expectedInput: "",
|
||||
reason: skillIds.length > 0 ? "legacy_retention" : "",
|
||||
source: skillIds.length > 0 ? "fallback" : "none",
|
||||
@ -654,6 +722,7 @@ export function getConversationContinuation(
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT retained_skill_ids, continuation_state,
|
||||
retained_skill_snapshots, task_state,
|
||||
continuation_expected_input, continuation_reason,
|
||||
continuation_source, updated_at
|
||||
FROM conversations WHERE id = ?`,
|
||||
@ -662,7 +731,9 @@ export function getConversationContinuation(
|
||||
| Pick<
|
||||
ConversationRow,
|
||||
| "retained_skill_ids"
|
||||
| "retained_skill_snapshots"
|
||||
| "continuation_state"
|
||||
| "task_state"
|
||||
| "continuation_expected_input"
|
||||
| "continuation_reason"
|
||||
| "continuation_source"
|
||||
@ -671,13 +742,15 @@ export function getConversationContinuation(
|
||||
| undefined;
|
||||
|
||||
const validStates = new Set([
|
||||
"active",
|
||||
"complete",
|
||||
"awaiting_input",
|
||||
"offer_pending",
|
||||
"abandoned",
|
||||
]);
|
||||
const validSources = new Set(["none", "deterministic", "ai", "fallback"]);
|
||||
const state = validStates.has(row?.continuation_state ?? "")
|
||||
? row!.continuation_state
|
||||
const state = validStates.has(row?.task_state ?? "")
|
||||
? row!.task_state
|
||||
: "complete";
|
||||
const source = validSources.has(row?.continuation_source ?? "")
|
||||
? row!.continuation_source
|
||||
@ -686,9 +759,13 @@ export function getConversationContinuation(
|
||||
return {
|
||||
state,
|
||||
skillIds:
|
||||
state === "complete"
|
||||
state === "complete" || state === "abandoned"
|
||||
? []
|
||||
: safeJsonArray(row?.retained_skill_ids ?? "[]"),
|
||||
skillSnapshots:
|
||||
state === "complete" || state === "abandoned"
|
||||
? []
|
||||
: parseSkillSnapshots(row?.retained_skill_snapshots),
|
||||
expectedInput: row?.continuation_expected_input ?? "",
|
||||
reason: row?.continuation_reason ?? "",
|
||||
source,
|
||||
@ -701,22 +778,46 @@ export function setConversationContinuation(
|
||||
continuation: Omit<ConversationContinuation, "updatedAt">,
|
||||
) {
|
||||
const requestedSkillIds = [...new Set(continuation.skillIds)].slice(0, 12);
|
||||
const terminal =
|
||||
continuation.state === "complete" ||
|
||||
continuation.state === "abandoned";
|
||||
const state =
|
||||
continuation.state === "complete" || requestedSkillIds.length === 0
|
||||
? ("complete" as const)
|
||||
: continuation.state;
|
||||
const uniqueIds = state === "complete" ? [] : requestedSkillIds;
|
||||
terminal || requestedSkillIds.length > 0
|
||||
? continuation.state
|
||||
: ("complete" as const);
|
||||
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();
|
||||
db.prepare(
|
||||
`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_source = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
JSON.stringify(uniqueIds),
|
||||
JSON.stringify(snapshots),
|
||||
legacyState,
|
||||
state,
|
||||
state === "complete" ? "" : continuation.expectedInput.slice(0, 240),
|
||||
state === "complete" || state === "abandoned"
|
||||
? ""
|
||||
: continuation.expectedInput.slice(0, 240),
|
||||
continuation.reason.slice(0, 400),
|
||||
continuation.source,
|
||||
now,
|
||||
@ -725,8 +826,11 @@ export function setConversationContinuation(
|
||||
return {
|
||||
state,
|
||||
skillIds: uniqueIds,
|
||||
skillSnapshots: snapshots,
|
||||
expectedInput:
|
||||
state === "complete" ? "" : continuation.expectedInput.slice(0, 240),
|
||||
state === "complete" || state === "abandoned"
|
||||
? ""
|
||||
: continuation.expectedInput.slice(0, 240),
|
||||
reason: continuation.reason.slice(0, 400),
|
||||
source: continuation.source,
|
||||
updatedAt: now,
|
||||
@ -756,7 +860,9 @@ export function resetConversation(id: string) {
|
||||
db.prepare(
|
||||
`UPDATE conversations
|
||||
SET title = '', retained_skill_ids = '[]',
|
||||
retained_skill_snapshots = '[]',
|
||||
continuation_state = 'complete',
|
||||
task_state = 'complete',
|
||||
continuation_expected_input = '',
|
||||
continuation_reason = '',
|
||||
continuation_source = 'none',
|
||||
@ -772,6 +878,71 @@ export function resetConversation(id: string) {
|
||||
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[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
@ -785,6 +956,8 @@ export function listMessages(conversationId: string): ChatMessage[] {
|
||||
content: row.content,
|
||||
selectedSkillIds: safeJsonArray(row.selected_skill_ids),
|
||||
usedSkillIds: safeJsonArray(row.used_skill_ids),
|
||||
skillSnapshots: parseSkillSnapshots(row.skill_snapshots_json),
|
||||
skillExecutions: parseSkillExecutions(row.skill_executions_json),
|
||||
status: row.status,
|
||||
errorMessage: row.error_message || undefined,
|
||||
usage: parseMessageUsage(row.usage_json),
|
||||
@ -804,6 +977,8 @@ export function getMessage(id: string): ChatMessage | null {
|
||||
content: row.content,
|
||||
selectedSkillIds: safeJsonArray(row.selected_skill_ids),
|
||||
usedSkillIds: safeJsonArray(row.used_skill_ids),
|
||||
skillSnapshots: parseSkillSnapshots(row.skill_snapshots_json),
|
||||
skillExecutions: parseSkillExecutions(row.skill_executions_json),
|
||||
status: row.status,
|
||||
errorMessage: row.error_message || undefined,
|
||||
usage: parseMessageUsage(row.usage_json),
|
||||
@ -819,8 +994,10 @@ export function insertMessage(
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO messages
|
||||
(id, conversation_id, role, content, selected_skill_ids, used_skill_ids, status, error_message, usage_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(id, conversation_id, role, content, selected_skill_ids, used_skill_ids,
|
||||
skill_snapshots_json, skill_executions_json, status, error_message,
|
||||
usage_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
message.id,
|
||||
conversationId,
|
||||
@ -828,6 +1005,8 @@ export function insertMessage(
|
||||
message.content,
|
||||
JSON.stringify(message.selectedSkillIds),
|
||||
JSON.stringify(message.usedSkillIds),
|
||||
JSON.stringify(message.skillSnapshots),
|
||||
JSON.stringify(message.skillExecutions),
|
||||
message.status,
|
||||
message.errorMessage ?? "",
|
||||
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 {
|
||||
return {
|
||||
id: row.id,
|
||||
@ -931,6 +1119,20 @@ export function createChatRunWithMessages(input: {
|
||||
|
||||
insertMessage(input.conversationId, input.userMessage);
|
||||
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({
|
||||
...input.run,
|
||||
conversationId: input.conversationId,
|
||||
@ -1007,16 +1209,6 @@ export function expireStaleChatRun(id: string, maxAgeMs = 180_000) {
|
||||
error_message = '回答任务因服务重启或超时而中断'
|
||||
WHERE id = ? AND status = 'streaming'`,
|
||||
).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, {
|
||||
type: "error",
|
||||
message: "回答任务因服务重启或超时而中断,请重新发送",
|
||||
@ -1045,16 +1237,87 @@ export function completeChatRunIfNotCancelled(id: string) {
|
||||
return Number(result.changes) > 0;
|
||||
}
|
||||
|
||||
export function requestChatRunStop(id: string) {
|
||||
export function finalizeChatRunAsStopped(input: {
|
||||
runId: string;
|
||||
content: string;
|
||||
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();
|
||||
const result = db
|
||||
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 chat_runs
|
||||
SET cancel_requested = 1, updated_at = ?
|
||||
WHERE id = ? AND status IN ('pending', 'running')`,
|
||||
`UPDATE conversations
|
||||
SET title = ?, updated_at = ?
|
||||
WHERE id = ? AND title = ''`,
|
||||
)
|
||||
.run(now, id);
|
||||
return Number(result.changes) > 0;
|
||||
.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
|
||||
SET status = 'stopped', cancel_requested = 1, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
).run(now, input.runId);
|
||||
db.exec("COMMIT");
|
||||
return { changed: true, title };
|
||||
} catch (error) {
|
||||
db.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function isChatRunStopRequested(id: string) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type {
|
||||
BuilderEvaluation,
|
||||
BuilderProposal,
|
||||
ConversationContinuation,
|
||||
ModelCallUsage,
|
||||
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) {
|
||||
return [
|
||||
skill.name,
|
||||
@ -412,16 +441,6 @@ export async function decideSkillUsage(
|
||||
const recentConversation = (options?.history ?? []).slice(-6);
|
||||
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") {
|
||||
return {
|
||||
skillIds: [],
|
||||
@ -453,13 +472,15 @@ export async function decideSkillUsage(
|
||||
[
|
||||
{
|
||||
role: "system",
|
||||
content: `你是 Skill 路由器。你必须完全独立判断候选 Skill 是否真正有助于回答当前问题。
|
||||
只选择与任务直接相关、会实质改变回答过程或格式的 Skill;不要因为用户选中了就使用。
|
||||
content: `你是 Skill 编排器。候选 Skill 都是用户主动勾选、希望本轮采用的工作规范,因此默认全部使用。
|
||||
先把用户请求拆成所有明确或可合理推导的子任务,再判断每个候选 Skill 能否帮助其中至少一个子任务。
|
||||
Skill 的触发条件是适用性指南而非死板关键词门槛;只要能力实质匹配,就应保留。一个 Skill 可以使用前序 Skill 产生的结构化结果,所需输入也可以从用户材料或前序结果中推导。
|
||||
只有候选 Skill 与所有子任务明显无关、能力不可用,或与用户明确要求存在不可调和冲突时,才能排除。不得仅因为用户没有显式列出该 Skill 的全部输入而排除。
|
||||
结合最近对话理解“需要”“可以”“1 小时”等省略回复;如果它是在回答上一轮的具体提议或必要追问,应继续选择相关的候选 Skill。
|
||||
如果用户已经切换到新话题,不要沿用上一轮保留的 Skill。
|
||||
逐个判断所有候选项;可以一个都不选、只选一个或选择多个。
|
||||
reason 用一句话解释本轮选择依据。
|
||||
输出 JSON,格式:{"usedSkillIds":["skill_id"],"reason":"选择依据"}。只能返回候选 ID,不得使用关键词匹配等外部规则代替判断。`,
|
||||
逐个检查所有候选项;excludedSkillIds 只填写有充分理由排除的候选 ID,通常应为空数组。
|
||||
reason 用一句话说明保留的 Skill 如何覆盖本轮子任务,以及必要时为何排除个别 Skill。
|
||||
输出 JSON,格式:{"excludedSkillIds":[],"reason":"编排依据"}。只能返回候选 ID,不得使用关键词匹配等外部规则代替判断。`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
@ -491,9 +512,18 @@ reason 用一句话解释本轮选择依据。
|
||||
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 {
|
||||
skillIds: (result.usedSkillIds ?? []).filter((id) => allowed.has(id)),
|
||||
source: "ai" as const,
|
||||
skillIds: routedSkillIds,
|
||||
source: isContinuation ? ("continuation" as const) : ("ai" as const),
|
||||
reason: result.reason,
|
||||
};
|
||||
}
|
||||
@ -536,46 +566,46 @@ ${details}`;
|
||||
.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: {
|
||||
userMessage: string;
|
||||
assistantResponse: string;
|
||||
selectedSkills: Skill[];
|
||||
usedSkills: Skill[];
|
||||
continuation: ConversationContinuation;
|
||||
history: ConversationTurn[];
|
||||
onUsage?: (usage: ModelCallUsage) => void;
|
||||
}) {
|
||||
const fallback = fallbackSkillContinuation(
|
||||
input.assistantResponse,
|
||||
input.usedSkills,
|
||||
);
|
||||
const fallbackIds = fallback.skillIds;
|
||||
if (input.usedSkills.length === 0) {
|
||||
const selectedSkillIds = input.selectedSkills.map((skill) => skill.id);
|
||||
if (selectedSkillIds.length === 0) {
|
||||
return {
|
||||
state: "complete" as const,
|
||||
skillIds: [],
|
||||
expectedInput: "",
|
||||
reason: "本轮未使用 Skill,不需要保留续接状态。",
|
||||
reason: "当前需求没有启用 Skill。",
|
||||
source: "deterministic" as const,
|
||||
};
|
||||
}
|
||||
if (fallback.detected) {
|
||||
if (
|
||||
input.continuation.state !== "complete" &&
|
||||
classifyContinuationReply(input.userMessage) === "decline"
|
||||
) {
|
||||
return {
|
||||
state: fallback.detected.state,
|
||||
skillIds: fallbackIds,
|
||||
expectedInput: fallback.detected.expectedInput,
|
||||
reason: fallback.detected.reason,
|
||||
state: "abandoned" as const,
|
||||
skillIds: [],
|
||||
expectedInput: "",
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -595,28 +625,35 @@ export async function decideSkillContinuation(input: {
|
||||
[
|
||||
{
|
||||
role: "system",
|
||||
content: `你是多轮任务状态判断器。判断助手本轮回复后,当前任务是否明确停在“等待用户提供必要信息、确认选择或补充材料”的状态。
|
||||
content: `你是多轮需求生命周期判断器。判断助手本轮回复后,当前用户需求处于什么状态。
|
||||
|
||||
规则:
|
||||
- active:需求仍在推进中,但本轮既没有明确要求必要输入,也没有等待确认具体提议;
|
||||
- awaiting_input:助手明确要求用户补充完成任务所必需的信息;
|
||||
- offer_pending:助手提出了一个具体、可执行的后续动作并正在等待用户接受或拒绝;
|
||||
- complete:本轮已经交付完成且没有等待用户回应。泛泛的“如果需要我可以继续”“还有什么可以帮你”等礼貌性邀请属于 complete;
|
||||
- abandoned:用户明确取消、放弃或结束了原需求;
|
||||
- “需要我根据你的时间调整计划吗”“要不要我继续生成迁移步骤”等有明确动作和对象的提议属于 offer_pending;
|
||||
- 只能保留本轮 actuallyUsedSkills 中下一轮仍必需的 Skill;
|
||||
- 未实际使用的 Skill 永远不能保留;
|
||||
- 如果 state 是 complete,keepSkillIds 必须为空;
|
||||
- expectedInput 简洁描述下一轮期待用户回答的内容;complete 时必须为空;
|
||||
- selectedSkills 是用户为整个需求勾选的候选集合,actuallyUsedSkills 只是本轮实际应用的子集;不要决定保留哪些 Skill;
|
||||
- expectedInput 简洁描述下一轮期待用户回答的内容;complete 和 abandoned 时必须为空;
|
||||
- reason 用一句话说明状态依据;
|
||||
- 不要根据问号单独判断,要区分必要追问与可选邀请。
|
||||
|
||||
只输出 JSON:
|
||||
{"state":"awaiting_input","keepSkillIds":["skill_id"],"expectedInput":"需要用户补充的内容","reason":"判断理由"}`,
|
||||
{"state":"awaiting_input","expectedInput":"需要用户补充的内容","reason":"判断理由"}`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify({
|
||||
userMessage: input.userMessage.slice(0, 4_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) => ({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
@ -629,29 +666,21 @@ export async function decideSkillContinuation(input: {
|
||||
input.onUsage,
|
||||
);
|
||||
|
||||
const allowedIds = new Set(input.usedSkills.map((skill) => skill.id));
|
||||
const modelSkillIds =
|
||||
result.state !== "complete"
|
||||
? result.keepSkillIds.filter((id) => allowedIds.has(id))
|
||||
: [];
|
||||
const state =
|
||||
modelSkillIds.length > 0 ? result.state : ("complete" as const);
|
||||
const terminal =
|
||||
result.state === "complete" || result.state === "abandoned";
|
||||
return {
|
||||
state,
|
||||
skillIds: state === "complete" ? [] : modelSkillIds,
|
||||
expectedInput: state === "complete" ? "" : result.expectedInput,
|
||||
reason:
|
||||
state === "complete" && result.state !== "complete"
|
||||
? "模型未返回有效的待续接 Skill。"
|
||||
: result.reason,
|
||||
state: result.state,
|
||||
skillIds: terminal ? [] : selectedSkillIds,
|
||||
expectedInput: terminal ? "" : result.expectedInput,
|
||||
reason: result.reason,
|
||||
source: "ai" as const,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
state: "complete" as const,
|
||||
skillIds: [],
|
||||
state: "active" as const,
|
||||
skillIds: selectedSkillIds,
|
||||
expectedInput: "",
|
||||
reason: "状态判断失败,按完成处理以避免 Skill 意外粘连。",
|
||||
reason: "状态判断失败,暂时维持当前需求的 Skill 选择。",
|
||||
source: "fallback" as const,
|
||||
};
|
||||
}
|
||||
@ -734,6 +763,30 @@ function fallbackBuilderSuggestions(nodes: SkillNode[]) {
|
||||
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: {
|
||||
nodes: SkillNode[];
|
||||
skillName: string;
|
||||
@ -761,7 +814,7 @@ export async function generateBuilderSuggestions(input: {
|
||||
content: `你是 Skill 构建器的快捷答案生成器。根据当前 Skill 上下文,为正在完善的唯一节点生成 2-4 条中文候选答案。
|
||||
|
||||
要求:
|
||||
- 每条都是用户点击后可直接发送并写入节点的完整答案,不是问题、标题、操作说明或空泛提示;
|
||||
- 每条都是用户点击后可直接发送并生成待确认节点提案的完整答案,不是问题、标题、操作说明或空泛提示;
|
||||
- 候选答案要覆盖不同但合理的方向,结合已完成节点避免前后冲突;
|
||||
- 每条不超过 80 个字符,不使用 Markdown,不要写“你可以”“请补充”等引导语;
|
||||
- 只为 currentNode 生成,不能提前替用户完成后续节点;
|
||||
@ -843,6 +896,8 @@ function normalizeBuilderResult(input: {
|
||||
nodes: SkillNode[];
|
||||
skillName: string;
|
||||
skillDescription: string;
|
||||
baseProposal?: BuilderProposal | null;
|
||||
mode?: "propose" | "apply";
|
||||
}) {
|
||||
const allowedKeys = new Set<SkillNodeKey>(
|
||||
SKILL_NODE_DEFINITIONS.map((node) => node.key),
|
||||
@ -854,6 +909,12 @@ function normalizeBuilderResult(input: {
|
||||
const intent = input.result.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 ?? []) {
|
||||
if (!allowedKeys.has(update.nodeKey)) continue;
|
||||
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 nodes = input.nodes.map((node) => {
|
||||
const update = updates.get(node.key);
|
||||
const update = appliedUpdates.get(node.key);
|
||||
if (!update) {
|
||||
return {
|
||||
...node,
|
||||
@ -905,13 +995,14 @@ function normalizeBuilderResult(input: {
|
||||
(node, index) =>
|
||||
node.completed &&
|
||||
!input.nodes[index].completed &&
|
||||
!updates.has(node.key),
|
||||
!appliedUpdates.has(node.key),
|
||||
);
|
||||
const defaultReply = allComplete
|
||||
? "五个节点都已经形成可执行规范。你可以继续描述想修改的内容,或保存这个 Skill。"
|
||||
: SKILL_NODE_DEFINITIONS[activeIndex].prompt;
|
||||
let reply = input.result.reply?.trim() || defaultReply;
|
||||
const qualityFailures = [...updates.entries()].flatMap(([key, update]) => {
|
||||
const qualityFailures = [...appliedUpdates.entries()].flatMap(
|
||||
([key, update]) => {
|
||||
if (update.completed) return [];
|
||||
const quality = evaluateSkillNodeQuality(key, update.content);
|
||||
if (quality.complete) return [];
|
||||
@ -921,12 +1012,15 @@ function normalizeBuilderResult(input: {
|
||||
return quality.missing.map(
|
||||
(missing) => `${title ?? key}还缺少:${missing}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (qualityFailures.length > 0) {
|
||||
reply = `已保存本轮提供的内容,但还不能标记为完成:${qualityFailures.join(";")}。\n\n${SKILL_NODE_DEFINITIONS[activeIndex].prompt}`;
|
||||
if (proposal) {
|
||||
reply = formatBuilderProposal(proposal);
|
||||
} else if (qualityFailures.length > 0) {
|
||||
reply = `已确认写入本轮内容,但还不能标记为完成:${qualityFailures.join(";")}。\n\n${SKILL_NODE_DEFINITIONS[activeIndex].prompt}`;
|
||||
} else if (allComplete) {
|
||||
const updatedTitles = [...updates.keys()]
|
||||
const updatedTitles = [...appliedUpdates.keys()]
|
||||
.map(
|
||||
(key) =>
|
||||
SKILL_NODE_DEFINITIONS.find((definition) => definition.key === key)
|
||||
@ -938,7 +1032,7 @@ function normalizeBuilderResult(input: {
|
||||
.map((node) => node.title)
|
||||
.join("、");
|
||||
reply = [
|
||||
updatedTitles ? `已更新「${updatedTitles}」。` : "",
|
||||
updatedTitles ? `已确认并写入「${updatedTitles}」。` : "",
|
||||
unlockedTitles ? `之前准备好的「${unlockedTitles}」也已自动解锁。` : "",
|
||||
"五个节点都已经形成可执行规范,你可以继续描述修改内容,或保存这个 Skill。",
|
||||
].join("");
|
||||
@ -949,15 +1043,21 @@ function normalizeBuilderResult(input: {
|
||||
return {
|
||||
reply,
|
||||
intent,
|
||||
action: proposal
|
||||
? ("proposed" as const)
|
||||
: appliedUpdates.size > 0
|
||||
? ("applied" as const)
|
||||
: ("none" as const),
|
||||
skillName:
|
||||
input.result.skillName?.trim().slice(0, 40) ||
|
||||
input.skillName ||
|
||||
"未命名 Skill",
|
||||
appliedUpdates.size > 0 ? proposedSkillName : input.skillName,
|
||||
skillDescription:
|
||||
input.result.skillDescription?.trim().slice(0, 180) ||
|
||||
input.skillDescription,
|
||||
appliedUpdates.size > 0
|
||||
? proposedSkillDescription
|
||||
: input.skillDescription,
|
||||
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,
|
||||
nodeQuality: sequentialNodes.map((node) => {
|
||||
const quality = evaluateSkillNodeQuality(node.key, node.content);
|
||||
@ -998,6 +1098,7 @@ function demoBuilderEvaluation(input: {
|
||||
nodes: SkillNode[];
|
||||
skillName: string;
|
||||
skillDescription: string;
|
||||
pendingProposal: BuilderProposal | null;
|
||||
}) {
|
||||
const currentDefinition =
|
||||
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[]]> = [
|
||||
[
|
||||
"trigger",
|
||||
@ -1095,7 +1207,7 @@ function demoBuilderEvaluation(input: {
|
||||
const routedUpdates = SKILL_NODE_DEFINITIONS.flatMap((definition, index) => {
|
||||
const additions = routedSegments.get(definition.key);
|
||||
if (!additions) return [];
|
||||
const existing = input.nodes[index];
|
||||
const existing = draftNodes[index];
|
||||
const addition = additions.join("\n");
|
||||
return [
|
||||
{
|
||||
@ -1142,17 +1254,82 @@ function demoBuilderEvaluation(input: {
|
||||
nodes: input.nodes,
|
||||
skillName: input.skillName,
|
||||
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: {
|
||||
message: string;
|
||||
nodes: SkillNode[];
|
||||
skillName: string;
|
||||
skillDescription: string;
|
||||
pendingProposal: BuilderProposal | null;
|
||||
messages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
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(
|
||||
input.message,
|
||||
);
|
||||
@ -1185,6 +1362,8 @@ export async function evaluateBuilderTurn(input: {
|
||||
|
||||
五个节点必须严格按顺序完成:触发条件 → 输入参数 → 执行步骤 → 输出格式 → 约束与测试。
|
||||
用户不会手动指定要编辑哪个节点。你必须根据最新消息的语义,自主判断它应该更新一个或多个节点。
|
||||
你输出的 updates 是“待用户确认的完整提案”,服务端不会立即写入节点。用户明确确认后,服务端才会应用提案。
|
||||
如果输入中存在 pendingProposal,说明用户正在修改上一版提案。请在保留未被否定内容的基础上生成完整的新提案;不要声称已经保存、更新或完成节点。
|
||||
如果一条消息同时涵盖多个节点,updates 必须包含所有真正被覆盖的节点,不能只更新当前节点。
|
||||
可以修改已经完成的节点,也可以提前评估后续节点的内容。completed 表示该节点内容本身是否已经具体、完整、可执行;即使前置节点尚未完成,只要后续节点内容本身合格,也应返回 completed: true。服务端会保存这个判断并控制界面只能按顺序解锁。
|
||||
例如用户一次提供了第 1、2、3、5 节点的合格内容,应为这四个节点都返回 completed: true;界面只会先完成 1、2、3。用户之后补齐第 4 节点时,之前准备好的第 5 节点会自动完成。
|
||||
@ -1209,7 +1388,7 @@ ${SKILL_CAPABILITY_BOUNDARY}
|
||||
- 信息足够时将对应节点 completed 设为 true;
|
||||
- 仍有任一未完成节点时,reply 必须继续追问一个最关键、可直接回答的问题;
|
||||
- 如果 intent 为 provide_spec 且最近两次已经问过近似问题,可以采用合理默认值、明确告知用户,并将当前节点完成,避免卡住;request_guidance 永远不能因此完成节点;
|
||||
- reply 先说明 AI 判断更新了哪些节点,再提出唯一的下一问题;全部完成时才不再追问;
|
||||
- reply 简要说明提案判断,不得声称已经写入或保存节点;服务端会统一展示完整提案与确认提示;
|
||||
- suggestions 必须给出 2-4 个针对当前待完善节点、用户可直接采用或继续补充的短建议;不能重复用户已经确认的内容;
|
||||
- suggestionNodeKey 必须等于应用本轮 updates 后的第一个未完成节点;全部节点完成时仍填写 constraints;
|
||||
- 根据已有内容持续改进 skillName(2-12 个汉字为佳)与 skillDescription。
|
||||
@ -1245,6 +1424,7 @@ ${SKILL_CAPABILITY_BOUNDARY}
|
||||
completed: node.completed,
|
||||
})),
|
||||
},
|
||||
pendingProposal: input.pendingProposal,
|
||||
recentConversation: input.messages.slice(-10),
|
||||
firstIncompleteNode:
|
||||
SKILL_NODE_DEFINITIONS[activeNodeIndex(input.nodes)].key,
|
||||
@ -1260,5 +1440,6 @@ ${SKILL_CAPABILITY_BOUNDARY}
|
||||
nodes: input.nodes,
|
||||
skillName: input.skillName,
|
||||
skillDescription: input.skillDescription,
|
||||
baseProposal: input.pendingProposal,
|
||||
});
|
||||
}
|
||||
|
||||
99
src/lib/skill-snapshots.ts
Normal file
99
src/lib/skill-snapshots.ts
Normal 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)!,
|
||||
);
|
||||
}
|
||||
@ -78,12 +78,24 @@ export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: number;
|
||||
status: "draft" | "active";
|
||||
nodes: SkillNode[];
|
||||
createdAt: 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 {
|
||||
promptTokens: number;
|
||||
promptCacheHitTokens: number;
|
||||
@ -111,12 +123,34 @@ export interface MessageUsage extends TokenUsage {
|
||||
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 {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
selectedSkillIds: string[];
|
||||
usedSkillIds: string[];
|
||||
skillSnapshots: SkillSnapshot[];
|
||||
skillExecutions: SkillExecution[];
|
||||
status: "complete" | "streaming" | "stopped" | "error";
|
||||
errorMessage?: string;
|
||||
usage?: MessageUsage;
|
||||
@ -131,9 +165,11 @@ export interface Conversation {
|
||||
}
|
||||
|
||||
export type ConversationContinuationState =
|
||||
| "active"
|
||||
| "complete"
|
||||
| "awaiting_input"
|
||||
| "offer_pending";
|
||||
| "offer_pending"
|
||||
| "abandoned";
|
||||
|
||||
export type ConversationContinuationSource =
|
||||
| "none"
|
||||
@ -144,6 +180,7 @@ export type ConversationContinuationSource =
|
||||
export interface ConversationContinuation {
|
||||
state: ConversationContinuationState;
|
||||
skillIds: string[];
|
||||
skillSnapshots: SkillSnapshot[];
|
||||
expectedInput: string;
|
||||
reason: string;
|
||||
source: ConversationContinuationSource;
|
||||
@ -159,13 +196,28 @@ export interface BuilderMessage {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BuilderProposalUpdate {
|
||||
nodeKey: SkillNodeKey;
|
||||
content: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface BuilderProposal {
|
||||
skillName: string;
|
||||
skillDescription: string;
|
||||
updates: BuilderProposalUpdate[];
|
||||
}
|
||||
|
||||
export interface BuilderEvaluation {
|
||||
reply: string;
|
||||
intent: "provide_spec" | "request_guidance";
|
||||
action: "none" | "proposed" | "applied" | "discarded";
|
||||
skillName: string;
|
||||
skillDescription: string;
|
||||
activeNode: SkillNodeKey;
|
||||
updatedNodeKeys: SkillNodeKey[];
|
||||
proposedNodeKeys: SkillNodeKey[];
|
||||
proposal: BuilderProposal | null;
|
||||
suggestionNodeKey: SkillNodeKey;
|
||||
nodeQuality: Array<{
|
||||
key: SkillNodeKey;
|
||||
@ -222,6 +274,26 @@ export type SseEvent = SequencedEvent &
|
||||
| "keyword_fallback";
|
||||
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";
|
||||
state: ConversationContinuationState;
|
||||
|
||||
@ -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({
|
||||
id: z.string().min(1).optional(),
|
||||
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),
|
||||
skillName: z.string().max(40).default("未命名 Skill"),
|
||||
skillDescription: z.string().max(180).default(""),
|
||||
pendingProposal: builderProposalSchema.nullable().default(null),
|
||||
nodes: skillNodesSchema,
|
||||
messages: z
|
||||
.array(
|
||||
@ -73,15 +93,20 @@ export const builderRequestSchema = z.object({
|
||||
|
||||
export const skillRouterResultSchema = z
|
||||
.object({
|
||||
usedSkillIds: z.array(z.string()).max(12),
|
||||
excludedSkillIds: z.array(z.string()).max(12),
|
||||
reason: z.string().trim().min(1).max(400),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const skillContinuationResultSchema = z
|
||||
.object({
|
||||
state: z.enum(["awaiting_input", "offer_pending", "complete"]),
|
||||
keepSkillIds: z.array(z.string()).max(12),
|
||||
state: z.enum([
|
||||
"active",
|
||||
"awaiting_input",
|
||||
"offer_pending",
|
||||
"complete",
|
||||
"abandoned",
|
||||
]),
|
||||
expectedInput: z.string().trim().max(240),
|
||||
reason: z.string().trim().min(1).max(400),
|
||||
})
|
||||
@ -97,7 +122,10 @@ export const builderModelResultSchema = z
|
||||
.object({
|
||||
intent: z.enum(["provide_spec", "request_guidance"]),
|
||||
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
|
||||
.array(
|
||||
z
|
||||
|
||||
Reference in New Issue
Block a user