@musnows/scriverse 0.9.4 → 0.9.6

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.
Files changed (45) hide show
  1. package/dist/ai-protocol.js +6 -0
  2. package/dist/ai-protocol.js.map +1 -1
  3. package/dist/ai-skills.js +134 -0
  4. package/dist/ai-skills.js.map +1 -0
  5. package/dist/ai-write-plans.js +2279 -0
  6. package/dist/ai-write-plans.js.map +1 -0
  7. package/dist/ai.js +2781 -154
  8. package/dist/ai.js.map +1 -1
  9. package/dist/app.js +328 -17
  10. package/dist/app.js.map +1 -1
  11. package/dist/chapter-annotation-anchor.js +327 -0
  12. package/dist/chapter-annotation-anchor.js.map +1 -0
  13. package/dist/chapter-title-numbering.js +56 -0
  14. package/dist/chapter-title-numbering.js.map +1 -0
  15. package/dist/database.js +478 -3
  16. package/dist/database.js.map +1 -1
  17. package/dist/public/ai-context-meter.js +3 -3
  18. package/dist/public/ai-interactive.js +483 -0
  19. package/dist/public/app.js +1991 -268
  20. package/dist/public/chapter-editor-behavior.js +40 -0
  21. package/dist/public/chapter-line-id-tracker.d.ts +25 -0
  22. package/dist/public/chapter-line-id-tracker.js +151 -0
  23. package/dist/public/index.html +79 -19
  24. package/dist/public/model-config.d.ts +1 -0
  25. package/dist/public/model-config.js +9 -4
  26. package/dist/public/styles.css +337 -57
  27. package/dist/public/theme-init.js +25 -1
  28. package/dist/remote-mcp.js +496 -0
  29. package/dist/remote-mcp.js.map +1 -0
  30. package/dist/roleplay-memory.js +53 -0
  31. package/dist/roleplay-memory.js.map +1 -0
  32. package/dist/security.js +4 -0
  33. package/dist/security.js.map +1 -1
  34. package/dist/semantic-search.js +225 -0
  35. package/dist/semantic-search.js.map +1 -0
  36. package/dist/skills/continue-writing/SKILL.md +23 -0
  37. package/dist/skills/polish-writing/SKILL.md +23 -0
  38. package/dist/store.js +695 -51
  39. package/dist/store.js.map +1 -1
  40. package/dist/ui-module-preload.js +27 -0
  41. package/dist/ui-module-preload.js.map +1 -0
  42. package/dist/user-auth.js +33 -1
  43. package/dist/user-auth.js.map +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +6 -1
@@ -5,8 +5,8 @@ export function formatAiContextUsageTooltip(usage) {
5
5
  const contextTokens = Math.max(0, Math.round(Number(usage.contextTokens) || 0)).toLocaleString("zh-CN");
6
6
  const conversationTokens = Math.max(0, Math.round(Number(usage.conversationTokens) || 0)).toLocaleString("zh-CN");
7
7
  const conversationBudget = Math.max(0, Math.round(Number(usage.conversationBudgetTokens) || 0)).toLocaleString("zh-CN");
8
- const outputReserve = Math.max(0, Math.round(Number(usage.outputReserveTokens) || 0)).toLocaleString("zh-CN");
9
- return `总输入 ${inputTokens} / ${contextWindow} tok · 作品上下文 ${contextTokens} tok · 对话历史 ${conversationTokens} / ${conversationBudget} tok · 输出预留 ${outputReserve} tok`;
8
+ const outputTokens = Math.max(0, Math.round(Number(usage.outputTokens) || 0)).toLocaleString("zh-CN");
9
+ return `总输入 ${inputTokens} / ${contextWindow} tok · 作品上下文 ${contextTokens} tok · 对话历史 ${conversationTokens} / ${conversationBudget} tok · 当前调用实际输出 ${outputTokens} tok`;
10
10
  }
11
11
 
12
12
  function tokenCount(value) {
@@ -62,7 +62,7 @@ export function normalizeAiContextTokenDistribution(usage) {
62
62
  ? tokenCount(distribution.contextTokens)
63
63
  : tokenCount(usage?.inputTokens);
64
64
  const outputTokens = Math.min(
65
- tokenCount(distribution.outputTokens ?? usage?.outputReserveTokens),
65
+ tokenCount(distribution.outputTokens ?? usage?.outputTokens),
66
66
  Math.max(0, contextWindow - systemPromptTokens - functionTokens - skillsTokens - inputTokens)
67
67
  );
68
68
  const occupiedTokens = systemPromptTokens + functionTokens + skillsTokens + inputTokens + outputTokens;
@@ -0,0 +1,483 @@
1
+ // AI 可写工具与持久化审批工作流的前端组件:
2
+ // 审批卡片(对话内)、完整修改明细、AskUserQuestions 回答框与审批中心列表。
3
+ // 所有内容均以服务端返回为准渲染;本模块不直接发起请求。
4
+
5
+ export const INTERACTIVE_AI_TOOL_NAMES = {
6
+ propose_write_plan: "提交写入审批",
7
+ ask_user_question: "向作者提问"
8
+ };
9
+
10
+ export const INTERACTIVE_AI_TOOL_DESCRIPTIONS = {
11
+ propose_write_plan: "AI 只提交修改计划:系统按当前数据库生成字段级明细,作者确认后才会原子执行;任何情况下都不能删除条目或改写正文。",
12
+ ask_user_question: "一次提问恰好一个问题,附带 2-6 个预设选项;作者未回答前 AI 必须等待,不能编造答案。"
13
+ };
14
+
15
+ /** 作品设置页的 AI 可写工具开关清单:与 ai-write-plans.ts 的常量保持一致。 */
16
+ export const AI_WRITE_TOOLS_META = [
17
+ { id: "settings", label: "世界设定", description: "允许 AI 创建或编辑世界设定词条(不能删除)。" },
18
+ { id: "characters", label: "角色", description: "允许 AI 创建或编辑角色条目(不能删除)。" },
19
+ { id: "races", label: "种族", description: "允许 AI 创建或编辑种族设定(不能删除)。" },
20
+ { id: "organizations", label: "组织", description: "允许 AI 创建或编辑组织设定(不能删除组织)。" },
21
+ { id: "timeline", label: "时间线", description: "允许 AI 创建或编辑时间轴轨道与事件(不能删除)。" },
22
+ { id: "relationships", label: "人物关系", description: "允许 AI 创建或编辑人物关系(不能删除)。" },
23
+ { id: "outlines", label: "大纲/伏笔", description: "允许 AI 编辑章节大纲与创建或编辑伏笔(不能删除)。" },
24
+ { id: "annotations", label: "正文评论/待办", description: "允许 AI 在正文指定位置添加评论或待办批注,不改动正文本身。" },
25
+ { id: "analysis_tasks", label: "分析任务", description: "允许 AI 触发既有类型的分析任务进入现有队列。" },
26
+ { id: "ask_user_questions", label: "用户提问", description: "允许 AI 通过 AskUserQuestions 向你提出单选问题。" }
27
+ ];
28
+
29
+ const PLAN_STATUS_LABELS = {
30
+ pending: "待确认",
31
+ rejected: "已拒绝",
32
+ expired: "已过期",
33
+ invalidated: "已失效",
34
+ executing: "执行中",
35
+ executed: "执行成功",
36
+ failed: "执行失败"
37
+ };
38
+
39
+ const QUESTION_STATUS_LABELS = {
40
+ pending: "待回答",
41
+ answered: "已回答",
42
+ rejected: "已拒绝",
43
+ expired: "已过期"
44
+ };
45
+
46
+ export function aiPlanStatusLabel(status) {
47
+ return PLAN_STATUS_LABELS[String(status)] ?? String(status);
48
+ }
49
+
50
+ export function aiQuestionStatusLabel(status) {
51
+ return QUESTION_STATUS_LABELS[String(status)] ?? String(status);
52
+ }
53
+
54
+ /** 状态徽章色调:CSS 里按 data-tone 展示统一配色。 */
55
+ export function statusTone(status) {
56
+ switch (String(status)) {
57
+ case "pending": return "pending";
58
+ case "executing": return "running";
59
+ case "executed":
60
+ case "answered": return "success";
61
+ case "rejected":
62
+ case "invalidated":
63
+ case "failed": return "danger";
64
+ default: return "muted";
65
+ }
66
+ }
67
+
68
+ function esc(value) {
69
+ return String(value ?? "")
70
+ .replaceAll("&", "&")
71
+ .replaceAll("<", "&lt;")
72
+ .replaceAll(">", "&gt;")
73
+ .replaceAll('"', "&quot;")
74
+ .replaceAll("'", "&#39;");
75
+ }
76
+
77
+ function formatDateTime(value) {
78
+ if (!value) return "";
79
+ const date = new Date(value);
80
+ if (Number.isNaN(date.getTime())) return "";
81
+ return new Intl.DateTimeFormat("zh-CN", {
82
+ year: "numeric", month: "2-digit", day: "2-digit",
83
+ hour: "2-digit", minute: "2-digit", hour12: false
84
+ }).format(date);
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // 工具调用载荷解析
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * 解析 propose_write_plan / ask_user_question 的工具结果为卡片模型。
93
+ * 失败结果形如 { ok:false, error:{code,message} };成功计划含 plan 摘要与最近计划列表。
94
+ */
95
+ export function parseInteractiveToolPayload(toolCall) {
96
+ const name = String(toolCall?.name ?? "");
97
+ if (name !== "propose_write_plan" && name !== "ask_user_question") return null;
98
+ const raw = toolCall?.result;
99
+ let result = raw;
100
+ if (typeof raw === "string") {
101
+ try { result = JSON.parse(raw); } catch { result = null; }
102
+ }
103
+ const ok = result?.ok === true && toolCall?.status !== "failed";
104
+ const error = result?.error ?? null;
105
+ if (name === "propose_write_plan") {
106
+ return {
107
+ kind: "plan",
108
+ ok,
109
+ name,
110
+ calledAt: toolCall?.calledAt ?? "",
111
+ plan: ok ? result.plan ?? null : null,
112
+ recentPlans: Array.isArray(result?.recentPlans) ? result.recentPlans : [],
113
+ message: typeof result?.message === "string" ? result.message : "",
114
+ error
115
+ };
116
+ }
117
+ const options = Array.isArray(toolCall?.arguments?.options) ? toolCall.arguments.options : [];
118
+ return {
119
+ kind: "question",
120
+ ok,
121
+ name,
122
+ calledAt: toolCall?.calledAt ?? "",
123
+ question: ok ? result.question ?? null : null,
124
+ argumentOptions: options.map((option) => String(option)),
125
+ message: typeof result?.message === "string" ? result.message : "",
126
+ error
127
+ };
128
+ }
129
+
130
+ /**
131
+ * 会话内的最新审批详情缓存:确认/撤销之后写回,避免卡片在重渲染时回退到提交时刻的快照。
132
+ */
133
+ const livePlanDetails = new Map();
134
+
135
+ export function cacheAiWritePlanDetail(detail) {
136
+ if (detail?.id) livePlanDetails.set(String(detail.id), detail);
137
+ }
138
+
139
+ export function cachedAiWritePlanDetail(planId) {
140
+ return livePlanDetails.get(String(planId)) ?? null;
141
+ }
142
+
143
+ /** 用户提问的实时状态缓存:提交回答/拒绝后写回。 */
144
+ const liveQuestionViews = new Map();
145
+
146
+ export function cacheAiQuestionView(question) {
147
+ if (question?.id) liveQuestionViews.set(String(question.id), question);
148
+ }
149
+
150
+ function cachedAiQuestionView(questionId) {
151
+ return liveQuestionViews.get(String(questionId)) ?? null;
152
+ }
153
+
154
+ /**
155
+ * 判断交互式工具调用当前是否会渲染出可操作的待处理卡片;
156
+ * 历史消息据此自动展开"思考与执行过程",避免待确认/待回答入口被折叠隐藏。
157
+ */
158
+ export function isInteractiveToolPending(toolCall) {
159
+ const model = parseInteractiveToolPayload(toolCall);
160
+ if (!model?.ok) return false;
161
+ if (model.kind === "plan") {
162
+ const detail = model.plan?.id ? cachedAiWritePlanDetail(model.plan.id) : null;
163
+ return (detail?.status ?? model.plan?.status ?? "") === "pending";
164
+ }
165
+ const question = model.question?.id ? cachedAiQuestionView(model.question.id) : null;
166
+ return ((question ?? model.question)?.status ?? "") === "pending";
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // 对话内交互卡片
171
+ // ---------------------------------------------------------------------------
172
+
173
+ function cardShell(kind, title, toolName) {
174
+ const card = document.createElement("section");
175
+ card.className = `ai-interactive-card ai-${kind}-card`;
176
+ card.dataset.interactiveTool = toolName;
177
+ const head = document.createElement("div");
178
+ head.className = "ai-interactive-card-head";
179
+ const label = document.createElement("strong");
180
+ label.textContent = title;
181
+ head.append(label);
182
+ card.append(head);
183
+ return card;
184
+ }
185
+
186
+ function metaRow(entries) {
187
+ const dl = document.createElement("dl");
188
+ dl.className = "ai-interactive-meta";
189
+ for (const [term, definition] of entries) {
190
+ const item = document.createElement("div");
191
+ const dt = document.createElement("dt");
192
+ dt.textContent = term;
193
+ const dd = document.createElement("dd");
194
+ dd.textContent = definition;
195
+ item.append(dt, dd);
196
+ dl.append(item);
197
+ }
198
+ return dl;
199
+ }
200
+
201
+ function statusBadge(text, tone) {
202
+ const badge = document.createElement("span");
203
+ badge.className = "ai-status-chip";
204
+ badge.dataset.tone = tone;
205
+ badge.textContent = text;
206
+ return badge;
207
+ }
208
+
209
+ function actionButton(labelText, className, onClick) {
210
+ const button = document.createElement("button");
211
+ button.type = "button";
212
+ button.className = className;
213
+ button.textContent = labelText;
214
+ button.addEventListener("click", onClick);
215
+ return button;
216
+ }
217
+
218
+ function buildPlanCard(model, actions) {
219
+ // 优先使用缓存中的最新详情(用户刚刚确认/撤销过),否则回退到工具结果摘要。
220
+ const liveDetail = model.plan ? cachedAiWritePlanDetail(model.plan.id) : null;
221
+ const status = liveDetail?.status ?? model.plan?.status ?? "";
222
+ const statusLabel = liveDetail?.statusLabel ?? aiPlanStatusLabel(status);
223
+ const summary = liveDetail?.aiSummary ?? model.plan?.aiSummary ?? "";
224
+ const operationCount = liveDetail?.operationCount ?? model.plan?.operationCount ?? 0;
225
+ const card = cardShell("write-plan", `${INTERACTIVE_AI_TOOL_NAMES.propose_write_plan}`, model.name);
226
+ const head = card.querySelector(".ai-interactive-card-head");
227
+ head.append(statusBadge(statusLabel || "待确认", statusTone(status)));
228
+
229
+ if (summary) {
230
+ const body = document.createElement("p");
231
+ body.className = "ai-interactive-summary";
232
+ body.textContent = summary;
233
+ card.append(body);
234
+ }
235
+
236
+ const targets = liveDetail?.operations?.map((operation) => operation.title)
237
+ ?? (Array.isArray(model.plan?.targets) ? model.plan.targets : []);
238
+ const moduleLabels = liveDetail?.moduleLabels ?? model.plan?.moduleLabels;
239
+ const items = [["操作对象", targets.length > 0 ? targets.join("、") : `${operationCount} 个操作`]];
240
+ if (Array.isArray(moduleLabels) && moduleLabels.length > 0) {
241
+ items.push(["涉及模块", moduleLabels.join("、")]);
242
+ }
243
+ if (status === "executed") items.push(["执行时间", formatDateTime(liveDetail?.executedAt)]);
244
+ if (status === "rejected") items.push(["处理时间", formatDateTime(liveDetail?.decidedAt)]);
245
+ card.append(metaRow(items));
246
+
247
+ if (liveDetail?.invalidReason) {
248
+ const reason = document.createElement("p");
249
+ reason.className = "ai-interactive-warn";
250
+ reason.textContent = `失效原因:${liveDetail.invalidReason}`;
251
+ card.append(reason);
252
+ } else if (liveDetail?.failureMessage) {
253
+ const failure = document.createElement("p");
254
+ failure.className = "ai-interactive-warn";
255
+ failure.textContent = `失败原因:${liveDetail.failureMessage}`;
256
+ card.append(failure);
257
+ }
258
+
259
+ const actionsBar = document.createElement("div");
260
+ actionsBar.className = "ai-interactive-actions";
261
+ const planId = model.plan?.id ?? liveDetail?.id ?? "";
262
+ actionsBar.append(actionButton("完整修改明细", "ghost-button ai-card-action", () => actions.openPlanDetail(planId)));
263
+ if (status === "pending") {
264
+ actionsBar.append(
265
+ actionButton("拒绝", "ghost-button ai-card-danger", () => actions.rejectPlan(planId)),
266
+ actionButton("确认执行", "primary-button ai-card-primary", () => actions.confirmPlan(planId))
267
+ );
268
+ } else if (status === "executed") {
269
+ actionsBar.append(actionButton("查看执行结果与撤销", "ghost-button ai-card-action", () => actions.openPlanDetail(planId)));
270
+ }
271
+ card.append(actionsBar);
272
+
273
+ const note = document.createElement("p");
274
+ note.className = "ai-interactive-note";
275
+ note.textContent = status === "pending"
276
+ ? "确认后系统会重新校验权限、开关与目标版本,全部通过才原子执行;拒绝或过期都不会产生任何写入。"
277
+ : (model.message || "详情与审计记录见 AI 操作审批中心。");
278
+ card.append(note);
279
+ return card;
280
+ }
281
+
282
+ function buildQuestionCard(model, actions) {
283
+ // 回答/拒绝后缓存里有更新状态时优先展示。
284
+ const question = cachedAiQuestionView(model.question?.id) ?? model.question;
285
+ const status = question?.status ?? "";
286
+ const statusLabel = question ? (question.statusLabel ?? aiQuestionStatusLabel(status)) : "";
287
+ const card = cardShell("question", `${INTERACTIVE_AI_TOOL_NAMES.ask_user_question}`, model.name);
288
+ if (question?.id) card.dataset.questionId = String(question.id);
289
+ const head = card.querySelector(".ai-interactive-card-head");
290
+ if (statusLabel) head.append(statusBadge(statusLabel, statusTone(status)));
291
+
292
+ if (question?.question) {
293
+ const body = document.createElement("p");
294
+ body.className = "ai-interactive-summary";
295
+ body.textContent = question.question;
296
+ card.append(body);
297
+ }
298
+
299
+ const options = question?.options?.length
300
+ ? question.options.map((option) => option.label)
301
+ : model.argumentOptions;
302
+ if (options.length > 0) {
303
+ const list = document.createElement("ol");
304
+ list.className = "ai-question-option-preview";
305
+ for (const [index, option] of options.entries()) {
306
+ const item = document.createElement("li");
307
+ item.textContent = option;
308
+ if (index === 0) item.classList.add("is-recommended");
309
+ list.append(item);
310
+ }
311
+ card.append(list);
312
+ }
313
+
314
+ const questionMeta = [];
315
+ if (question?.expiresAt) questionMeta.push(["有效期至", formatDateTime(question.expiresAt)]);
316
+ if (status === "answered" && question?.answerText) questionMeta.push(["工具结果", question.answerText]);
317
+ if (question?.decidedAt) questionMeta.push(["处理时间", formatDateTime(question.decidedAt)]);
318
+ if (questionMeta.length > 0) card.append(metaRow(questionMeta));
319
+
320
+ const actionsBar = document.createElement("div");
321
+ actionsBar.className = "ai-interactive-actions";
322
+ if (question && status === "pending") {
323
+ actionsBar.append(
324
+ actionButton("暂不回答", "ghost-button ai-card-danger", () => actions.rejectQuestion(question.id)),
325
+ actionButton("回答问题", "primary-button ai-card-primary", () => actions.openQuestionDialog(question.id))
326
+ );
327
+ } else if (question) {
328
+ actionsBar.append(actionButton("查看回答状态", "ghost-button ai-card-action", () => actions.openApprovalCenter()));
329
+ }
330
+ card.append(actionsBar);
331
+
332
+ const note = document.createElement("p");
333
+ note.className = "ai-interactive-note";
334
+ note.textContent = !model.ok
335
+ ? "本次提问未能创建。"
336
+ : status === "answered"
337
+ ? "作者回答已作为 ask_user_question 的工具结果返回,Agent 已继续原工作流。"
338
+ : status === "rejected" || status === "expired"
339
+ ? "未获得作者回答,Agent 不得依赖该选择继续写入。"
340
+ : "选择一个预设选项后仍可填写补充信息,也可以只填写自定义回答;不作答时该问题会过期。";
341
+ card.append(note);
342
+ return card;
343
+ }
344
+
345
+ function buildFailureCard(model) {
346
+ const card = cardShell("interactive-failure", `${INTERACTIVE_AI_TOOL_NAMES[model.name] ?? model.name}`, model.name);
347
+ const head = card.querySelector(".ai-interactive-card-head");
348
+ head.append(statusBadge("调用失败", "danger"));
349
+ const body = document.createElement("p");
350
+ body.className = "ai-interactive-summary";
351
+ body.textContent = String(model.error?.message ?? "可写工具调用失败,未产生任何写入或提问。");
352
+ card.append(body);
353
+ const note = document.createElement("p");
354
+ note.className = "ai-interactive-note";
355
+ note.textContent = "失败的工具调用不会写入任何数据;AI 需要根据错误信息调整后再重新发起。";
356
+ card.append(note);
357
+ return card;
358
+ }
359
+
360
+ /**
361
+ * 为交互式可写工具构建消息流内的操作卡片;非交互式工具返回 null。
362
+ */
363
+ export function createInteractiveToolCard(toolCall, actions) {
364
+ const model = parseInteractiveToolPayload(toolCall);
365
+ if (!model) return null;
366
+ try {
367
+ if (!model.ok) return buildFailureCard(model);
368
+ if (model.kind === "plan" && model.plan) return buildPlanCard(model, actions);
369
+ if (model.kind === "question" && model.question) return buildQuestionCard(model, actions);
370
+ return buildFailureCard({ ...model, ok: false, error: model.error ?? { code: "UNKNOWN", message: "工具结果缺少必要数据。" } });
371
+ } catch {
372
+ return null;
373
+ }
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // 完整修改明细(审批详情弹窗主体)
378
+ // ---------------------------------------------------------------------------
379
+
380
+ function diffPreviewBlock(preview, className) {
381
+ if (!preview) return "";
382
+ return `<pre class="diff-preview ${className}">${esc(preview)}</pre>`;
383
+ }
384
+
385
+ function fieldDiffRows(operation) {
386
+ if (!operation.fields?.length) return "";
387
+ return `<table class="ai-field-diff-table"><thead><tr><th>字段</th><th>修改前</th><th>修改后</th></tr></thead><tbody>${operation.fields.map((field) => `
388
+ <tr class="${field.changed ? "is-changed" : ""}">
389
+ <td><strong>${esc(field.label)}</strong>${field.addedLines || field.removedLines ? `<small>新增 ${field.addedLines} 行 / 删除 ${field.removedLines} 行</small>` : ""}</td>
390
+ <td>${field.before === null || field.before === undefined || field.before === "" ? '<span class="diff-empty">空</span>' : `<div class="diff-value">${esc(field.before)}</div>${diffPreviewBlock(field.previewBefore, "diff-remove")}`}</td>
391
+ <td>${field.after === null || field.after === undefined || field.after === "" ? '<span class="diff-empty">空</span>' : `<div class="diff-value">${esc(field.after)}</div>${diffPreviewBlock(field.previewAfter, "diff-add")}`}</td>
392
+ </tr>`).join("")}</tbody></table>`;
393
+ }
394
+
395
+ function operationSection(operation) {
396
+ const headingBits = [`#${operation.seq} ${operation.opTypeLabel}`, operation.moduleLabel];
397
+ if (operation.targetVersionNo !== null && operation.targetVersionNo !== undefined) {
398
+ headingBits.push(`当前版本 v${operation.targetVersionNo}`);
399
+ }
400
+ const modules = operation.requiredModuleLabels?.length
401
+ ? `<div class="ai-op-required">所需模块权限:${operation.requiredModuleLabels.map((label) => `<span>${esc(label)}</span>`).join("")}</div>`
402
+ : "";
403
+ const annotation = operation.annotation
404
+ ? `<div class="ai-op-annotation"><div><span>类型</span><strong>${esc(operation.annotation.kindLabel)}</strong></div><div><span>位置</span><strong>第 ${operation.annotation.startLine}-${operation.annotation.endLine} 行</strong></div>${operation.annotation.quote ? `<blockquote class="ai-op-quote">${esc(operation.annotation.quote)}</blockquote>` : ""}<p class="ai-op-note">${esc(operation.annotation.note)}</p></div>`
405
+ : "";
406
+ const task = operation.task
407
+ ? `<div class="ai-op-task"><div><span>任务类型</span><strong>${esc(operation.task.taskTypeLabel)}</strong></div><div><span>分析范围</span><strong>${esc(operation.task.scopeSummary)}</strong></div>${operation.task.modelId ? `<div><span>指定模型</span><code>${esc(operation.task.modelId)}</code></div>` : ""}</div>`
408
+ : "";
409
+ const result = operation.result
410
+ ? `<div class="ai-op-result"><strong>执行结果</strong><p>${esc(operation.result.summary)}</p><small>版本号:${operation.result.versionNo ?? "-"} · 操作者与审计记录见本页底部。</small></div>`
411
+ : "";
412
+ const audits = operation.auditRecords?.length
413
+ ? `<ul class="ai-op-audits">${operation.auditRecords.map((record) => `<li><strong>${esc(record.actor)}</strong><span>${esc(record.action)} · ${esc(formatDateTime(record.createdAt))}</span></li>`).join("")}</ul>`
414
+ : "";
415
+ return `<section class="ai-operation-item${operation.restricted ? " is-restricted" : ""}">
416
+ <header><h4>${headingBits.map((bit) => esc(bit)).join(" · ")}</h4>${operation.restricted ? '<span class="ai-status-chip" data-tone="muted">无权查看内容</span>' : ""}</header>
417
+ <h5>${esc(operation.title)}</h5>
418
+ ${annotation}${task}${fieldDiffRows(operation)}${result}${audits}${modules}
419
+ </section>`;
420
+ }
421
+
422
+ /** 审批详情弹窗主体:操作列表由服务端固化生成,前端只做呈现。 */
423
+ export function renderWritePlanDetailMarkup(detail) {
424
+ const overallTone = statusTone(detail.status);
425
+ const headerMeta = [];
426
+ if (detail.kindLabel) headerMeta.push(detail.kindLabel);
427
+ if (detail.operationCount) headerMeta.push(`${detail.operationCount} 个操作`);
428
+ if (detail.createdAt) headerMeta.push(`发起于 ${formatDateTime(detail.createdAt)}`);
429
+ if (detail.decidedAt) headerMeta.push(`处理于 ${formatDateTime(detail.decidedAt)}`);
430
+ const banner = detail.status === "invalidated" && detail.invalidReason
431
+ ? `<p class="ai-plan-banner is-invalidated">${esc(detail.invalidReason)}</p>`
432
+ : detail.status === "failed" && detail.failureMessage
433
+ ? `<p class="ai-plan-banner is-failed">${esc(detail.failureMessage)}</p>`
434
+ : "";
435
+ const operations = (detail.operations ?? []).map((operation) => operationSection(operation)).join("");
436
+ const footerBits = [];
437
+ if (detail.sourcePlanId) footerBits.push(`来源审批:${detail.sourcePlanId}`);
438
+ if (detail.initiatorUserId) footerBits.push(`发起人 ID:${detail.initiatorUserId}`);
439
+ if (detail.decidedByName || detail.decidedByUserId) footerBits.push(`操作者:${detail.decidedByName || detail.decidedByUserId}`);
440
+ footerBits.push("每次确认都会写入审计日志");
441
+ return `
442
+ <div class="ai-plan-detail-head">
443
+ <span class="ai-status-chip" data-tone="${overallTone}">${esc(aiPlanStatusLabel(detail.status))}</span>
444
+ <span class="eyebrow">${headerMeta.map((item) => esc(item)).join(" · ")}</span>
445
+ </div>
446
+ <h3 class="ai-plan-summary-title">AI 简述</h3>
447
+ <p class="ai-plan-summary-text">${esc(detail.aiSummary)}</p>
448
+ ${banner}
449
+ <div class="ai-operation-list">${operations || '<p class="usage-measurement-note">没有可展示的操作记录。</p>'}</div>
450
+ <p class="usage-measurement-note ai-plan-audit-note">${footerBits.map((item) => esc(item)).join(" · ")}</p>`;
451
+ }
452
+
453
+ /** 审批中心列表行。 */
454
+ export function renderApprovalCenterRows(plans, questions = []) {
455
+ if (!plans.length && !questions.length) {
456
+ return '<p class="usage-measurement-note ai-approval-empty">还没有审批或提问记录。AI 提交交互后会出现在这里。</p>';
457
+ }
458
+ const planRows = plans.map((plan) => `
459
+ <li>
460
+ <button type="button" class="ai-approval-row" data-plan-id="${esc(plan.id)}">
461
+ <span class="ai-status-chip" data-tone="${statusTone(plan.status)}">${esc(plan.statusLabel)}</span>
462
+ <span class="ai-approval-row-main">
463
+ <strong>${plan.kind === "undo" ? "撤销审批" : "写入审批"} · ${esc(plan.aiSummary)}</strong>
464
+ <small>${esc((plan.moduleLabels ?? []).join("、")) || "—"} · ${Number(plan.operationCount)} 个操作 · ${esc(formatDateTime(plan.createdAt))}</small>
465
+ </span>
466
+ <span class="ai-approval-row-open" aria-hidden="true">查看</span>
467
+ </button>
468
+ </li>`).join("");
469
+ const questionRows = questions.map((question) => `
470
+ <li>
471
+ <button type="button" class="ai-approval-row" data-question-id="${esc(question.id)}">
472
+ <span class="ai-status-chip" data-tone="${statusTone(question.status)}">${esc(question.statusLabel)}</span>
473
+ <span class="ai-approval-row-main">
474
+ <strong>AI 提问 · ${esc(question.question)}</strong>
475
+ <small>${question.answerText ? `回答:${esc(question.answerText)} · ` : ""}${esc(formatDateTime(question.createdAt))}</small>
476
+ </span>
477
+ <span class="ai-approval-row-open">查看</span>
478
+ </button>
479
+ </li>`).join("");
480
+ return `<ul class="ai-approval-list">${planRows}${questionRows}</ul>`;
481
+ }
482
+
483
+ export { esc as aiEsc, formatDateTime as aiFormatDateTime };