dsh-novel-write-engine 0.2.1 → 0.2.2
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.
- package/CHANGELOG.md +36 -1
- package/README.md +101 -49
- package/lib/client.js +276 -27
- package/lib/index.d.ts +291 -20
- package/lib/index.js +1534 -136
- package/lib/types/client/dto.d.ts +29 -3
- package/lib/types/client/panel.d.ts +1 -1
- package/lib/types/client/viewmodel.d.ts +31 -2
- package/lib/types/constants.d.ts +4 -1
- package/lib/types/types.d.ts +18 -1
- package/package.json +1 -1
- package/skills/entry.md +7 -0
package/lib/client.js
CHANGED
|
@@ -42,7 +42,7 @@ window.__ModuleLoader__.load({
|
|
|
42
42
|
novel_word_count: "按引擎字数口径核对正文长度。"
|
|
43
43
|
};
|
|
44
44
|
// src/client/viewmodel.ts
|
|
45
|
-
const PANEL_SCHEMA_VERSION =
|
|
45
|
+
const PANEL_SCHEMA_VERSION = 4;
|
|
46
46
|
const PHASE_LABELS = {
|
|
47
47
|
P1: "世界观",
|
|
48
48
|
P2: "章节计划",
|
|
@@ -104,6 +104,15 @@ window.__ModuleLoader__.load({
|
|
|
104
104
|
state: "loading",
|
|
105
105
|
title: "网文工坊",
|
|
106
106
|
subtitle: "正在整理书稿…",
|
|
107
|
+
root: "",
|
|
108
|
+
projects: [],
|
|
109
|
+
selectedRoot: null,
|
|
110
|
+
selection: {
|
|
111
|
+
root: null,
|
|
112
|
+
source: "none"
|
|
113
|
+
},
|
|
114
|
+
resumeText: "",
|
|
115
|
+
damaged: false,
|
|
107
116
|
stageLabel: "读取中",
|
|
108
117
|
finished: false,
|
|
109
118
|
revisionLabel: "",
|
|
@@ -165,6 +174,34 @@ window.__ModuleLoader__.load({
|
|
|
165
174
|
subtitle: message,
|
|
166
175
|
stageLabel: "连接异常"
|
|
167
176
|
});
|
|
177
|
+
const pathTail = (root) => {
|
|
178
|
+
const parts = root.split(/[\\/]/).filter((part) => part !== "");
|
|
179
|
+
return parts[parts.length - 1] ?? root;
|
|
180
|
+
};
|
|
181
|
+
const projectViews = (payload) => {
|
|
182
|
+
const selected = payload.selection.root;
|
|
183
|
+
const titleCounts = /* @__PURE__ */ new Map();
|
|
184
|
+
for (const project of payload.projects ?? []) titleCounts.set(project.title, (titleCounts.get(project.title) ?? 0) + 1);
|
|
185
|
+
return (payload.projects ?? []).map((project) => {
|
|
186
|
+
const duplicate = (titleCounts.get(project.title) ?? 0) > 1;
|
|
187
|
+
const state = project.state === "ok" ? "" : project.state === "damaged" ? "(待修复)" : project.state === "missing" ? "(目录不存在)" : "(不可读)";
|
|
188
|
+
return {
|
|
189
|
+
root: project.root,
|
|
190
|
+
title: project.title,
|
|
191
|
+
label: `《${project.title}》${duplicate ? ` — ${pathTail(project.root)}` : ""}${state}`,
|
|
192
|
+
active: project.active,
|
|
193
|
+
registered: project.registered,
|
|
194
|
+
state: project.state,
|
|
195
|
+
selected: selected === project.root
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
const baseProjection = (payload) => ({
|
|
200
|
+
projects: projectViews(payload),
|
|
201
|
+
selectedRoot: payload.selection.root,
|
|
202
|
+
selection: payload.selection,
|
|
203
|
+
root: payload.selection.root ?? ""
|
|
204
|
+
});
|
|
168
205
|
const phaseLabel = (id) => PHASE_LABELS[id] ?? id;
|
|
169
206
|
const stageLabel = (stage) => STAGE_LABELS[stage] ?? stage;
|
|
170
207
|
const platformLabel = (platform) => PLATFORM_LABELS[platform] ?? platform;
|
|
@@ -187,20 +224,55 @@ window.__ModuleLoader__.load({
|
|
|
187
224
|
subtitle: "面板与引擎版本不一致,请刷新页面。",
|
|
188
225
|
stageLabel: "需要刷新"
|
|
189
226
|
};
|
|
227
|
+
const base = baseProjection(payload);
|
|
228
|
+
if (payload.missing !== void 0) return {
|
|
229
|
+
...loading(),
|
|
230
|
+
...base,
|
|
231
|
+
state: "missing",
|
|
232
|
+
title: payload.missing.reason === "unreadable" ? "项目不可读" : "项目不在了",
|
|
233
|
+
subtitle: `${payload.missing.title} · ${payload.missing.root}`,
|
|
234
|
+
stageLabel: "不可用",
|
|
235
|
+
missing: {
|
|
236
|
+
root: payload.missing.root,
|
|
237
|
+
title: payload.missing.title,
|
|
238
|
+
code: payload.missing.code,
|
|
239
|
+
reason: payload.missing.reason,
|
|
240
|
+
hint: payload.missing.hint
|
|
241
|
+
}
|
|
242
|
+
};
|
|
190
243
|
if (payload.empty === true) return {
|
|
191
244
|
...loading(),
|
|
245
|
+
...base,
|
|
192
246
|
state: "empty",
|
|
193
247
|
subtitle: payload.error ?? "还没有项目。调用 novel_init 开始第一本书。",
|
|
194
248
|
stageLabel: "等待创建"
|
|
195
249
|
};
|
|
250
|
+
if (payload.selection.root === null || payload.summary === void 0) {
|
|
251
|
+
if (base.projects.length > 0) return {
|
|
252
|
+
...loading(),
|
|
253
|
+
...base,
|
|
254
|
+
state: "choose",
|
|
255
|
+
title: "选择一本项目",
|
|
256
|
+
subtitle: "发现多本书,先选一本;面板只改变查看对象,不改变任何会话的写作绑定。",
|
|
257
|
+
stageLabel: "待选择"
|
|
258
|
+
};
|
|
259
|
+
return {
|
|
260
|
+
...loading(),
|
|
261
|
+
...base,
|
|
262
|
+
state: "empty",
|
|
263
|
+
subtitle: payload.error ?? "还没有项目。调用 novel_init 开始第一本书。",
|
|
264
|
+
stageLabel: "等待创建"
|
|
265
|
+
};
|
|
266
|
+
}
|
|
196
267
|
const summary = payload.summary;
|
|
268
|
+
const chapterRows = payload.chapters?.rows ?? [];
|
|
197
269
|
const blocking = summary.blocking.map((item) => ({
|
|
198
270
|
code: item.code,
|
|
199
271
|
scope: item.scope ?? "",
|
|
200
272
|
detail: item.detail,
|
|
201
273
|
hint: item.hint ?? ""
|
|
202
274
|
}));
|
|
203
|
-
const rows =
|
|
275
|
+
const rows = chapterRows.map((row) => {
|
|
204
276
|
const scoreValue = Number.isFinite(row.score) ? Math.max(0, Math.min(150, row.score)) : 0;
|
|
205
277
|
return {
|
|
206
278
|
num: String(row.num),
|
|
@@ -222,9 +294,15 @@ window.__ModuleLoader__.load({
|
|
|
222
294
|
const next = summary.next;
|
|
223
295
|
const finished = summary.finished === true;
|
|
224
296
|
const doneLabel = summary.stage === "done" ? finished ? "全书完成" : "本章已归档" : stageLabel(summary.stage);
|
|
297
|
+
const selected = base.projects.find((project) => project.selected);
|
|
225
298
|
return {
|
|
299
|
+
...base,
|
|
226
300
|
state: "ready",
|
|
227
301
|
title: `《${summary.title}》`,
|
|
302
|
+
root: summary.root === "" ? base.root : summary.root,
|
|
303
|
+
damaged: selected?.state === "damaged",
|
|
304
|
+
resumeText: payload.resume?.text ?? "",
|
|
305
|
+
volumeNumber: summary.volume,
|
|
228
306
|
subtitle: summary.stage === "done" ? `${summary.genre} · ${platformLabel(summary.platform)} · ${finished ? `共 ${summary.totalChapters} 章` : `下一章 第 ${summary.chapter} 章`}${volumeLabel(summary)}` : `${summary.genre} · ${platformLabel(summary.platform)} · 第 ${summary.chapter} 章${volumeLabel(summary)}`,
|
|
229
307
|
stageLabel: doneLabel,
|
|
230
308
|
finished,
|
|
@@ -425,7 +503,22 @@ window.__ModuleLoader__.load({
|
|
|
425
503
|
}
|
|
426
504
|
.nwe-refresh:hover:not(:disabled) { border-color: color-mix(in srgb, var(--nwe-gold) 65%, var(--nwe-border)); transform: translateY(-1px); }
|
|
427
505
|
.nwe-refresh:disabled { cursor: wait; opacity: .55; }
|
|
428
|
-
.nwe-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
|
|
506
|
+
.nwe-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; flex-wrap: wrap; }
|
|
507
|
+
.nwe-project { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
508
|
+
.nwe-project-label { color: var(--nwe-muted); font-size: 11px; white-space: nowrap; }
|
|
509
|
+
.nwe-project-select {
|
|
510
|
+
max-width: 260px;
|
|
511
|
+
min-height: 36px;
|
|
512
|
+
padding: 6px 28px 6px 10px;
|
|
513
|
+
border: 1px solid var(--nwe-border);
|
|
514
|
+
border-radius: 10px;
|
|
515
|
+
background: var(--nwe-raised);
|
|
516
|
+
color: var(--nwe-ink);
|
|
517
|
+
font: inherit;
|
|
518
|
+
font-size: 12px;
|
|
519
|
+
}
|
|
520
|
+
.nwe-rootline { margin-top: 5px; color: var(--nwe-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; overflow-wrap: anywhere; }
|
|
521
|
+
.nwe-damaged { margin-top: 10px; padding: 9px 12px; border: 1px solid color-mix(in srgb, var(--nwe-rose) 36%, var(--nwe-border)); border-radius: 10px; color: var(--nwe-rose); background: color-mix(in srgb, var(--nwe-rose) 7%, transparent); font-size: 12px; }
|
|
429
522
|
.nwe-help {
|
|
430
523
|
display: inline-flex;
|
|
431
524
|
align-items: center;
|
|
@@ -616,8 +709,13 @@ window.__ModuleLoader__.load({
|
|
|
616
709
|
.nwe-progress { overflow: hidden; height: 7px; margin-top: 10px; border-radius: 99px; background: var(--nwe-border); }
|
|
617
710
|
.nwe-progress > span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--nwe-jade), var(--nwe-gold)); }
|
|
618
711
|
.nwe-progress-legend { display: flex; justify-content: space-between; margin-top: 5px; color: var(--nwe-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
|
|
712
|
+
.nwe-volumes { max-height: 190px; overflow-y: auto; overscroll-behavior: contain; padding-right: 3px; }
|
|
619
713
|
.nwe-volume { display: flex; justify-content: space-between; gap: 8px; margin-top: 7px; color: var(--nwe-muted); font-size: 11px; }
|
|
620
714
|
.nwe-volume strong { overflow: hidden; color: var(--nwe-ink); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
|
715
|
+
.nwe-volume.is-current strong { color: var(--nwe-gold); }
|
|
716
|
+
.nwe-copy { margin-top: 8px; padding: 6px 10px; border: 1px solid var(--nwe-border); border-radius: 8px; background: var(--nwe-raised); color: var(--nwe-muted); font: inherit; font-size: 11px; cursor: pointer; }
|
|
717
|
+
.nwe-copy:hover { border-color: color-mix(in srgb, var(--nwe-gold) 65%, var(--nwe-border)); color: var(--nwe-ink); }
|
|
718
|
+
.nwe-resume-fallback { margin-top: 6px; padding: 8px 10px; border: 1px solid var(--nwe-border); border-radius: 8px; background: color-mix(in srgb, #080a0d 24%, var(--nwe-surface)); color: var(--nwe-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; line-height: 1.6; white-space: pre-wrap; }
|
|
621
719
|
.nwe-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
|
622
720
|
.nwe-chip { padding: 3px 8px; border: 1px solid var(--nwe-border); border-radius: 999px; color: var(--nwe-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
|
|
623
721
|
.nwe-bars { display: flex; align-items: flex-end; gap: 5px; height: 54px; margin-top: 10px; }
|
|
@@ -696,6 +794,10 @@ window.__ModuleLoader__.load({
|
|
|
696
794
|
.nwe-state-title { font-family: "Noto Serif SC", "Songti SC", Georgia, serif; font-size: 19px; }
|
|
697
795
|
.nwe-state-text { max-width: 460px; margin-top: 6px; color: var(--nwe-muted); font-size: 13px; }
|
|
698
796
|
.nwe-command { display: inline-block; margin-top: 14px; padding: 7px 12px; border: 1px solid var(--nwe-border); border-radius: 8px; background: var(--nwe-raised); color: var(--nwe-gold); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
|
797
|
+
.nwe-project-choices { display: flex; flex-direction: column; gap: 8px; width: min(560px, 100%); margin-top: 16px; }
|
|
798
|
+
.nwe-project-choice { display: flex; flex-direction: column; gap: 3px; padding: 10px 13px; border: 1px solid var(--nwe-border); border-radius: 10px; background: var(--nwe-raised); color: var(--nwe-ink); font: inherit; font-size: 13px; text-align: left; cursor: pointer; }
|
|
799
|
+
.nwe-project-choice:hover { border-color: color-mix(in srgb, var(--nwe-gold) 65%, var(--nwe-border)); }
|
|
800
|
+
.nwe-project-choice span { overflow-wrap: anywhere; color: var(--nwe-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
|
|
699
801
|
.nwe-skeleton { display: grid; gap: 10px; margin-top: 12px; }
|
|
700
802
|
.nwe-skeleton-line { height: 16px; border-radius: 8px; background: linear-gradient(90deg, var(--nwe-border), color-mix(in srgb, var(--nwe-border) 40%, transparent), var(--nwe-border)); background-size: 200% 100%; animation: nwe-shimmer 1.6s linear infinite; }
|
|
701
803
|
.nwe-skeleton-line.is-wide { height: 72px; }
|
|
@@ -714,6 +816,8 @@ window.__ModuleLoader__.load({
|
|
|
714
816
|
.nwe-root { padding: 14px; }
|
|
715
817
|
.nwe-hero { flex-direction: column; padding: 18px; }
|
|
716
818
|
.nwe-actions { width: 100%; }
|
|
819
|
+
.nwe-project { width: 100%; order: 3; }
|
|
820
|
+
.nwe-project-select { flex: 1; max-width: none; }
|
|
717
821
|
.nwe-refresh { flex: 1; justify-content: center; }
|
|
718
822
|
.nwe-help { flex: 0 0 auto; }
|
|
719
823
|
.nwe-modal-backdrop { align-items: flex-end; padding: 8px; }
|
|
@@ -782,7 +886,7 @@ window.__ModuleLoader__.load({
|
|
|
782
886
|
title: "建一本书的项目",
|
|
783
887
|
tool: "novel_init",
|
|
784
888
|
body: "在对话里说清书名、类型、平台、作者、每章字数区间、总章数和项目根目录;模型会调用 novel_init。项目根目录建议用绝对路径,例如 /home/me/novels/星海。",
|
|
785
|
-
note: "类型是固定的 12 类,平台只能是 fanqie / qidian / qimao / jinjiang
|
|
889
|
+
note: "类型是固定的 12 类,平台只能是 fanqie / qidian / qimao / jinjiang。0.2.2 起显式 project_root 会绑定当前会话;多项目先用 /novel scan 发现,再用 /novel use 切换。"
|
|
786
890
|
},
|
|
787
891
|
{
|
|
788
892
|
title: "写书级蓝图与卷结构",
|
|
@@ -847,8 +951,8 @@ window.__ModuleLoader__.load({
|
|
|
847
951
|
{
|
|
848
952
|
title: "循环下一章,最后导出全书",
|
|
849
953
|
tool: "novel_report / novel_doctor / novel_export",
|
|
850
|
-
body: "每章重复 P1→P8。随时用 novel_report
|
|
851
|
-
note: "改已归档章要先 novel_reopen;项目坏了先 novel_state 看 raw,再按 next 用 novel_amend 修,不要手改 chapters/ 或 state
|
|
954
|
+
body: "每章重复 P1→P8。随时用 /novel scan 发现项目(加 --register 只登记、不切换),/novel use 切换当前会话、/novel resume 或面板“复制续写指令”接续下一章;novel_report 看进度,novel_doctor 体检,novel_search 查正文/大纲/台账,最后导出 md、txt、epub、outline、worldbook 或 proposal。",
|
|
955
|
+
note: "改已归档章要先 novel_reopen;项目坏了先 novel_state 看 raw,再按 next 用 novel_amend 修,不要手改 chapters/ 或 state/。项目目录被删时绑定会保留并报 missing,不会自动换书;选错书先用 /novel current 确认,再用 /novel use 切回,或 /novel unbind 清掉当前会话绑定。"
|
|
852
956
|
}
|
|
853
957
|
];
|
|
854
958
|
const TUTORIAL_PROMPT = `请用「网文工坊」引擎写一本小说:
|
|
@@ -860,6 +964,8 @@ brief P6 → check → brief P8 → archive 的顺序推进。
|
|
|
860
964
|
正文只通过 write_draft/revise 写;不要直接改 state/、chapters/ 或 outline/。
|
|
861
965
|
每次提交前先领取当前阶段任务书,失败时按返回的 code 和 blocking 一次改完。`;
|
|
862
966
|
const TUTORIAL_NEXT_PROMPT = `继续写当前项目的下一章。先调用 novel_pipeline(或 novel_status)确认当前阶段、项目根目录和下一步工具,不要猜阶段。
|
|
967
|
+
如果当前会话还没绑定或不确定是哪本书:先用 /novel current 看会话绑定,再调用 novel_status 看 selection;需要切换时用 /novel use <路径|书名|.>,不要靠 active 猜。
|
|
968
|
+
第一次调用带 project_root 时,它会成为当前会话的持久绑定;后续无 root 调用会继续这本书。
|
|
863
969
|
|
|
864
970
|
如果下一章还没有章卡:先用 novel_state {kind:"outline"} 取出全部章卡,再在末尾加上下一章的卡,用 novel_outline 整体提交;不要只提交新卡,也不要把已有 planned 卡丢掉。
|
|
865
971
|
如果 targets.total_chapters 已经写满:先用 novel_amend 把 total_chapters 调到至少包含下一章,不能低于已归档章数。
|
|
@@ -918,10 +1024,13 @@ brief P6 → check → brief P8 → archive 的顺序推进。
|
|
|
918
1024
|
"aria-valuemax": 100,
|
|
919
1025
|
"aria-valuenow": percent,
|
|
920
1026
|
"aria-label": "全书归档进度"
|
|
921
|
-
}, (0, react.createElement)("span", { style: { width: `${percent}%` } })), (0, react.createElement)("div", { className: "nwe-progress-legend" }, (0, react.createElement)("span", null, target === null ? `目标未设置 · 共 ${total} 张章卡` : `目标 ${target} 章 · 共 ${total} 张章卡`), (0, react.createElement)("span", null, `${percent}% 已归档`)),
|
|
922
|
-
className: "nwe-
|
|
1027
|
+
}, (0, react.createElement)("span", { style: { width: `${percent}%` } })), (0, react.createElement)("div", { className: "nwe-progress-legend" }, (0, react.createElement)("span", null, target === null ? `目标未设置 · 共 ${total} 张章卡` : `目标 ${target} 章 · 共 ${total} 张章卡`), (0, react.createElement)("span", null, `${percent}% 已归档`)), view.book.volumes.length === 0 ? (0, react.createElement)("div", { className: "nwe-volume" }, (0, react.createElement)("span", null, "还没有卷")) : (0, react.createElement)("div", {
|
|
1028
|
+
className: "nwe-volumes",
|
|
1029
|
+
"aria-label": "卷列表"
|
|
1030
|
+
}, ...view.book.volumes.map((volume, index) => (0, react.createElement)("div", {
|
|
1031
|
+
className: `nwe-volume${view.volumeNumber === index + 1 ? " is-current" : ""}`,
|
|
923
1032
|
key: `${volume.title}-${volume.range}`
|
|
924
|
-
}, (0, react.createElement)("strong", null, volume.title), (0, react.createElement)("span", null, `${volume.range} · ${volume.planned} 计划 / ${volume.archived} 归档`)))
|
|
1033
|
+
}, (0, react.createElement)("strong", null, volume.title), (0, react.createElement)("span", null, `${volume.range} · ${volume.planned} 计划 / ${volume.archived} 归档`))))), (0, react.createElement)("div", { className: "nwe-book-card" }, (0, react.createElement)("div", { className: "nwe-book-head" }, (0, react.createElement)("span", { className: "nwe-book-title" }, "世界观"), (0, react.createElement)("span", { className: "nwe-book-note" }, `${chips.reduce((sum, [, value]) => sum + value, 0)} 条`)), (0, react.createElement)("div", { className: "nwe-chips" }, ...chips.map(([label, value]) => (0, react.createElement)("span", {
|
|
925
1034
|
className: "nwe-chip",
|
|
926
1035
|
key: label
|
|
927
1036
|
}, `${label} ${value}`))), (0, react.createElement)("div", {
|
|
@@ -954,6 +1063,65 @@ brief P6 → check → brief P8 → archive 的顺序推进。
|
|
|
954
1063
|
className: "nwe-section-note",
|
|
955
1064
|
style: { marginTop: 12 }
|
|
956
1065
|
}, "正在读取项目状态…"));
|
|
1066
|
+
const projectSelector = (view, onSelect) => {
|
|
1067
|
+
if (view.projects.length === 0) return null;
|
|
1068
|
+
return (0, react.createElement)("div", { className: "nwe-project" }, (0, react.createElement)("label", {
|
|
1069
|
+
className: "nwe-project-label",
|
|
1070
|
+
htmlFor: "nwe-project-select"
|
|
1071
|
+
}, "项目"), (0, react.createElement)("select", {
|
|
1072
|
+
id: "nwe-project-select",
|
|
1073
|
+
className: "nwe-project-select",
|
|
1074
|
+
"aria-label": "切换项目",
|
|
1075
|
+
value: view.selectedRoot ?? "",
|
|
1076
|
+
onChange: (event) => {
|
|
1077
|
+
onSelect(event.target.value === "" ? null : event.target.value);
|
|
1078
|
+
}
|
|
1079
|
+
}, view.selectedRoot === null ? (0, react.createElement)("option", { value: "" }, "选择项目…") : null, ...view.projects.map((project) => (0, react.createElement)("option", {
|
|
1080
|
+
key: project.root,
|
|
1081
|
+
value: project.root
|
|
1082
|
+
}, project.label))));
|
|
1083
|
+
};
|
|
1084
|
+
const choosePanel = (view, onSelect) => (0, react.createElement)("div", {
|
|
1085
|
+
className: "nwe-state",
|
|
1086
|
+
role: "status"
|
|
1087
|
+
}, (0, react.createElement)("div", { className: "nwe-state-icon" }, stateIcon()), (0, react.createElement)("div", { className: "nwe-state-title" }, "发现多本书,先选一本"), (0, react.createElement)("div", { className: "nwe-state-text" }, view.subtitle), (0, react.createElement)("div", { className: "nwe-project-choices" }, ...view.projects.map((project) => (0, react.createElement)("button", {
|
|
1088
|
+
key: project.root,
|
|
1089
|
+
type: "button",
|
|
1090
|
+
className: "nwe-project-choice",
|
|
1091
|
+
onClick: () => onSelect(project.root)
|
|
1092
|
+
}, project.label, (0, react.createElement)("span", null, project.root)))));
|
|
1093
|
+
const missingPanel = (view) => (0, react.createElement)("div", {
|
|
1094
|
+
className: "nwe-state",
|
|
1095
|
+
role: "alert"
|
|
1096
|
+
}, (0, react.createElement)("div", { className: "nwe-state-icon" }, stateIcon()), (0, react.createElement)("div", { className: "nwe-state-title" }, view.missing?.reason === "unreadable" ? "项目不可读" : "项目不在了"), (0, react.createElement)("div", { className: "nwe-state-text" }, view.missing?.hint ?? view.subtitle), view.missing === void 0 ? null : (0, react.createElement)("div", { className: "nwe-state-text" }, (0, react.createElement)("code", { className: "nwe-command" }, `${view.missing.code} · ${view.missing.reason}`), (0, react.createElement)("div", { className: "nwe-rootline" }, view.missing.root)));
|
|
1097
|
+
const CopyResumeButton = ({ text }) => {
|
|
1098
|
+
const [status, setStatus] = (0, react.useState)("idle");
|
|
1099
|
+
(0, react.useEffect)(() => {
|
|
1100
|
+
setStatus("idle");
|
|
1101
|
+
}, [text]);
|
|
1102
|
+
(0, react.useEffect)(() => {
|
|
1103
|
+
if (status !== "copied") return;
|
|
1104
|
+
const timer = setTimeout(() => setStatus("idle"), 2e3);
|
|
1105
|
+
return () => clearTimeout(timer);
|
|
1106
|
+
}, [status]);
|
|
1107
|
+
if (text === "") return null;
|
|
1108
|
+
const copy = async () => {
|
|
1109
|
+
try {
|
|
1110
|
+
const clipboard = globalThis.navigator?.clipboard;
|
|
1111
|
+
if (typeof clipboard?.writeText !== "function") throw new Error("clipboard unavailable");
|
|
1112
|
+
await clipboard.writeText(text);
|
|
1113
|
+
setStatus("copied");
|
|
1114
|
+
} catch {
|
|
1115
|
+
setStatus("failed");
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
return (0, react.createElement)(react.Fragment, null, (0, react.createElement)("button", {
|
|
1119
|
+
type: "button",
|
|
1120
|
+
className: "nwe-copy",
|
|
1121
|
+
"aria-label": "复制当前项目的续写指令",
|
|
1122
|
+
onClick: () => void copy()
|
|
1123
|
+
}, status === "copied" ? "已复制" : "复制续写指令"), status === "failed" ? (0, react.createElement)("pre", { className: "nwe-resume-fallback" }, text) : null);
|
|
1124
|
+
};
|
|
957
1125
|
const statePanel = (state, view, loading, onRefresh) => {
|
|
958
1126
|
const title = state === "empty" ? "还没有开局" : state === "outdated" ? "面板需要刷新" : "书稿读取失败";
|
|
959
1127
|
return (0, react.createElement)("div", {
|
|
@@ -1011,7 +1179,7 @@ brief P6 → check → brief P8 → archive 的顺序推进。
|
|
|
1011
1179
|
ref: closeRef,
|
|
1012
1180
|
onClick: onClose,
|
|
1013
1181
|
"aria-label": "关闭使用教程"
|
|
1014
|
-
}, (0, react.createElement)("span", { "aria-hidden": "true" }, "×"))), (0, react.createElement)("div", { className: "nwe-modal-body" }, (0, react.createElement)("div", { className: "nwe-tutorial-intro" }, (0, react.createElement)("p", null, "网文工坊不是一个“帮你想剧情”的按钮,而是一台有状态的写作引擎:你提供内容,引擎负责阶段、字数、门控、评审、归档和文件。"), (0, react.createElement)("p", null, "每一步都先领取当前阶段的任务书,再提交;提交后看引擎返回的 blocking 与下一步。面板只读,真正的写操作都在对话里的工具调用中。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "开始前:记住三件事"), (0, react.createElement)("div", { className: "nwe-tutorial-cards" }, (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "只用引擎入口写"), (0, react.createElement)("span", null, "正文只走 novel_write_draft / novel_revise;状态只走工具。手改 chapters/、state/、outline/ 会破坏事务与校验。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "每一步先领 brief"), (0, react.createElement)("span", null, "任务书携带当前上下文和 digest;跳过它会撞 PG-01。任务书过期就重新领取,不要复用旧上下文。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "失败先读 code"), (0, react.createElement)("span", null, "参数错改参数,门控没过改正文,系统故障查目录/稍后重试。blocking 会一次列全,一次改完再交。"))), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "用哪个会话:"), " 想获得完整的引擎保证,请新开一个「网文工坊」预设会话;普通会话里模型可能还挂着文件工具,能绕过引擎直接改稿。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "一本书的完整流程"), (0, react.createElement)("p", null, "前四步是开书准备,后面是每章循环。第一次先按顺序走完一章,熟悉后再批量推进整本。"), (0, react.createElement)("ol", { className: "nwe-steps" }, ...TUTORIAL_STEPS.map((step, index) => tutorialStep(step, index)))), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "面板怎么看"), (0, react.createElement)("div", { className: "nwe-tutorial-cards" }, (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "顶部统计"), (0, react.createElement)("span", null, "已归档章、总字数、平均分、待处理门控。数字来自引擎,不是正文估算。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "九阶段轨与下一步"), (0, react.createElement)("span", null, "亮起的阶段是当前进度,“下一步”卡会直接给出该调用的工具名。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "章节与门控"), (0, react.createElement)("span", null, "章节卡展开可看字数、评分与状态;门控区按 code/detail/hint 列出所有阻塞。"))), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "刷新:"), " 面板每 15 秒静默同步一次,也可以点右上角刷新。它是只读投影;不会替你改稿、归档或修状态。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "卡住时先看这里"), (0, react.createElement)("ul", { className: "nwe-faq" }, (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "为什么一直让我先领 brief?"), (0, react.createElement)("p", null, "这是正常门控。任务书决定当前阶段可用的字段与上下文 digest;先按返回值调用 novel_brief,再继续。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "门控失败,改一条还是全改?"), (0, react.createElement)("p", null, "一次看完所有 blocking,再一起改。逐条试会反复烧掉阶段轮次;错误里的 code 会告诉你属于参数、门控还是系统故障。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "字数和评分为什么对不上?"), (0, react.createElement)("p", null, "字数以 engine 返回为准,可用 novel_word_count 核对;review 的 total 必须等于 150 减全部扣分,不能另算一套。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "P4/P6 过不去怎么办?"), (0, react.createElement)("p", null, "P4 有 blocker 就领 P5 任务书修订,再回 P4 重评;P6 失败同样回修订。不要 waive blocker,也不要只跑半个 scope 就归档。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "想改已经归档的章节?"), (0, react.createElement)("p", null, "先 novel_reopen,带非空 reason。它会从归档备份恢复,重开后重新走 P3→P8;不要直接编辑归档卡或正文文件。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "状态文档坏了能手工修吗?"), (0, react.createElement)("p", null, "先用 novel_state 看 raw 和 next;需要修复时用 novel_amend,并在 reason 写清原因。手改 YAML 会丢失事务保护。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "有多个项目,面板看的是哪本?"), (0, react.createElement)("p", null, "
|
|
1182
|
+
}, (0, react.createElement)("span", { "aria-hidden": "true" }, "×"))), (0, react.createElement)("div", { className: "nwe-modal-body" }, (0, react.createElement)("div", { className: "nwe-tutorial-intro" }, (0, react.createElement)("p", null, "网文工坊不是一个“帮你想剧情”的按钮,而是一台有状态的写作引擎:你提供内容,引擎负责阶段、字数、门控、评审、归档和文件。"), (0, react.createElement)("p", null, "每一步都先领取当前阶段的任务书,再提交;提交后看引擎返回的 blocking 与下一步。面板只读,真正的写操作都在对话里的工具调用中。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "开始前:记住三件事"), (0, react.createElement)("div", { className: "nwe-tutorial-cards" }, (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "只用引擎入口写"), (0, react.createElement)("span", null, "正文只走 novel_write_draft / novel_revise;状态只走工具。手改 chapters/、state/、outline/ 会破坏事务与校验。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "每一步先领 brief"), (0, react.createElement)("span", null, "任务书携带当前上下文和 digest;跳过它会撞 PG-01。任务书过期就重新领取,不要复用旧上下文。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "失败先读 code"), (0, react.createElement)("span", null, "参数错改参数,门控没过改正文,系统故障查目录/稍后重试。blocking 会一次列全,一次改完再交。"))), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "用哪个会话:"), " 想获得完整的引擎保证,请新开一个「网文工坊」预设会话;普通会话里模型可能还挂着文件工具,能绕过引擎直接改稿。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "一本书的完整流程"), (0, react.createElement)("p", null, "前四步是开书准备,后面是每章循环。第一次先按顺序走完一章,熟悉后再批量推进整本。"), (0, react.createElement)("ol", { className: "nwe-steps" }, ...TUTORIAL_STEPS.map((step, index) => tutorialStep(step, index)))), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "面板怎么看"), (0, react.createElement)("div", { className: "nwe-tutorial-cards" }, (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "顶部统计"), (0, react.createElement)("span", null, "已归档章、总字数、平均分、待处理门控。数字来自引擎,不是正文估算。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "九阶段轨与下一步"), (0, react.createElement)("span", null, "亮起的阶段是当前进度,“下一步”卡会直接给出该调用的工具名。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "章节与门控"), (0, react.createElement)("span", null, "章节卡展开可看字数、评分与状态;门控区按 code/detail/hint 列出所有阻塞。"))), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "刷新:"), " 面板每 15 秒静默同步一次,也可以点右上角刷新。它是只读投影;不会替你改稿、归档或修状态。"), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "项目状态:"), "选择器里 choose 表示发现多本书、还没选;项目不在了表示目录缺失,绑定仍会保留;项目不可读表示权限或 IO 错误,先修权限或换一本。面板选择只影响查看,不改变任何会话的写作绑定。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "卡住时先看这里"), (0, react.createElement)("ul", { className: "nwe-faq" }, (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "为什么一直让我先领 brief?"), (0, react.createElement)("p", null, "这是正常门控。任务书决定当前阶段可用的字段与上下文 digest;先按返回值调用 novel_brief,再继续。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "门控失败,改一条还是全改?"), (0, react.createElement)("p", null, "一次看完所有 blocking,再一起改。逐条试会反复烧掉阶段轮次;错误里的 code 会告诉你属于参数、门控还是系统故障。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "字数和评分为什么对不上?"), (0, react.createElement)("p", null, "字数以 engine 返回为准,可用 novel_word_count 核对;review 的 total 必须等于 150 减全部扣分,不能另算一套。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "P4/P6 过不去怎么办?"), (0, react.createElement)("p", null, "P4 有 blocker 就领 P5 任务书修订,再回 P4 重评;P6 失败同样回修订。不要 waive blocker,也不要只跑半个 scope 就归档。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "想改已经归档的章节?"), (0, react.createElement)("p", null, "先 novel_reopen,带非空 reason。它会从归档备份恢复,重开后重新走 P3→P8;不要直接编辑归档卡或正文文件。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "状态文档坏了能手工修吗?"), (0, react.createElement)("p", null, "先用 novel_state 看 raw 和 next;需要修复时用 novel_amend,并在 reason 写清原因。手改 YAML 会丢失事务保护。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "有多个项目,面板看的是哪本?"), (0, react.createElement)("p", null, "面板用顶部项目选择器切换查看对象;选择只改 ?root= 和浏览器 localStorage,不调用 activate、不写会话绑定。写作项目请用 /novel use 或显式 project_root。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "选错书、想清掉绑定怎么办?"), (0, react.createElement)("p", null, "先用 /novel current 看当前会话绑定;要换书用 /novel use <路径|书名|.>,要清掉只影响当前会话的绑定用 /novel unbind。不要靠 active 猜,也不要手改 state/session-bindings.yaml。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "扫描到了,为什么还没切过去?"), (0, react.createElement)("p", null, "scan 只读发现;scan --register 只登记项目,不选择、不改 active、不绑会话。真正切换写作项目要用 /novel use,或第一次调用显式传 project_root。")))), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "可以直接复制给模型的起手话术"), (0, react.createElement)("pre", { className: "nwe-prompt" }, TUTORIAL_PROMPT)), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "续写下一章时,直接复制这段给 Agent"), (0, react.createElement)("p", null, "这段会先确认状态,再处理缺章卡和目标章数已满,最后按 P1→P8 推进;归档前会要求你确认 state_delta。"), (0, react.createElement)("pre", { className: "nwe-prompt" }, TUTORIAL_NEXT_PROMPT)), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "一章完成检查清单"), (0, react.createElement)("ul", { className: "nwe-checklist" }, (0, react.createElement)("li", null, "本章 P1/P2 计划已提交,P3 正文已通过格式与字数门控。"), (0, react.createElement)("li", null, "P4 评审 total 对账无误,没有未修 blocker。"), (0, react.createElement)("li", null, "P6 五个语义项都有不同句子的正文引用,完整 scope 通过。"), (0, react.createElement)("li", null, "P8 任务书当前,novel_archive 带完整 state_delta 成功。"), (0, react.createElement)("li", null, "面板出现已归档章节,门控区没有新的阻塞。")))), (0, react.createElement)("div", { className: "nwe-modal-foot" }, "教程基于 0.2.2 · 具体字段以当前任务书和工具返回为准 · 面板只读")));
|
|
1015
1183
|
const NovelHelp = () => {
|
|
1016
1184
|
const [open, setOpen] = (0, react.useState)(false);
|
|
1017
1185
|
const triggerRef = (0, react.useRef)(null);
|
|
@@ -1059,27 +1227,31 @@ brief P6 → check → brief P8 → archive 的顺序推进。
|
|
|
1059
1227
|
* Pure view: given a view model, return the element tree. Keeping the hooks in
|
|
1060
1228
|
* `NovelPanel` makes every visual state renderable without a browser.
|
|
1061
1229
|
*/
|
|
1062
|
-
function panelElement(view, loading, onRefresh) {
|
|
1230
|
+
function panelElement(view, loading, onRefresh, onSelectProject = () => void 0) {
|
|
1063
1231
|
const ready = view.state === "ready";
|
|
1232
|
+
const selectable = ready || view.state === "choose" || view.state === "missing" || view.state === "loading" && view.projects.length > 0;
|
|
1064
1233
|
return (0, react.createElement)("section", {
|
|
1065
1234
|
"data-plugin": "dsh-novel-write-engine",
|
|
1066
1235
|
className: "nwe-root",
|
|
1067
1236
|
"aria-busy": loading,
|
|
1068
1237
|
"data-state": view.state
|
|
1069
|
-
}, (0, react.createElement)("style", { key: "nwe-style" }, CSS), (0, react.createElement)("header", { className: "nwe-hero" }, (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "nwe-eyebrow" }, "NOVEL STUDIO · 网文工坊"), (0, react.createElement)("h1", { className: "nwe-title" }, view.title), (0, react.createElement)("div", { className: "nwe-meta" }, (0, react.createElement)("span", { className: `nwe-stage${view.finished ? " is-done" : ""}` }, view.stageLabel), ready ? (0, react.createElement)("span", null, view.subtitle) : null, ready ? (0, react.createElement)("span", { "aria-hidden": "true" }, "·") : null, ready ? (0, react.createElement)("span", null, view.revisionLabel) : null)), (0, react.createElement)("div", { className: "nwe-actions" }, (0, react.createElement)(NovelHelp), (0, react.createElement)("button", {
|
|
1238
|
+
}, (0, react.createElement)("style", { key: "nwe-style" }, CSS), (0, react.createElement)("header", { className: "nwe-hero" }, (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "nwe-eyebrow" }, "NOVEL STUDIO · 网文工坊"), (0, react.createElement)("h1", { className: "nwe-title" }, view.title), (0, react.createElement)("div", { className: "nwe-meta" }, (0, react.createElement)("span", { className: `nwe-stage${view.finished ? " is-done" : ""}` }, view.stageLabel), ready ? (0, react.createElement)("span", null, view.subtitle) : null, ready ? (0, react.createElement)("span", { "aria-hidden": "true" }, "·") : null, ready ? (0, react.createElement)("span", null, view.revisionLabel) : null, view.state === "choose" || view.state === "missing" || view.state === "loading" ? (0, react.createElement)("span", null, view.subtitle) : null), ready && view.root !== "" ? (0, react.createElement)("div", { className: "nwe-rootline" }, view.root) : null), (0, react.createElement)("div", { className: "nwe-actions" }, selectable ? projectSelector(view, onSelectProject) : null, (0, react.createElement)(NovelHelp), (0, react.createElement)("button", {
|
|
1070
1239
|
type: "button",
|
|
1071
1240
|
className: "nwe-refresh",
|
|
1072
1241
|
onClick: onRefresh,
|
|
1073
1242
|
disabled: loading,
|
|
1074
1243
|
"aria-label": "刷新项目状态"
|
|
1075
|
-
}, refreshIcon(), loading ? "刷新中…" : "刷新"))), ready ? (0, react.createElement)("div", {
|
|
1244
|
+
}, refreshIcon(), loading ? "刷新中…" : "刷新"))), ready && view.damaged ? (0, react.createElement)("div", {
|
|
1245
|
+
className: "nwe-damaged",
|
|
1246
|
+
role: "status"
|
|
1247
|
+
}, "项目定义不可读;先用 novel_state 查看,再用 novel_amend 按 next 修复。面板不会把它伪装成正常项目。") : null, ready ? (0, react.createElement)("div", {
|
|
1076
1248
|
className: "nwe-stats",
|
|
1077
1249
|
role: "group",
|
|
1078
1250
|
"aria-label": "项目统计"
|
|
1079
|
-
}, stat("已归档", String(view.stats.chapters), "章"), stat("总字数", view.stats.words.toLocaleString("zh-CN"), "字"), stat("平均评分", view.stats.chapters === 0 ? "—" : String(view.stats.averageScore), "/150"), stat("待处理", String(view.stats.blocking), "项")) : null, view.state === "loading" ? loadingState() : null, view.state === "error" || view.state === "empty" || view.state === "outdated" ? statePanel(view.state, view, loading, onRefresh) : null, ready ? bookSection(view) : null, ready ? phases(view) : null, ready && view.nextTitle !== "" ? (0, react.createElement)("section", {
|
|
1251
|
+
}, stat("已归档", String(view.stats.chapters), "章"), stat("总字数", view.stats.words.toLocaleString("zh-CN"), "字"), stat("平均评分", view.stats.chapters === 0 ? "—" : String(view.stats.averageScore), "/150"), stat("待处理", String(view.stats.blocking), "项")) : null, view.state === "loading" ? loadingState() : null, view.state === "choose" ? choosePanel(view, onSelectProject) : null, view.state === "missing" ? missingPanel(view) : null, view.state === "error" || view.state === "empty" || view.state === "outdated" ? statePanel(view.state, view, loading, onRefresh) : null, ready ? bookSection(view) : null, ready ? phases(view) : null, ready && view.nextTitle !== "" ? (0, react.createElement)("section", {
|
|
1080
1252
|
className: "nwe-next",
|
|
1081
1253
|
"aria-label": "下一步"
|
|
1082
|
-
}, (0, react.createElement)("div", { className: "nwe-next-icon" }, arrowIcon()), (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "nwe-next-kicker" }, "NEXT MOVE"), (0, react.createElement)("div", { className: "nwe-next-title" }, view.nextTitle), (0, react.createElement)("div", { className: "nwe-next-detail" }, view.nextDetail)), view.nextTool === "" ? null : (0, react.createElement)("code", { className: "nwe-next-tool" }, view.nextTool)) : null, ready ? (0, react.createElement)("section", { className: "nwe-section" }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h2", { className: "nwe-section-title" }, "章节"), (0, react.createElement)("span", { className: "nwe-section-note" }, `${view.stats.chapters} 条记录${view.stats.chapters > view.rows.filter((row) => row.tone === "ok").length ? ` · 显示最近 ${view.rows.length} 条` : ""}`)), view.rows.length === 0 ? (0, react.createElement)("div", {
|
|
1254
|
+
}, (0, react.createElement)("div", { className: "nwe-next-icon" }, arrowIcon()), (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "nwe-next-kicker" }, "NEXT MOVE"), (0, react.createElement)("div", { className: "nwe-next-title" }, view.nextTitle), (0, react.createElement)("div", { className: "nwe-next-detail" }, view.nextDetail), (0, react.createElement)(CopyResumeButton, { text: view.resumeText })), view.nextTool === "" ? null : (0, react.createElement)("code", { className: "nwe-next-tool" }, view.nextTool)) : null, ready ? (0, react.createElement)("section", { className: "nwe-section" }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h2", { className: "nwe-section-title" }, "章节"), (0, react.createElement)("span", { className: "nwe-section-note" }, `${view.stats.chapters} 条记录${view.stats.chapters > view.rows.filter((row) => row.tone === "ok").length ? ` · 显示最近 ${view.rows.length} 条` : ""}`)), view.rows.length === 0 ? (0, react.createElement)("div", {
|
|
1083
1255
|
className: "nwe-empty",
|
|
1084
1256
|
style: {
|
|
1085
1257
|
marginTop: 12,
|
|
@@ -1099,30 +1271,103 @@ brief P6 → check → brief P8 → archive 的顺序推进。
|
|
|
1099
1271
|
"aria-hidden": "true"
|
|
1100
1272
|
}, "!"), (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "nwe-block-code" }, item.code), (0, react.createElement)("div", { className: "nwe-block-detail" }, item.detail), item.hint === "" ? null : (0, react.createElement)("div", { className: "nwe-block-hint" }, item.hint)))))) : null, ready ? activitySection(view) : null, footer());
|
|
1101
1273
|
}
|
|
1274
|
+
const PANEL_ROOT_KEY = "nwe.panel-root.v1";
|
|
1275
|
+
const storage = () => globalThis.localStorage;
|
|
1276
|
+
const readStoredRoot = () => {
|
|
1277
|
+
try {
|
|
1278
|
+
const value = storage()?.getItem(PANEL_ROOT_KEY);
|
|
1279
|
+
return value === void 0 || value === null || value === "" ? null : value;
|
|
1280
|
+
} catch {
|
|
1281
|
+
return null;
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
const storeRoot = (root) => {
|
|
1285
|
+
try {
|
|
1286
|
+
const target = storage();
|
|
1287
|
+
if (target === void 0) return;
|
|
1288
|
+
if (root === null) target.removeItem(PANEL_ROOT_KEY);
|
|
1289
|
+
else target.setItem(PANEL_ROOT_KEY, root);
|
|
1290
|
+
} catch {}
|
|
1291
|
+
};
|
|
1292
|
+
const panelPath = (root) => {
|
|
1293
|
+
const base = PANEL_ROUTE.replace(/^\/+/, "");
|
|
1294
|
+
return root === null ? base : `${base}?root=${encodeURIComponent(root)}`;
|
|
1295
|
+
};
|
|
1102
1296
|
/** Stateful wrapper: fetch once, refresh on demand and after returning to the tab. */
|
|
1103
1297
|
function NovelPanel() {
|
|
1104
1298
|
const [view, setView] = (0, react.useState)(() => buildView(null));
|
|
1105
1299
|
const [loading, setLoading] = (0, react.useState)(true);
|
|
1300
|
+
const [switching, setSwitching] = (0, react.useState)(false);
|
|
1301
|
+
const [selectedRoot, setSelectedRoot] = (0, react.useState)(() => readStoredRoot());
|
|
1106
1302
|
const alive = (0, react.useRef)(true);
|
|
1107
|
-
const
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1303
|
+
const requestId = (0, react.useRef)(0);
|
|
1304
|
+
const visibleRequest = (0, react.useRef)(0);
|
|
1305
|
+
const controllerRef = (0, react.useRef)(null);
|
|
1306
|
+
const queryRootRef = (0, react.useRef)(selectedRoot);
|
|
1307
|
+
const loadRef = (0, react.useRef)(async () => void 0);
|
|
1308
|
+
const load = (0, react.useCallback)(async (silent = false, root = queryRootRef.current) => {
|
|
1309
|
+
if (silent && visibleRequest.current !== 0) return;
|
|
1310
|
+
const id = ++requestId.current;
|
|
1311
|
+
controllerRef.current?.abort();
|
|
1312
|
+
const controller = new AbortController();
|
|
1313
|
+
controllerRef.current = controller;
|
|
1314
|
+
if (!silent) {
|
|
1315
|
+
visibleRequest.current = id;
|
|
1316
|
+
setLoading(true);
|
|
1317
|
+
}
|
|
1318
|
+
const timeout = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(1e4) : void 0;
|
|
1319
|
+
const any = typeof AbortSignal === "undefined" ? void 0 : AbortSignal.any?.bind(AbortSignal);
|
|
1320
|
+
const anySignal = timeout === void 0 ? controller.signal : typeof any === "function" ? any([controller.signal, timeout]) : controller.signal;
|
|
1321
|
+
const timeoutTimer = timeout === void 0 || typeof any !== "function" ? setTimeout(() => controller.abort(), 1e4) : void 0;
|
|
1112
1322
|
try {
|
|
1113
|
-
const
|
|
1114
|
-
|
|
1115
|
-
|
|
1323
|
+
const result = await fetchPanel(fetch, panelPath(root), anySignal);
|
|
1324
|
+
if (!alive.current || id !== requestId.current) return;
|
|
1325
|
+
const payload = result.payload;
|
|
1326
|
+
if (payload !== null && payload.missing?.reason === "not_registered" && root !== null) {
|
|
1327
|
+
queryRootRef.current = null;
|
|
1328
|
+
storeRoot(null);
|
|
1329
|
+
setSelectedRoot(null);
|
|
1330
|
+
await loadRef.current(false, null);
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
if (payload !== null) setSelectedRoot(payload.selection.root);
|
|
1116
1334
|
setView((previous) => {
|
|
1117
|
-
const next = buildView(
|
|
1335
|
+
const next = buildView(payload, result.error);
|
|
1118
1336
|
if (silent && result.error !== null && previous.state === "ready") return previous;
|
|
1119
1337
|
return next;
|
|
1120
1338
|
});
|
|
1121
1339
|
} finally {
|
|
1122
|
-
|
|
1123
|
-
if (!silent &&
|
|
1340
|
+
if (timeoutTimer !== void 0) clearTimeout(timeoutTimer);
|
|
1341
|
+
if (!silent && visibleRequest.current === id) visibleRequest.current = 0;
|
|
1342
|
+
if (id === requestId.current && !silent && alive.current) {
|
|
1343
|
+
setLoading(false);
|
|
1344
|
+
setSwitching(false);
|
|
1345
|
+
}
|
|
1124
1346
|
}
|
|
1125
1347
|
}, []);
|
|
1348
|
+
loadRef.current = load;
|
|
1349
|
+
const selectProject = (0, react.useCallback)((root) => {
|
|
1350
|
+
queryRootRef.current = root;
|
|
1351
|
+
storeRoot(root);
|
|
1352
|
+
setSelectedRoot(root);
|
|
1353
|
+
setSwitching(true);
|
|
1354
|
+
setView((previous) => ({
|
|
1355
|
+
...previous,
|
|
1356
|
+
state: "loading",
|
|
1357
|
+
title: "切换项目…",
|
|
1358
|
+
subtitle: root === null ? "正在读取默认项目…" : `正在读取 ${root}…`,
|
|
1359
|
+
stageLabel: "读取中",
|
|
1360
|
+
selectedRoot: root,
|
|
1361
|
+
selection: {
|
|
1362
|
+
root,
|
|
1363
|
+
source: "query"
|
|
1364
|
+
},
|
|
1365
|
+
resumeText: "",
|
|
1366
|
+
damaged: false,
|
|
1367
|
+
finished: false
|
|
1368
|
+
}));
|
|
1369
|
+
load(false, root);
|
|
1370
|
+
}, [load]);
|
|
1126
1371
|
(0, react.useEffect)(() => {
|
|
1127
1372
|
alive.current = true;
|
|
1128
1373
|
load();
|
|
@@ -1137,12 +1382,16 @@ brief P6 → check → brief P8 → archive 的顺序推进。
|
|
|
1137
1382
|
doc.addEventListener("visibilitychange", refreshWhenVisible);
|
|
1138
1383
|
return () => {
|
|
1139
1384
|
alive.current = false;
|
|
1385
|
+
controllerRef.current?.abort();
|
|
1140
1386
|
win.clearInterval(timer);
|
|
1141
1387
|
win.removeEventListener("focus", refreshWhenVisible);
|
|
1142
1388
|
doc.removeEventListener("visibilitychange", refreshWhenVisible);
|
|
1143
1389
|
};
|
|
1144
1390
|
}, [load]);
|
|
1145
|
-
return panelElement(
|
|
1391
|
+
return panelElement({
|
|
1392
|
+
...view,
|
|
1393
|
+
selectedRoot
|
|
1394
|
+
}, loading || switching, () => void load(false, queryRootRef.current), selectProject);
|
|
1146
1395
|
}
|
|
1147
1396
|
/** Sidebar icon: a small book mark, inheriting the current text colour. */
|
|
1148
1397
|
function NovelIcon({ size }) {
|