@musnows/scriverse 0.5.11 → 0.6.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.
@@ -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=20260730-table-wrap-menu-v1">
13
+ <link rel="stylesheet" href="/styles.css?v=20260730-ai-error-drafts-filter-align-model-availability-token-distribution-v1">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -309,7 +309,11 @@
309
309
  <div id="ai-prompt" class="ai-prompt" contenteditable="true" role="textbox" aria-multiline="true" aria-keyshortcuts="Enter" title="Enter 发送,Shift+Enter 换行" data-placeholder="告诉 AI 你想讨论或修改什么……"></div>
310
310
  <div id="ai-mention-menu" class="ai-mention-menu hidden" role="listbox" aria-label="引用角色、设定或章节"></div>
311
311
  <div class="prompt-composer-actions">
312
- <output id="ai-context-meter" class="ai-context-meter is-empty" aria-live="polite" aria-label="当前上下文用量" tabindex="0"><b>—</b></output>
312
+ <button id="ai-context-meter" class="ai-context-meter is-empty" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="ai-context-popover" aria-live="polite" aria-label="当前上下文用量"><b>—</b></button>
313
+ <section id="ai-context-popover" class="ai-context-popover hidden" role="dialog" aria-labelledby="ai-context-popover-title" aria-describedby="ai-context-popover-description">
314
+ <header class="ai-context-popover-header"><div><strong id="ai-context-popover-title">Token 分布</strong><small id="ai-context-popover-description">按当前模型上下文窗口计算</small></div><button id="ai-context-popover-close" class="ai-context-popover-close" type="button" aria-label="关闭 Token 分布">×</button></header>
315
+ <div id="ai-context-distribution" class="ai-context-distribution" role="list" aria-label="当前 Token 分布"></div>
316
+ </section>
313
317
  <button id="ai-send" class="ai-send-button" type="button" aria-label="发送消息">发送</button>
314
318
  </div>
315
319
  </div>
@@ -884,6 +888,6 @@
884
888
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
885
889
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
886
890
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
887
- <script type="module" src="/app.js?v=20260730-table-wrap-menu-v1"></script>
891
+ <script type="module" src="/app.js?v=20260730-ai-error-model-availability-token-distribution-conversation-title-v1"></script>
888
892
  </body>
889
893
  </html>
@@ -0,0 +1,17 @@
1
+ export interface ModuleRequestOptions {
2
+ refresh?: boolean;
3
+ }
4
+
5
+ export interface ModuleRequestCache {
6
+ request<T>(
7
+ workId: string,
8
+ module: string,
9
+ requestKey: string,
10
+ loader: () => Promise<T> | T,
11
+ options?: ModuleRequestOptions
12
+ ): Promise<T>;
13
+ invalidate(workId: string, module: string): void;
14
+ clear(): void;
15
+ }
16
+
17
+ export function createModuleRequestCache(): ModuleRequestCache;
@@ -0,0 +1,35 @@
1
+ export function createModuleRequestCache() {
2
+ const scopes = new Map();
3
+
4
+ const scopeKey = (workId, module) => `${String(workId)}\u0000${String(module)}`;
5
+ const cloneResult = (value) => structuredClone(value);
6
+
7
+ function request(workId, module, requestKey, loader, { refresh = false } = {}) {
8
+ const key = scopeKey(workId, module);
9
+ let scope = scopes.get(key);
10
+ if (!scope) {
11
+ scope = new Map();
12
+ scopes.set(key, scope);
13
+ }
14
+ if (refresh) scope.delete(requestKey);
15
+ if (scope.has(requestKey)) return scope.get(requestKey).then(cloneResult);
16
+
17
+ const pending = Promise.resolve().then(loader);
18
+ scope.set(requestKey, pending);
19
+ pending.catch(() => {
20
+ if (scope.get(requestKey) === pending) scope.delete(requestKey);
21
+ if (scope.size === 0) scopes.delete(key);
22
+ });
23
+ return pending.then(cloneResult);
24
+ }
25
+
26
+ function invalidate(workId, module) {
27
+ scopes.delete(scopeKey(workId, module));
28
+ }
29
+
30
+ function clear() {
31
+ scopes.clear();
32
+ }
33
+
34
+ return { request, invalidate, clear };
35
+ }
@@ -9,7 +9,7 @@ export type StreamTypewriter = {
9
9
  reveal(): string;
10
10
  };
11
11
 
12
- export function streamTypewriterBatchSize(pendingCharacters: number, finishingFrames?: number | null): number;
12
+ export function streamTypewriterBatchSize(pendingCharacters: number, finishing?: boolean): number;
13
13
 
14
14
  export function createStreamTypewriter<FrameHandle = number>(options: {
15
15
  onRender: (text: string, progress: StreamTypewriterProgress) => void;
@@ -1,13 +1,18 @@
1
- const DEFAULT_FINISH_FRAMES = 36;
2
- const DEFAULT_BACKLOG_FRAMES = 18;
1
+ const STREAMING_CHARACTERS_PER_FRAME = 1;
2
+ const FINISHING_CHARACTERS_PER_FRAME = 2;
3
+ const FINISHING_ACCELERATION = 0.9;
4
+ const MAX_STREAMING_CHARACTERS_PER_FRAME = 12;
5
+ const MAX_FINISHING_CHARACTERS_PER_FRAME = 24;
3
6
 
4
- export function streamTypewriterBatchSize(pendingCharacters, finishingFrames = null) {
7
+ export function streamTypewriterBatchSize(pendingCharacters, finishing = false) {
5
8
  const pending = Math.max(0, Math.floor(Number(pendingCharacters) || 0));
6
9
  if (pending === 0) return 0;
7
- const targetFrames = finishingFrames === null
8
- ? DEFAULT_BACKLOG_FRAMES
9
- : Math.max(1, Math.floor(Number(finishingFrames) || 1));
10
- return Math.max(1, Math.ceil(pending / targetFrames));
10
+ const minimum = finishing ? FINISHING_CHARACTERS_PER_FRAME : STREAMING_CHARACTERS_PER_FRAME;
11
+ const maximum = finishing ? MAX_FINISHING_CHARACTERS_PER_FRAME : MAX_STREAMING_CHARACTERS_PER_FRAME;
12
+ const adaptive = finishing
13
+ ? Math.ceil(Math.sqrt(pending) * FINISHING_ACCELERATION)
14
+ : Math.ceil(pending / 30);
15
+ return Math.min(pending, maximum, Math.max(minimum, adaptive));
11
16
  }
12
17
 
13
18
  export function createStreamTypewriter({
@@ -22,7 +27,7 @@ export function createStreamTypewriter({
22
27
  const pendingCharacters = [];
23
28
  const idleResolvers = [];
24
29
  let scheduledFrame = null;
25
- let finishingFrames = null;
30
+ let finishing = false;
26
31
 
27
32
  const snapshot = () => visibleCharacters.join("");
28
33
  const resolveIdle = () => {
@@ -42,9 +47,8 @@ export function createStreamTypewriter({
42
47
  scheduledFrame = null;
43
48
  const batchSize = reducedMotion
44
49
  ? pendingCharacters.length
45
- : streamTypewriterBatchSize(pendingCharacters.length, finishingFrames);
50
+ : streamTypewriterBatchSize(pendingCharacters.length, finishing);
46
51
  visibleCharacters.push(...pendingCharacters.splice(0, batchSize));
47
- if (finishingFrames !== null) finishingFrames = Math.max(1, finishingFrames - 1);
48
52
  render();
49
53
  if (pendingCharacters.length) schedule();
50
54
  else resolveIdle();
@@ -60,7 +64,7 @@ export function createStreamTypewriter({
60
64
  },
61
65
  finish() {
62
66
  if (!pendingCharacters.length && scheduledFrame === null) return Promise.resolve(snapshot());
63
- finishingFrames = reducedMotion ? 1 : DEFAULT_FINISH_FRAMES;
67
+ finishing = true;
64
68
  schedule();
65
69
  return new Promise((resolve) => idleResolvers.push(resolve));
66
70
  },
@@ -70,7 +74,7 @@ export function createStreamTypewriter({
70
74
  scheduledFrame = null;
71
75
  }
72
76
  visibleCharacters.push(...pendingCharacters.splice(0));
73
- finishingFrames = null;
77
+ finishing = false;
74
78
  render();
75
79
  resolveIdle();
76
80
  return snapshot();
@@ -1100,7 +1100,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1100
1100
  .module-nav .ai-analysis-entry,
1101
1101
  .module-nav .ai-analysis-entry:hover,
1102
1102
  .module-nav .ai-analysis-entry.active { background: transparent; color: var(--accent-dark); font-weight: 650; }
1103
- #module-more-button { grid-column: 1 / -1; color: var(--accent-dark); text-align: center; }
1103
+ #module-more-button { grid-column: 2; color: var(--accent-dark); text-align: center; }
1104
1104
  .panel-heading { display: flex; align-items: center; gap: 8px; padding: 15px 0 9px 7px; color: var(--muted); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; }
1105
1105
  .panel-heading-actions { display: flex; align-items: center; gap: 7px; margin-left: auto; }
1106
1106
  .chapter-batch-button { display: grid; flex: none; place-items: center; width: 24px; height: 24px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); }
@@ -1275,9 +1275,9 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1275
1275
  .character-filter-toolbar-actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; min-height: 38px; }
1276
1276
  .character-filter-result-count { color: var(--muted); font-size: 11px; white-space: nowrap; }
1277
1277
  .character-filter-toolbar-actions button:disabled { cursor: default; opacity: .45; }
1278
- .draft-filter-toolbar { display: flex; align-items: end; gap: 10px; margin-bottom: 18px; padding: 12px 14px; border: 1px solid var(--line); background: var(--surface-soft); }
1278
+ .draft-filter-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; padding: 12px 14px; border: 1px solid var(--line); background: var(--surface-soft); }
1279
1279
  .draft-filter-toolbar label { color: var(--muted); font-size: 11px; }
1280
- .draft-filter-toolbar select { min-width: 180px; padding: 8px 10px; background: var(--surface); }
1280
+ .draft-filter-toolbar select { min-width: 180px; min-height: 38px; padding: 8px 10px; background: var(--surface); font-size: 11px; }
1281
1281
  .draft-filter-toolbar span { margin-left: auto; align-self: center; color: var(--muted); font-size: 11px; }
1282
1282
  .character-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1283
1283
  .character-duplicate-review { grid-column: 1 / -1; }
@@ -1301,9 +1301,24 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1301
1301
  .card-actions .primary-button:hover, .card-actions .primary-button:focus-visible { background: var(--accent-dark); }
1302
1302
  .provider-card-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); }
1303
1303
  .provider-card { min-width: 0; }
1304
+ .provider-card.is-disabled { border-style: dashed; border-color: color-mix(in srgb, var(--accent) 68%, var(--line)); background: color-mix(in srgb, var(--accent) 5%, var(--surface-soft)); }
1305
+ .provider-card-meta { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
1306
+ .provider-card-meta small { min-width: 0; }
1307
+ .provider-status-badge, .model-status-badge { display: inline-flex; flex: 0 0 auto; align-items: center; min-height: 22px; padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px; font-size: 9px; font-weight: 700; line-height: 1.1; white-space: nowrap; }
1308
+ .provider-status-badge.is-enabled { border-color: color-mix(in srgb, var(--green) 48%, var(--line)); background: color-mix(in srgb, var(--green) 10%, var(--surface)); color: var(--green); }
1309
+ .provider-status-badge.is-disabled, .model-status-badge.is-disabled { border-color: color-mix(in srgb, var(--accent) 68%, var(--line)); background: color-mix(in srgb, var(--accent) 18%, var(--surface)); color: var(--accent-dark); }
1310
+ .provider-status-badge.is-error, .model-status-badge.is-unavailable { border-color: color-mix(in srgb, var(--accent) 46%, var(--line)); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); color: var(--accent-dark); }
1311
+ .provider-disabled-notice { display: flex; align-items: baseline; gap: 8px; margin: 12px 0; padding: 9px 11px; border-left: 3px solid var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); }
1312
+ .provider-disabled-notice strong { color: var(--accent-dark); font-size: 12px; }
1313
+ .provider-disabled-notice span { color: var(--muted); font-size: 10px; line-height: 1.4; }
1304
1314
  .provider-card p, .provider-card .model-pill { overflow-wrap: anywhere; word-break: break-word; }
1305
1315
  .provider-models { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
1306
- .provider-card .model-pill { max-width: 100%; margin: 0; border: 0; text-align: left; white-space: normal; cursor: pointer; }
1316
+ .provider-model-row { display: flex; align-items: center; gap: 6px; min-width: 0; max-width: 100%; }
1317
+ .provider-model-row.is-unavailable { padding: 4px 6px; border: 1px dashed color-mix(in srgb, var(--accent) 42%, var(--line)); border-radius: 4px; background: color-mix(in srgb, var(--accent) 4%, transparent); }
1318
+ .provider-card .model-pill { flex: 1 1 auto; max-width: 100%; margin: 0; border: 0; text-align: left; white-space: normal; cursor: pointer; }
1319
+ .provider-model-row .model-pill { min-width: 0; }
1320
+ .provider-model-row.is-unavailable .model-pill { color: var(--muted); opacity: .72; }
1321
+ .provider-model-row .model-test-button { flex: 0 0 auto; min-height: 30px; padding: 6px 9px; }
1307
1322
  .provider-card .model-pill:hover, .provider-card .model-pill:focus-visible { background: var(--surface-hover); color: var(--ink); }
1308
1323
  .provider-card .card-actions { flex-wrap: wrap; }
1309
1324
  .pill { display: inline-block; padding: 3px 7px; margin: 0 4px 4px 0; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--muted); font-size: 9px; }
@@ -2038,7 +2053,8 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2038
2053
  .assistant-message > .message-heading, .user-message > .message-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 5px; opacity: .65; font-size: 9px; }
2039
2054
  .message-heading > span { letter-spacing: .12em; }.message-heading time { flex: 0 0 auto; font-variant-numeric: tabular-nums; letter-spacing: .03em; }
2040
2055
  .message-body { min-width: 0; overflow-wrap: anywhere; }
2041
- .is-streaming .message-body:empty::after, .is-streaming .message-body > :last-child::after { content: ""; display: inline-block; width: 2px; height: 1.05em; margin-left: 3px; border-radius: 1px; background: var(--accent-dark); vertical-align: -.16em; animation: ai-stream-cursor 850ms steps(1, end) infinite; }
2056
+ .assistant-message.is-error .message-body { font-family: inherit; font-size: inherit; line-height: inherit; }
2057
+ .assistant-message.is-error .ai-error-text { margin: 0; white-space: pre-wrap; font: inherit; }
2042
2058
  .message-card-actions { position: absolute; right: 0; bottom: -31px; display: flex; align-items: center; gap: 12px; height: 24px; }
2043
2059
  .message-card-actions button { display: inline-flex; align-items: center; gap: 4px; min-width: 0; padding: 3px 2px; border: 0; background: transparent; color: var(--muted); font-size: 9px; }
2044
2060
  .message-card-actions button:hover, .message-card-actions button:focus-visible { color: var(--accent-dark); }
@@ -2087,8 +2103,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2087
2103
  .ai-process-intermediate-step .ai-process-step-body { border-left-color: var(--muted); }
2088
2104
  .ai-process-tool-step { padding-top: 1px; }
2089
2105
  @keyframes ai-process-pulse { 0%, 100% { opacity: .45; transform: scale(.8); } 50% { opacity: 1; transform: scale(1); } }
2090
- @keyframes ai-stream-cursor { 0%, 54% { opacity: 1; } 55%, 100% { opacity: 0; } }
2091
- @media (prefers-reduced-motion: reduce) { .is-streaming .ai-process-details[open] > summary::before, .is-streaming .message-body:empty::after, .is-streaming .message-body > :last-child::after { animation: none; } }
2106
+ @media (prefers-reduced-motion: reduce) { .is-streaming .ai-process-details[open] > summary::before { animation: none; } }
2092
2107
  .ai-tool-call-list { display: grid; gap: 5px; margin-top: 10px; }
2093
2108
  .ai-tool-call-summary { display: flex; align-items: center; justify-content: space-between; width: 100%; min-width: 0; padding: 7px 9px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface-soft); color: var(--muted); font: 9px/1.4 var(--font-latin), monospace; text-align: left; }
2094
2109
  .ai-tool-call-summary::after { content: "查看详情"; flex: 0 0 auto; margin-left: 8px; color: var(--accent-dark); }
@@ -2141,11 +2156,33 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2141
2156
  .ai-prompt-reference button { width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: inherit; font: 14px/1 var(--font-latin), monospace; }
2142
2157
  .ai-prompt-reference button:hover, .ai-prompt-reference button:focus-visible { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
2143
2158
  .prompt-composer-actions { position: absolute; right: 8px; bottom: 8px; display: flex; gap: 6px; align-items: center; }
2144
- .ai-context-meter { --context-usage: 0; --context-meter-color: var(--green); position: relative; display: grid; flex: 0 0 32px; place-items: center; width: 32px; height: 32px; overflow: visible; border: 0; border-radius: 50%; background: conic-gradient(var(--context-meter-color) calc(var(--context-usage) * 1%), rgba(143,132,116,.2) 0); color: var(--ink); font-family: var(--font-latin); line-height: 1; }
2159
+ .ai-context-meter { --context-usage: 0; --context-meter-color: var(--green); position: relative; display: grid; flex: 0 0 32px; place-items: center; width: 32px; height: 32px; overflow: visible; border: 0; border-radius: 50%; background: conic-gradient(var(--context-meter-color) calc(var(--context-usage) * 1%), rgba(143,132,116,.2) 0); color: var(--ink); font-family: var(--font-latin); line-height: 1; cursor: pointer; }
2145
2160
  .ai-context-meter::before { content: ""; position: absolute; inset: 3px; border-radius: 50%; background: var(--paper); box-shadow: inset 0 0 0 1px rgba(143,132,116,.12); }
2146
- .ai-context-meter::after { content: attr(data-tooltip); position: absolute; z-index: 30; right: 0; bottom: calc(100% + 8px); width: max-content; max-width: 220px; padding: 6px 8px; border: 1px solid rgba(255,255,255,.1); border-radius: 4px; background: #292724; color: #fff; font: 10px/1.35 var(--font-latin), monospace; white-space: nowrap; box-shadow: 0 6px 18px rgba(49,42,32,.25); opacity: 0; pointer-events: none; transform: translateY(3px); transition: opacity .12s ease, transform .12s ease; }
2147
- .ai-context-meter:hover::after, .ai-context-meter:focus-visible::after { opacity: 1; transform: translateY(0); }
2161
+ .ai-context-meter:hover, .ai-context-meter:focus-visible, .ai-context-meter[aria-expanded="true"] { outline: 0; filter: brightness(.96); }
2162
+ .ai-context-meter:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
2148
2163
  .ai-context-meter b { position: relative; z-index: 1; font-size: 9px; font-weight: 600; }.ai-context-meter.is-warning { --context-meter-color: #bf6a35; }.ai-context-meter.is-danger { --context-meter-color: #ad463c; }.ai-context-meter.is-empty { opacity: .62; }
2164
+ .ai-context-popover { position: absolute; right: 0; bottom: calc(100% + 10px); z-index: 40; display: grid; width: min(320px, calc(100vw - 32px)); gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 7px; background: var(--paper); color: var(--ink); box-shadow: 0 14px 38px rgba(36, 30, 24, .18); }
2165
+ .ai-context-popover.hidden { display: none; }
2166
+ .ai-context-popover::after { position: absolute; right: 12px; bottom: -6px; width: 10px; height: 10px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--paper); content: ""; transform: rotate(45deg); }
2167
+ .ai-context-popover-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
2168
+ .ai-context-popover-header > div { display: grid; gap: 3px; min-width: 0; }
2169
+ .ai-context-popover-header strong { font-size: 12px; }
2170
+ .ai-context-popover-header small { color: var(--muted); font: 9px/1.4 var(--font-latin), var(--font-cjk), sans-serif; }
2171
+ .ai-context-popover-close { flex: 0 0 auto; width: 24px; height: 24px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); font: 17px/1 var(--font-latin), sans-serif; }
2172
+ .ai-context-popover-close:hover, .ai-context-popover-close:focus-visible { border-color: var(--accent); color: var(--accent-dark); outline: 0; }
2173
+ .ai-context-distribution { display: grid; gap: 10px; }
2174
+ .ai-context-distribution-row { display: grid; gap: 5px; }
2175
+ .ai-context-distribution-label { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font: 10px/1.3 var(--font-latin), sans-serif; }
2176
+ .ai-context-distribution-label > span { min-width: 0; color: var(--ink); }
2177
+ .ai-context-distribution-label small { color: var(--muted); font: 9px/1.3 var(--font-cjk), sans-serif; }
2178
+ .ai-context-distribution-label strong { flex: 0 0 auto; color: var(--muted); font: 10px/1.3 var(--font-latin), monospace; font-weight: 500; }
2179
+ .ai-context-distribution-track { height: 5px; overflow: hidden; border-radius: 3px; background: color-mix(in srgb, var(--muted) 14%, transparent); }
2180
+ .ai-context-distribution-track > span { display: block; width: calc(var(--distribution-percent) * 1%); height: 100%; min-width: 0; border-radius: inherit; background: var(--distribution-color, var(--accent)); transition: width .16s ease; }
2181
+ .ai-context-distribution-row[data-key="system-prompt"] { --distribution-color: var(--accent); }
2182
+ .ai-context-distribution-row[data-key="function"] { --distribution-color: #9a7651; }
2183
+ .ai-context-distribution-row[data-key="skills"] { --distribution-color: var(--muted); }
2184
+ .ai-context-distribution-row[data-key="context"] { --distribution-color: var(--green); }
2185
+ .ai-context-distribution-row[data-key="left"] { --distribution-color: color-mix(in srgb, var(--muted) 55%, transparent); }
2149
2186
  .ai-send-button { min-width: 54px; height: 32px; padding: 0 11px; border: 0; border-radius: 4px; background: var(--accent); color: #fff; font-size: 10px; font-weight: 600; box-shadow: 0 4px 12px rgba(139,61,44,.2); }
2150
2187
  .ai-send-button:hover { background: var(--accent-dark); }.ai-send-button:disabled { cursor: wait; opacity: .62; }
2151
2188
  .ai-citations { display: grid; gap: 6px; max-height: 190px; overflow-y: auto; margin-bottom: 8px; }
@@ -2278,6 +2315,9 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2278
2315
  .trace-dialog .dialog-fields { max-height: 68vh; }
2279
2316
  .large-dialog { width: min(1120px, 94vw); max-height: 94dvh; }
2280
2317
  .large-dialog .dialog-fields { max-height: 72dvh; padding: 24px 28px; }
2318
+ .editor-dialog { width: min(1180px, 94vw); max-height: calc(100dvh - 16px); }
2319
+ .editor-dialog .dialog-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); max-height: 76dvh; padding: 24px 28px; }
2320
+ .editor-dialog .dialog-fields > .form-field-note, .editor-dialog .dialog-fields > .markdown-editor-field { grid-column: 1 / -1; }
2281
2321
  .dialog::backdrop { background: rgba(34,30,25,.42); backdrop-filter: blur(3px); }
2282
2322
  .dialog-header { display: flex; justify-content: space-between; align-items: flex-start; padding: 22px 24px 16px; border-bottom: 1px solid var(--line); }
2283
2323
  .dialog-header h2 { margin: 0; font-size: 23px; font-weight: 500; }
@@ -2555,6 +2595,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2555
2595
  .markdown-editor-field { display: grid; gap: 10px; min-width: 0; }
2556
2596
  .markdown-editor-field > span { color: var(--muted); font-size: 11px; }
2557
2597
  .markdown-editor-field .vditor-editor-host { min-height: 360px; }
2598
+ .editor-dialog .markdown-editor-field .vditor-editor-host.vditor { min-height: clamp(420px, 56dvh, 640px) !important; }
2558
2599
  .vditor-editor-host { width: 100%; height: 100%; min-width: 0; min-height: 0; }
2559
2600
  .vditor-editor-host.vditor { height: 100% !important; min-height: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); }
2560
2601
  .vditor-editor-host.vditor:not(.vditor--dark) { --border-color: var(--line); --panel-background-color: var(--surface); --toolbar-background-color: var(--surface-soft); --toolbar-icon-color: var(--muted); --toolbar-icon-hover-color: var(--accent-dark); --textarea-background-color: var(--surface); --textarea-text-color: var(--ink); --heading-border-color: var(--line); --blockquote-color: var(--muted); }
@@ -2613,6 +2654,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2613
2654
  .character-editor-section-fields textarea { min-height: 120px; resize: vertical; line-height: 1.65; }
2614
2655
  .character-editor-section-fields .section-list-row textarea { min-height: 180px; }
2615
2656
  .character-editor-section-fields .member-chip { position: relative; display: inline-flex; width: auto; cursor: pointer; }
2657
+ .character-editor-section-fields .member-chip input[type="checkbox"] { position: absolute; width: 1px !important; min-width: 1px; height: 1px; padding: 0; border: 0; opacity: 0; }
2616
2658
  .character-editor-section-fields .item-list-row button, .character-editor-section-fields .structured-list-row button { min-width: 54px; }
2617
2659
  .knowledge-markdown-sections { display: grid; grid-column: 1 / -1; gap: 14px; min-width: 0; }
2618
2660
  .knowledge-markdown-list-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
@@ -2814,6 +2856,8 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2814
2856
  .setting-markdown-field { height: 70dvh; }
2815
2857
  .setting-markdown-heading, .markdown-editor-field-heading { align-items: flex-start; flex-direction: column; gap: 4px; }
2816
2858
  .setting-markdown-field > .vditor-editor-host, .markdown-editor-field .vditor-editor-host { min-height: 42dvh; }
2859
+ .editor-dialog .dialog-fields { grid-template-columns: minmax(0, 1fr); max-height: 76dvh; }
2860
+ .editor-dialog .markdown-editor-field .vditor-editor-host.vditor { min-height: 52dvh !important; }
2817
2861
  .character-editor-workspace, .character-editor-workspace.history-open { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
2818
2862
  .character-editor-nav { flex-direction: row; overflow-x: auto; padding: 8px 10px; border-right: 0; border-bottom: 1px solid var(--line); }
2819
2863
  .character-editor-nav button { flex: 0 0 130px; }
@@ -2965,7 +3009,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2965
3009
  .app-shell.shelf-mode .main-panel { grid-column: 1; grid-row: 2; min-height: 0; }
2966
3010
  .ai-panel {
2967
3011
  position: fixed;
2968
- z-index: 25;
3012
+ z-index: 35;
2969
3013
  right: 0;
2970
3014
  bottom: 0;
2971
3015
  left: 0;
package/dist/store.js CHANGED
@@ -6,6 +6,7 @@ import { paginated, paginationSql } from "./pagination.js";
6
6
  import { currentRequestActor } from "./request-context.js";
7
7
  import { classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
8
8
  import { countWords, documentShortSearchTerms, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
9
+ import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
9
10
  const defaultPlatformPageSizes = {
10
11
  drafts: 30,
11
12
  settings: 30,
@@ -122,6 +123,10 @@ export const versionedEntityTypes = [
122
123
  "chapter-outline",
123
124
  "foreshadow"
124
125
  ];
126
+ export function defaultAiConversationTitle(prompt) {
127
+ const normalized = prompt.replace(/\s+/gu, " ").trim();
128
+ return Array.from(normalized).slice(0, 15).join("") || "新对话";
129
+ }
125
130
  function isRecord(value) {
126
131
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
127
132
  }
@@ -688,6 +693,9 @@ export class Store {
688
693
  agentTools: json(String(row?.agent_tools_json ?? '["story_index","read_chapters","search_story_entities","grep","read_character_sections","search_drafts"]'), ["story_index", "read_chapters", "search_story_entities", "grep", "read_character_sections", "search_drafts"])
689
694
  .map((tool) => tool === "query_story_knowledge" ? "search_story_entities" : tool)
690
695
  .filter((tool, index, tools) => tools.indexOf(tool) === index),
696
+ titleGenerationModelId: row?.title_generation_model_id === null || row?.title_generation_model_id === undefined
697
+ ? null
698
+ : String(row.title_generation_model_id),
691
699
  updatedAt: String(row?.updated_at ?? "")
692
700
  };
693
701
  }
@@ -704,12 +712,15 @@ export class Store {
704
712
  const nextBookSummaryContextPercent = input.bookSummaryContextPercent ?? Number(current.bookSummaryContextPercent);
705
713
  const nextContextCompactThreshold = input.contextCompactThreshold ?? Number(current.contextCompactThreshold);
706
714
  const nextAgentTools = input.agentTools ?? current.agentTools;
715
+ const nextTitleGenerationModelId = input.titleGenerationModelId === undefined
716
+ ? (current.titleGenerationModelId ? String(current.titleGenerationModelId) : null)
717
+ : input.titleGenerationModelId?.trim() || null;
707
718
  this.db.run(`INSERT INTO work_ai_settings (
708
719
  work_id, system_prompt, auto_run_enabled, auto_run_concurrency, auto_run_batch_limit,
709
720
  auto_run_daily_task_limit, auto_run_failure_threshold, auto_run_paused, auto_run_pause_reason,
710
721
  auto_run_resume_at, auto_run_consecutive_failures, book_summary_context_percent,
711
- context_compact_threshold, agent_tools_json, updated_at
712
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
722
+ context_compact_threshold, agent_tools_json, title_generation_model_id, updated_at
723
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
713
724
  ON CONFLICT(work_id) DO UPDATE SET
714
725
  system_prompt = excluded.system_prompt,
715
726
  auto_run_enabled = excluded.auto_run_enabled,
@@ -724,7 +735,8 @@ export class Store {
724
735
  book_summary_context_percent = excluded.book_summary_context_percent,
725
736
  context_compact_threshold = excluded.context_compact_threshold,
726
737
  agent_tools_json = excluded.agent_tools_json,
727
- updated_at = excluded.updated_at`, workId, nextPrompt, nextEnabled ? 1 : 0, Math.min(8, Math.max(1, nextConcurrency)), Math.min(200, Math.max(1, nextBatchLimit)), Math.min(10_000, Math.max(0, nextDailyTaskLimit)), Math.min(10, Math.max(1, nextFailureThreshold)), current.autoRunPaused ? 1 : 0, String(current.autoRunPauseReason ?? ""), current.autoRunResumeAt === null ? null : String(current.autoRunResumeAt), Math.max(0, Number(current.autoRunConsecutiveFailures) || 0), Math.min(90, Math.max(1, nextBookSummaryContextPercent)), Math.min(90, Math.max(50, nextContextCompactThreshold)), JSON.stringify(nextAgentTools), timestamp);
738
+ title_generation_model_id = excluded.title_generation_model_id,
739
+ updated_at = excluded.updated_at`, workId, nextPrompt, nextEnabled ? 1 : 0, Math.min(8, Math.max(1, nextConcurrency)), Math.min(200, Math.max(1, nextBatchLimit)), Math.min(10_000, Math.max(0, nextDailyTaskLimit)), Math.min(10, Math.max(1, nextFailureThreshold)), current.autoRunPaused ? 1 : 0, String(current.autoRunPauseReason ?? ""), current.autoRunResumeAt === null ? null : String(current.autoRunResumeAt), Math.max(0, Number(current.autoRunConsecutiveFailures) || 0), Math.min(90, Math.max(1, nextBookSummaryContextPercent)), Math.min(90, Math.max(50, nextContextCompactThreshold)), JSON.stringify(nextAgentTools), nextTitleGenerationModelId, timestamp);
728
740
  this.audit(workId, "work.ai-settings.updated", "work-ai-settings", workId, {
729
741
  systemPromptChanged: input.systemPrompt !== undefined,
730
742
  autoRunEnabled: nextEnabled,
@@ -734,7 +746,8 @@ export class Store {
734
746
  autoRunFailureThreshold: Math.min(10, Math.max(1, nextFailureThreshold)),
735
747
  bookSummaryContextPercent: Math.min(90, Math.max(1, nextBookSummaryContextPercent)),
736
748
  contextCompactThreshold: Math.min(90, Math.max(50, nextContextCompactThreshold)),
737
- agentTools: nextAgentTools
749
+ agentTools: nextAgentTools,
750
+ titleGenerationModelId: nextTitleGenerationModelId
738
751
  });
739
752
  return this.getWorkAiSettings(workId);
740
753
  }
@@ -891,6 +904,41 @@ export class Store {
891
904
  }));
892
905
  return { ...work, volumes };
893
906
  }
907
+ getWorkVolumeDirectory(workId) {
908
+ const work = this.getWork(workId);
909
+ const permissions = work.modulePermissions;
910
+ if (permissions.prose === "none")
911
+ return { ...work, volumes: [] };
912
+ const volumeRows = this.db.all(`SELECT volume.*,
913
+ (SELECT COUNT(*) FROM chapters chapter WHERE chapter.volume_id = volume.id AND chapter.deleted_at IS NULL) AS chapter_count
914
+ FROM volumes volume WHERE volume.work_id = ? ORDER BY volume.sort_order, volume.created_at`, workId);
915
+ const volumes = volumeRows.map((row) => ({
916
+ ...this.mapVolume(row),
917
+ chapterCount: numberValue(row, "chapter_count"),
918
+ chapters: []
919
+ }));
920
+ return { ...work, volumes };
921
+ }
922
+ listVolumeChapters(volumeId) {
923
+ const volume = this.getVolume(volumeId);
924
+ const work = this.getWork(String(volume.workId));
925
+ if (work.modulePermissions.prose === "none")
926
+ return [];
927
+ return this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
928
+ analysis_status, excluded_from_analysis, created_at, updated_at
929
+ FROM chapters WHERE volume_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at`, volumeId).map((row) => this.mapChapterDirectoryEntry(row));
930
+ }
931
+ listVolumeChaptersPage(volumeId, pagination) {
932
+ const volume = this.getVolume(volumeId);
933
+ const work = this.getWork(String(volume.workId));
934
+ if (work.modulePermissions.prose === "none")
935
+ return paginated([], pagination);
936
+ const page = paginationSql(pagination);
937
+ const rows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
938
+ analysis_status, excluded_from_analysis, created_at, updated_at
939
+ FROM chapters WHERE volume_id = ? AND deleted_at IS NULL ORDER BY sort_order, created_at${page.sql}`, volumeId, ...page.params);
940
+ return paginated(rows.map((row) => this.mapChapterDirectoryEntry(row)), pagination);
941
+ }
894
942
  getWorkDirectoryPage(workId, pagination) {
895
943
  const work = this.getWork(workId);
896
944
  const permissions = work.modulePermissions;
@@ -4091,7 +4139,7 @@ export class Store {
4091
4139
  throw notFound("AI 对话");
4092
4140
  if (requiredString(conversation, "work_id") !== workId)
4093
4141
  throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
4094
- const rows = this.db.all("SELECT id, role, content FROM ai_conversation_messages WHERE conversation_id = ? ORDER BY created_at, rowid", conversationId);
4142
+ const rows = this.db.all("SELECT id, role, content, metadata_json FROM ai_conversation_messages WHERE conversation_id = ? ORDER BY created_at, rowid", conversationId);
4095
4143
  const compactedMessageCount = Math.min(rows.length, Math.max(0, numberValue(conversation, "compacted_message_count")));
4096
4144
  return {
4097
4145
  workId,
@@ -4104,10 +4152,23 @@ export class Store {
4104
4152
  .map((message) => ({
4105
4153
  id: requiredString(message, "id"),
4106
4154
  role: requiredString(message, "role") === "assistant" ? "assistant" : "user",
4107
- content: requiredString(message, "content")
4155
+ content: requiredString(message, "content"),
4156
+ metadata: json(requiredString(message, "metadata_json"), {})
4108
4157
  }))
4109
4158
  };
4110
4159
  }
4160
+ getAiConversationTitleContext(conversationId, workId) {
4161
+ const conversation = this.db.get("SELECT title, work_id FROM ai_conversations WHERE id = ?", conversationId);
4162
+ if (!conversation)
4163
+ throw notFound("AI 对话");
4164
+ if (requiredString(conversation, "work_id") !== workId)
4165
+ throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
4166
+ const messages = this.db.all("SELECT role, content FROM ai_conversation_messages WHERE conversation_id = ? ORDER BY created_at, rowid", conversationId).map((message) => ({
4167
+ role: requiredString(message, "role") === "assistant" ? "assistant" : "user",
4168
+ content: requiredString(message, "content")
4169
+ }));
4170
+ return { title: requiredString(conversation, "title"), messages };
4171
+ }
4111
4172
  setAiConversationContextWarning(conversationId, pending) {
4112
4173
  const conversation = this.db.get("SELECT id FROM ai_conversations WHERE id = ?", conversationId);
4113
4174
  if (!conversation)
@@ -4121,20 +4182,38 @@ export class Store {
4121
4182
  this.db.run("UPDATE ai_conversations SET compacted_summary = ?, compacted_message_count = ?, context_warning_at = NULL, updated_at = ? WHERE id = ?", summary, Math.max(0, compactedMessageCount), now(), conversationId);
4122
4183
  return this.getAiConversation(conversationId);
4123
4184
  }
4185
+ setAiConversationTitle(conversationId, title) {
4186
+ const conversation = this.db.get("SELECT id FROM ai_conversations WHERE id = ?", conversationId);
4187
+ if (!conversation)
4188
+ throw notFound("AI 对话");
4189
+ const normalizedTitle = title.replace(/\s+/gu, " ").trim().slice(0, 200) || "新对话";
4190
+ this.db.run("UPDATE ai_conversations SET title = ?, updated_at = ? WHERE id = ?", normalizedTitle, now(), conversationId);
4191
+ return this.getAiConversation(conversationId);
4192
+ }
4124
4193
  addAiConversationMessage(conversationId, input) {
4125
4194
  const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
4126
4195
  if (!conversation)
4127
4196
  throw notFound("AI 对话");
4197
+ const requestId = input.requestId?.trim() || null;
4198
+ if (requestId) {
4199
+ const existing = this.db.get("SELECT * FROM ai_conversation_messages WHERE conversation_id = ? AND request_id = ?", conversationId, requestId);
4200
+ if (existing)
4201
+ return this.mapAiConversationMessage(existing);
4202
+ }
4128
4203
  const messageId = id("message");
4129
4204
  const timestamp = now();
4130
4205
  const title = requiredString(conversation, "title") === "新对话" && input.role === "user"
4131
- ? input.content.replace(/\s+/gu, " ").trim().slice(0, 36) || "新对话"
4206
+ ? defaultAiConversationTitle(input.content)
4132
4207
  : requiredString(conversation, "title");
4133
4208
  this.db.transaction(() => {
4134
- this.db.run("INSERT INTO ai_conversation_messages (id, conversation_id, role, content, citations_json, metadata_json, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", messageId, conversationId, input.role, input.content, JSON.stringify(input.citations ?? []), JSON.stringify(input.metadata ?? {}), timestamp, currentRequestActor()?.userId ?? null);
4135
- this.db.run("UPDATE ai_conversations SET title = ?, updated_at = ? WHERE id = ?", title, timestamp, conversationId);
4209
+ this.db.run("INSERT INTO ai_conversation_messages (id, conversation_id, role, content, citations_json, metadata_json, request_id, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(conversation_id, request_id) WHERE request_id IS NOT NULL DO NOTHING", messageId, conversationId, input.role, input.content, JSON.stringify(input.citations ?? []), JSON.stringify(input.metadata ?? {}), requestId, timestamp, currentRequestActor()?.userId ?? null);
4210
+ const inserted = this.db.get("SELECT id FROM ai_conversation_messages WHERE id = ?", messageId);
4211
+ if (inserted)
4212
+ this.db.run("UPDATE ai_conversations SET title = ?, updated_at = ? WHERE id = ?", title, timestamp, conversationId);
4136
4213
  });
4137
- const message = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ?", messageId);
4214
+ const message = requestId
4215
+ ? this.db.get("SELECT * FROM ai_conversation_messages WHERE conversation_id = ? AND request_id = ?", conversationId, requestId)
4216
+ : this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ?", messageId);
4138
4217
  if (!message)
4139
4218
  throw notFound("AI 对话消息");
4140
4219
  return this.mapAiConversationMessage(message);
@@ -4157,7 +4236,7 @@ export class Store {
4157
4236
  this.db.transaction(() => {
4158
4237
  this.db.run("INSERT INTO ai_conversations (id, work_id, title, compacted_summary, compacted_message_count, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", forkId, requiredString(conversation, "work_id"), title.slice(0, 200), forkSummary, forkCompactedCount, timestamp, timestamp, currentRequestActor()?.userId ?? null);
4159
4238
  for (const message of messages.slice(0, targetIndex + 1)) {
4160
- this.db.run("INSERT INTO ai_conversation_messages (id, conversation_id, role, content, citations_json, metadata_json, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", id("message"), forkId, requiredString(message, "role"), requiredString(message, "content"), requiredString(message, "citations_json"), requiredString(message, "metadata_json"), requiredString(message, "created_at"), currentRequestActor()?.userId ?? null);
4239
+ this.db.run("INSERT INTO ai_conversation_messages (id, conversation_id, role, content, citations_json, metadata_json, request_id, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", id("message"), forkId, requiredString(message, "role"), requiredString(message, "content"), requiredString(message, "citations_json"), requiredString(message, "metadata_json"), optionalString(message, "request_id"), requiredString(message, "created_at"), currentRequestActor()?.userId ?? null);
4161
4240
  }
4162
4241
  });
4163
4242
  return this.getAiConversation(forkId);
@@ -4184,6 +4263,7 @@ export class Store {
4184
4263
  content: requiredString(row, "content"),
4185
4264
  citations: json(requiredString(row, "citations_json"), []),
4186
4265
  metadata: json(requiredString(row, "metadata_json"), {}),
4266
+ requestId: optionalString(row, "request_id"),
4187
4267
  createdAt: requiredString(row, "created_at")
4188
4268
  };
4189
4269
  }
@@ -5615,18 +5695,14 @@ export class Store {
5615
5695
  const goal = this.db.get("SELECT * FROM writing_goals WHERE work_id = ?", workId);
5616
5696
  const dailyGoal = goal ? numberValue(goal, "daily_goal") : 1000;
5617
5697
  const targetTotal = goal ? numberValue(goal, "target_total") : 100000;
5618
- const today = new Date();
5619
- today.setUTCHours(0, 0, 0, 0);
5620
- const start = new Date(today);
5621
- start.setUTCDate(start.getUTCDate() - days + 1);
5622
- const startKey = start.toISOString().slice(0, 10);
5698
+ const calendar = buildWritingCalendar(new Date(), days);
5623
5699
  const versions = this.db.all(`SELECT chapter_id, content, source, created_at FROM chapter_versions
5624
- WHERE work_id = ? AND created_at <= ? ORDER BY created_at, version_no, id`, workId, `${today.toISOString().slice(0, 10)}T23:59:59.999Z`);
5700
+ WHERE work_id = ? AND created_at < ? ORDER BY created_at, version_no, id`, workId, calendar.endExclusive);
5625
5701
  const chapterWords = new Map();
5626
5702
  const events = new Map();
5627
5703
  for (const version of versions) {
5628
- const day = requiredString(version, "created_at").slice(0, 10);
5629
- if (day < startKey) {
5704
+ const day = writingDateKey(new Date(requiredString(version, "created_at")), calendar.timeZone);
5705
+ if (day < calendar.startKey) {
5630
5706
  chapterWords.set(requiredString(version, "chapter_id"), requiredString(version, "source") === "delete" ? 0 : countWords(requiredString(version, "content")));
5631
5707
  }
5632
5708
  else {
@@ -5637,10 +5713,7 @@ export class Store {
5637
5713
  }
5638
5714
  let previousTotal = [...chapterWords.values()].reduce((sum, value) => sum + value, 0);
5639
5715
  const trend = [];
5640
- for (let index = 0; index < days; index += 1) {
5641
- const date = new Date(start);
5642
- date.setUTCDate(start.getUTCDate() + index);
5643
- const day = date.toISOString().slice(0, 10);
5716
+ for (const day of calendar.dateKeys) {
5644
5717
  for (const version of events.get(day) ?? []) {
5645
5718
  chapterWords.set(requiredString(version, "chapter_id"), requiredString(version, "source") === "delete" ? 0 : countWords(requiredString(version, "content")));
5646
5719
  }