@musnows/scriverse 0.6.13 → 0.7.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.
@@ -17,7 +17,7 @@ import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-ma
17
17
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
18
18
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
19
19
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260801-entity-lifecycle-v1";
20
- import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260731-drafts-to-ideas-v1";
20
+ import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260809-global-replace-v1";
21
21
  import {
22
22
  chapterVersionSourceLabel,
23
23
  foreshadowStatusLabel,
@@ -36,7 +36,7 @@ import {
36
36
  taskScopeLabel,
37
37
  timelineStatusLabel,
38
38
  characterStateFieldLabel
39
- } from "/display-labels.js?v=20260804-agent-history-search-v1";
39
+ } from "/display-labels.js?v=20260809-global-replace-v1";
40
40
  import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260731-work-comments-v2";
41
41
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
42
42
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
@@ -331,6 +331,16 @@ function canReadAggregateContent(work = state.work) {
331
331
  .every((module) => canReadModule(module, work));
332
332
  }
333
333
 
334
+ function canGlobalReplaceScope(scope, work = state.work) {
335
+ if (scope === "settings") return canEditModule("settings", work);
336
+ if (scope === "prose-and-settings") return canEditProse(work) && canEditModule("settings", work);
337
+ return canEditProse(work);
338
+ }
339
+
340
+ function canGlobalReplaceAny(work = state.work) {
341
+ return Boolean(work) && (canGlobalReplaceScope("prose", work) || canGlobalReplaceScope("settings", work));
342
+ }
343
+
334
344
  function applyWorkAccessMode() {
335
345
  const viewOnly = Boolean(state.work) && !canEditWork();
336
346
  const proseReadOnly = Boolean(state.work) && !canEditProse();
@@ -1648,7 +1658,7 @@ function resolveAiProcessDuration(metadata, steps, completedAt) {
1648
1658
  return Math.max(0, completedTime - Math.min(...startedTimes));
1649
1659
  }
1650
1660
 
1651
- function renderAiProcessSteps(message, steps, completed, durationMs = null) {
1661
+ function renderAiProcessSteps(message, steps, completed, durationMs = null, visibleContents = null) {
1652
1662
  message.querySelector(".ai-process-details")?.remove();
1653
1663
  if (!Array.isArray(steps) || !steps.length) return;
1654
1664
  const details = document.createElement("details");
@@ -1688,7 +1698,8 @@ function renderAiProcessSteps(message, steps, completed, durationMs = null) {
1688
1698
  label.textContent = `第 ${Number(step.round) || 1} 轮 · ${step.type === "thinking" ? "Thinking" : "中间输出"}`;
1689
1699
  const body = document.createElement("div");
1690
1700
  body.className = "message-body ai-process-step-body";
1691
- body.innerHTML = renderMarkdown(step.content);
1701
+ const content = visibleContents?.has(step) ? visibleContents.get(step) : step.content;
1702
+ body.innerHTML = renderMarkdown(content);
1692
1703
  section.append(label, body);
1693
1704
  list.append(section);
1694
1705
  }
@@ -1942,7 +1953,7 @@ function applyAiRoleplayCharacter(character) {
1942
1953
  if (active) $("#ai-scope").value = "none";
1943
1954
  $(".ai-panel").classList.toggle("is-roleplaying", active);
1944
1955
  $("#ai-prompt").dataset.placeholder = active
1945
- ? `以 ${String(state.aiRoleplayCharacter.name)} 的身份开始对话……`
1956
+ ? `与 ${String(state.aiRoleplayCharacter.name)} 角色开始对话……`
1946
1957
  : "告诉 AI 你想讨论或修改什么……";
1947
1958
  renderAiRoleplayCharacterSelect();
1948
1959
  syncAiTaskOptions();
@@ -2673,6 +2684,10 @@ function invalidateModuleRequestsAfterMutation(path, method) {
2673
2684
  if (path.includes("/relationships")) affected.add("relationships");
2674
2685
  if (path.includes("/chapter-annotations/") || /\/chapters\/[^/]+\/annotations(?:$|\?)/u.test(path)) affected.add("comments");
2675
2686
  if (path.includes("/reviews")) affected.add("reviews");
2687
+ if (/\/api\/works\/[^/]+\/replace(?:$|\?)/u.test(path)) {
2688
+ affected.add("settings");
2689
+ if (state.work?.id) moduleRequestCache.invalidate(state.work.id, "settings");
2690
+ }
2676
2691
  if (path.includes("/entity-versions/")) {
2677
2692
  if (path.includes("/draft/")) affected.add("drafts");
2678
2693
  if (path.includes("/setting/")) affected.add("settings");
@@ -3510,6 +3525,7 @@ function renderSettingsHub() {
3510
3525
  $("#collaboration-button").disabled = !canManageWork;
3511
3526
  $("#writing-progress-button").disabled = !hasWork || !canReadModule("editor");
3512
3527
  $("#work-audit-button").disabled = !canManageWork;
3528
+ $("#global-replace-button").classList.toggle("hidden", !canGlobalReplaceAny());
3513
3529
  $("#top-search-button").disabled = !canReadAggregate;
3514
3530
  $("#export-button").disabled = !canExportManuscript;
3515
3531
  $("#export-button").setAttribute("aria-expanded", "false");
@@ -4246,11 +4262,156 @@ async function openSearchDialog() {
4246
4262
  $("#search-dialog .eyebrow").textContent = `当前作品 · 《${state.work.title}》`;
4247
4263
  $("#search-query").value = "";
4248
4264
  $("#search-type").value = "";
4265
+ $("#search-to-replace").disabled = !canGlobalReplaceAny();
4249
4266
  $("#search-results").innerHTML = '<p class="search-results-empty">输入关键词后开始检索。</p>';
4250
4267
  $("#search-dialog").showModal();
4251
4268
  queueMicrotask(() => $("#search-query").focus());
4252
4269
  }
4253
4270
 
4271
+ const globalReplaceScopeLabels = Object.freeze({
4272
+ prose: "正文",
4273
+ settings: "设定库",
4274
+ "prose-and-settings": "正文+设定库"
4275
+ });
4276
+
4277
+ function syncGlobalReplaceScopeOptions() {
4278
+ const dialog = $("#replace-dialog");
4279
+ const options = [...dialog.querySelectorAll('input[name="replaceScope"]')];
4280
+ for (const option of options) {
4281
+ const allowed = canGlobalReplaceScope(option.value);
4282
+ option.disabled = !allowed;
4283
+ option.closest(".replace-scope-option")?.classList.toggle("is-disabled", !allowed);
4284
+ }
4285
+ const selected = options.find((option) => option.checked && !option.disabled) ?? options.find((option) => !option.disabled);
4286
+ options.forEach((option) => { option.checked = option === selected; });
4287
+ const scope = selected?.value ?? "";
4288
+ $("#replace-submit").disabled = !scope || !canGlobalReplaceScope(scope);
4289
+ $("#replace-permission-note").textContent = scope
4290
+ ? "替换完成后,命中的章节和设定会分别生成新的版本历史。"
4291
+ : "当前账户没有可写入的正文或设定库权限。";
4292
+ }
4293
+
4294
+ function openGlobalReplaceDialog() {
4295
+ if (!state.work) {
4296
+ toast("请先打开一部作品", "error");
4297
+ return;
4298
+ }
4299
+ if (!canGlobalReplaceAny()) {
4300
+ toast("当前账户没有正文或设定库的编辑权限", "error");
4301
+ return;
4302
+ }
4303
+ if ($("#search-dialog").open) $("#search-dialog").close();
4304
+ $("#replace-form").reset();
4305
+ syncGlobalReplaceScopeOptions();
4306
+ $("#replace-dialog").showModal();
4307
+ queueMicrotask(() => $("#replace-find").focus());
4308
+ }
4309
+
4310
+ async function refreshWorkAfterGlobalReplace(route, result) {
4311
+ const workId = state.work?.id;
4312
+ if (!workId) return;
4313
+ const nextWork = result?.work ?? await api(`/api/works/${encodeURIComponent(workId)}?directory=volumes`);
4314
+ if (!nextWork || nextWork.id !== workId) return;
4315
+ state.work = nextWork;
4316
+ state.work.volumes = state.work.volumes.map((volume) => ({ ...volume, chapters: Array.isArray(volume.chapters) ? volume.chapters : [] }));
4317
+ state.works = state.works.map((work) => work.id === workId ? { ...work, ...nextWork } : work);
4318
+ state.settings = [];
4319
+ loadedVolumeChapterIds.clear();
4320
+ volumeChapterLoadingIds.clear();
4321
+ volumeChapterRequests.clear();
4322
+ for (const volume of state.work.volumes) loadedVolumeChapterIds.add(volume.id);
4323
+ state.collapsedVolumeIds = new Set(state.work.volumes.map((volume) => volume.id));
4324
+ if (String(result?.scope) === "prose" || String(result?.scope) === "prose-and-settings") {
4325
+ state.chapter = null;
4326
+ lastSavedChapterSnapshot = null;
4327
+ }
4328
+ applyWorkAccessMode();
4329
+ showSystemStatus();
4330
+ updateDocumentTitle(state.work);
4331
+ $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
4332
+ $("#top-search-button").disabled = !canReadAggregateContent();
4333
+ renderTree();
4334
+ if (route.view === "editor" && route.chapterId && canReadModule("editor")) {
4335
+ await selectChapter(route.chapterId);
4336
+ } else if (route.view === "module") {
4337
+ await showModule(route.module);
4338
+ } else if (route.view === "settings") {
4339
+ renderSettingsHub();
4340
+ replacePageRoute({ view: "settings", workId: state.work.id, ...settingsRouteContext() });
4341
+ } else if (route.view === "welcome") {
4342
+ showWelcome(true);
4343
+ }
4344
+ }
4345
+
4346
+ async function submitGlobalReplace(event) {
4347
+ event.preventDefault();
4348
+ if (!state.work) return;
4349
+ const find = $("#replace-find").value;
4350
+ const replacement = $("#replace-with").value;
4351
+ const scope = $("#replace-form").querySelector('input[name="replaceScope"]:checked')?.value ?? "prose";
4352
+ if (!find.trim()) {
4353
+ toast("请输入要查找的内容", "error");
4354
+ $("#replace-find").focus();
4355
+ return;
4356
+ }
4357
+ if (!canGlobalReplaceScope(scope)) {
4358
+ toast("当前账户没有所选范围的编辑权限", "error");
4359
+ syncGlobalReplaceScopeOptions();
4360
+ return;
4361
+ }
4362
+ const dialog = $("#replace-dialog");
4363
+ const reopenDialog = () => {
4364
+ dialog.showModal();
4365
+ syncGlobalReplaceScopeOptions();
4366
+ queueMicrotask(() => $("#replace-find").focus());
4367
+ };
4368
+ dialog.close();
4369
+ if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,执行全局替换会放弃这些修改。是否继续?"))) {
4370
+ reopenDialog();
4371
+ return;
4372
+ }
4373
+ const scopeLabel = globalReplaceScopeLabels[scope] ?? "正文";
4374
+ const replacementLabel = replacement ? `“${replacement}”` : "空内容";
4375
+ const confirmed = await confirmToast(`将把《${state.work.title}》的${scopeLabel}中所有“${find}”替换为${replacementLabel}。每个命中对象都会生成新版本,确认继续吗?`, {
4376
+ title: "确认全局替换",
4377
+ confirmLabel: "确认替换"
4378
+ });
4379
+ if (!confirmed) {
4380
+ reopenDialog();
4381
+ return;
4382
+ }
4383
+ const button = $("#replace-submit");
4384
+ const route = currentPageRoute();
4385
+ const workId = state.work.id;
4386
+ button.disabled = true;
4387
+ button.textContent = "替换中…";
4388
+ cancelChapterAutoSave();
4389
+ state.dirty = false;
4390
+ try {
4391
+ const result = await api(`/api/works/${encodeURIComponent(workId)}/replace`, {
4392
+ method: "POST",
4393
+ body: { find, replacement, scope },
4394
+ skipOptimisticVersion: true
4395
+ });
4396
+ $("#replace-dialog").close();
4397
+ if (Number(result.totalMatches) > 0) await refreshWorkAfterGlobalReplace(route, result);
4398
+ if (Number(result.totalMatches) > 0) {
4399
+ const changedTargets = [];
4400
+ if (Number(result.chapterCount) > 0) changedTargets.push(`${result.chapterCount} 章`);
4401
+ if (Number(result.settingCount) > 0) changedTargets.push(`${result.settingCount} 条设定`);
4402
+ toast(`全局替换完成:${result.totalMatches} 处,已更新 ${changedTargets.join("、")}`);
4403
+ } else {
4404
+ toast("没有找到需要替换的内容");
4405
+ }
4406
+ } catch (error) {
4407
+ toast(error.message, "error");
4408
+ } finally {
4409
+ button.disabled = false;
4410
+ button.textContent = "开始替换";
4411
+ syncGlobalReplaceScopeOptions();
4412
+ }
4413
+ }
4414
+
4254
4415
  function highlightedSearchText(value, query) {
4255
4416
  return splitGlobalSearchHighlight(value, query)
4256
4417
  .map((segment) => segment.match ? `<mark>${esc(segment.text)}</mark>` : esc(segment.text))
@@ -10827,6 +10988,29 @@ async function streamChat(body) {
10827
10988
  let finalAnswerStarted = false;
10828
10989
  const processStartedAt = Date.now();
10829
10990
  const elapsedProcessTime = () => Math.max(0, Date.now() - processStartedAt);
10991
+ const processStepTypewriters = new Map();
10992
+ const processStepVisibleContents = new Map();
10993
+ const renderStreamingProcessSteps = (completed, durationMs = elapsedProcessTime()) => {
10994
+ renderAiProcessSteps(message, processSteps, completed, durationMs, processStepVisibleContents);
10995
+ };
10996
+ const processStepTypewriter = (step) => {
10997
+ const existing = processStepTypewriters.get(step);
10998
+ if (existing) return existing;
10999
+ processStepVisibleContents.set(step, "");
11000
+ const typewriter = createStreamTypewriter({
11001
+ onRender: (text) => {
11002
+ processStepVisibleContents.set(step, text);
11003
+ renderStreamingProcessSteps(finalAnswerStarted);
11004
+ scrollAiFeedToBottom();
11005
+ }
11006
+ });
11007
+ processStepTypewriters.set(step, typewriter);
11008
+ return typewriter;
11009
+ };
11010
+ const finishProcessStepTypewriters = () => Promise.all([...processStepTypewriters.values()].map((typewriter) => typewriter.finish()));
11011
+ const revealProcessStepTypewriters = () => {
11012
+ for (const typewriter of processStepTypewriters.values()) typewriter.reveal();
11013
+ };
10830
11014
  try {
10831
11015
  const response = await fetch(`/api/works/${state.work.id}/chat/stream`, {
10832
11016
  method: "POST",
@@ -10886,7 +11070,7 @@ async function streamChat(body) {
10886
11070
  streamedText += delta;
10887
11071
  if (streamedText.length > 0) finalAnswerStarted = true;
10888
11072
  typewriter.append(delta);
10889
- if (firstFinalDelta && processSteps.length) renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
11073
+ if (firstFinalDelta && processSteps.length) renderStreamingProcessSteps(true, elapsedProcessTime());
10890
11074
  meta.textContent = "正在生成回复……";
10891
11075
  } else if (eventName === "process_step") {
10892
11076
  mountAssistantMessage();
@@ -10896,7 +11080,11 @@ async function streamChat(body) {
10896
11080
  const existing = append ? processSteps.find((item) => item.id === step.id && item.type === step.type) : null;
10897
11081
  if (existing && typeof step.content === "string") existing.content += step.content;
10898
11082
  else processSteps.push(step);
10899
- renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
11083
+ const targetStep = existing ?? step;
11084
+ if (typeof step.content === "string" && step.content.length > 0 && step.type === "thinking") {
11085
+ processStepTypewriter(targetStep).append(step.content);
11086
+ }
11087
+ renderStreamingProcessSteps(finalAnswerStarted, elapsedProcessTime());
10900
11088
  meta.textContent = step.type === "thinking"
10901
11089
  ? `正在思考 · 第 ${Number(step.round) || 1} 轮`
10902
11090
  : step.type === "context_compaction"
@@ -10911,7 +11099,7 @@ async function streamChat(body) {
10911
11099
  if (toolCall.status === "failed") setAiAssistantStatus("error");
10912
11100
  toolCalls.push(toolCall);
10913
11101
  processSteps.push(aiToolProcessStep(toolCall, round));
10914
- renderAiProcessSteps(message, processSteps, finalAnswerStarted, elapsedProcessTime());
11102
+ renderStreamingProcessSteps(finalAnswerStarted, elapsedProcessTime());
10915
11103
  meta.textContent = `已调用 ${toolCalls.length} 个工具,正在等待模型处理结果`;
10916
11104
  scrollAiFeedToBottom();
10917
11105
  } else if (eventName === "context_compacted") {
@@ -10923,7 +11111,7 @@ async function streamChat(body) {
10923
11111
  persistedMessageCreatedAt = typeof payload.messageCreatedAt === "string" ? payload.messageCreatedAt : null;
10924
11112
  conversationTitle = typeof payload.conversationTitle === "string" ? payload.conversationTitle : null;
10925
11113
  setAiContextMeter(payload.contextUsage);
10926
- await typewriter.finish();
11114
+ await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
10927
11115
  message.classList.remove("is-streaming");
10928
11116
  content.setAttribute("aria-busy", "false");
10929
11117
  message.querySelector(".message-heading > span").textContent = "助手";
@@ -10949,16 +11137,17 @@ async function streamChat(body) {
10949
11137
  if (chunk.done) break;
10950
11138
  }
10951
11139
  if (buffer.trim()) await consume(buffer);
10952
- await typewriter.finish();
11140
+ await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
10953
11141
  if (streamError) throw streamError;
10954
11142
  return { action: contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage };
10955
11143
  } catch (error) {
10956
11144
  mountAssistantMessage();
10957
11145
  typewriter.reveal();
11146
+ revealProcessStepTypewriters();
10958
11147
  message.classList.remove("is-streaming");
10959
11148
  content.setAttribute("aria-busy", "false");
10960
11149
  message.querySelector(".message-heading > span").textContent = aiAssistantLabel("生成中断");
10961
- renderAiProcessSteps(message, processSteps, true, elapsedProcessTime());
11150
+ renderStreamingProcessSteps(true, elapsedProcessTime());
10962
11151
  meta.textContent = "生成中断";
10963
11152
  scrollAiFeedToBottom();
10964
11153
  throw error;
@@ -11365,6 +11554,7 @@ $("#home-button").addEventListener("click", async () => {
11365
11554
  $("#settings-button").addEventListener("click", () => {
11366
11555
  void showSettingsHub();
11367
11556
  });
11557
+ $("#global-replace-button").addEventListener("click", openGlobalReplaceDialog);
11368
11558
  $("#account-button").addEventListener("click", () => {
11369
11559
  const expanded = $("#account-menu").classList.toggle("hidden") === false;
11370
11560
  $("#account-button").setAttribute("aria-expanded", String(expanded));
@@ -12620,12 +12810,17 @@ $("#background-task-open-analysis").addEventListener("click", () => {
12620
12810
  showModule("tasks").catch((error) => toast(error.message, "error"));
12621
12811
  });
12622
12812
  $("#search-dialog-close").addEventListener("click", () => $("#search-dialog").close());
12813
+ $("#search-to-replace").addEventListener("click", openGlobalReplaceDialog);
12623
12814
  $("#search-form").addEventListener("submit", async (event) => {
12624
12815
  event.preventDefault();
12625
12816
  await runWorkSearch().catch((error) => {
12626
12817
  $("#search-results").innerHTML = `<p class="search-results-status">${esc(error.message)}</p>`;
12627
12818
  });
12628
12819
  });
12820
+ $("#replace-dialog-close").addEventListener("click", () => $("#replace-dialog").close());
12821
+ $("#replace-cancel").addEventListener("click", () => $("#replace-dialog").close());
12822
+ $("#replace-form").querySelectorAll('input[name="replaceScope"]').forEach((input) => input.addEventListener("change", syncGlobalReplaceScopeOptions));
12823
+ $("#replace-form").addEventListener("submit", submitGlobalReplace);
12629
12824
  $("#export-button").addEventListener("click", (event) => {
12630
12825
  event.preventDefault();
12631
12826
  event.stopPropagation();
@@ -74,7 +74,7 @@ export function providerProtocolLabel(value) {
74
74
  }
75
75
 
76
76
  export function chapterVersionSourceLabel(value) {
77
- return enumLabel({ manual: "人工保存", auto: "自动保存", "ai-suggestion": "AI 建议", restore: "历史恢复", import: "文件导入", create: "初始版本" }, value, "其他来源");
77
+ return enumLabel({ manual: "人工保存", auto: "自动保存", "ai-suggestion": "AI 建议", restore: "历史恢复", import: "文件导入", create: "初始版本", "global-replace": "全局替换" }, value, "其他来源");
78
78
  }
79
79
 
80
80
  export function occurrenceRoleLabel(value) {
@@ -20,7 +20,8 @@ export function entityVersionSourceLabel(source) {
20
20
  restore: "历史回滚",
21
21
  analysis: "AI 分析",
22
22
  merge: "事件合并",
23
- split: "事件拆分"
23
+ split: "事件拆分",
24
+ "global-replace": "全局替换"
24
25
  })[source] ?? "其他来源";
25
26
  }
26
27
 
@@ -10,7 +10,7 @@
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
12
  <link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
13
- <link rel="stylesheet" href="/styles.css?v=20260807-task-reference-links-v1">
13
+ <link rel="stylesheet" href="/styles.css?v=20260809-global-replace-v6">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -214,6 +214,7 @@
214
214
  <button id="collaboration-button" class="settings-hub-card" type="button"><span class="settings-card-mark">协</span><strong>作品协作</strong><small>邀请注册用户共同编辑当前作品</small></button>
215
215
  <button id="writing-progress-button" class="settings-hub-card" type="button"><span class="settings-card-mark">字</span><strong>写作目标</strong><small>每日目标、总字数目标与近 30 天趋势</small></button>
216
216
  <button id="work-audit-button" class="settings-hub-card" type="button"><span class="settings-card-mark">录</span><strong>操作记录</strong><small>查看作品修改、操作者、对象与时间</small></button>
217
+ <button id="global-replace-button" class="settings-hub-card" type="button"><span class="settings-card-mark">替</span><strong>全局替换</strong><small>替换已保存正文、设定库或两者内容</small></button>
217
218
  <button id="appearance-button" class="settings-hub-card" type="button" data-settings-action="appearance"><span class="settings-card-mark">Aa</span><strong>显示设置</strong><small>中文字体、等宽英文字体、字号与行距</small></button>
218
219
  <button id="export-button" class="settings-hub-card" type="button" data-settings-action="export" aria-haspopup="menu" aria-controls="manuscript-export-menu" aria-expanded="false"><span class="settings-card-mark">出</span><strong>导出正文</strong><small>选择导出 Markdown ZIP 或 DOCX;不包含角色和设定资料</small></button>
219
220
  </div>
@@ -610,7 +611,7 @@
610
611
  <dialog id="search-dialog" class="dialog wide-dialog" aria-labelledby="search-dialog-title">
611
612
  <div class="dialog-header">
612
613
  <div><span class="eyebrow">当前作品</span><h2 id="search-dialog-title">全文检索</h2></div>
613
- <button id="search-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button>
614
+ <div class="settings-dialog-header-actions"><button id="search-to-replace" class="ghost-button" type="button">全局替换</button><button id="search-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div>
614
615
  </div>
615
616
  <div class="access-dialog-body">
616
617
  <form id="search-form" class="search-form">
@@ -638,6 +639,31 @@
638
639
  </div>
639
640
  </dialog>
640
641
 
642
+ <dialog id="replace-dialog" class="dialog replace-dialog" aria-labelledby="replace-dialog-title" aria-describedby="replace-dialog-description">
643
+ <form id="replace-form">
644
+ <div class="dialog-header">
645
+ <div><span class="eyebrow">内容工具</span><h2 id="replace-dialog-title">全局替换</h2><p id="replace-dialog-description" class="dialog-header-meta">只处理已保存内容;每个被修改的章节或设定都会保留一个可恢复版本。</p></div>
646
+ <button id="replace-dialog-close" class="dialog-close" aria-label="关闭全局替换" type="button">×</button>
647
+ </div>
648
+ <div class="replace-dialog-body">
649
+ <label class="replace-field">查找内容<input id="replace-find" name="find" type="text" maxlength="500" autocomplete="off" required placeholder="输入要查找的文字"></label>
650
+ <label class="replace-field">替换为<textarea id="replace-with" name="replacement" maxlength="200000" rows="4" placeholder="输入替换后的文字;留空表示删除"></textarea></label>
651
+ <fieldset class="replace-scope-fieldset" aria-describedby="replace-scope-description">
652
+ <legend>替换范围</legend>
653
+ <p id="replace-scope-description">默认只替换章节正文内容。</p>
654
+ <label class="replace-scope-option"><input type="radio" name="replaceScope" value="prose" checked><span><strong>正文</strong><small>仅替换章节正文内容</small></span></label>
655
+ <label class="replace-scope-option"><input type="radio" name="replaceScope" value="settings"><span><strong>设定库</strong><small>仅替换世界观设定内容</small></span></label>
656
+ <label class="replace-scope-option"><input type="radio" name="replaceScope" value="prose-and-settings"><span><strong>正文+设定库</strong><small>同时替换章节正文和世界观设定内容</small></span></label>
657
+ </fieldset>
658
+ <p id="replace-permission-note" class="replace-permission-note" role="status"></p>
659
+ </div>
660
+ <div class="dialog-actions">
661
+ <button id="replace-cancel" class="ghost-button" type="button">取消</button>
662
+ <button id="replace-submit" class="primary-button" type="submit">开始替换</button>
663
+ </div>
664
+ </form>
665
+ </dialog>
666
+
641
667
  <dialog id="appearance-dialog" class="dialog">
642
668
  <form id="appearance-form" method="dialog">
643
669
  <div class="dialog-header">
@@ -998,6 +1024,6 @@
998
1024
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
999
1025
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
1000
1026
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
1001
- <script type="module" src="/app.js?v=20260807-task-reference-links-v1"></script>
1027
+ <script type="module" src="/app.js?v=20260809-global-replace-v2"></script>
1002
1028
  </body>
1003
1029
  </html>
@@ -944,6 +944,27 @@ body.work-viewer-mode .character-editor-actions { display: none !important; }
944
944
  font-size: 13px;
945
945
  }
946
946
  .search-form .primary-button { min-height: 38px; white-space: nowrap; }
947
+ .replace-dialog { display: flex; width: min(620px, calc(100vw - 32px)); height: min(680px, calc(100dvh - 32px)); max-height: calc(100dvh - 32px); overflow: hidden; }
948
+ .replace-dialog:not([open]) { display: none; }
949
+ .replace-dialog > form { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: 100%; min-height: 0; }
950
+ .replace-dialog-body { display: grid; min-height: 0; gap: 16px; padding: 20px 24px 6px; overflow-y: auto; }
951
+ .replace-field { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
952
+ .replace-field input, .replace-field textarea { width: 100%; padding: 9px 10px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); color: var(--ink); font-size: 13px; line-height: 1.6; }
953
+ .replace-field textarea { min-height: 96px; resize: vertical; }
954
+ .replace-scope-fieldset { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; padding: 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-soft); }
955
+ .replace-scope-fieldset legend { padding: 0 6px; color: var(--ink); font-size: 12px; font-weight: 650; }
956
+ .replace-scope-fieldset legend, .replace-scope-fieldset > p { grid-column: 1 / -1; }
957
+ .replace-scope-fieldset > p { margin: -1px 0 1px; color: var(--muted); font-size: 10px; line-height: 1.5; }
958
+ .replace-scope-option { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: start; gap: 7px; min-width: 0; padding: 8px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface); cursor: pointer; }
959
+ .replace-scope-option > span { display: grid; gap: 3px; min-width: 0; }
960
+ .replace-scope-option strong { color: var(--ink); font-size: 12px; }
961
+ .replace-scope-option small { color: var(--muted); font-size: 10px; line-height: 1.45; }
962
+ .replace-scope-option:has(input:checked) { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); box-shadow: inset 0 0 0 1px var(--accent); }
963
+ .replace-scope-option.is-disabled, .replace-scope-option:has(input:disabled) { cursor: not-allowed; opacity: .5; }
964
+ .replace-scope-option input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
965
+ .replace-permission-note { margin: 0; color: var(--accent-dark); font-size: 10px; line-height: 1.5; }
966
+ .replace-permission-note:empty { display: none; }
967
+ .replace-dialog .primary-button:disabled { cursor: not-allowed; opacity: .42; }
947
968
  .search-results { display: grid; gap: 8px; max-height: min(52vh, 480px); overflow: auto; }
948
969
  .search-results-summary { margin: 0; padding: 2px 2px 5px; color: var(--muted); font-size: 10px; }
949
970
  .search-results-empty, .search-results-status {
@@ -3343,6 +3364,10 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
3343
3364
  .dialog-header h2 { font-size: 20px; }
3344
3365
  .settings-dialog-header-actions { gap: 6px; }
3345
3366
  .dialog-fields, .appearance-grid, .access-dialog-body, .account-settings-body, .versions-list, .entity-history-list, .chapter-annotations-list { padding-inline: 16px; }
3367
+ .replace-dialog-body { padding-inline: 16px; }
3368
+ .replace-dialog { height: calc(100dvh - 20px); max-height: calc(100dvh - 20px); }
3369
+ .replace-scope-fieldset { grid-template-columns: minmax(0, 1fr); }
3370
+ .replace-scope-fieldset legend, .replace-scope-fieldset > p { grid-column: auto; }
3346
3371
  .task-detail-overview { grid-template-columns: minmax(0, 1fr); padding: 12px; }
3347
3372
  .task-detail-overview > * { grid-column: 1 !important; }
3348
3373
  .task-result-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -3397,7 +3422,8 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
3397
3422
  }
3398
3423
 
3399
3424
  @media (max-width: 480px) {
3400
- .top-actions .settings-button { display: none; }
3425
+ .app-shell:not(.shelf-mode) .topbar .brand-block > div { display: none; }
3426
+ .top-actions .settings-button { display: grid; flex: 0 0 32px; }
3401
3427
  .topbar-icon-button, .theme-toggle, .account-button { flex: 0 0 32px; width: 32px; min-width: 32px; height: 32px; }
3402
3428
  button.account-button { width: 32px; height: 32px; }
3403
3429
  .top-actions { gap: 3px; }
package/dist/store.js CHANGED
@@ -1488,6 +1488,81 @@ export class Store {
1488
1488
  });
1489
1489
  return this.getChapter(chapterId);
1490
1490
  }
1491
+ replaceWorkText(workId, input) {
1492
+ const find = input.find;
1493
+ if (!find)
1494
+ throw new AppError(400, "REPLACE_TEXT_REQUIRED", "查找内容不能为空");
1495
+ if (!["prose", "settings", "prose-and-settings"].includes(input.scope)) {
1496
+ throw new AppError(400, "REPLACE_SCOPE_INVALID", "替换范围无效");
1497
+ }
1498
+ const operationId = id("globalReplace");
1499
+ let chapterCount = 0;
1500
+ let settingCount = 0;
1501
+ let totalMatches = 0;
1502
+ const includeProse = input.scope === "prose" || input.scope === "prose-and-settings";
1503
+ const includeSettings = input.scope === "settings" || input.scope === "prose-and-settings";
1504
+ const replaceLiteral = (value) => {
1505
+ let matches = 0;
1506
+ let offset = 0;
1507
+ while (true) {
1508
+ const index = value.indexOf(find, offset);
1509
+ if (index < 0)
1510
+ break;
1511
+ matches += 1;
1512
+ offset = index + find.length;
1513
+ }
1514
+ return {
1515
+ content: value.replaceAll(find, () => input.replacement),
1516
+ matches
1517
+ };
1518
+ };
1519
+ this.getWork(workId);
1520
+ this.db.transaction(() => {
1521
+ if (includeProse) {
1522
+ const chapters = this.db.all("SELECT id, content FROM chapters WHERE work_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at", workId);
1523
+ for (const row of chapters) {
1524
+ const chapterId = requiredString(row, "id");
1525
+ const result = replaceLiteral(requiredString(row, "content"));
1526
+ if (!result.matches || result.content === requiredString(row, "content"))
1527
+ continue;
1528
+ this.saveChapter(chapterId, { content: result.content }, "global-replace", operationId, "全局替换正文");
1529
+ chapterCount += 1;
1530
+ totalMatches += result.matches;
1531
+ }
1532
+ }
1533
+ if (includeSettings) {
1534
+ const settings = this.db.all("SELECT id, content FROM settings WHERE work_id = ? ORDER BY id", workId);
1535
+ for (const row of settings) {
1536
+ const settingId = requiredString(row, "id");
1537
+ const currentContent = requiredString(row, "content");
1538
+ const result = replaceLiteral(currentContent);
1539
+ if (!result.matches || result.content === currentContent)
1540
+ continue;
1541
+ this.updateSetting(settingId, { content: result.content }, "global-replace", operationId, "全局替换设定库");
1542
+ settingCount += 1;
1543
+ totalMatches += result.matches;
1544
+ }
1545
+ }
1546
+ if (totalMatches > 0) {
1547
+ this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", now(), workId);
1548
+ this.audit(workId, "work.global-replace", "work", workId, {
1549
+ operationId,
1550
+ scope: input.scope,
1551
+ chapterCount,
1552
+ settingCount,
1553
+ totalMatches
1554
+ });
1555
+ }
1556
+ });
1557
+ return {
1558
+ operationId,
1559
+ scope: input.scope,
1560
+ chapterCount,
1561
+ settingCount,
1562
+ totalMatches,
1563
+ work: this.getWorkDirectory(workId)
1564
+ };
1565
+ }
1491
1566
  restoreChapter(chapterId, versionNo, expectedVersionNo) {
1492
1567
  const version = this.db.get("SELECT * FROM chapter_versions WHERE chapter_id = ? AND version_no = ?", chapterId, versionNo);
1493
1568
  if (!version)