@musnows/scriverse 1.0.2 → 1.0.4

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.
@@ -51,6 +51,17 @@ export function aiQuestionStatusLabel(status) {
51
51
  return QUESTION_STATUS_LABELS[String(status)] ?? String(status);
52
52
  }
53
53
 
54
+ /** 待回答或尚未加载完成时不允许静默关闭提问窗口。 */
55
+ export function aiQuestionDialogCanClose(question) {
56
+ return ["answered", "rejected", "expired"].includes(String(question?.status ?? ""));
57
+ }
58
+
59
+ /** 优先读取结构化错误码,并兼容修复前只保存了错误正文的历史消息。 */
60
+ export function aiFailureCode(text, metadata = {}) {
61
+ if (typeof metadata?.errorCode === "string" && metadata.errorCode) return metadata.errorCode;
62
+ return String(text ?? "").match(/(?:^|\n)错误码:([^\n]+)(?:\n|$)/u)?.[1]?.trim() ?? "";
63
+ }
64
+
54
65
  /** 状态徽章色调:CSS 里按 data-tone 展示统一配色。 */
55
66
  export function statusTone(status) {
56
67
  switch (String(status)) {
@@ -114,19 +125,63 @@ export function parseInteractiveToolPayload(toolCall) {
114
125
  error
115
126
  };
116
127
  }
117
- const options = Array.isArray(toolCall?.arguments?.options) ? toolCall.arguments.options : [];
128
+ const rawArguments = toolCall?.arguments;
129
+ let suppliedArguments = rawArguments;
130
+ if (typeof rawArguments === "string") {
131
+ try { suppliedArguments = JSON.parse(rawArguments); } catch { suppliedArguments = null; }
132
+ }
133
+ const argumentQuestions = Array.isArray(suppliedArguments?.questions)
134
+ ? suppliedArguments.questions
135
+ : suppliedArguments?.question
136
+ ? [{ question: suppliedArguments.question, options: suppliedArguments.options }]
137
+ : [];
138
+ const resultQuestion = ok && result?.question && typeof result.question === "object" && !Array.isArray(result.question)
139
+ ? result.question
140
+ : null;
141
+ const question = resultQuestion && (!Array.isArray(resultQuestion.questions) || resultQuestion.questions.length === 0) && argumentQuestions.length > 0
142
+ ? { ...resultQuestion, questions: argumentQuestions }
143
+ : resultQuestion;
118
144
  return {
119
145
  kind: "question",
120
146
  ok,
121
147
  name,
122
148
  calledAt: toolCall?.calledAt ?? "",
123
- question: ok ? result.question ?? null : null,
124
- argumentOptions: options.map((option) => String(option)),
149
+ question,
150
+ argumentQuestions,
125
151
  message: typeof result?.message === "string" ? result.message : "",
126
152
  error
127
153
  };
128
154
  }
129
155
 
156
+ /** 兼容历史单题、批量题目、字符串选项与完整 API 选项对象。 */
157
+ export function normalizeAiQuestionItems(question) {
158
+ const items = Array.isArray(question?.questions) && question.questions.length > 0
159
+ ? question.questions
160
+ : [{
161
+ question: question?.question ?? "",
162
+ options: question?.options ?? [],
163
+ selectedOption: question?.selectedOption ?? null,
164
+ customAnswer: question?.customAnswer ?? "",
165
+ answerText: question?.answerText ?? "",
166
+ isCustomAnswer: question?.isCustomAnswer === true
167
+ }];
168
+ return items.map((item, index) => ({
169
+ ...item,
170
+ index,
171
+ question: String(item?.question ?? ""),
172
+ options: (Array.isArray(item?.options) ? item.options : []).map((option, optionIndex) => (
173
+ option && typeof option === "object" && !Array.isArray(option)
174
+ ? {
175
+ ...option,
176
+ index: Number.isInteger(option.index) ? Number(option.index) : optionIndex,
177
+ label: String(option.label ?? ""),
178
+ recommended: option.recommended === true || (option.recommended === undefined && optionIndex === 0)
179
+ }
180
+ : { index: optionIndex, label: String(option ?? ""), recommended: optionIndex === 0 }
181
+ ))
182
+ }));
183
+ }
184
+
130
185
  /**
131
186
  * 会话内的最新审批详情缓存:确认/撤销之后写回,避免卡片在重渲染时回退到提交时刻的快照。
132
187
  */
@@ -294,9 +349,7 @@ function buildQuestionCard(model, actions) {
294
349
  card.append(body);
295
350
  }
296
351
 
297
- const options = question?.options?.length
298
- ? question.options.map((option) => option.label)
299
- : model.argumentOptions;
352
+ const options = normalizeAiQuestionItems(question).flatMap((item) => item.options.map((option) => option.label));
300
353
  if (options.length > 0) {
301
354
  const list = document.createElement("ol");
302
355
  list.className = "ai-question-option-preview";
@@ -50,13 +50,17 @@ export function createAiRequestManager() {
50
50
  if (!current) return false;
51
51
  activeRequests.delete(key);
52
52
  current.controller.abort(createAiRequestAbortError(reason));
53
+ current.resolveFinished();
53
54
  return true;
54
55
  };
55
56
 
56
57
  const cancelAll = (reason = "AI 请求已取消") => {
57
58
  const requests = [...activeRequests.values()];
58
59
  activeRequests.clear();
59
- for (const current of requests) current.controller.abort(createAiRequestAbortError(reason));
60
+ for (const current of requests) {
61
+ current.controller.abort(createAiRequestAbortError(reason));
62
+ current.resolveFinished();
63
+ }
60
64
  return requests.length;
61
65
  };
62
66
 
@@ -67,7 +71,9 @@ export function createAiRequestManager() {
67
71
  generation += 1;
68
72
  const request = snapshot(input, controller, generation);
69
73
  if (!request.workId) throw new Error("AI 请求必须绑定作品");
70
- activeRequests.set(key, { controller, snapshot: request });
74
+ let resolveFinished;
75
+ const finished = new Promise((resolve) => { resolveFinished = resolve; });
76
+ activeRequests.set(key, { controller, snapshot: request, finished, resolveFinished });
71
77
  return request;
72
78
  };
73
79
 
@@ -98,6 +104,7 @@ export function createAiRequestManager() {
98
104
 
99
105
  const finish = (request) => {
100
106
  if (!isCurrent(request)) return false;
107
+ activeRequests.get(request.tabId).resolveFinished();
101
108
  activeRequests.delete(request.tabId);
102
109
  return true;
103
110
  };
@@ -108,6 +115,7 @@ export function createAiRequestManager() {
108
115
  cancel,
109
116
  cancelAll,
110
117
  finish,
118
+ whenIdle: (tabId) => activeRequests.get(requestKey({ tabId }))?.finished ?? Promise.resolve(),
111
119
  hasActive: (tabId = null) => tabId === null
112
120
  ? activeRequests.size > 0
113
121
  : activeRequests.has(requestKey({ tabId })),
@@ -1,5 +1,6 @@
1
1
  import { buildRelationshipGraph, createGalaxyRenderer, normalizeGalaxyFrameRate, normalizeGalaxyMotionMode, renderRelationshipMindMap } from "/relationship-graph.js?v=20260817-relationship-canvas-scale-v1&feature=galaxy-motion-mode-v3&feature=galaxy-edge-label-threshold-v1";
2
2
  import { formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
+ import { countProseWords } from "/text-count.js?v=20260906-chapter-word-count-consistency-v1";
3
4
  import { renderMarkdown } from "/markdown.js?v=20260830-adjacent-blockquotes-v1";
4
5
  import { createImWorkspace } from "/im.js?v=20260904-im-judge-outcomes-v106";
5
6
  import { findAiMention, listAiMentionOptions, mergeAiReferenceScope, userMessageMentionNames } from "/ai-mentions.js?v=20260811-user-message-mentions-v1";
@@ -14,7 +15,7 @@ import {
14
15
  } from "/roleplay-turn.js?v=20260823-ai-roleplay-scene-turn-v2";
15
16
  import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
16
17
  import { createAiChatTabManager, normalizeAiChatTabLimit } from "/ai-chat-tabs.js?v=20260816-ai-chat-switcher-v2";
17
- import { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManager, isAiRequestCancellation } from "/ai-request-manager.js?v=20260816-ai-chat-tabs-v1";
18
+ import { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManager, isAiRequestCancellation } from "/ai-request-manager.js?v=20260905-question-stream-v2";
18
19
  import { calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
19
20
  import { buildChapterLineMirror, findChapterLineWindow } from "/chapter-editor-virtualization.js?v=20260810-visible-lines-v1";
20
21
  import { CHAPTER_PARAGRAPH_INDENT, calculateChapterCaretScroll, chapterLineIndexAtOffset, insertIndentedParagraph } from "/chapter-editor-behavior.js?v=20260828-centered-scroll-v1";
@@ -31,7 +32,7 @@ import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, MODEL_THINKING_EFFORT_
31
32
  import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260822-private-ai-endpoint-hint-v1";
32
33
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
33
34
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260814-ai-model-lock-v1";
34
- import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260818-ai-agent-turn-process-v1";
35
+ import { createStreamTypewriter, createStreamTypewriterSpeedController } from "/stream-typewriter.js?v=20260906-background-stream-v2";
35
36
  import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
36
37
  import { buildUsageCalendar, formatCacheHitRate, formatEstimatedCost, formatTokenCount, usageCalendarYears } from "/ai-usage.js?v=20260830-ai-usage-year-v1";
37
38
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
@@ -40,15 +41,18 @@ import { isPhoneClient } from "/phone-client.js?v=20260819-phone-client-v1";
40
41
  import { formatAiToolCallResult } from "/ai-tool-call.js?v=20260801-ai-tool-result-chars-v1";
41
42
  import {
42
43
  AI_WRITE_TOOLS_META,
44
+ aiFailureCode,
45
+ aiQuestionDialogCanClose,
43
46
  cacheAiQuestionView,
44
47
  cacheAiWritePlanDetail,
45
48
  createInteractiveToolCard,
49
+ normalizeAiQuestionItems,
46
50
  parseInteractiveToolPayload,
47
51
  renderApprovalCenterRows,
48
52
  renderWritePlanDetailMarkup,
49
53
  isInteractiveToolPending,
50
54
  aiFormatDateTime
51
- } from "/ai-interactive.js?v=20260903-question-batch-v7";
55
+ } from "/ai-interactive.js?v=20260906-question-recovery-v1";
52
56
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
53
57
  import { bindPlainTextPaste } from "/plain-text-paste.js?v=20260815-plain-text-paste-v1";
54
58
  import { clipboardImageFiles } from "/character-markdown.js?v=20260820-ai-chat-image-attachments-v1";
@@ -665,8 +669,8 @@ function aiSendButtonIconMarkup(stateName) {
665
669
 
666
670
  function syncAiRequestControls() {
667
671
  const activeTabId = activeAiChatTab()?.id;
668
- const sending = aiRequestManager.hasActive(activeTabId);
669
672
  const continuingQuestion = activeTabId ? aiQuestionContinuationTabIds.has(activeTabId) : false;
673
+ const sending = aiRequestManager.hasActive(activeTabId) && !continuingQuestion;
670
674
  const switching = aiConversationNavigationPending !== null;
671
675
  const button = $("#ai-send");
672
676
  const stateName = sending ? "stop" : (switching || continuingQuestion) ? "switching" : "send";
@@ -2897,6 +2901,41 @@ function aiRetryStreamRequestBody(body, retry) {
2897
2901
  : body;
2898
2902
  }
2899
2903
 
2904
+ async function discardPendingAiQuestion(message, button) {
2905
+ const feed = message.closest(".ai-feed");
2906
+ const tab = aiChatTabManager.get(feed?.dataset.aiTabId);
2907
+ if (!tab?.conversationId || !tab.workId || String(tab.workId) !== String(state.work?.id ?? "")) {
2908
+ throw new Error("无法确定待回答问题所属的当前对话");
2909
+ }
2910
+ const label = button.querySelector("span");
2911
+ button.disabled = true;
2912
+ if (label) label.textContent = "正在作废提问";
2913
+ try {
2914
+ const expectedQuestionId = String(message.dataset.pendingQuestionId ?? "");
2915
+ const parameters = new URLSearchParams({ conversationId: tab.conversationId, status: "pending", limit: "1" });
2916
+ const payload = await api(`/api/works/${encodeURIComponent(tab.workId)}/ai/questions?${parameters}`);
2917
+ const questions = Array.isArray(payload) ? payload : (Array.isArray(payload?.questions) ? payload.questions : []);
2918
+ const questionId = String(questions.find((question) => String(question?.id ?? "") === expectedQuestionId)?.id ?? questions[0]?.id ?? "");
2919
+ if (!questionId) {
2920
+ if (label) label.textContent = "提问已处理";
2921
+ button.setAttribute("aria-label", "待回答提问已处理");
2922
+ toast("当前对话已经没有待回答问题,可以重新发送消息");
2923
+ return;
2924
+ }
2925
+ await respondAiUserQuestion(questionId, { action: "reject" });
2926
+ for (const action of tab.feed.querySelectorAll(".ai-question-discard-button")) {
2927
+ action.disabled = true;
2928
+ action.setAttribute("aria-label", "待回答提问已作废");
2929
+ const actionLabel = action.querySelector("span");
2930
+ if (actionLabel) actionLabel.textContent = "提问已作废";
2931
+ }
2932
+ } catch (error) {
2933
+ button.disabled = false;
2934
+ if (label) label.textContent = "作废提问并继续";
2935
+ throw error;
2936
+ }
2937
+ }
2938
+
2900
2939
  function renderMessageCardActions(message) {
2901
2940
  let actions = message.querySelector(".message-card-actions");
2902
2941
  if (!actions) {
@@ -2926,18 +2965,30 @@ function renderMessageCardActions(message) {
2926
2965
  actions.append(copy);
2927
2966
  }
2928
2967
  if (message.dataset.status === "failed" && message.classList.contains("assistant-message")) {
2929
- const retry = document.createElement("button");
2930
- retry.type = "button";
2931
- retry.className = "message-retry-button";
2932
- retry.setAttribute("aria-label", "重试");
2933
- retry.innerHTML = '<svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 0 0-14.7-4L4 9"/><path d="M4 4v5h5"/><path d="M4 13a8 8 0 0 0 14.7 4L20 15"/><path d="M20 20v-5h-5"/></svg><span>重试</span>';
2934
- retry.addEventListener("click", () => {
2935
- retry.disabled = true;
2936
- void retryAiMessage(message).finally(() => {
2937
- if (retry.isConnected) retry.disabled = false;
2968
+ if (message.dataset.errorCode === "AI_QUESTION_PENDING") {
2969
+ const discard = document.createElement("button");
2970
+ discard.type = "button";
2971
+ discard.className = "message-retry-button ai-question-discard-button";
2972
+ discard.setAttribute("aria-label", "作废待回答提问并让 AI 继续");
2973
+ discard.innerHTML = '<svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5l14 14M19 5 5 19"/><path d="M15 12h6M18 9l3 3-3 3"/></svg><span>作废提问并继续</span>';
2974
+ discard.addEventListener("click", () => {
2975
+ void discardPendingAiQuestion(message, discard).catch((error) => toast(`作废提问失败:${error.message}`, "error"));
2938
2976
  });
2939
- });
2940
- actions.append(retry);
2977
+ actions.append(discard);
2978
+ } else {
2979
+ const retry = document.createElement("button");
2980
+ retry.type = "button";
2981
+ retry.className = "message-retry-button";
2982
+ retry.setAttribute("aria-label", "重试");
2983
+ retry.innerHTML = '<svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 0 0-14.7-4L4 9"/><path d="M4 4v5h5"/><path d="M4 13a8 8 0 0 0 14.7 4L20 15"/><path d="M20 20v-5h-5"/></svg><span>重试</span>';
2984
+ retry.addEventListener("click", () => {
2985
+ retry.disabled = true;
2986
+ void retryAiMessage(message).finally(() => {
2987
+ if (retry.isConnected) retry.disabled = false;
2988
+ });
2989
+ });
2990
+ actions.append(retry);
2991
+ }
2941
2992
  } else if (message.dataset.messageId && message.classList.contains("assistant-message")) {
2942
2993
  const fork = document.createElement("button");
2943
2994
  fork.type = "button";
@@ -2952,6 +3003,7 @@ function renderMessageCardActions(message) {
2952
3003
  return;
2953
3004
  }
2954
3005
  fork.disabled = true;
3006
+ const dismissForkingToast = persistentToast("正在创建分支对话…");
2955
3007
  try {
2956
3008
  const sourceTab = aiChatTabManager.get(message.closest(".ai-feed")?.dataset.aiTabId);
2957
3009
  if (!sourceTab?.conversationId) throw new Error("无法确定消息所属对话");
@@ -2965,6 +3017,8 @@ function renderMessageCardActions(message) {
2965
3017
  } catch (error) {
2966
3018
  fork.disabled = false;
2967
3019
  toast(error.message, "error");
3020
+ } finally {
3021
+ dismissForkingToast();
2968
3022
  }
2969
3023
  });
2970
3024
  actions.append(fork);
@@ -3258,14 +3312,14 @@ function handleInteractiveToolCallEvent(toolCall) {
3258
3312
  return;
3259
3313
  }
3260
3314
  if (name !== "ask_user_question" || toolCall.status === "failed") return;
3261
- const question = toolCall.result?.question;
3315
+ const question = parseInteractiveToolPayload(toolCall)?.question;
3262
3316
  if (!question?.id) return;
3263
3317
  cacheAiQuestionView(question);
3264
3318
  const questionId = String(question.id);
3265
3319
  if (autoOpenedQuestionIds.has(questionId)) return;
3266
3320
  autoOpenedQuestionIds.add(questionId);
3267
3321
  // 直接弹出回答框等待作者选择;不会预填任何答案。
3268
- openAiUserQuestionDialog(questionId).catch(() => undefined);
3322
+ openAiUserQuestionDialog(questionId, question).catch(() => undefined);
3269
3323
  }
3270
3324
 
3271
3325
  function questionsEndpoint(path) {
@@ -3388,22 +3442,7 @@ async function fetchAiUserQuestion(questionId) {
3388
3442
  }
3389
3443
 
3390
3444
  function aiQuestionItems(question) {
3391
- const items = Array.isArray(question?.questions) && question.questions.length > 0
3392
- ? question.questions
3393
- : [{
3394
- question: question?.question ?? "",
3395
- options: question?.options ?? [],
3396
- selectedOption: question?.selectedOption ?? null,
3397
- customAnswer: question?.customAnswer ?? "",
3398
- answerText: question?.answerText ?? "",
3399
- isCustomAnswer: question?.isCustomAnswer === true
3400
- }];
3401
- return items.map((item, index) => ({
3402
- ...item,
3403
- index,
3404
- question: String(item?.question ?? ""),
3405
- options: Array.isArray(item?.options) ? item.options : []
3406
- }));
3445
+ return normalizeAiQuestionItems(question);
3407
3446
  }
3408
3447
 
3409
3448
  function resetAiQuestionDialogState(question) {
@@ -3444,6 +3483,14 @@ function syncAiQuestionAnswerCount() {
3444
3483
  $("#ai-question-answer-count").textContent = `已填写 ${input.value.length} / ${maximum}`;
3445
3484
  }
3446
3485
 
3486
+ function syncAiQuestionDismissState() {
3487
+ const closeButton = $("#ai-question-close");
3488
+ const canClose = aiQuestionDialogCanClose(currentAiQuestionDialogView);
3489
+ closeButton.disabled = !canClose;
3490
+ closeButton.classList.toggle("hidden", !canClose);
3491
+ $("#ai-question-dialog").dataset.dismissLocked = String(!canClose);
3492
+ }
3493
+
3447
3494
  function renderAiUserQuestionOptions(question) {
3448
3495
  const host = $("#ai-question-options");
3449
3496
  const customInput = $("#ai-question-custom-answer");
@@ -3498,6 +3545,7 @@ function renderAiUserQuestionOptions(question) {
3498
3545
  // 提交按钮由选择状态驱动:待回答且已选择(或输入)时才可提交。
3499
3546
  if (isPending) syncAiQuestionSubmitState();
3500
3547
  else $("#ai-question-submit").disabled = true;
3548
+ syncAiQuestionDismissState();
3501
3549
  $("#ai-question-submit").textContent = items.length > 1 ? "提交全部回答" : "提交回答";
3502
3550
  $("#ai-question-skip").disabled = !isPending;
3503
3551
  $("#ai-question-expiry").textContent = isPending
@@ -3513,17 +3561,25 @@ async function refreshAiQuestionDialog() {
3513
3561
  return question;
3514
3562
  }
3515
3563
 
3516
- async function openAiUserQuestionDialog(questionId) {
3564
+ async function openAiUserQuestionDialog(questionId, initialQuestion = null) {
3517
3565
  aiQuestionDialogQuestionId = String(questionId);
3518
3566
  const dialog = $("#ai-question-dialog");
3519
3567
  if (!dialog.open) dialog.showModal();
3520
- $("#ai-question-text").textContent = "";
3521
- $("#ai-question-options").replaceChildren();
3522
- $("#ai-question-custom-answer").value = "";
3523
- $("#ai-question-navigation").classList.add("hidden");
3524
- $("#ai-question-progress").textContent = "正在加载问题";
3525
- syncAiQuestionAnswerCount();
3526
- $("#ai-question-expiry").textContent = "正在加载问题……";
3568
+ if (initialQuestion?.id && String(initialQuestion.id) === aiQuestionDialogQuestionId) {
3569
+ currentAiQuestionDialogView = initialQuestion;
3570
+ resetAiQuestionDialogState(initialQuestion);
3571
+ renderAiUserQuestionOptions(initialQuestion);
3572
+ } else {
3573
+ currentAiQuestionDialogView = null;
3574
+ $("#ai-question-text").textContent = "";
3575
+ $("#ai-question-options").replaceChildren();
3576
+ $("#ai-question-custom-answer").value = "";
3577
+ $("#ai-question-navigation").classList.add("hidden");
3578
+ $("#ai-question-progress").textContent = "正在加载问题";
3579
+ syncAiQuestionAnswerCount();
3580
+ $("#ai-question-expiry").textContent = "正在加载问题……";
3581
+ syncAiQuestionDismissState();
3582
+ }
3527
3583
  try {
3528
3584
  return await refreshAiQuestionDialog();
3529
3585
  } catch (error) {
@@ -3532,18 +3588,23 @@ async function openAiUserQuestionDialog(questionId) {
3532
3588
  }
3533
3589
  }
3534
3590
 
3535
- function beginAiQuestionContinuationUi(conversationId) {
3591
+ async function beginAiQuestionContinuationUi(conversationId) {
3536
3592
  const tab = conversationId ? aiChatTabManager.findByConversation(conversationId) : null;
3537
3593
  if (!tab) return null;
3594
+ const workId = state.work.id;
3595
+ await aiRequestManager.whenIdle(tab.id);
3596
+ if (state.work?.id !== workId || !aiChatTabManager.get(tab.id)) throw createAiRequestAbortError("AI 请求目标已切换");
3597
+ const requestHolder = { snapshot: aiRequestManager.begin({ tabId: tab.id, workId, conversationId }) };
3538
3598
  aiQuestionContinuationTabIds.add(tab.id);
3539
3599
  setAiChatTabStatus(tab, "streaming");
3540
3600
  if (isActiveAiChatTab(tab)) syncAiRequestControls();
3541
3601
  scrollAiFeedToBottom(tab.feed);
3542
- return { tab };
3602
+ return { tab, requestHolder };
3543
3603
  }
3544
3604
 
3545
3605
  function finishAiQuestionContinuationUi(continuationUi, failed = false) {
3546
3606
  if (!continuationUi) return;
3607
+ aiRequestManager.finish(continuationUi.requestHolder.snapshot);
3547
3608
  aiQuestionContinuationTabIds.delete(continuationUi.tab.id);
3548
3609
  if (aiChatTabManager.get(continuationUi.tab.id)) setAiChatTabStatus(continuationUi.tab, failed ? "error" : "ready");
3549
3610
  if (isActiveAiChatTab(continuationUi.tab)) syncAiRequestControls();
@@ -3557,7 +3618,7 @@ async function reloadAiQuestionConversation(conversationId) {
3557
3618
  if (String(conversation.workId ?? "") !== String(state.work?.id ?? "")) throw new Error("AI 对话不属于当前作品");
3558
3619
  upsertAiConversationSummary(conversation);
3559
3620
  applyConversationToAiChatTab(tab, conversation);
3560
- activateAiChatTab(tab.id, { persistCurrent: false, force: true });
3621
+ if (isActiveAiChatTab(tab)) activateAiChatTab(tab.id, { persistCurrent: false, force: true });
3561
3622
  return conversation;
3562
3623
  }
3563
3624
 
@@ -3577,9 +3638,13 @@ async function respondAiUserQuestion(questionId, payload) {
3577
3638
  const conversationId = typeof knownQuestion?.conversationId === "string" ? knownQuestion.conversationId : null;
3578
3639
  if (questionDialog.open) questionDialog.close();
3579
3640
  if (approvalCenterDialog.open) approvalCenterDialog.close();
3580
- continuationUi = beginAiQuestionContinuationUi(conversationId);
3641
+ continuationUi = await beginAiQuestionContinuationUi(conversationId);
3581
3642
  let question;
3582
- if (payload.action === "reject") {
3643
+ if (continuationUi) {
3644
+ const endpoint = questionsEndpoint(`/${encodeURIComponent(String(questionId))}/${payload.action === "reject" ? "reject" : "answer"}`);
3645
+ const streamed = await streamChat(continuationUi.requestHolder, payload.action === "reject" ? {} : { answers: payload.answers }, createAiIdempotencyKey(), { endpoint });
3646
+ question = streamed.question;
3647
+ } else if (payload.action === "reject") {
3583
3648
  question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/reject`), { method: "POST" });
3584
3649
  } else {
3585
3650
  question = await api(questionsEndpoint(`/${encodeURIComponent(String(questionId))}/answer`), {
@@ -3588,8 +3653,7 @@ async function respondAiUserQuestion(questionId, payload) {
3588
3653
  });
3589
3654
  }
3590
3655
  cacheAiQuestionView(question);
3591
- currentAiQuestionDialogView = question;
3592
- aiQuestionDialogQuestionId = String(question.id);
3656
+ if (aiQuestionDialogQuestionId === String(question.id)) currentAiQuestionDialogView = question;
3593
3657
  await reloadAiQuestionConversation(question.conversationId ?? conversationId);
3594
3658
  toast(payload.action === "reject" ? "已跳过该提问批次,AI 已继续处理" : "全部回答已提交,AI 已继续处理");
3595
3659
  return question;
@@ -3609,6 +3673,7 @@ async function respondAiUserQuestion(questionId, payload) {
3609
3673
  } finally {
3610
3674
  finishAiQuestionContinuationUi(continuationUi, continuationFailed);
3611
3675
  aiQuestionDialogBusy = false;
3676
+ if (questionDialog.open && currentAiQuestionDialogView?.status === "pending") renderAiUserQuestionOptions(currentAiQuestionDialogView);
3612
3677
  }
3613
3678
  }
3614
3679
 
@@ -5895,6 +5960,15 @@ function formatAiFailureMessage(error) {
5895
5960
  return lines.join("\n");
5896
5961
  }
5897
5962
 
5963
+ function aiFailureMessageMetadata(error) {
5964
+ const details = error?.details && typeof error.details === "object" && !Array.isArray(error.details) ? error.details : {};
5965
+ return {
5966
+ ...(typeof error?.code === "string" ? { errorCode: error.code.slice(0, 100) } : {}),
5967
+ ...(Number.isInteger(error?.status) ? { errorStatus: error.status } : {}),
5968
+ ...(typeof details.questionId === "string" ? { pendingQuestionId: details.questionId } : {})
5969
+ };
5970
+ }
5971
+
5898
5972
  function isAgentToolCallLimitFailure(text) {
5899
5973
  return /more than \d+ tool calls in one response cycle\./u.test(String(text ?? ""));
5900
5974
  }
@@ -9293,8 +9367,8 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
9293
9367
  function updateChapterStats() {
9294
9368
  if (!state.chapter) return;
9295
9369
  const text = $("#chapter-content").value;
9296
- const count = Array.from(text.replace(/\s/g, "")).length;
9297
- $("#chapter-stats").textContent = `${count} 字 · v${state.chapter.versionNo}`;
9370
+ const count = countProseWords(text);
9371
+ $("#chapter-stats").textContent = `${count.toLocaleString("zh-CN")} 字 · v${state.chapter.versionNo}`;
9298
9372
  }
9299
9373
 
9300
9374
  function readReadingStorage(key) {
@@ -18277,6 +18351,7 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18277
18351
  return;
18278
18352
  }
18279
18353
  const failureMessage = formatAiFailureMessage(error);
18354
+ const failureMetadata = aiFailureMessageMetadata(error);
18280
18355
  let persistedFailureMessage = null;
18281
18356
  try {
18282
18357
  persistedFailureMessage = await persistAiConversationMessage(
@@ -18284,13 +18359,13 @@ async function sendAiWithOptions({ ignoreContextWarning = false, retry = null }
18284
18359
  "assistant",
18285
18360
  failureMessage,
18286
18361
  [],
18287
- {},
18362
+ failureMetadata,
18288
18363
  { requestId: aiAssistantRequestId(request) }
18289
18364
  );
18290
18365
  updateAiConversationSummaryFromMessage(persistedFailureMessage);
18291
18366
  } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
18292
18367
  if (aiRequestTargetsCurrentState(request)) {
18293
- appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id, { tab });
18368
+ appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, failureMetadata, persistedFailureMessage?.id, { tab });
18294
18369
  }
18295
18370
  } finally {
18296
18371
  aiRequestManager.finish(requestHolder.snapshot);
@@ -18313,7 +18388,7 @@ function renderAiStreamingCharacterProgress(meta, visibleCharacters) {
18313
18388
  meta.replaceChildren("正在生成 · ", createAiStreamCharacterCount(visible), " 字");
18314
18389
  }
18315
18390
 
18316
- async function streamChat(requestHolder, body, idempotencyKey) {
18391
+ async function streamChat(requestHolder, body, idempotencyKey, { endpoint = null } = {}) {
18317
18392
  const tab = aiChatTabForRequest(requestHolder.snapshot);
18318
18393
  if (!tab) throw createAiRequestAbortError("Agent 对话页签已关闭");
18319
18394
  const feed = tab.feed;
@@ -18336,7 +18411,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
18336
18411
  window.clearInterval(streamConnectionTimer);
18337
18412
  streamConnectionTimer = null;
18338
18413
  };
18339
- const streamConnectionEstablishedEvents = new Set(["delta", "process_step", "tool_call", "context_compacted", "complete", "request_status", "error"]);
18414
+ const streamConnectionEstablishedEvents = new Set(["continuation", "delta", "process_step", "tool_call", "context_compacted", "complete", "request_status", "error"]);
18340
18415
  const streamSpeedController = createStreamTypewriterSpeedController();
18341
18416
  let messageMounted = false;
18342
18417
  const mountAssistantMessage = () => {
@@ -18366,12 +18441,14 @@ async function streamChat(requestHolder, body, idempotencyKey) {
18366
18441
  let persistedMessageCreatedAt = null;
18367
18442
  let conversationTitle = null;
18368
18443
  let writingSuggestion = null;
18444
+ let question = null;
18369
18445
  let persistedUserMessage = null;
18370
18446
  let contextAction = "ready";
18371
18447
  let warningOnly = false;
18372
18448
  let streamContextCompacted = false;
18373
18449
  const processStartedAt = Date.now();
18374
- const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
18450
+ let previousProcessDurationMs = 0;
18451
+ const elapsedProcessTime = () => previousProcessDurationMs + Math.max(0, Date.now() - processStartedAt);
18375
18452
  const processStepTypewriters = new Map();
18376
18453
  const processStepVisibleContents = new Map();
18377
18454
  const renderStreamingProcessSteps = (completed, durationMs = elapsedProcessTime()) => {
@@ -18400,7 +18477,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
18400
18477
  };
18401
18478
  try {
18402
18479
  const request = assertAiRequestCurrent(requestHolder.snapshot);
18403
- const response = await fetch(`/api/works/${encodeURIComponent(request.workId)}/chat/stream`, {
18480
+ const response = await fetch(endpoint ?? `/api/works/${encodeURIComponent(request.workId)}/chat/stream`, {
18404
18481
  method: "POST",
18405
18482
  headers: { "Content-Type": "application/json", Accept: "text/event-stream", "X-CSRF-Token": state.csrfToken, "Idempotency-Key": idempotencyKey },
18406
18483
  body: JSON.stringify(body),
@@ -18415,7 +18492,23 @@ async function streamChat(requestHolder, body, idempotencyKey) {
18415
18492
  const consume = async (eventName, payload) => {
18416
18493
  assertAiRequestCurrent(requestHolder.snapshot);
18417
18494
  if (streamConnectionEstablishedEvents.has(eventName)) stopStreamConnectionTimer();
18418
- if (eventName === "context") {
18495
+ if (eventName === "continuation") {
18496
+ if (String(payload.conversationId ?? "") !== requestHolder.snapshot.conversationId) throw new Error("流式续接返回了其他对话");
18497
+ toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : [];
18498
+ processSteps = Array.isArray(payload.processSteps) ? payload.processSteps : [];
18499
+ previousProcessDurationMs = Math.max(0, Number(payload.processDurationMs) || 0);
18500
+ const previousMessage = [...feed.querySelectorAll(".assistant-message[data-message-id]")]
18501
+ .find((candidate) => candidate.dataset.messageId === String(payload.messageId));
18502
+ if (previousMessage) {
18503
+ attachMessageHeading(message, aiAssistantLabel("正在生成", tab.roleplayCharacter), undefined, tab);
18504
+ previousMessage.replaceWith(message);
18505
+ messageMounted = true;
18506
+ } else mountAssistantMessage();
18507
+ attachMessageIdentity(message, payload.messageId);
18508
+ renderStreamingProcessSteps(false);
18509
+ meta.textContent = "已收到回答,正在继续思考与执行……";
18510
+ scrollAiFeedToBottom(feed);
18511
+ } else if (eventName === "context") {
18419
18512
  contextAction = typeof payload.action === "string" ? payload.action : "ready";
18420
18513
  if (!tab.promptSent) setAiChatTabContextUsage(tab, payload.usage);
18421
18514
  if (payload.conversation?.id) {
@@ -18512,6 +18605,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
18512
18605
  setAiChatTabContextUsage(tab, payload.contextUsage);
18513
18606
  if (messageMounted) meta.textContent = "已压缩工具上下文,正在继续生成";
18514
18607
  } else if (eventName === "complete") {
18608
+ question = payload.question ?? null;
18515
18609
  if (payload.warningOnly === true) {
18516
18610
  warningOnly = true;
18517
18611
  setAiChatTabContextUsage(tab, payload.contextUsage);
@@ -18568,7 +18662,7 @@ async function streamChat(requestHolder, body, idempotencyKey) {
18568
18662
  assertAiRequestCurrent(requestHolder.snapshot);
18569
18663
  if (streamError) throw streamError;
18570
18664
  assertAiStreamCompleted(streamCompleted);
18571
- return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, writingSuggestion, userMessage: persistedUserMessage };
18665
+ return { action: warningOnly ? "warn" : contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, writingSuggestion, userMessage: persistedUserMessage, question };
18572
18666
  } catch (error) {
18573
18667
  const streamFailure = error instanceof Error ? error : new Error(String(error ?? "AI 流式调用失败"));
18574
18668
  const interruptionCode = typeof streamFailure.code === "string" ? streamFailure.code.slice(0, 100) : "AI_STREAM_FAILED";
@@ -18654,6 +18748,9 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
18654
18748
  const isInterrupted = role === "assistant" && metadata?.interrupted === true;
18655
18749
  const interruptionCode = typeof metadata?.interruptionCode === "string" ? metadata.interruptionCode : "AI_STREAM_FAILED";
18656
18750
  message.className = `${role === "user" ? "user-message" : "assistant-message"}${isFailure || isInterrupted ? " is-error" : ""}`;
18751
+ const errorCode = isFailure ? aiFailureCode(text, metadata) : "";
18752
+ if (errorCode) message.dataset.errorCode = errorCode;
18753
+ if (isFailure && typeof metadata?.pendingQuestionId === "string") message.dataset.pendingQuestionId = metadata.pendingQuestionId;
18657
18754
  const parsedUserTurn = role === "user" ? parseRoleplayUserTurn(text) : null;
18658
18755
  const messageBody = isFailure
18659
18756
  ? `<p class="ai-error-text">${esc(text)}</p>${aiToolCallSettingsLinkMarkup(text)}`
@@ -18691,11 +18788,20 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
18691
18788
  scene.append(sceneLabel, sceneBody);
18692
18789
  message.querySelector(".message-body")?.prepend(scene);
18693
18790
  }
18791
+ const chapterReferences = state.work?.volumes.flatMap((volume) => volume.chapters.map((chapter) => ({
18792
+ id: chapter.id,
18793
+ name: `${volume.title} / ${chapter.title}`
18794
+ }))) ?? [];
18795
+ const settingReferences = state.settings.map((setting) => ({ id: setting.id, name: setting.title }));
18796
+ const contextSettingReferences = [{ id: "include-setting-info", name: "注入上下文设定" }];
18694
18797
  const mentionGroups = role === "user"
18695
18798
  ? [
18696
18799
  ["角色", metadata?.mentionCharacterIds, state.characters],
18697
18800
  ["种族", metadata?.mentionRaceIds, state.races],
18698
- ["组织", metadata?.mentionOrganizationIds, state.organizations]
18801
+ ["组织", metadata?.mentionOrganizationIds, state.organizations],
18802
+ ["设定", metadata?.mentionSettingIds, settingReferences],
18803
+ ["章节", metadata?.mentionChapterIds, chapterReferences],
18804
+ ["能力", metadata?.mentionContextSettingIds, contextSettingReferences]
18699
18805
  ]
18700
18806
  .flatMap(([kind, ids, items]) => userMessageMentionNames(ids, items).map((name) => ({ kind, name })))
18701
18807
  : [];
@@ -18793,7 +18899,10 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
18793
18899
  function continuationGuardMarkup(guard) {
18794
18900
  if (!guard) return "";
18795
18901
  const issues = Array.isArray(guard.issues) ? guard.issues : [];
18796
- return `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>`;
18902
+ const failure = typeof guard.failure === "string" && guard.failure.trim()
18903
+ ? guard.failure.trim()
18904
+ : "无法完成检查,请谨慎采纳";
18905
+ return `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<details class="guard-failure-details"><summary>查看失败原因</summary><p>${esc(failure)}</p></details>` : issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>`;
18797
18906
  }
18798
18907
 
18799
18908
  async function applyAcceptedWritingSuggestion(message, suggestion) {
@@ -21282,7 +21391,15 @@ $("#ai-write-plan-undo").addEventListener("click", () => {
21282
21391
  if (!aiWritePlanDialogPlanId) return;
21283
21392
  undoAiWritePlan(aiWritePlanDialogPlanId);
21284
21393
  });
21285
- $("#ai-question-close").addEventListener("click", () => $("#ai-question-dialog").close());
21394
+ $("#ai-question-close").addEventListener("click", () => {
21395
+ if (!aiQuestionDialogCanClose(currentAiQuestionDialogView)) return;
21396
+ $("#ai-question-dialog").close();
21397
+ });
21398
+ $("#ai-question-dialog").addEventListener("cancel", (event) => {
21399
+ if (aiQuestionDialogCanClose(currentAiQuestionDialogView)) return;
21400
+ event.preventDefault();
21401
+ toast("请先提交全部回答,或选择“暂不回答”让 AI 继续", "warning");
21402
+ });
21286
21403
 
21287
21404
  function syncAiQuestionSubmitState() {
21288
21405
  const submit = $("#ai-question-submit");