@musnows/scriverse 0.7.12 → 0.7.13

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.
@@ -3,15 +3,69 @@ const FINISHING_CHARACTERS_PER_FRAME = 2;
3
3
  const FINISHING_ACCELERATION = 0.9;
4
4
  const MAX_STREAMING_CHARACTERS_PER_FRAME = 12;
5
5
  const MAX_FINISHING_CHARACTERS_PER_FRAME = 24;
6
+ const STREAMING_FRAME_RATE = 60;
7
+ const DEFAULT_STREAMING_CHARACTERS_PER_SECOND = STREAMING_CHARACTERS_PER_FRAME * STREAMING_FRAME_RATE;
8
+ const MIN_STREAMING_CHARACTERS_PER_SECOND = DEFAULT_STREAMING_CHARACTERS_PER_SECOND;
9
+ const MAX_STREAMING_CHARACTERS_PER_SECOND = MAX_STREAMING_CHARACTERS_PER_FRAME * STREAMING_FRAME_RATE;
10
+ const SPEED_SAMPLE_MAX_GAP_MS = 1_000;
11
+ const SPEED_SMOOTHING = 0.25;
6
12
 
7
- export function streamTypewriterBatchSize(pendingCharacters, finishing = false) {
13
+ function clampCharactersPerSecond(value) {
14
+ const speed = Number(value);
15
+ if (!Number.isFinite(speed)) return DEFAULT_STREAMING_CHARACTERS_PER_SECOND;
16
+ return Math.min(MAX_STREAMING_CHARACTERS_PER_SECOND, Math.max(MIN_STREAMING_CHARACTERS_PER_SECOND, speed));
17
+ }
18
+
19
+ function currentTime() {
20
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
21
+ }
22
+
23
+ export function createStreamTypewriterSpeedController({
24
+ now = currentTime,
25
+ initialCharactersPerSecond = DEFAULT_STREAMING_CHARACTERS_PER_SECOND
26
+ } = {}) {
27
+ let charactersPerSecond = clampCharactersPerSecond(initialCharactersPerSecond);
28
+ let lastObservedAt = null;
29
+
30
+ const update = (observedCharactersPerSecond) => {
31
+ const observed = clampCharactersPerSecond(observedCharactersPerSecond);
32
+ charactersPerSecond = clampCharactersPerSecond(
33
+ charactersPerSecond + (observed - charactersPerSecond) * SPEED_SMOOTHING
34
+ );
35
+ };
36
+
37
+ return {
38
+ observe(characterCount) {
39
+ const characters = Math.max(0, Math.floor(Number(characterCount) || 0));
40
+ if (characters === 0) return;
41
+ const observedAt = Number(now());
42
+ if (Number.isFinite(observedAt) && lastObservedAt !== null) {
43
+ const elapsed = observedAt - lastObservedAt;
44
+ if (elapsed > 0 && elapsed <= SPEED_SAMPLE_MAX_GAP_MS) {
45
+ update(characters / elapsed * 1_000);
46
+ }
47
+ }
48
+ if (Number.isFinite(observedAt)) lastObservedAt = observedAt;
49
+
50
+ // 单次收到的大块内容也需要让同一条流的后续轮次继承当前的追赶速度。
51
+ const backlogSpeed = Math.ceil(characters / 30) * STREAMING_FRAME_RATE;
52
+ if (backlogSpeed > charactersPerSecond) charactersPerSecond = Math.min(MAX_STREAMING_CHARACTERS_PER_SECOND, backlogSpeed);
53
+ },
54
+ charactersPerSecond() {
55
+ return charactersPerSecond;
56
+ }
57
+ };
58
+ }
59
+
60
+ export function streamTypewriterBatchSize(pendingCharacters, finishing = false, charactersPerSecond = DEFAULT_STREAMING_CHARACTERS_PER_SECOND) {
8
61
  const pending = Math.max(0, Math.floor(Number(pendingCharacters) || 0));
9
62
  if (pending === 0) return 0;
10
63
  const minimum = finishing ? FINISHING_CHARACTERS_PER_FRAME : STREAMING_CHARACTERS_PER_FRAME;
11
64
  const maximum = finishing ? MAX_FINISHING_CHARACTERS_PER_FRAME : MAX_STREAMING_CHARACTERS_PER_FRAME;
65
+ const speedBased = Math.ceil(clampCharactersPerSecond(charactersPerSecond) / STREAMING_FRAME_RATE);
12
66
  const adaptive = finishing
13
- ? Math.ceil(Math.sqrt(pending) * FINISHING_ACCELERATION)
14
- : Math.ceil(pending / 30);
67
+ ? Math.max(Math.ceil(Math.sqrt(pending) * FINISHING_ACCELERATION), speedBased)
68
+ : Math.max(Math.ceil(pending / 30), speedBased);
15
69
  return Math.min(pending, maximum, Math.max(minimum, adaptive));
16
70
  }
17
71
 
@@ -19,7 +73,8 @@ export function createStreamTypewriter({
19
73
  onRender,
20
74
  scheduleFrame = (callback) => window.requestAnimationFrame(callback),
21
75
  cancelFrame = (handle) => window.cancelAnimationFrame(handle),
22
- reducedMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true
76
+ reducedMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true,
77
+ speedController = null
23
78
  }) {
24
79
  if (typeof onRender !== "function") throw new TypeError("onRender must be a function");
25
80
 
@@ -47,7 +102,11 @@ export function createStreamTypewriter({
47
102
  scheduledFrame = null;
48
103
  const batchSize = reducedMotion
49
104
  ? pendingCharacters.length
50
- : streamTypewriterBatchSize(pendingCharacters.length, finishing);
105
+ : streamTypewriterBatchSize(
106
+ pendingCharacters.length,
107
+ finishing,
108
+ speedController?.charactersPerSecond?.()
109
+ );
51
110
  visibleCharacters.push(...pendingCharacters.splice(0, batchSize));
52
111
  render();
53
112
  if (pendingCharacters.length) schedule();
@@ -59,6 +118,7 @@ export function createStreamTypewriter({
59
118
  append(value) {
60
119
  const characters = Array.from(String(value ?? ""));
61
120
  if (!characters.length) return;
121
+ speedController?.observe?.(characters.length);
62
122
  pendingCharacters.push(...characters);
63
123
  schedule();
64
124
  },
@@ -1788,6 +1788,8 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1788
1788
  .character-extraction-editor-toolbar { justify-content: flex-end; }
1789
1789
  .character-extraction-editor-toolbar > span { margin-right: auto; color: var(--accent-dark); font-weight: 600; }
1790
1790
  .character-extraction-editor-toolbar button { padding: 6px 9px; font-size: 9px; }
1791
+ .character-extraction-apply-button { flex: 0 0 auto; }
1792
+ .character-extraction-apply-note { margin: -5px 0 0; color: var(--muted); font-size: 9px; line-height: 1.5; }
1791
1793
  .character-extraction-candidate-list { display: grid; gap: 9px; min-width: 0; }
1792
1794
  .character-extraction-candidate { display: grid; gap: 10px; min-width: 0; padding: 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-soft); transition: opacity .15s ease, border-color .15s ease; }
1793
1795
  .character-extraction-candidate.is-skipped { opacity: .7; }
@@ -2582,7 +2584,11 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2582
2584
  .ai-tool-call-info code, .ai-tool-call-info time { font-family: var(--font-latin), monospace; }
2583
2585
  .ai-tool-call-detail section { min-width: 0; }
2584
2586
  .ai-tool-call-detail h3 { margin: 0 0 7px; color: var(--muted); font-size: 10px; font-weight: 600; }
2585
- .ai-tool-call-detail pre { min-height: 120px; max-height: 32vh; overflow: auto; margin: 0; padding: 12px; border: 1px solid var(--line); border-radius: 5px; background: var(--paper-deep); color: var(--ink); font: 10px/1.55 var(--font-latin), monospace; white-space: pre-wrap; word-break: break-word; }
2587
+ .ai-tool-call-code-block { position: relative; min-width: 0; }
2588
+ .ai-tool-call-detail pre { min-height: 120px; max-height: 32vh; overflow: auto; margin: 0; padding: 30px 12px 12px; border: 1px solid var(--line); border-radius: 5px; background: var(--paper-deep); color: var(--ink); font: 10px/1.55 var(--font-latin), monospace; white-space: pre-wrap; word-break: break-word; }
2589
+ .ai-tool-call-copy-button { position: absolute; z-index: 1; top: 6px; right: 6px; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; min-height: 0; padding: 0; border: 1px solid transparent; border-radius: 4px; background: color-mix(in srgb, var(--paper-deep) 86%, var(--ink)); color: var(--muted); }
2590
+ .ai-tool-call-copy-button svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.7; }
2591
+ .ai-tool-call-copy-button:hover, .ai-tool-call-copy-button:focus-visible, .ai-tool-call-copy-button.is-copied { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); background: color-mix(in srgb, var(--accent) 12%, var(--paper-deep)); color: var(--accent-dark); outline: none; }
2586
2592
  #ai-tool-call-arguments { min-height: 0; max-height: 26vh; }
2587
2593
  .ai-tool-call-detail section:last-child pre { min-height: 180px; }
2588
2594
  @media (max-width: 680px) { .ai-tool-call-info { grid-template-columns: minmax(0, 1fr); }.ai-tool-call-detail pre { min-height: 100px; max-height: 26vh; }#ai-tool-call-arguments { min-height: 0; max-height: 20vh; }.ai-tool-call-detail section:last-child pre { min-height: 140px; } }
@@ -2701,6 +2707,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2701
2707
  line-height: 1.55;
2702
2708
  animation: toast-appear-top .18s ease;
2703
2709
  }
2710
+ .toast.ai-new-conversation-toast { white-space: pre-line; }
2704
2711
  .toast-region[data-position="bottom-right"] .toast { animation-name: toast-appear-bottom; }
2705
2712
  .toast.error {
2706
2713
  border-color: color-mix(in srgb, var(--toast-error-fg) 16%, transparent);
@@ -2835,6 +2842,10 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2835
2842
  .dialog-fields .form-field-note { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.65; }
2836
2843
  .dialog-fields label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
2837
2844
  .dialog-fields input, .dialog-fields textarea, .dialog-fields select { width: 100%; padding: 9px 10px; font-size: 13px; }
2845
+ .dialog-fields select { padding-inline-end: 28px; }
2846
+ .dialog-fields .character-extraction-candidate-toggle { display: inline-flex; width: auto; align-items: center; gap: 7px; margin: 0; color: var(--ink); font-size: 9px; white-space: nowrap; }
2847
+ .dialog-fields .character-extraction-candidate-toggle input[type="checkbox"] { flex: 0 0 18px; width: 18px; min-width: 18px; height: 18px; padding: 0; }
2848
+ .dialog-fields .character-extraction-candidate-toggle input[type="checkbox"]:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
2838
2849
  .dialog-fields textarea { resize: vertical; min-height: 90px; line-height: 1.6; }
2839
2850
  .system-restart-dialog { width: min(500px, 92vw); }
2840
2851
  .system-restart-message { margin: 0; color: var(--ink); font-size: 13px; line-height: 1.7; }
@@ -2880,30 +2891,72 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2880
2891
  .task-chapter-field select:disabled { cursor: not-allowed; }
2881
2892
  .task-scope-field { min-width: 0; transition: opacity .15s ease; }
2882
2893
  .task-scope-field.is-disabled { opacity: .48; }
2894
+ .task-scope-field-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; min-width: 0; color: var(--muted); font-size: 11px; }
2895
+ .task-scope-field-heading > span { min-width: 0; }
2896
+ .task-scope-field-heading small { color: var(--muted); font-size: 9px; }
2883
2897
  .task-scope-picker { display: grid; gap: 8px; min-width: 0; }
2884
- .dialog-fields .task-scope-trigger { display: flex; width: 100%; min-height: 42px; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); color: var(--ink); font-size: 12px; text-align: left; }
2898
+ .dialog-fields .task-scope-trigger { display: flex; width: 100%; min-height: 42px; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); color: var(--ink); font-size: 12px; line-height: 1.2; text-align: left; }
2885
2899
  .task-scope-trigger:hover, .task-scope-trigger:focus-visible, .task-scope-trigger[aria-expanded="true"] { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 12%, transparent); outline: none; }
2886
2900
  .task-scope-trigger:disabled { cursor: not-allowed; opacity: .48; }
2887
2901
  .task-scope-trigger > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2888
- .task-scope-trigger-meta { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 8px; color: var(--muted); font-family: var(--font-latin), monospace; font-size: 10px; }
2889
- .task-scope-chevron { font-size: 15px; line-height: 1; transition: transform .15s ease; }
2902
+ .task-scope-trigger-meta { display: inline-flex; flex: 0 0 auto; height: 18px; align-items: center; gap: 8px; color: var(--muted); font-family: var(--font-latin), monospace; font-size: 10px; line-height: 1; }
2903
+ .task-scope-chevron { display: inline-grid; width: 16px; height: 18px; place-items: center; font-size: 15px; line-height: 1; transition: transform .15s ease; }
2890
2904
  .task-scope-trigger[aria-expanded="true"] .task-scope-chevron { transform: rotate(180deg); }
2891
- .task-scope-bubble { position: relative; display: grid; gap: 9px; padding: 12px; border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--line)); border-radius: 8px; background: var(--surface-soft); box-shadow: 0 10px 30px rgba(48,39,31,.12); }
2892
- .task-scope-bubble::before { position: absolute; top: -6px; right: 20px; width: 10px; height: 10px; border-top: 1px solid color-mix(in srgb, var(--accent) 36%, var(--line)); border-left: 1px solid color-mix(in srgb, var(--accent) 36%, var(--line)); background: var(--surface-soft); content: ""; transform: rotate(45deg); }
2893
- .dialog-fields .task-scope-search { gap: 5px; }
2894
- .dialog-fields .task-scope-search input { min-height: 36px; padding: 7px 9px; background: var(--surface); }
2895
- .task-scope-bubble-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 9px; }
2896
- .task-scope-bubble-meta button { padding: 2px 0; border: 0; background: transparent; color: var(--accent-dark); font-size: 9px; }
2897
- .task-scope-bubble-meta button:disabled { color: var(--muted); cursor: default; opacity: .45; }
2898
- .task-scope-options { display: grid; gap: 6px; max-height: 220px; overflow-y: auto; padding-right: 3px; }
2899
- .dialog-fields .task-scope-option { display: grid; grid-template-columns: 16px minmax(0, 1fr); align-items: center; gap: 8px; min-width: 0; padding: 8px 9px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); color: var(--muted); cursor: pointer; }
2905
+ .task-scope-panel { display: grid; gap: 14px; min-width: 0; padding: 14px; border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--line)); border-radius: 8px; background: var(--surface-soft); box-shadow: 0 12px 30px rgba(48,39,31,.12); }
2906
+ .task-scope-panel.hidden { display: none; }
2907
+ .task-scope-panel-header, .task-scope-panel-section-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; }
2908
+ .task-scope-panel-header > div, .task-scope-panel-section-header > div { display: grid; gap: 3px; min-width: 0; }
2909
+ .task-scope-panel-header strong, .task-scope-panel-section-header strong { color: var(--ink); font-size: 11px; font-weight: 650; }
2910
+ .task-scope-panel-header small { color: var(--muted); font-size: 9px; line-height: 1.5; }
2911
+ .task-scope-author-note-banner { display: grid; grid-template-columns: 20px minmax(0, 1fr); align-items: start; gap: 8px; padding: 9px 10px; border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--line)); border-left: 3px solid var(--accent); border-radius: 6px; background: color-mix(in srgb, var(--accent) 8%, var(--surface)); color: var(--muted); font-size: 9px; line-height: 1.55; }
2912
+ .task-scope-author-note-banner-icon { display: grid; width: 18px; height: 18px; place-items: center; border-radius: 50%; background: var(--accent); color: #fff; font-family: var(--font-latin), monospace; font-size: 11px; font-weight: 700; line-height: 1; }
2913
+ .task-scope-author-note-banner div { display: grid; gap: 2px; min-width: 0; }
2914
+ .task-scope-author-note-banner strong { color: var(--accent-dark); font-size: 10px; }
2915
+ .task-scope-clear { flex: 0 0 auto; padding: 5px 7px; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--accent-dark); font-size: 9px; }
2916
+ .task-scope-clear:hover:not(:disabled), .task-scope-clear:focus-visible { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, transparent); outline: none; }
2917
+ .task-scope-clear:disabled { cursor: default; opacity: .45; }
2918
+ .task-scope-panel-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(230px, .8fr); gap: 12px; min-width: 0; }
2919
+ .task-scope-available, .task-scope-selected { display: grid; align-content: start; gap: 10px; min-width: 0; min-height: 360px; padding: 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); }
2920
+ .task-scope-panel-section-header > span { flex: 0 0 auto; color: var(--muted); font-family: var(--font-latin), monospace; font-size: 9px; }
2921
+ .task-scope-search { gap: 5px !important; }
2922
+ .task-scope-search > span { color: var(--muted); font-size: 9px; }
2923
+ .dialog-fields .task-scope-search input { min-height: 34px; padding: 6px 9px; background: var(--surface-soft); font-size: 10px; }
2924
+ .task-scope-options { display: grid; align-content: start; gap: 6px; max-height: min(42vh, 360px); overflow-y: auto; padding: 2px 4px 2px 0; }
2925
+ .task-scope-volume-group { display: grid; gap: 5px; min-width: 0; padding: 5px 0 7px; border-bottom: 1px solid color-mix(in srgb, var(--line) 72%, transparent); }
2926
+ .task-scope-volume-group:last-child { border-bottom: 0; }
2927
+ .task-scope-volume-group > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 2px 2px; }
2928
+ .task-scope-volume-group > header strong { overflow: hidden; color: var(--accent-dark); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
2929
+ .task-scope-volume-group > header span { flex: 0 0 auto; color: var(--muted); font-family: var(--font-latin), monospace; font-size: 8px; }
2930
+ .dialog-fields .task-scope-volume-option { position: relative; display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 8px; min-width: 0; margin: 0; color: var(--ink); cursor: pointer; }
2931
+ .task-scope-volume-option input { position: absolute; width: 1px !important; min-width: 1px; height: 1px; padding: 0; border: 0; opacity: 0; }
2932
+ .task-scope-volume-option:hover, .task-scope-volume-option:has(input:focus-visible) { color: var(--accent-dark); }
2933
+ .task-scope-volume-option:has(input:checked) .task-scope-checkbox, .task-scope-volume-option:has(input:indeterminate) .task-scope-checkbox { border-color: var(--accent); background: var(--accent); }
2934
+ .task-scope-volume-option:has(input:checked) .task-scope-checkbox::after { opacity: 1; transform: translateY(-1px) rotate(-45deg) scale(1); }
2935
+ .task-scope-volume-option input:indeterminate + .task-scope-checkbox::after { width: 8px; height: 2px; border: 0; background: #fff; opacity: 1; transform: none; }
2936
+ .task-scope-volume-copy { display: grid; gap: 2px; min-width: 0; }
2937
+ .task-scope-volume-copy strong { overflow: hidden; color: inherit !important; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
2938
+ .task-scope-volume-copy small { overflow: hidden; color: var(--muted); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
2939
+ .dialog-fields .task-scope-option { position: relative; display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 8px; min-width: 0; padding: 8px 9px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-soft); color: var(--muted); cursor: pointer; transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease; }
2900
2940
  .dialog-fields .task-scope-option.hidden { display: none; }
2901
- .dialog-fields .task-scope-option input { width: 15px; height: 15px; margin: 0; }
2902
- .task-scope-option > span { display: grid; gap: 2px; min-width: 0; }
2941
+ .task-scope-option:hover, .task-scope-option:has(input:focus-visible) { border-color: color-mix(in srgb, var(--accent) 54%, var(--line)); }
2942
+ .task-scope-option input { position: absolute; width: 1px !important; min-width: 1px; height: 1px; padding: 0; border: 0; opacity: 0; }
2943
+ .task-scope-checkbox { display: inline-grid; width: 18px; height: 18px; place-items: center; border: 1px solid var(--line-strong); border-radius: 4px; background: var(--surface); transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease; }
2944
+ .task-scope-checkbox::after { width: 8px; height: 5px; border: solid #fff; border-width: 0 0 2px 2px; content: ""; opacity: 0; transform: translateY(-1px) rotate(-45deg) scale(.7); transition: opacity .12s ease, transform .12s ease; }
2903
2945
  .task-scope-option strong { overflow: hidden; color: var(--ink); font-size: 10px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
2946
+ .task-scope-option-copy { display: grid; gap: 2px; min-width: 0; }
2904
2947
  .task-scope-option small { overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
2905
- .task-scope-option:has(input:checked) { border-color: color-mix(in srgb, var(--accent) 62%, var(--line)); background: color-mix(in srgb, var(--accent) 7%, var(--surface)); }
2906
- .task-scope-option input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2948
+ .task-scope-option:has(input:checked) { border-color: color-mix(in srgb, var(--accent) 62%, var(--line)); background: color-mix(in srgb, var(--accent) 7%, var(--surface)); box-shadow: inset 2px 0 var(--accent); }
2949
+ .task-scope-option:has(input:checked) .task-scope-checkbox { border-color: var(--accent); background: var(--accent); }
2950
+ .task-scope-option:has(input:checked) .task-scope-checkbox::after { opacity: 1; transform: translateY(-1px) rotate(-45deg) scale(1); }
2951
+ .task-scope-selected-note { margin: -2px 0 0; color: var(--muted); font-size: 9px; line-height: 1.5; }
2952
+ .task-scope-selected-list { display: grid; align-content: start; gap: 6px; max-height: min(42vh, 360px); overflow-y: auto; padding: 2px 4px 2px 0; }
2953
+ .task-scope-selected-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; min-width: 0; padding: 8px 9px; border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line)); border-radius: 5px; background: color-mix(in srgb, var(--accent) 5%, var(--surface)); }
2954
+ .task-scope-selected-copy { display: grid; gap: 2px; min-width: 0; }
2955
+ .task-scope-selected-copy strong { overflow: hidden; color: var(--ink); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
2956
+ .task-scope-selected-copy small { overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
2957
+ .task-scope-selected-item button { display: grid; width: 24px; height: 24px; place-items: center; padding: 0; border: 1px solid transparent; border-radius: 50%; background: transparent; color: var(--muted); font-size: 16px; line-height: 1; }
2958
+ .task-scope-selected-item button:hover, .task-scope-selected-item button:focus-visible { border-color: var(--line); background: var(--surface); color: var(--accent-dark); outline: none; }
2959
+ .task-scope-selected-empty { margin: 0; padding: 28px 12px; border: 1px dashed var(--line); color: var(--muted); font-size: 10px; line-height: 1.5; text-align: center; }
2907
2960
  .task-scope-empty { margin: 0; padding: 18px 8px; color: var(--muted); font-size: 10px; text-align: center; }
2908
2961
  .relationship-analysis-options { display: grid; gap: 14px; }
2909
2962
  .relationship-character-field { min-width: 0; }
@@ -3683,6 +3736,9 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
3683
3736
  .replace-scope-fieldset legend, .replace-scope-fieldset > p { grid-column: auto; }
3684
3737
  .task-detail-overview { grid-template-columns: minmax(0, 1fr); padding: 12px; }
3685
3738
  .task-detail-overview > * { grid-column: 1 !important; }
3739
+ .task-scope-panel-grid { grid-template-columns: minmax(0, 1fr); }
3740
+ .task-scope-available, .task-scope-selected { min-height: 0; }
3741
+ .task-scope-options, .task-scope-selected-list { max-height: 28vh; }
3686
3742
  .task-result-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
3687
3743
  .task-result-item-details, .task-result-json-loader { grid-template-columns: minmax(0, 1fr); }
3688
3744
  .task-result-json-loader button { justify-self: start; }
package/dist/store.js CHANGED
@@ -13,6 +13,7 @@ import { countWords, documentShortSearchTerms, escapeSqlLikePattern, id, json, n
13
13
  import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
14
14
  import { resolveMaxAgentToolCallLimit } from "./ai-tool-results.js";
15
15
  const WORK_LIST_BATCH_SIZE = 500;
16
+ const ENTITY_LIST_BATCH_SIZE = 400;
16
17
  export const RECYCLE_BIN_RETENTION_DAYS = 30;
17
18
  function recycleBinExpiresAt(deletedAt) {
18
19
  return new Date(new Date(deletedAt).getTime() + RECYCLE_BIN_RETENTION_DAYS * 24 * 60 * 60_000).toISOString();
@@ -338,6 +339,18 @@ export class Store {
338
339
  const row = this.db.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = ? AND entity_id = ?", type, entityId);
339
340
  return numberValue(row ?? {}, "version_no");
340
341
  }
342
+ currentEntityVersionNos(type, entityIds) {
343
+ const versions = new Map();
344
+ for (let offset = 0; offset < entityIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
345
+ const batchIds = entityIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
346
+ const placeholders = batchIds.map(() => "?").join(", ");
347
+ const rows = this.db.all(`SELECT entity_id, MAX(version_no) AS version_no FROM entity_versions
348
+ WHERE entity_type = ? AND entity_id IN (${placeholders}) GROUP BY entity_id`, type, ...batchIds);
349
+ for (const row of rows)
350
+ versions.set(requiredString(row, "entity_id"), numberValue(row, "version_no"));
351
+ }
352
+ return versions;
353
+ }
341
354
  currentChapterVersionNo(chapterId) {
342
355
  return numberValue(this.db.get("SELECT MAX(version_no) AS version_no FROM chapter_versions WHERE chapter_id = ?", chapterId) ?? {}, "version_no");
343
356
  }
@@ -1263,18 +1276,19 @@ export class Store {
1263
1276
  }));
1264
1277
  return { ...work, volumes, directoryPage: pageResult };
1265
1278
  }
1266
- getStoryIndexChapterPage(workId, offset, limit) {
1279
+ getStoryIndexChapterPage(workId, offset, limit, options = {}) {
1267
1280
  const work = this.getWork(workId);
1268
1281
  const permissions = work.modulePermissions;
1269
1282
  if (permissions.prose === "none")
1270
1283
  return { totalChapters: 0, chapters: [] };
1284
+ const authorNoteFilter = options.excludeAuthorNotes ? " AND chapter.chapter_type <> '作者的话'" : "";
1271
1285
  const countRow = this.db.get(`SELECT COUNT(*) AS count FROM chapters chapter
1272
1286
  JOIN volumes volume ON volume.id = chapter.volume_id
1273
- WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL`, workId);
1287
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}`, workId);
1274
1288
  const chapterRows = this.db.all(`SELECT chapter.id, chapter.title, chapter.version_no, volume.title AS volume_title
1275
1289
  FROM chapters chapter
1276
1290
  JOIN volumes volume ON volume.id = chapter.volume_id
1277
- WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
1291
+ WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL${authorNoteFilter}
1278
1292
  ORDER BY volume.sort_order, volume.created_at, chapter.sort_order, chapter.created_at
1279
1293
  LIMIT ? OFFSET ?`, workId, limit, offset);
1280
1294
  const chapterIds = chapterRows.map((row) => requiredString(row, "id"));
@@ -2440,12 +2454,13 @@ export class Store {
2440
2454
  this.db.run(`UPDATE chapter_paragraph_line_ranges SET chapter_version = ?
2441
2455
  WHERE paragraph_id IN (SELECT id FROM chapter_paragraph_search WHERE chapter_id = ?)`, versionNo, chapterId);
2442
2456
  }
2443
- searchChapterParagraphs(workId, keyword, limit = 20) {
2457
+ searchChapterParagraphs(workId, keyword, limit = 20, options = {}) {
2444
2458
  this.getWork(workId);
2445
2459
  const normalizedKeyword = normalizeDocumentSearchText(keyword.trim());
2446
2460
  if (!normalizedKeyword)
2447
2461
  return [];
2448
2462
  const safeLimit = Math.min(100, Math.max(1, Math.trunc(limit)));
2463
+ const authorNoteFilter = options.excludeAuthorNotes ? " AND chapter.chapter_type <> '作者的话'" : "";
2449
2464
  const columns = `SELECT paragraph.chapter_id, chapter.title AS chapter_title, paragraph.content
2450
2465
  FROM chapter_paragraph_search paragraph
2451
2466
  JOIN chapters chapter ON chapter.id = paragraph.chapter_id
@@ -2453,12 +2468,12 @@ export class Store {
2453
2468
  const rows = [...normalizedKeyword].length < 3
2454
2469
  ? this.db.all(`${columns}
2455
2470
  JOIN chapter_paragraph_short_terms term ON term.paragraph_id = paragraph.id
2456
- WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND term.term = ?
2471
+ WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL${authorNoteFilter} AND term.term = ?
2457
2472
  ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
2458
2473
  LIMIT ?`, workId, normalizedKeyword, safeLimit)
2459
2474
  : this.db.all(`${columns}
2460
2475
  JOIN chapter_paragraph_search_fts fts ON fts.rowid = paragraph.id
2461
- WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL AND chapter_paragraph_search_fts MATCH ?
2476
+ WHERE paragraph.work_id = ? AND chapter.deleted_at IS NULL${authorNoteFilter} AND chapter_paragraph_search_fts MATCH ?
2462
2477
  ORDER BY volume.sort_order, chapter.sort_order, paragraph.paragraph_order
2463
2478
  LIMIT ?`, workId, `"${normalizedKeyword.replaceAll('"', '""')}"`, safeLimit);
2464
2479
  return rows.map((row) => ({
@@ -3029,20 +3044,25 @@ export class Store {
3029
3044
  WHERE fo.foreshadow_id = ? ORDER BY v.sort_order, c.sort_order, fo.created_at`, foreshadowId).map((item) => this.mapForeshadowOccurrence(item));
3030
3045
  const status = requiredString(row, "status");
3031
3046
  const plannedPayoffChapterId = optionalString(row, "planned_payoff_chapter_id");
3047
+ const overdue = Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
3048
+ && this.chapterSequence(workId, plannedPayoffChapterId) < this.chapterSequence(workId, currentChapterId));
3049
+ return this.mapForeshadow(row, occurrences, this.currentEntityVersionNo("foreshadow", foreshadowId), overdue);
3050
+ }
3051
+ mapForeshadow(row, occurrences, versionNo, overdue) {
3052
+ const status = requiredString(row, "status");
3032
3053
  return {
3033
3054
  id: requiredString(row, "id"),
3034
- workId,
3055
+ workId: requiredString(row, "work_id"),
3035
3056
  title: requiredString(row, "title"),
3036
3057
  description: requiredString(row, "description"),
3037
3058
  status,
3038
3059
  importance: requiredString(row, "importance"),
3039
- plannedPayoffChapterId,
3060
+ plannedPayoffChapterId: optionalString(row, "planned_payoff_chapter_id"),
3040
3061
  resolutionNote: requiredString(row, "resolution_note"),
3041
3062
  unresolved: status === "planned" || status === "planted",
3042
- overdue: Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
3043
- && this.chapterSequence(workId, plannedPayoffChapterId) < this.chapterSequence(workId, currentChapterId)),
3063
+ overdue,
3044
3064
  occurrences,
3045
- versionNo: this.currentEntityVersionNo("foreshadow", foreshadowId),
3065
+ versionNo,
3046
3066
  createdAt: requiredString(row, "created_at"),
3047
3067
  updatedAt: requiredString(row, "updated_at")
3048
3068
  };
@@ -3054,8 +3074,9 @@ export class Store {
3054
3074
  const where = status === "unresolved"
3055
3075
  ? "AND status IN ('planned', 'planted')"
3056
3076
  : status === "resolved" ? "AND status IN ('resolved', 'abandoned')" : "";
3057
- return this.db.all(`SELECT id FROM foreshadows WHERE work_id = ? ${where}
3058
- ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at`, workId).map((row) => this.getForeshadow(requiredString(row, "id"), currentChapterId));
3077
+ const rows = this.db.all(`SELECT * FROM foreshadows WHERE work_id = ? ${where}
3078
+ ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at`, workId);
3079
+ return this.mapForeshadowList(rows, currentChapterId);
3059
3080
  }
3060
3081
  listForeshadowsPage(workId, pagination, status = "all", currentChapterId) {
3061
3082
  this.getWork(workId);
@@ -3065,9 +3086,63 @@ export class Store {
3065
3086
  ? "AND status IN ('planned', 'planted')"
3066
3087
  : status === "resolved" ? "AND status IN ('resolved', 'abandoned')" : "";
3067
3088
  const page = paginationSql(pagination);
3068
- const rows = this.db.all(`SELECT id FROM foreshadows WHERE work_id = ? ${where}
3089
+ const rows = this.db.all(`SELECT * FROM foreshadows WHERE work_id = ? ${where}
3069
3090
  ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at${page.sql}`, workId, ...page.params);
3070
- return paginated(rows.map((row) => this.getForeshadow(requiredString(row, "id"), currentChapterId)), pagination);
3091
+ return paginated(this.mapForeshadowList(rows, currentChapterId), pagination);
3092
+ }
3093
+ mapForeshadowList(rows, currentChapterId) {
3094
+ if (rows.length === 0)
3095
+ return [];
3096
+ const foreshadowIds = rows.map((row) => requiredString(row, "id"));
3097
+ const batch = {
3098
+ occurrences: new Map(),
3099
+ versions: this.currentEntityVersionNos("foreshadow", foreshadowIds),
3100
+ chapterSequences: new Map()
3101
+ };
3102
+ for (let offset = 0; offset < foreshadowIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
3103
+ const batchIds = foreshadowIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
3104
+ const placeholders = batchIds.map(() => "?").join(", ");
3105
+ const occurrences = this.db.all(`SELECT fo.*, c.title AS chapter_title, c.volume_id, c.sort_order AS chapter_order,
3106
+ v.title AS volume_title, v.sort_order AS volume_order
3107
+ FROM foreshadow_occurrences fo
3108
+ JOIN chapters c ON c.id = fo.chapter_id
3109
+ JOIN volumes v ON v.id = c.volume_id
3110
+ WHERE fo.foreshadow_id IN (${placeholders})
3111
+ ORDER BY fo.foreshadow_id, v.sort_order, c.sort_order, fo.created_at`, ...batchIds);
3112
+ for (const occurrence of occurrences) {
3113
+ const foreshadowId = requiredString(occurrence, "foreshadow_id");
3114
+ const grouped = batch.occurrences.get(foreshadowId) ?? [];
3115
+ grouped.push(this.mapForeshadowOccurrence(occurrence));
3116
+ batch.occurrences.set(foreshadowId, grouped);
3117
+ }
3118
+ }
3119
+ if (currentChapterId) {
3120
+ const chapterIds = [...new Set([
3121
+ currentChapterId,
3122
+ ...rows.map((row) => optionalString(row, "planned_payoff_chapter_id")).filter((chapterId) => Boolean(chapterId))
3123
+ ])];
3124
+ for (let offset = 0; offset < chapterIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
3125
+ const batchIds = chapterIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
3126
+ const placeholders = batchIds.map(() => "?").join(", ");
3127
+ const sequences = this.db.all(`SELECT c.id, v.sort_order * 1000000 + c.sort_order AS sequence
3128
+ FROM chapters c JOIN volumes v ON v.id = c.volume_id
3129
+ WHERE c.id IN (${placeholders}) AND c.work_id = ?`, ...batchIds, requiredString(rows[0] ?? {}, "work_id"));
3130
+ for (const sequence of sequences) {
3131
+ batch.chapterSequences.set(requiredString(sequence, "id"), numberValue(sequence, "sequence"));
3132
+ }
3133
+ }
3134
+ }
3135
+ const currentChapterSequence = currentChapterId
3136
+ ? batch.chapterSequences.get(currentChapterId) ?? Number.MAX_SAFE_INTEGER
3137
+ : Number.MAX_SAFE_INTEGER;
3138
+ return rows.map((row) => {
3139
+ const foreshadowId = requiredString(row, "id");
3140
+ const status = requiredString(row, "status");
3141
+ const plannedPayoffChapterId = optionalString(row, "planned_payoff_chapter_id");
3142
+ const overdue = Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
3143
+ && (batch.chapterSequences.get(plannedPayoffChapterId) ?? Number.MAX_SAFE_INTEGER) < currentChapterSequence);
3144
+ return this.mapForeshadow(row, batch.occurrences.get(foreshadowId) ?? [], batch.versions.get(foreshadowId) ?? 0, overdue);
3145
+ });
3071
3146
  }
3072
3147
  listChapterForeshadowReminders(workId, chapterId) {
3073
3148
  this.getWork(workId);
@@ -3750,13 +3825,16 @@ export class Store {
3750
3825
  }
3751
3826
  listOrganizations(workId, includeMarkdown = true) {
3752
3827
  this.getWork(workId);
3753
- return this.db.all("SELECT * FROM organizations WHERE work_id = ? ORDER BY name", workId).map((row) => this.mapOrganization(row, includeMarkdown));
3828
+ const rows = this.db.all("SELECT * FROM organizations WHERE work_id = ? ORDER BY name", workId);
3829
+ const batch = this.organizationListBatch(rows);
3830
+ return rows.map((row) => this.mapOrganization(row, includeMarkdown, batch));
3754
3831
  }
3755
3832
  listOrganizationsPage(workId, pagination, includeMarkdown = true) {
3756
3833
  this.getWork(workId);
3757
3834
  const page = paginationSql(pagination);
3758
3835
  const rows = this.db.all(`SELECT * FROM organizations WHERE work_id = ? ORDER BY name${page.sql}`, workId, ...page.params);
3759
- return paginated(rows.map((row) => this.mapOrganization(row, includeMarkdown)), pagination);
3836
+ const batch = this.organizationListBatch(rows);
3837
+ return paginated(rows.map((row) => this.mapOrganization(row, includeMarkdown, batch)), pagination);
3760
3838
  }
3761
3839
  getOrganization(organizationId) {
3762
3840
  const row = this.db.get("SELECT * FROM organizations WHERE id = ?", organizationId);
@@ -3843,20 +3921,50 @@ export class Store {
3843
3921
  });
3844
3922
  return { mergeId, target: this.getOrganization(targetOrganizationId), source };
3845
3923
  }
3846
- mapOrganization(row, includeMarkdown = true) {
3924
+ organizationListBatch(rows) {
3925
+ const organizationIds = rows.map((row) => requiredString(row, "id"));
3926
+ const batch = {
3927
+ members: new Map(),
3928
+ versions: this.currentEntityVersionNos("organization", organizationIds)
3929
+ };
3930
+ for (let offset = 0; offset < organizationIds.length; offset += ENTITY_LIST_BATCH_SIZE) {
3931
+ const batchIds = organizationIds.slice(offset, offset + ENTITY_LIST_BATCH_SIZE);
3932
+ const placeholders = batchIds.map(() => "?").join(", ");
3933
+ const members = this.db.all(`SELECT m.organization_id, c.id, c.name, m.role, m.note
3934
+ FROM character_organization_memberships m
3935
+ JOIN characters c ON c.id = m.character_id
3936
+ WHERE m.organization_id IN (${placeholders}) ORDER BY m.organization_id, c.name`, ...batchIds);
3937
+ for (const member of members) {
3938
+ const organizationId = requiredString(member, "organization_id");
3939
+ const grouped = batch.members.get(organizationId) ?? [];
3940
+ grouped.push({
3941
+ characterId: requiredString(member, "id"),
3942
+ name: requiredString(member, "name"),
3943
+ role: requiredString(member, "role"),
3944
+ note: requiredString(member, "note")
3945
+ });
3946
+ batch.members.set(organizationId, grouped);
3947
+ }
3948
+ }
3949
+ return batch;
3950
+ }
3951
+ mapOrganization(row, includeMarkdown = true, batch) {
3952
+ const organizationId = requiredString(row, "id");
3847
3953
  const settingsSections = knowledgeSectionsFromStored(row.settings_sections_json, json(requiredString(row, "settings_json"), []));
3848
3954
  const settings = settingsFromKnowledgeSections(settingsSections);
3849
- const members = this.db.all(`SELECT c.id, c.name, m.role, m.note
3850
- FROM character_organization_memberships m
3851
- JOIN characters c ON c.id = m.character_id
3852
- WHERE m.organization_id = ? ORDER BY c.name`, requiredString(row, "id")).map((member) => ({
3853
- characterId: requiredString(member, "id"),
3854
- name: requiredString(member, "name"),
3855
- role: requiredString(member, "role"),
3856
- note: requiredString(member, "note")
3857
- }));
3955
+ const members = batch
3956
+ ? batch.members.get(organizationId) ?? []
3957
+ : this.db.all(`SELECT c.id, c.name, m.role, m.note
3958
+ FROM character_organization_memberships m
3959
+ JOIN characters c ON c.id = m.character_id
3960
+ WHERE m.organization_id = ? ORDER BY c.name`, organizationId).map((member) => ({
3961
+ characterId: requiredString(member, "id"),
3962
+ name: requiredString(member, "name"),
3963
+ role: requiredString(member, "role"),
3964
+ note: requiredString(member, "note")
3965
+ }));
3858
3966
  return {
3859
- id: requiredString(row, "id"),
3967
+ id: organizationId,
3860
3968
  workId: requiredString(row, "work_id"),
3861
3969
  name: requiredString(row, "name"),
3862
3970
  description: requiredString(row, "description"),
@@ -3866,7 +3974,7 @@ export class Store {
3866
3974
  : { settings: [], settingsCount: settingsSections.length }),
3867
3975
  memberIds: members.map((member) => member.characterId),
3868
3976
  members,
3869
- versionNo: this.currentEntityVersionNo("organization", requiredString(row, "id")),
3977
+ versionNo: batch ? batch.versions.get(organizationId) ?? 0 : this.currentEntityVersionNo("organization", organizationId),
3870
3978
  createdAt: requiredString(row, "created_at"),
3871
3979
  updatedAt: requiredString(row, "updated_at")
3872
3980
  };
@@ -5652,6 +5760,43 @@ export class Store {
5652
5760
  throw notFound("AI 对话消息");
5653
5761
  return this.mapAiConversationMessage(persistInterruption(message));
5654
5762
  }
5763
+ upsertAiConversationAssistantMessage(conversationId, requestId, content, metadata = {}, syncSearchIndex = false) {
5764
+ const normalizedRequestId = requestId.trim();
5765
+ if (!normalizedRequestId)
5766
+ throw new AppError(400, "AI_MESSAGE_REQUEST_ID_REQUIRED", "AI 助手消息缺少请求标识");
5767
+ const existing = this.db.get("SELECT * FROM ai_conversation_messages WHERE conversation_id = ? AND request_id = ?", conversationId, normalizedRequestId);
5768
+ if (!existing) {
5769
+ return this.addAiConversationMessage(conversationId, {
5770
+ role: "assistant",
5771
+ content,
5772
+ requestId: normalizedRequestId,
5773
+ metadata
5774
+ });
5775
+ }
5776
+ if (requiredString(existing, "role") !== "assistant") {
5777
+ throw new AppError(409, "AI_MESSAGE_ROLE_MISMATCH", "请求标识已用于用户消息");
5778
+ }
5779
+ const currentMetadata = json(requiredString(existing, "metadata_json"), {});
5780
+ const nextMetadata = { ...currentMetadata, ...metadata };
5781
+ const contentChanged = requiredString(existing, "content") !== content;
5782
+ const metadataChanged = JSON.stringify(currentMetadata) !== JSON.stringify(nextMetadata);
5783
+ if (!contentChanged && !metadataChanged) {
5784
+ if (syncSearchIndex)
5785
+ this.syncAiHistorySearchShortTermsForSource("message", requiredString(existing, "id"));
5786
+ return this.mapAiConversationMessage(existing);
5787
+ }
5788
+ const timestamp = now();
5789
+ this.db.transaction(() => {
5790
+ this.db.run("UPDATE ai_conversation_messages SET content = ?, metadata_json = ? WHERE id = ?", content, JSON.stringify(nextMetadata), requiredString(existing, "id"));
5791
+ this.db.run("UPDATE ai_conversations SET updated_at = ? WHERE id = ?", timestamp, conversationId);
5792
+ if (syncSearchIndex)
5793
+ this.syncAiHistorySearchShortTermsForSource("message", requiredString(existing, "id"));
5794
+ });
5795
+ const updated = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ?", requiredString(existing, "id"));
5796
+ if (!updated)
5797
+ throw notFound("AI 对话消息");
5798
+ return this.mapAiConversationMessage(updated);
5799
+ }
5655
5800
  beginAiConversationStreamRequest(input, referenceTime = new Date()) {
5656
5801
  const timestamp = referenceTime.toISOString();
5657
5802
  const leaseExpiresAt = new Date(referenceTime.getTime() + AI_CONVERSATION_STREAM_REQUEST_LEASE_MS).toISOString();
@@ -5769,10 +5914,18 @@ export class Store {
5769
5914
  if (!assistant)
5770
5915
  throw new AppError(400, "AI_STREAM_ASSISTANT_MISMATCH", "AI 回复消息不属于当前对话请求");
5771
5916
  }
5917
+ const resolvedAssistantMessageId = assistantMessageId ?? (() => {
5918
+ const userMessageId = optionalString(request, "user_message_id");
5919
+ if (!userMessageId)
5920
+ return null;
5921
+ const assistant = this.db.get(`SELECT id FROM ai_conversation_messages
5922
+ WHERE conversation_id = ? AND role = 'assistant' AND request_id = ?`, requiredString(request, "conversation_id"), `assistant:${userMessageId}`);
5923
+ return assistant ? requiredString(assistant, "id") : null;
5924
+ })();
5772
5925
  this.db.run(`UPDATE ai_conversation_stream_requests
5773
5926
  SET status = ?, terminal_reason = ?, assistant_message_id = COALESCE(assistant_message_id, ?),
5774
5927
  lease_expires_at = NULL, updated_at = ?, completed_at = ?
5775
- WHERE id = ? AND status = 'in_progress'`, status, terminalReason.slice(0, 500), assistantMessageId ?? null, timestamp, timestamp, requestId);
5928
+ WHERE id = ? AND status = 'in_progress'`, status, terminalReason.slice(0, 500), resolvedAssistantMessageId, timestamp, timestamp, requestId);
5776
5929
  const completed = this.db.get("SELECT * FROM ai_conversation_stream_requests WHERE id = ?", requestId);
5777
5930
  if (!completed)
5778
5931
  throw notFound("AI 对话请求");
@@ -6023,11 +6176,13 @@ export class Store {
6023
6176
  ...(Array.isArray(scope.chapterIds) ? scope.chapterIds.filter((value) => typeof value === "string") : [])
6024
6177
  ];
6025
6178
  for (const chapterId of [...new Set(selectedChapterIds)]) {
6026
- const chapter = this.db.get("SELECT work_id, version_no FROM chapters WHERE id = ? AND deleted_at IS NULL", chapterId);
6179
+ const chapter = this.db.get("SELECT work_id, version_no, chapter_type FROM chapters WHERE id = ? AND deleted_at IS NULL", chapterId);
6027
6180
  if (!chapter)
6028
6181
  throw notFound("章节");
6029
6182
  if (requiredString(chapter, "work_id") !== workId)
6030
6183
  throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
6184
+ if (requiredString(chapter, "chapter_type") === "作者的话")
6185
+ continue;
6031
6186
  sourceVersions[chapterId] = numberValue(chapter, "version_no");
6032
6187
  }
6033
6188
  if (scope.type === "book" || scope.type === "volume") {
@@ -6051,6 +6206,7 @@ export class Store {
6051
6206
  FROM chapters chapter
6052
6207
  JOIN volumes volume ON volume.id = chapter.volume_id
6053
6208
  WHERE chapter.work_id = ? AND chapter.deleted_at IS NULL AND volume.deleted_at IS NULL
6209
+ AND chapter.chapter_type <> '作者的话'
6054
6210
  ${volumeFilter}`, workId, ...(scope.type === "volume" ? [...selectedVolumeIdSet] : []));
6055
6211
  for (const chapter of chapterRows) {
6056
6212
  sourceVersions[requiredString(chapter, "id")] = numberValue(chapter, "version_no");