@spzxg/cli-ui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -2
- package/dist/cli-ui-embed-BeBZKDZO.js +36487 -0
- package/dist/cli-ui-index-CuwqVeUk.js +13516 -0
- package/dist/cli-ui.css +1 -0
- package/dist/cli-ui.js +11 -0
- package/package.json +49 -4
- package/src/chat/CliChat.tsx +516 -0
- package/src/chat/Composer.tsx +194 -0
- package/src/chat/export.ts +34 -0
- package/src/core/blocks.ts +132 -0
- package/src/core/client.ts +230 -0
- package/src/core/config.ts +220 -0
- package/src/core/conversation.ts +371 -0
- package/src/core/provider.tsx +89 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +491 -0
- package/src/core/types.ts +278 -0
- package/src/elements.tsx +122 -0
- package/src/embed.ts +47 -0
- package/src/index.ts +34 -0
- package/src/msglist/CliMsgList.tsx +248 -0
- package/src/operator.css +1628 -0
- package/src/styles.css +50 -0
- package/src/ui/Markdown.tsx +26 -0
- package/src/ui/TextBlock.tsx +49 -0
- package/src/ui/Transcript.tsx +408 -0
- package/src/ui/format.ts +101 -0
- package/src/ui/icons.tsx +89 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +76 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 组件的界面配置:哪些模块显示、文案怎么写。全部可选,不给就是缺省(全部显示、默认文案)。
|
|
3
|
+
*
|
|
4
|
+
* <CliChat config={{ header: { export: false, close: false }, composer: { placeholder: "问点什么…", fileUpload: false } }} />
|
|
5
|
+
* <CliMsgList config={{ header: { refresh: false }, card: { turns: false } }} />
|
|
6
|
+
*
|
|
7
|
+
* 开关类的模块写 false 就整块隐藏,写对象就逐项开关;文案里可以用 {title}(对话标题)、{agent}(智能体名)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── cli-chat ─────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface CliChatConfig {
|
|
13
|
+
/** 顶部栏。false = 整栏隐藏;对象 = 逐项开关。 */
|
|
14
|
+
header?: boolean | {
|
|
15
|
+
/** 面包屑:色块 + 名称 / 对话。 */
|
|
16
|
+
crumb?: boolean;
|
|
17
|
+
/** 「导出执行过程」按钮。 */
|
|
18
|
+
export?: boolean;
|
|
19
|
+
/** 对话标题。 */
|
|
20
|
+
title?: boolean;
|
|
21
|
+
/** 智能体标签(Codex / Claude Code)。 */
|
|
22
|
+
agent?: boolean;
|
|
23
|
+
/** token 用量。 */
|
|
24
|
+
usage?: boolean;
|
|
25
|
+
/** 模型(默认 + 实际模型)。 */
|
|
26
|
+
model?: boolean;
|
|
27
|
+
/** 对话状态(运行中 / 需要你处理 / 已结束)。 */
|
|
28
|
+
status?: boolean;
|
|
29
|
+
/** 「结束对话」按钮。 */
|
|
30
|
+
close?: boolean;
|
|
31
|
+
};
|
|
32
|
+
/** 底部输入框。false = 不显示输入框(只读);对象 = 逐项配置。 */
|
|
33
|
+
composer?: boolean | {
|
|
34
|
+
/** 回复时的占位文案,可用 {title} {agent}。 */
|
|
35
|
+
placeholder?: string;
|
|
36
|
+
/** 新对话(还没有 jobId)时的占位文案。 */
|
|
37
|
+
placeholderNew?: string;
|
|
38
|
+
/** 智能体正在执行、发的消息会排队时的占位文案。 */
|
|
39
|
+
placeholderRunning?: string;
|
|
40
|
+
/** 不能发送时(对话已结束、只读)的占位文案。 */
|
|
41
|
+
placeholderDisabled?: string;
|
|
42
|
+
/** 底部「⏎ 发送 / ⇧⏎ 换行」提示。 */
|
|
43
|
+
hints?: boolean;
|
|
44
|
+
/** 底部字数统计。 */
|
|
45
|
+
charCount?: boolean;
|
|
46
|
+
/** 文件上传(按钮、拖拽、粘贴文件)。只收 TXT / Markdown / Word。 */
|
|
47
|
+
fileUpload?: boolean;
|
|
48
|
+
/** 「文件」按钮的文字。 */
|
|
49
|
+
fileButtonText?: string;
|
|
50
|
+
/** 运行中的停止按钮。 */
|
|
51
|
+
stop?: boolean;
|
|
52
|
+
/** 运行中允许发消息排队;false = 运行中不能发。 */
|
|
53
|
+
queue?: boolean;
|
|
54
|
+
};
|
|
55
|
+
/** 对话区。 */
|
|
56
|
+
transcript?: {
|
|
57
|
+
/** 右下角的上一条 / 下一条 / 跳到最新按钮。 */
|
|
58
|
+
nav?: boolean;
|
|
59
|
+
/** 「第 1 次会话 · 当前」分隔线。 */
|
|
60
|
+
sessionLabel?: boolean;
|
|
61
|
+
/** 消息时间。 */
|
|
62
|
+
timestamps?: boolean;
|
|
63
|
+
/** 首条消息上的「初始提示」「已随项目上下文发送」标记。 */
|
|
64
|
+
initialBadge?: boolean;
|
|
65
|
+
/** 工具调用卡片(命令、读写文件……);false 只留文字往来(提问卡片照常显示)。 */
|
|
66
|
+
tools?: boolean;
|
|
67
|
+
/** 「智能体 · 运行中 0:12」那一行。 */
|
|
68
|
+
runningIndicator?: boolean;
|
|
69
|
+
};
|
|
70
|
+
/** 消息头上的称呼。 */
|
|
71
|
+
labels?: { user?: string; agent?: string };
|
|
72
|
+
/** 新对话的空白页。false = 不显示。 */
|
|
73
|
+
empty?: boolean | { title?: string; description?: string };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ResolvedChatConfig {
|
|
77
|
+
header: { show: boolean; crumb: boolean; export: boolean; title: boolean; agent: boolean; usage: boolean; model: boolean; status: boolean; close: boolean };
|
|
78
|
+
composer: {
|
|
79
|
+
show: boolean; placeholder: string; placeholderNew: string; placeholderRunning: string; placeholderDisabled: string | null;
|
|
80
|
+
hints: boolean; charCount: boolean; fileUpload: boolean; fileButtonText: string; stop: boolean; queue: boolean;
|
|
81
|
+
};
|
|
82
|
+
transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean };
|
|
83
|
+
labels: { user: string; agent: string };
|
|
84
|
+
empty: { show: boolean; title: string; description: string };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const DEFAULT_CHAT_CONFIG: ResolvedChatConfig = {
|
|
88
|
+
header: { show: true, crumb: true, export: true, title: true, agent: true, usage: true, model: true, status: true, close: true },
|
|
89
|
+
composer: {
|
|
90
|
+
show: true,
|
|
91
|
+
placeholder: "在“{title}”中回复 {agent}…(可拖入 TXT / Markdown / Word 文件)",
|
|
92
|
+
placeholderNew: "输入第一条消息,开始新对话…(可拖入 TXT / Markdown / Word 文件)",
|
|
93
|
+
placeholderRunning: "排队一条后续消息…(本轮结束后发送)",
|
|
94
|
+
placeholderDisabled: null,
|
|
95
|
+
hints: true, charCount: true, fileUpload: true, fileButtonText: "文件", stop: true, queue: true,
|
|
96
|
+
},
|
|
97
|
+
transcript: { nav: true, sessionLabel: true, timestamps: true, initialBadge: true, tools: true, runningIndicator: true },
|
|
98
|
+
labels: { user: "你", agent: "智能体" },
|
|
99
|
+
empty: { show: true, title: "开始一个新对话", description: "在下面输入第一条消息。可以附上 TXT、Markdown 或 Word 文件,内容会转成文字一起发送。" },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfig {
|
|
103
|
+
const d = DEFAULT_CHAT_CONFIG;
|
|
104
|
+
const header = toggle(config.header, d.header);
|
|
105
|
+
const composer = toggle(config.composer, d.composer);
|
|
106
|
+
const empty = toggle(config.empty, d.empty);
|
|
107
|
+
// 没有文件上传时,缺省文案里的「可拖入文件」提示也去掉。
|
|
108
|
+
if (!composer.fileUpload) {
|
|
109
|
+
const strip = (text: string) => text.replace(/(可拖入 TXT \/ Markdown \/ Word 文件)$/, "");
|
|
110
|
+
if (!isObj(config.composer) || config.composer.placeholder === undefined) composer.placeholder = strip(composer.placeholder);
|
|
111
|
+
if (!isObj(config.composer) || config.composer.placeholderNew === undefined) composer.placeholderNew = strip(composer.placeholderNew);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
header,
|
|
115
|
+
composer,
|
|
116
|
+
transcript: { ...d.transcript, ...defined(config.transcript) },
|
|
117
|
+
labels: { ...d.labels, ...defined(config.labels) },
|
|
118
|
+
empty,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── cli-msglist ──────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export interface CliMsgListConfig {
|
|
125
|
+
/** 顶部栏。false = 整栏隐藏;对象 = 逐项开关。 */
|
|
126
|
+
header?: boolean | {
|
|
127
|
+
/** 色块 + 名称。 */
|
|
128
|
+
title?: boolean;
|
|
129
|
+
/** 「+ 对话」按钮。 */
|
|
130
|
+
newButton?: boolean;
|
|
131
|
+
/** 「+ 对话」按钮的文字。 */
|
|
132
|
+
newButtonText?: string;
|
|
133
|
+
/** 刷新按钮。 */
|
|
134
|
+
refresh?: boolean;
|
|
135
|
+
/** 上下文说明框(需同时给 context 或 onEditContext)。 */
|
|
136
|
+
context?: boolean;
|
|
137
|
+
};
|
|
138
|
+
/** 搜索框:true 总是显示,false 不显示,"auto"(缺省)对话数 ≥ 6 时显示。 */
|
|
139
|
+
search?: boolean | "auto";
|
|
140
|
+
searchPlaceholder?: string;
|
|
141
|
+
/** 卡片上显示哪些内容。 */
|
|
142
|
+
card?: {
|
|
143
|
+
/** 状态点和状态文字。 */
|
|
144
|
+
status?: boolean;
|
|
145
|
+
/** 智能体标签。 */
|
|
146
|
+
agent?: boolean;
|
|
147
|
+
/** 第一条消息摘要。 */
|
|
148
|
+
description?: boolean;
|
|
149
|
+
/** 底部活动(运行中 · 3 分钟前……)。 */
|
|
150
|
+
activity?: boolean;
|
|
151
|
+
/** 「N 轮对话」。 */
|
|
152
|
+
turns?: boolean;
|
|
153
|
+
};
|
|
154
|
+
/** 分组标题;写 false 隐藏这一组。 */
|
|
155
|
+
groups?: { needs?: string | false; active?: string | false; done?: string | false; cancelled?: string | false };
|
|
156
|
+
/** 没有对话时的提示。 */
|
|
157
|
+
emptyTitle?: string;
|
|
158
|
+
emptyDescription?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface ResolvedMsgListConfig {
|
|
162
|
+
header: { show: boolean; title: boolean; newButton: boolean; newButtonText: string; refresh: boolean; context: boolean };
|
|
163
|
+
search: boolean | "auto";
|
|
164
|
+
searchPlaceholder: string;
|
|
165
|
+
card: { status: boolean; agent: boolean; description: boolean; activity: boolean; turns: boolean };
|
|
166
|
+
groups: { needs: string | false; active: string | false; done: string | false; cancelled: string | false };
|
|
167
|
+
emptyTitle: string;
|
|
168
|
+
emptyDescription: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const DEFAULT_MSGLIST_CONFIG: ResolvedMsgListConfig = {
|
|
172
|
+
header: { show: true, title: true, newButton: true, newButtonText: "对话", refresh: true, context: true },
|
|
173
|
+
search: "auto",
|
|
174
|
+
searchPlaceholder: "搜索对话…",
|
|
175
|
+
card: { status: true, agent: true, description: true, activity: true, turns: true },
|
|
176
|
+
groups: { needs: "需要你处理", active: "进行中", done: "已结束", cancelled: "已取消" },
|
|
177
|
+
emptyTitle: "还没有对话",
|
|
178
|
+
emptyDescription: "点「对话」开始一个新的对话。",
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export function resolveMsgListConfig(config: CliMsgListConfig = {}): ResolvedMsgListConfig {
|
|
182
|
+
const d = DEFAULT_MSGLIST_CONFIG;
|
|
183
|
+
return {
|
|
184
|
+
header: toggle(config.header, d.header),
|
|
185
|
+
search: config.search ?? d.search,
|
|
186
|
+
searchPlaceholder: config.searchPlaceholder ?? d.searchPlaceholder,
|
|
187
|
+
card: { ...d.card, ...defined(config.card) },
|
|
188
|
+
groups: { ...d.groups, ...defined(config.groups) },
|
|
189
|
+
emptyTitle: config.emptyTitle ?? d.emptyTitle,
|
|
190
|
+
emptyDescription: config.emptyDescription ?? d.emptyDescription,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── 小工具 ───────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
/** 文案模板:{title}、{agent}。 */
|
|
197
|
+
export function fillTemplate(template: string, vars: Record<string, string>) {
|
|
198
|
+
return template.replace(/\{(\w+)\}/g, (match, key: string) => (key in vars ? vars[key] : match));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const isObj = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object";
|
|
202
|
+
|
|
203
|
+
/** 去掉值为 undefined 的键,免得盖掉缺省值。 */
|
|
204
|
+
function defined<T extends object>(value: T | undefined): Partial<T> {
|
|
205
|
+
if (!value) return {};
|
|
206
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial<T>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 开关型模块:false → 全关(show=false 且布尔项全 false,文案保留);true / 不给 → 缺省;对象 → 合并缺省。
|
|
211
|
+
*/
|
|
212
|
+
function toggle<T extends { show: boolean }>(value: boolean | object | undefined, defaults: T): T {
|
|
213
|
+
if (value === false) {
|
|
214
|
+
const off = { ...defaults, show: false } as Record<string, unknown>;
|
|
215
|
+
for (const key of Object.keys(off)) if (typeof off[key] === "boolean") off[key] = false;
|
|
216
|
+
return off as T;
|
|
217
|
+
}
|
|
218
|
+
if (!isObj(value)) return { ...defaults };
|
|
219
|
+
return { ...defaults, ...defined(value as Partial<T>), show: true };
|
|
220
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一个对话的前端状态,以及把 center 实时流的事件应用上去的纯函数(无 React,可单测)。
|
|
3
|
+
*
|
|
4
|
+
* 数据来自两层:
|
|
5
|
+
* · 中枢轮次(turn):每条消息一轮,排队 → 执行中 → 成功 / 失败 / 取消(或被合并进别的轮次)。
|
|
6
|
+
* · 节点转录(transcript + node 事件):Agent 实际的对话过程,规则与 Operator 的 useTaskStream 一致。
|
|
7
|
+
* 界面以节点转录为主;转录还没有(没派发、节点连不上)时用轮次的 input / reply 兜底。
|
|
8
|
+
*/
|
|
9
|
+
import type { AskAnswers, AskQuestion, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, JobStatus, Message, Msg, NodeEvent, StreamItem, StreamSnapshot, ToolData, TurnUsage } from "./types.js";
|
|
10
|
+
import { indexFromEnd, mergeSnapshot, toolDataOf } from "../ui/transcriptModel.js";
|
|
11
|
+
import { formatAnswersText } from "../ui/format.js";
|
|
12
|
+
|
|
13
|
+
export const TERMINAL_TURN = new Set(["succeeded", "failed", "cancelled", "merged"]);
|
|
14
|
+
export const ACTIVE_TURN = new Set(["queued", "dispatching", "running"]);
|
|
15
|
+
export const TERMINAL_JOB = new Set(["succeeded", "failed", "cancelled"]);
|
|
16
|
+
/** Operator 首轮固定的启动语句:正文其实在任务描述里(= 中枢第 1 轮的 input)。 */
|
|
17
|
+
export const OPERATOR_INITIAL_PROMPT = "Start working on the task described in the task context.";
|
|
18
|
+
|
|
19
|
+
export interface ConvJob {
|
|
20
|
+
status: JobStatus;
|
|
21
|
+
closed_reason: string | null;
|
|
22
|
+
node_id: string | null;
|
|
23
|
+
title?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ConvState {
|
|
27
|
+
jobId: string;
|
|
28
|
+
job: ConvJob | null;
|
|
29
|
+
/** 中枢轮次,按 seq 升序。 */
|
|
30
|
+
turns: CenterTurn[];
|
|
31
|
+
/** 节点转录(Operator 消息)。 */
|
|
32
|
+
msgs: Msg[];
|
|
33
|
+
hasTranscript: boolean;
|
|
34
|
+
nodeRunning: boolean;
|
|
35
|
+
resolvedModel: string | null;
|
|
36
|
+
/** 上次刷新任务详情之后,实时流里累计的用量(usage 事件)。 */
|
|
37
|
+
liveUsage: { fresh: number; cacheRead: number };
|
|
38
|
+
stream: "idle" | "connecting" | "live" | "reconnecting" | "ended" | "error";
|
|
39
|
+
streamError: string | null;
|
|
40
|
+
endReason: string | null;
|
|
41
|
+
continuedBy: string | null;
|
|
42
|
+
/** 本页提交过的提问回答(askId → 答案)。节点上那一轮已被中枢结束,不会回 ask_answered,只能本地记。 */
|
|
43
|
+
localAnswers: Record<string, AskAnswers>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const emptyConv = (jobId: string): ConvState => ({
|
|
47
|
+
jobId, job: null, turns: [], msgs: [], hasTranscript: false, nodeRunning: false, resolvedModel: null,
|
|
48
|
+
liveUsage: { fresh: 0, cacheRead: 0 }, stream: "idle", streamError: null, endReason: null, continuedBy: null, localAnswers: {},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export function upsertTurns(turns: CenterTurn[], incoming: CenterTurn[]): CenterTurn[] {
|
|
52
|
+
if (!incoming.length) return turns;
|
|
53
|
+
const byId = new Map(turns.map((turn) => [turn.turn_id, turn]));
|
|
54
|
+
for (const turn of incoming) byId.set(turn.turn_id, { ...byId.get(turn.turn_id), ...turn });
|
|
55
|
+
return [...byId.values()].sort((a, b) => a.seq - b.seq);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function applySnapshot(conv: ConvState, snapshot: StreamSnapshot): ConvState {
|
|
59
|
+
const { job } = snapshot;
|
|
60
|
+
return {
|
|
61
|
+
...conv,
|
|
62
|
+
job: { status: job.status, closed_reason: job.closed_reason, node_id: job.node_id, title: job.title },
|
|
63
|
+
turns: upsertTurns([], snapshot.turns),
|
|
64
|
+
resolvedModel: conv.resolvedModel ?? job.conversation?.last_turn?.resolved_model ?? null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const toMsg = (m: Message): Msg => ({ id: m.id, role: m.role, content: m.content, generation: m.generation, ts: m.created_at });
|
|
69
|
+
|
|
70
|
+
export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
|
|
71
|
+
let next = conv;
|
|
72
|
+
for (const item of items) {
|
|
73
|
+
if (item.type === "transcript") {
|
|
74
|
+
// 节点转录全量(连上 / 重连节点时各一次):整体替换,没变的消息保留对象引用。
|
|
75
|
+
const messages = Array.isArray(item.messages) ? item.messages.map(toMsg) : [];
|
|
76
|
+
next = { ...next, msgs: mergeSnapshot(next.msgs, messages), hasTranscript: true, nodeRunning: item.running === true };
|
|
77
|
+
} else if (item.type === "node") {
|
|
78
|
+
next = applyNodeEvent(next, item.event);
|
|
79
|
+
} else if (item.type === "turn") {
|
|
80
|
+
next = { ...next, turns: upsertTurns(next.turns, [item.turn]), resolvedModel: item.turn.resolved_model ?? next.resolvedModel };
|
|
81
|
+
} else if (item.type === "job") {
|
|
82
|
+
next = { ...next, job: { ...next.job, status: item.job.status, closed_reason: item.job.closed_reason, node_id: item.job.node_id } };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return next;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function upsertMsg(msgs: Msg[], m: Msg): Msg[] {
|
|
89
|
+
const i = indexFromEnd(msgs, m.id);
|
|
90
|
+
if (i === -1) return [...msgs, m];
|
|
91
|
+
const next = msgs.slice();
|
|
92
|
+
next[i] = { ...msgs[i], ...m };
|
|
93
|
+
return next;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let synthetic = 0;
|
|
97
|
+
const localId = (prefix: string) => `${prefix}-${Date.now()}-${++synthetic}`;
|
|
98
|
+
|
|
99
|
+
/** 节点的一个回合事件。与 Operator 的 handleStreamEvent 同一套规则。 */
|
|
100
|
+
export function applyNodeEvent(conv: ConvState, ev: NodeEvent): ConvState {
|
|
101
|
+
const e = ev as Record<string, any>;
|
|
102
|
+
const gen = typeof e.generation === "number" ? e.generation : 1;
|
|
103
|
+
const set = (msgs: Msg[]) => ({ ...conv, msgs });
|
|
104
|
+
switch (ev.type) {
|
|
105
|
+
case "user":
|
|
106
|
+
return { ...set(upsertMsg(conv.msgs, { id: e.msgId, role: "user", content: e.content, generation: gen, ts: e.ts })), nodeRunning: true };
|
|
107
|
+
case "assistant":
|
|
108
|
+
return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("a"), role: "assistant", content: e.content, generation: gen, ts: e.ts }));
|
|
109
|
+
case "tool": {
|
|
110
|
+
const data: ToolData = { title: e.title, detail: e.detail, peek: e.peek, diff: e.diff };
|
|
111
|
+
return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("t"), role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id }));
|
|
112
|
+
}
|
|
113
|
+
case "tool_result": {
|
|
114
|
+
// 先按消息 id 找(快照加载的消息也能对上),找不到再按 tool_use id。结果多在末尾,从后往前找。
|
|
115
|
+
const arr = conv.msgs;
|
|
116
|
+
let i = arr.length - 1;
|
|
117
|
+
while (i >= 0 && (arr[i].role !== "tool" || (arr[i].id !== e.msgId && arr[i].toolId !== e.id))) i--;
|
|
118
|
+
if (i < 0) return conv;
|
|
119
|
+
try {
|
|
120
|
+
const d = JSON.parse(arr[i].content) as ToolData;
|
|
121
|
+
d.result = e.content;
|
|
122
|
+
d.isError = e.isError;
|
|
123
|
+
if (e.peek) d.peek = e.peek;
|
|
124
|
+
const next = arr.slice();
|
|
125
|
+
next[i] = { ...arr[i], content: JSON.stringify(d) };
|
|
126
|
+
return set(next);
|
|
127
|
+
} catch {
|
|
128
|
+
return conv;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
case "ask": {
|
|
132
|
+
const data: ToolData = { title: "有问题需要你回答", ask: { id: e.id, questions: e.questions } };
|
|
133
|
+
return set(upsertMsg(conv.msgs, { id: e.msgId ?? `ask-${e.id}`, role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id }));
|
|
134
|
+
}
|
|
135
|
+
case "ask_answered":
|
|
136
|
+
return set(conv.msgs.map((m) => {
|
|
137
|
+
if (m.role !== "tool") return m;
|
|
138
|
+
try {
|
|
139
|
+
const d = JSON.parse(m.content) as ToolData;
|
|
140
|
+
if (m.toolId !== e.id && d.ask?.id !== e.id) return m;
|
|
141
|
+
d.ask = { id: d.ask?.id ?? e.id, questions: d.ask?.questions ?? [], answers: e.answers };
|
|
142
|
+
return { ...m, content: JSON.stringify(d) };
|
|
143
|
+
} catch {
|
|
144
|
+
return m;
|
|
145
|
+
}
|
|
146
|
+
}));
|
|
147
|
+
case "input": {
|
|
148
|
+
const data: ToolData = { title: "需要你提供信息", input: e.input };
|
|
149
|
+
return set(upsertMsg(conv.msgs, { id: e.msgId ?? `in-${e.input?.id}`, role: "tool", content: JSON.stringify(data), generation: gen }));
|
|
150
|
+
}
|
|
151
|
+
case "notice":
|
|
152
|
+
return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("n"), role: "system", content: e.content, generation: gen }));
|
|
153
|
+
case "error":
|
|
154
|
+
return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("e"), role: "system", content: e.content, generation: gen }));
|
|
155
|
+
case "model":
|
|
156
|
+
return { ...conv, resolvedModel: e.model };
|
|
157
|
+
case "usage": {
|
|
158
|
+
const u = e.usage as TurnUsage;
|
|
159
|
+
const fresh = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_tokens ?? 0);
|
|
160
|
+
return { ...conv, liveUsage: { fresh: conv.liveUsage.fresh + fresh, cacheRead: conv.liveUsage.cacheRead + (u.cache_read_tokens ?? 0) } };
|
|
161
|
+
}
|
|
162
|
+
case "turn_end":
|
|
163
|
+
return { ...conv, nodeRunning: false };
|
|
164
|
+
default:
|
|
165
|
+
// queued / dequeued 是 Operator 自己的排队;对话任务的排队在中枢(轮次),这里不用。
|
|
166
|
+
return conv;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 正在执行的那一轮(派发中或执行中)。 */
|
|
171
|
+
export const runningTurn = (conv: ConvState) => conv.turns.find((turn) => turn.status === "running" || turn.status === "dispatching") ?? null;
|
|
172
|
+
|
|
173
|
+
export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(conv);
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 界面上的转录:
|
|
177
|
+
* 1. 节点转录(首轮的固定启动语句换成真正的第一条消息);没有转录时用轮次兜底;
|
|
178
|
+
* 2. 没送到节点就失败 / 取消的轮次,补一行说明(否则用户只看到消息石沉大海);
|
|
179
|
+
* 3. 还在中枢排队的消息,作为「排队中」气泡(可撤回),由界面钉在最下面。
|
|
180
|
+
*/
|
|
181
|
+
export function viewMessages(conv: ConvState): Msg[] {
|
|
182
|
+
let out: Msg[] = [];
|
|
183
|
+
// 有节点消息就以节点为准:通常先收到全量转录,但新对话可能先来增量事件(user / assistant / tool),也算。
|
|
184
|
+
if (conv.msgs.length) {
|
|
185
|
+
// 节点转录只有当前这个 Operator 会话:它的开场是固定的启动语句,正文其实是开出这个会话的那一轮——
|
|
186
|
+
// 第 1 轮,或者节点会话重建过(context_reset)时最近重建的那一轮。
|
|
187
|
+
const opener = [...conv.turns].reverse().find((turn) => turn.context_reset && turn.started_at) ?? conv.turns.find((turn) => turn.seq === 1);
|
|
188
|
+
let replaced = false;
|
|
189
|
+
for (const m of conv.msgs) {
|
|
190
|
+
if (!replaced && m.role === "user" && m.content.trim() === OPERATOR_INITIAL_PROMPT && opener) {
|
|
191
|
+
replaced = true;
|
|
192
|
+
out.push({ ...m, content: opener.sent_input ?? opener.input });
|
|
193
|
+
} else out.push(m);
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
for (const turn of conv.turns) {
|
|
197
|
+
if (turn.status === "queued" || turn.status === "merged") continue;
|
|
198
|
+
if (turn.status !== "running" && !turn.started_at) continue;
|
|
199
|
+
const ts = new Date(turn.started_at ?? turn.created_at).getTime();
|
|
200
|
+
out.push({ id: `turn-in-${turn.turn_id}`, role: "user", content: turn.sent_input ?? turn.input, generation: 1, ts });
|
|
201
|
+
if (turn.reply) out.push({ id: `turn-out-${turn.turn_id}`, role: "assistant", content: turn.reply, generation: 1, ts: new Date(turn.ended_at ?? turn.created_at).getTime() });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
out = settleAsks(out, conv.localAnswers);
|
|
205
|
+
const generation = out.length ? out[out.length - 1].generation : 1;
|
|
206
|
+
// 中枢判定的结果,节点转录里看不到,补一行说明,按时间插到对应位置:
|
|
207
|
+
// · 没送到节点就失败 / 被撤回的消息(否则用户只看到消息石沉大海);
|
|
208
|
+
// · 执行了但被中枢判为失败的(超时中止、读不到执行记录、节点会话丢失、没有输出)。
|
|
209
|
+
// 用户自己按停止取消的不补:Operator 里停止也不写提示。
|
|
210
|
+
const notices: Msg[] = [];
|
|
211
|
+
for (const turn of conv.turns) {
|
|
212
|
+
const ts = new Date(turn.ended_at ?? turn.created_at).getTime();
|
|
213
|
+
const reason = turn.last_error ? `:${describeTurnError(turn.last_error)}` : "";
|
|
214
|
+
if ((turn.status === "failed" || turn.status === "cancelled") && !turn.started_at) {
|
|
215
|
+
notices.push({ id: `turn-fail-${turn.turn_id}`, role: "system", content: `⚠ 第 ${turn.seq} 条消息${turn.status === "cancelled" ? "已撤回" : "没有执行"}${reason}`, generation, ts });
|
|
216
|
+
} else if (turn.status === "failed") {
|
|
217
|
+
notices.push({ id: `turn-fail-${turn.turn_id}`, role: "system", content: `⚠ 这一轮没有完成${reason}`, generation, ts });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
out = insertByTime(out, notices);
|
|
221
|
+
const queuedHeads = new Set(conv.turns.filter((turn) => turn.status === "queued").map((turn) => turn.turn_id));
|
|
222
|
+
for (const turn of conv.turns) {
|
|
223
|
+
const waiting = turn.status === "queued" || (turn.status === "merged" && turn.merged_into_turn_id && queuedHeads.has(turn.merged_into_turn_id));
|
|
224
|
+
if (waiting) {
|
|
225
|
+
// 排在最前面、而且中枢记了等待原因的(节点连不上、没有可用节点、节点上还有回合没结束),把原因显示出来。
|
|
226
|
+
const note = turn.status === "queued" && turn.last_error ? describeTurnError(turn.last_error) : undefined;
|
|
227
|
+
out.push({ id: `turn-q-${turn.turn_id}`, role: "queued", content: turn.input, generation, ts: new Date(turn.created_at).getTime(), turnId: turn.turn_id, ...(note ? { note } : {}) });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 把说明行按时间插进转录:放在时间不晚于它的最后一条消息之后(没有时间的消息沿用前一条的时间)。 */
|
|
234
|
+
function insertByTime(msgs: Msg[], notices: Msg[]): Msg[] {
|
|
235
|
+
if (!notices.length) return msgs;
|
|
236
|
+
const out = msgs.slice();
|
|
237
|
+
for (const notice of [...notices].sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0))) {
|
|
238
|
+
let at = out.length;
|
|
239
|
+
let lastTs = -Infinity;
|
|
240
|
+
for (let i = 0; i < out.length; i++) {
|
|
241
|
+
lastTs = out[i].ts ?? lastTs;
|
|
242
|
+
if (lastTs > (notice.ts ?? Infinity)) { at = i; break; }
|
|
243
|
+
}
|
|
244
|
+
out.splice(at, 0, notice);
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 中枢的等待 / 失败原因(英文内部描述)翻成中文。 */
|
|
250
|
+
export function describeTurnError(raw: string): string {
|
|
251
|
+
const text = raw.trim();
|
|
252
|
+
const rules: Array<[RegExp, string]> = [
|
|
253
|
+
[/no healthy node available/i, "当前产品线没有可用的执行节点,等待超时"],
|
|
254
|
+
[/produced no output/i, "智能体没有产生任何输出,会话可能没能启动"],
|
|
255
|
+
[/transcript could not be read/i, "这一轮结束了,但一直读不到它的执行记录"],
|
|
256
|
+
[/remote task confirmed missing|remote task.*missing/i, "执行节点上的会话已丢失,下一条消息会开一个新会话"],
|
|
257
|
+
[/still running .* after abort/i, "已请求中止,但节点上的回合长时间没有停下"],
|
|
258
|
+
[/node unreachable/i, "执行节点暂时连不上,恢复后会继续"],
|
|
259
|
+
[/turn cancelled|cancel requested|cancelled by caller/i, "已取消"],
|
|
260
|
+
// 超时的原文也带 "aborted",要先于下面那条匹配。
|
|
261
|
+
[/timed? ?out|timeout/i, "执行超时,已中止"],
|
|
262
|
+
[/aborted|abort requested/i, "已中止"],
|
|
263
|
+
[/already running on the node/i, "节点上还有一轮没结束,等它结束后发送"],
|
|
264
|
+
[/center restarted/i, "中枢重启过,这条消息会重新发送"],
|
|
265
|
+
[/conversation (closed|expired)/i, "对话已结束"],
|
|
266
|
+
];
|
|
267
|
+
for (const [re, label] of rules) if (re.test(text)) return label;
|
|
268
|
+
return text;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── 提问卡片(AskUserQuestion) ────────────────────────
|
|
272
|
+
//
|
|
273
|
+
// Operator 里 Agent 提问时回合停在提问上,界面把回答 POST 给节点的 /answer。对话任务里中枢一看到
|
|
274
|
+
// 提问(awaiting_input)就结束这一轮(asked=true),节点上的提问随之作废,回答走「下一条消息」——
|
|
275
|
+
// 这正是 Operator 自己在「没有在等的回合」(resolved=false)时的做法:formatAnswersText 组成回复发出,
|
|
276
|
+
// 卡片立刻显示「你的回答」。节点不会再回 ask_answered,所以卡片的已答状态由这里推出来:
|
|
277
|
+
// · 本页刚提交的:localAnswers;
|
|
278
|
+
// · 刷新 / 别处打开的:提问之后的第一条用户消息如果是回答格式,就按它解析回答案。
|
|
279
|
+
|
|
280
|
+
/** formatAnswersText 的反解:不是这几个问题的回答返回 null。 */
|
|
281
|
+
export function parseAnswersText(text: string, questions: AskQuestion[]): AskAnswers | null {
|
|
282
|
+
const body = text.trim();
|
|
283
|
+
if (!body.startsWith("回答你的问题:")) return null;
|
|
284
|
+
const lines = body.split("\n").slice(1).map((line) => line.trim()).filter(Boolean);
|
|
285
|
+
if (lines.length !== questions.length) return null;
|
|
286
|
+
const answers: AskAnswers = [];
|
|
287
|
+
for (let i = 0; i < questions.length; i++) {
|
|
288
|
+
const prefix = `- ${questions[i].header || questions[i].question}:`;
|
|
289
|
+
if (!lines[i].startsWith(prefix)) return null;
|
|
290
|
+
const picked = lines[i].slice(prefix.length);
|
|
291
|
+
answers.push(picked === "(未选择)" ? [] : picked.split("、"));
|
|
292
|
+
}
|
|
293
|
+
// 再拼一遍要完全一致,免得把普通消息误认成回答。
|
|
294
|
+
return formatAnswersText(questions, answers).trim() === body ? answers : null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const settledCache = new WeakMap<Msg, { key: string; msg: Msg }>();
|
|
298
|
+
|
|
299
|
+
function settleAsks(msgs: Msg[], local: Record<string, AskAnswers>): Msg[] {
|
|
300
|
+
let changed = false;
|
|
301
|
+
const out = msgs.map((m, i) => {
|
|
302
|
+
if (m.role !== "tool") return m;
|
|
303
|
+
const data = toolDataOf(m);
|
|
304
|
+
if (!data.ask || data.ask.answers) return m;
|
|
305
|
+
let answers: AskAnswers | undefined = local[data.ask.id] ?? local[m.toolId ?? ""];
|
|
306
|
+
if (!answers) {
|
|
307
|
+
const next = msgs.slice(i + 1).find((x) => x.role === "user");
|
|
308
|
+
answers = (next && parseAnswersText(next.content, data.ask.questions)) || undefined;
|
|
309
|
+
}
|
|
310
|
+
if (!answers) return m;
|
|
311
|
+
// 同一条消息、同一份答案复用同一个对象:消息行是 memo 的,不要每次都重渲染。
|
|
312
|
+
const key = JSON.stringify(answers);
|
|
313
|
+
const hit = settledCache.get(m);
|
|
314
|
+
if (hit?.key === key) { changed = true; return hit.msg; }
|
|
315
|
+
const msg = { ...m, content: JSON.stringify({ ...data, ask: { ...data.ask, answers } }) };
|
|
316
|
+
settledCache.set(m, { key, msg });
|
|
317
|
+
changed = true;
|
|
318
|
+
return msg;
|
|
319
|
+
});
|
|
320
|
+
return changed ? out : msgs;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── 回调体 ───────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
/** 一轮结束 → 与中枢 conversation_turn 回调同样字段的载荷。 */
|
|
326
|
+
export function turnCallback(conv: ConvState, turn: CenterTurn, continuedFrom: string | null): ConversationTurnCallback {
|
|
327
|
+
const group = [turn, ...conv.turns.filter((t) => t.status === "merged" && t.merged_into_turn_id === turn.turn_id)].sort((a, b) => a.seq - b.seq);
|
|
328
|
+
return {
|
|
329
|
+
type: "conversation_turn",
|
|
330
|
+
callback_seq: turn.seq,
|
|
331
|
+
job_id: turn.job_id,
|
|
332
|
+
task_id: turn.task_id,
|
|
333
|
+
turn_id: turn.turn_id,
|
|
334
|
+
seq: turn.seq,
|
|
335
|
+
business_id: turn.business_id,
|
|
336
|
+
status: turn.status,
|
|
337
|
+
occurred_at: turn.ended_at ?? new Date().toISOString(),
|
|
338
|
+
input: turn.sent_input ?? turn.input,
|
|
339
|
+
messages: group.map((t) => ({ turn_id: t.turn_id, seq: t.seq, business_id: t.business_id, input: t.input })),
|
|
340
|
+
reply: turn.reply,
|
|
341
|
+
asked: turn.asked,
|
|
342
|
+
message: turn.last_error,
|
|
343
|
+
continued_from_job_id: continuedFrom,
|
|
344
|
+
context_reset: turn.context_reset,
|
|
345
|
+
merged: !!turn.merged,
|
|
346
|
+
merge_error: turn.merge_error ?? null,
|
|
347
|
+
agent: turn.agent,
|
|
348
|
+
resolved_model: turn.resolved_model,
|
|
349
|
+
total_tokens: turn.total_tokens, input_tokens: turn.input_tokens, output_tokens: turn.output_tokens,
|
|
350
|
+
cache_read_tokens: turn.cache_read_tokens, cache_creation_tokens: turn.cache_creation_tokens,
|
|
351
|
+
started_at: turn.started_at,
|
|
352
|
+
ended_at: turn.ended_at,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** 对话结束 → 与中枢 conversation_closed 回调同样字段的载荷。 */
|
|
357
|
+
export function closedCallback(conv: ConvState): ConversationClosedCallback {
|
|
358
|
+
// 与 center 一致:turns = 最大的 seq(被合并的消息也算),callback_seq = turns + 1。
|
|
359
|
+
const turns = conv.turns.reduce((max, t) => Math.max(max, t.seq), 0);
|
|
360
|
+
return {
|
|
361
|
+
type: "conversation_closed",
|
|
362
|
+
mode: "conversation",
|
|
363
|
+
callback_seq: turns + 1,
|
|
364
|
+
job_id: conv.jobId,
|
|
365
|
+
status: conv.job?.status ?? "succeeded",
|
|
366
|
+
closed_reason: conv.job?.closed_reason ?? null,
|
|
367
|
+
turns,
|
|
368
|
+
continued_by_job_id: conv.continuedBy,
|
|
369
|
+
occurred_at: new Date().toISOString(),
|
|
370
|
+
};
|
|
371
|
+
}
|