@cloud411716/fancy-webnovel 2.2.0 → 3.0.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.
- package/client.js +201 -15
- package/cordis.patch.yml +5 -0
- package/docs/douyin-plugin.md +118 -0
- package/docs/material-library/README.md +404 -0
- package/docs/material-library/import.js +437 -0
- package/docs/material-library/prompt.md +370 -0
- package/docs/material-library/queries.surql +94 -0
- package/docs/material-library/sample.jsonl +14 -0
- package/docs/material-library/schema.surql +46 -0
- package/docs/mcp-profile.example.yml +24 -0
- package/docs/story-graph-design.md +602 -0
- package/docs/story-graph-validation.md +81 -0
- package/docs/workspace-mcp.md +284 -0
- package/index.js +11 -7
- package/lib/atomic-write.js +109 -0
- package/lib/chapter-order.js +100 -0
- package/lib/confirmation.js +80 -0
- package/lib/db-init.js +123 -0
- package/lib/deployed-state.js +11 -11
- package/lib/direction-labels.js +265 -0
- package/lib/error-codes.js +31 -31
- package/lib/infra.js +29 -4
- package/lib/material-db.js +149 -0
- package/lib/path-guard.js +150 -0
- package/lib/projects.js +400 -0
- package/lib/scraper-utils.js +140 -0
- package/lib/surreal-server.js +338 -0
- package/lib/topic-decision.js +860 -0
- package/lib/topic-fields.js +251 -0
- package/lib/topic-schema.js +225 -0
- package/lib/workspace.js +501 -0
- package/package.json +29 -4
- package/plugins/fancy-analyze/analyze-definitions.md +519 -0
- package/plugins/fancy-analyze/index.js +1128 -77
- package/plugins/fancy-analyze/ingest-coverage.js +107 -0
- package/plugins/fancy-analyze/schema-analyze.md +355 -107
- package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
- package/plugins/fancy-analyze/summary-progress.js +71 -7
- package/plugins/fancy-analyze/templates.js +437 -139
- package/plugins/fancy-bootstrap/index.js +276 -131
- package/plugins/fancy-bootstrap/scripts/init.js +59 -25
- package/plugins/fancy-bootstrap/templates.js +35 -18
- package/plugins/fancy-db/index.js +172 -0
- package/plugins/fancy-douyin/deploy.js +134 -0
- package/plugins/fancy-douyin/index.js +551 -0
- package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
- package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
- package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
- package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
- package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
- package/plugins/fancy-douyin/state.js +197 -0
- package/plugins/fancy-douyin/templates.js +127 -0
- package/plugins/fancy-export/exporter.js +805 -0
- package/plugins/fancy-export/index.js +63 -0
- package/plugins/fancy-import/import-driver.js +29 -6
- package/plugins/fancy-import/index.js +13 -5
- package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
- package/plugins/fancy-import/templates.js +17 -8
- package/plugins/fancy-material/exporter.js +153 -0
- package/plugins/fancy-material/genres.js +256 -0
- package/plugins/fancy-material/index.js +835 -0
- package/plugins/fancy-material/inventory.js +143 -0
- package/plugins/fancy-material/kinds.js +313 -0
- package/plugins/fancy-material/queue.js +55 -0
- package/plugins/fancy-material/scripts/query.js +254 -0
- package/plugins/fancy-material/templates.js +258 -0
- package/plugins/fancy-material/tool-deploy.js +138 -0
- package/plugins/fancy-material/validate.js +149 -0
- package/plugins/fancy-project/index.js +133 -0
- package/plugins/fancy-scan/existing-block.js +100 -0
- package/plugins/fancy-scan/index.js +410 -21
- package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
- package/plugins/fancy-scan/scripts/append-block.js +725 -0
- package/plugins/fancy-scan/scripts/report-validate.js +824 -0
- package/plugins/fancy-scan/scripts/run-scan.js +169 -22
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
- package/plugins/fancy-scan/templates/blocks.js +155 -0
- package/plugins/fancy-scan/templates/platforms.js +372 -0
- package/plugins/fancy-scan/templates/report-schema.js +90 -0
- package/plugins/fancy-scan/templates.js +545 -146
- package/plugins/fancy-scan/tool-deploy.js +335 -0
- package/plugins/fancy-scan/wait-turn.js +46 -0
- package/plugins/fancy-story-builder/db/README.md +106 -0
- package/plugins/fancy-story-builder/db/client.js +228 -0
- package/plugins/fancy-story-builder/db/commands.js +145 -0
- package/plugins/fancy-story-builder/db/ids.js +39 -0
- package/plugins/fancy-story-builder/db/import.js +798 -0
- package/plugins/fancy-story-builder/db/project.js +299 -0
- package/plugins/fancy-story-builder/db/query.js +361 -0
- package/plugins/fancy-story-builder/db/schema.js +417 -0
- package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
- package/plugins/fancy-story-builder/flesh-output.js +19 -6
- package/plugins/fancy-story-builder/index.js +4105 -396
- package/plugins/fancy-story-builder/patch-driver.js +390 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
- package/plugins/fancy-story-builder/purpose-template.md +42 -66
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
- package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
- package/plugins/fancy-story-builder/references/glossary.md +15 -6
- package/plugins/fancy-story-builder/references/index.md +97 -6
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
- package/plugins/fancy-story-builder/schema-setting.json +1100 -0
- package/plugins/fancy-story-builder/schema-setting.md +201 -293
- package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
- package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
- package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
- package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
- package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
- package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
- package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
- package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
- package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
- package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
- package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
- package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
- package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
- package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
- package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
- package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
- package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
- package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
- package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
- package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
- package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
- package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
- package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
- package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
- package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
- package/plugins/fancy-story-builder/storydb/README.md +271 -0
- package/plugins/fancy-story-builder/storydb/cli.js +458 -0
- package/plugins/fancy-story-builder/storydb/client.js +351 -0
- package/plugins/fancy-story-builder/storydb/compile.js +452 -0
- package/plugins/fancy-story-builder/storydb/context.js +288 -0
- package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
- package/plugins/fancy-story-builder/storydb/driver.js +106 -0
- package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
- package/plugins/fancy-story-builder/storydb/export.js +175 -0
- package/plugins/fancy-story-builder/storydb/ids.js +31 -0
- package/plugins/fancy-story-builder/storydb/impact.js +170 -0
- package/plugins/fancy-story-builder/storydb/import.js +559 -0
- package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
- package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
- package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
- package/plugins/fancy-story-builder/storydb/stage.js +426 -0
- package/plugins/fancy-story-builder/storydb/store.js +891 -0
- package/plugins/fancy-story-builder/storydb/tools.js +592 -0
- package/plugins/fancy-story-builder/storydb/validation.js +183 -0
- package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
- package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
- package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
- package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
- package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +267 -44
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +121 -19
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +129 -21
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +76 -17
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +56 -8
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +63 -13
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +34 -6
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +81 -18
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +202 -42
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +160 -27
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +217 -41
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +215 -41
- package/plugins/fancy-story-builder/templates.js +272 -74
- package/plugins/fancy-story-builder/wiki-references.js +429 -93
- package/plugins/fancy-story-builder/writing-driver.js +336 -9
- package/plugins/fancy-topic/index.js +414 -61
- package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
- package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
- package/plugins/fancy-topic/scripts/select-topic.js +106 -63
- package/plugins/fancy-topic/templates.js +225 -122
- package/plugins/fancy-topic/tool-deploy.js +200 -0
- package/lib/safe-rm.js +0 -94
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fancy-material — workspace-level material library.
|
|
3
|
+
*
|
|
4
|
+
* Commands:
|
|
5
|
+
* /fancy-material send the generation prompt, wait for JSONL, validate, import
|
|
6
|
+
* /fancy-material import <file> validate then import an existing JSONL
|
|
7
|
+
* /fancy-material prompt print the prompt without sending it to the LLM
|
|
8
|
+
* /fancy-material export export the library to <workspace>/素材库.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
|
|
15
|
+
import { askUserQuestion, llmFollowup, notify, spawnScript } from '#infra';
|
|
16
|
+
import {
|
|
17
|
+
localIsoString,
|
|
18
|
+
readSecrets,
|
|
19
|
+
readWorkspace,
|
|
20
|
+
resolvePath,
|
|
21
|
+
resolveWorkspace,
|
|
22
|
+
workspacePaths,
|
|
23
|
+
} from '#workspace';
|
|
24
|
+
import { connectMaterial, ensureMaterialDatabase, materialDatabaseName, removeMaterialDatabase } from '#material-db';
|
|
25
|
+
import { makeWaitForTurn } from '../fancy-story-builder/writing-driver.js';
|
|
26
|
+
import { exportMaterials } from './exporter.js';
|
|
27
|
+
import { allKnownGenres, listTopicDecisionFiles, parseTopicDecisionGenres, resolveMaterialGenres } from './genres.js';
|
|
28
|
+
import { buildExistingIndex, countByKind, countKind } from './inventory.js';
|
|
29
|
+
import { KIND_LABELS, MATERIAL_KINDS, focusBlock, renderKindTable, resolveFocus } from './kinds.js';
|
|
30
|
+
import { nextBatchRequest, planMaterialQueue } from './queue.js';
|
|
31
|
+
import { deployMaterialTools } from './tool-deploy.js';
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_BATCH_SIZE,
|
|
34
|
+
DEFAULT_TARGET_PER_KIND,
|
|
35
|
+
exportResultText,
|
|
36
|
+
generationPrompt,
|
|
37
|
+
generationRunText,
|
|
38
|
+
helpText,
|
|
39
|
+
importResultText,
|
|
40
|
+
promptDisplayText,
|
|
41
|
+
renderPrompt,
|
|
42
|
+
validationFailedText,
|
|
43
|
+
} from './templates.js';
|
|
44
|
+
|
|
45
|
+
export const inject = ['commands', 'userQuestions'];
|
|
46
|
+
|
|
47
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
const DOCS_DIR = path.resolve(__dirname, '..', '..', 'docs', 'material-library');
|
|
49
|
+
const IMPORT_SCRIPT = path.join(DOCS_DIR, 'import.js');
|
|
50
|
+
const PROMPT_FILE = path.join(DOCS_DIR, 'prompt.md');
|
|
51
|
+
const QUERY_SCRIPT = path.join(__dirname, 'scripts', 'query.js');
|
|
52
|
+
const VALIDATE_SCRIPT = path.join(__dirname, 'validate.js');
|
|
53
|
+
|
|
54
|
+
const GENERATION_ATTEMPTS = 3;
|
|
55
|
+
const GENERATION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
56
|
+
const GENERATION_IDLE_GRACE_MS = 60 * 1000;
|
|
57
|
+
const MAX_STALLED_BATCHES = 3;
|
|
58
|
+
const KIND_FAILURE_STREAK_LIMIT = 3;
|
|
59
|
+
const FULL_PROMPT_EVERY = 10;
|
|
60
|
+
|
|
61
|
+
function cwdOf(session) {
|
|
62
|
+
return session && session.header && session.header.cwd ? session.header.cwd : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function workspaceOf(session) {
|
|
66
|
+
return resolveWorkspace({ cwd: cwdOf(session), required: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function envInt(name, fallback) {
|
|
70
|
+
const raw = process.env[name];
|
|
71
|
+
if (!raw) return fallback;
|
|
72
|
+
const value = Number.parseInt(String(raw), 10);
|
|
73
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function generationConfig() {
|
|
77
|
+
return {
|
|
78
|
+
targetPerKind: envInt('FANCY_MATERIAL_TARGET_PER_KIND', DEFAULT_TARGET_PER_KIND),
|
|
79
|
+
batchSize: envInt('FANCY_MATERIAL_BATCH_SIZE', DEFAULT_BATCH_SIZE),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function abortRequested(invocation) {
|
|
84
|
+
return Boolean(invocation && invocation.signal && invocation.signal.aborted);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function writeProgress(file, report) {
|
|
88
|
+
try {
|
|
89
|
+
fs.writeFileSync(file, JSON.stringify(report, null, 2) + '\n', 'utf8');
|
|
90
|
+
} catch { /* progress is best-effort */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readPrompt() {
|
|
94
|
+
try {
|
|
95
|
+
return fs.readFileSync(PROMPT_FILE, 'utf8');
|
|
96
|
+
} catch (err) {
|
|
97
|
+
throw new Error('缺少素材提示词文件:' + PROMPT_FILE + '(' + (err && err.message ? err.message : String(err)) + ')');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderBasePrompt({
|
|
102
|
+
mode,
|
|
103
|
+
genres,
|
|
104
|
+
topicFile,
|
|
105
|
+
existingIndex,
|
|
106
|
+
existingTotal,
|
|
107
|
+
batchId,
|
|
108
|
+
workspace,
|
|
109
|
+
focus,
|
|
110
|
+
queryScript,
|
|
111
|
+
validateScript,
|
|
112
|
+
targetPerKind = DEFAULT_TARGET_PER_KIND,
|
|
113
|
+
batchSize = DEFAULT_BATCH_SIZE,
|
|
114
|
+
}) {
|
|
115
|
+
return renderPrompt(readPrompt(), {
|
|
116
|
+
MATERIAL_MODE: mode,
|
|
117
|
+
GENRES: (genres || []).join('、'),
|
|
118
|
+
TOPIC_FILE: topicFile || '(无)',
|
|
119
|
+
KIND_TABLE: renderKindTable(),
|
|
120
|
+
FOCUS_BLOCK: focusBlock(focus),
|
|
121
|
+
TARGET_PER_KIND: String(targetPerKind),
|
|
122
|
+
BATCH_SIZE: String(batchSize),
|
|
123
|
+
AUDIENCE: '男频 / 女频 / 通用',
|
|
124
|
+
STYLE_RULES: '未指定(执行通用创作标准)',
|
|
125
|
+
BATCH_ID: batchId || 'material-YYYYMMDD-01',
|
|
126
|
+
SEED: batchId || 'material-v4',
|
|
127
|
+
EXISTING_INDEX: existingIndex || '(运行 /fancy-material 时由插件提供)',
|
|
128
|
+
EXISTING_TOTAL: Number.isFinite(existingTotal) ? String(existingTotal) : '(运行时读取)',
|
|
129
|
+
QUERY_SCRIPT: queryScript || QUERY_SCRIPT,
|
|
130
|
+
VALIDATE_SCRIPT: validateScript || VALIDATE_SCRIPT,
|
|
131
|
+
WORKSPACE: workspace || '(运行时提供)',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function stripQuotes(value) {
|
|
136
|
+
const text = String(value || '').trim();
|
|
137
|
+
if (text.length >= 2
|
|
138
|
+
&& ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))) {
|
|
139
|
+
return text.slice(1, -1);
|
|
140
|
+
}
|
|
141
|
+
return text;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve a user-provided import path. Relative paths are resolved from the
|
|
146
|
+
* session cwd first, then from the workspace directory.
|
|
147
|
+
*/
|
|
148
|
+
function resolveInputFile(raw, session, root) {
|
|
149
|
+
const value = stripQuotes(raw);
|
|
150
|
+
if (!value) return { input: '', file: null };
|
|
151
|
+
const cwd = cwdOf(session) || root;
|
|
152
|
+
const first = resolvePath(value, cwd);
|
|
153
|
+
const second = resolvePath(value, root);
|
|
154
|
+
for (const candidate of [first, second]) {
|
|
155
|
+
try {
|
|
156
|
+
if (fs.statSync(candidate).isFile()) return { input: value, file: candidate };
|
|
157
|
+
} catch { /* try next */ }
|
|
158
|
+
}
|
|
159
|
+
return { input: value, file: first };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function scriptDetail(result) {
|
|
163
|
+
const stderr = String(result && result.stderr || '').trim();
|
|
164
|
+
const stdout = String(result && result.output || '').trim();
|
|
165
|
+
return stderr || stdout || String(result && result.error || '').trim() || ('退出码 ' + (result && result.code));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function checkFile(file) {
|
|
169
|
+
return spawnScript(process.execPath, [IMPORT_SCRIPT, '--check', file], { timeout: 60000 });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Every generated card must keep at least one exact selected genre in `genres`.
|
|
174
|
+
* The model may add subgenres, but replacing the selected label with a synonym
|
|
175
|
+
* breaks genre filtering and the existing-library index.
|
|
176
|
+
*/
|
|
177
|
+
function genreCoverageProblem(file, genres) {
|
|
178
|
+
const selected = new Set((genres || []).filter(Boolean));
|
|
179
|
+
if (!selected.size) return '';
|
|
180
|
+
let text;
|
|
181
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return ''; }
|
|
182
|
+
const lines = text.split(/\r?\n/);
|
|
183
|
+
const bad = [];
|
|
184
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
185
|
+
const line = lines[i].trim();
|
|
186
|
+
if (!line) continue;
|
|
187
|
+
let record;
|
|
188
|
+
try { record = JSON.parse(line); } catch { continue; }
|
|
189
|
+
const values = Array.isArray(record.genres) ? record.genres.map(String) : [];
|
|
190
|
+
if (!values.some((genre) => selected.has(genre))) {
|
|
191
|
+
bad.push(`第 ${i + 1} 条「${record.name || record.id || '未命名'}」`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!bad.length) return '';
|
|
195
|
+
return '以下素材的 genres 没有原样包含本次主题材:' + bad.slice(0, 8).join('、')
|
|
196
|
+
+ (bad.length > 8 ? `等 ${bad.length} 条` : '')
|
|
197
|
+
+ '。请把本次题材列表中的主题材名原样写进 genres,可再附加子题材。';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A batch file must contain exactly the kind requested in the prompt. The
|
|
202
|
+
* queue schedule and per-kind counts assume one kind per batch; mixed files
|
|
203
|
+
* would make later kinds silently overshoot their target.
|
|
204
|
+
*/
|
|
205
|
+
function kindCoverageProblem(file, kind) {
|
|
206
|
+
if (!kind) return '';
|
|
207
|
+
let text;
|
|
208
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return ''; }
|
|
209
|
+
const lines = text.split(/\r?\n/);
|
|
210
|
+
const bad = [];
|
|
211
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
212
|
+
const line = lines[i].trim();
|
|
213
|
+
if (!line) continue;
|
|
214
|
+
let record;
|
|
215
|
+
try { record = JSON.parse(line); } catch { continue; }
|
|
216
|
+
if (String(record.kind || '') !== kind) {
|
|
217
|
+
bad.push(`第 ${i + 1} 条「${record.name || record.id || '未命名'}」是 ${record.kind || '空 kind'}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (!bad.length) return '';
|
|
221
|
+
return '本批只能写 `' + kind + '`,但文件里混入了其它 kind:' + bad.slice(0, 8).join('、')
|
|
222
|
+
+ (bad.length > 8 ? `等 ${bad.length} 条` : '')
|
|
223
|
+
+ '。请只保留本批 kind,重新写入目标文件。';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Generated batches belong to the builtin catalog. A batch may omit `source`
|
|
228
|
+
* (the database defaults it to builtin), but it must not mark entries as
|
|
229
|
+
* `other` to bypass the builtin kind list.
|
|
230
|
+
*/
|
|
231
|
+
function sourceCoverageProblem(file) {
|
|
232
|
+
let text;
|
|
233
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return ''; }
|
|
234
|
+
const lines = text.split(/\r?\n/);
|
|
235
|
+
const bad = [];
|
|
236
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
237
|
+
const line = lines[i].trim();
|
|
238
|
+
if (!line) continue;
|
|
239
|
+
let record;
|
|
240
|
+
try { record = JSON.parse(line); } catch { continue; }
|
|
241
|
+
if (record.source !== undefined && record.source !== 'builtin') {
|
|
242
|
+
bad.push(`第 ${i + 1} 条「${record.name || record.id || '未命名'}」是 ${record.source}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!bad.length) return '';
|
|
246
|
+
return '生成批次只能写 source=builtin,但文件里出现了其它 source:' + bad.slice(0, 8).join('、')
|
|
247
|
+
+ (bad.length > 8 ? `等 ${bad.length} 条` : '')
|
|
248
|
+
+ '。请把 source 改成 builtin,或省略 source(数据库默认 builtin)。';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function importEnv(root) {
|
|
252
|
+
const workspace = readWorkspace(root);
|
|
253
|
+
const secrets = readSecrets(root);
|
|
254
|
+
return {
|
|
255
|
+
...process.env,
|
|
256
|
+
SURREAL_ENDPOINT: workspace.surreal?.url || 'http://127.0.0.1:8000/rpc',
|
|
257
|
+
SURREAL_NAMESPACE: workspace.surreal?.namespace || 'fancy',
|
|
258
|
+
SURREAL_DATABASE: materialDatabaseName(workspace),
|
|
259
|
+
SURREAL_USER: secrets.surreal?.username || 'root',
|
|
260
|
+
SURREAL_PASS: secrets.surreal?.password || '',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function importFile(root, file, options = {}) {
|
|
265
|
+
const args = [IMPORT_SCRIPT];
|
|
266
|
+
if (options.noPrefetch) args.push('--no-prefetch');
|
|
267
|
+
args.push(file);
|
|
268
|
+
const result = await spawnScript(process.execPath, args, {
|
|
269
|
+
timeout: 180000,
|
|
270
|
+
env: importEnv(root),
|
|
271
|
+
});
|
|
272
|
+
if (result.code !== 0) throw new Error(scriptDetail(result));
|
|
273
|
+
return String(result.output || '').trim();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function materialTotal(root) {
|
|
277
|
+
const { db } = await connectMaterial(root);
|
|
278
|
+
try {
|
|
279
|
+
const rows = await db.query('SELECT count() FROM material GROUP ALL;');
|
|
280
|
+
const row = Array.isArray(rows) && Array.isArray(rows[0]) ? rows[0][0] : null;
|
|
281
|
+
return row ? Number(row.count) || 0 : 0;
|
|
282
|
+
} finally {
|
|
283
|
+
try { await db.close(); } catch { /* ignore */ }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function batchIdOfNow() {
|
|
288
|
+
const stamp = localIsoString().replace(/[-:T+.]/g, '').slice(0, 14);
|
|
289
|
+
return 'material-' + (stamp || String(Date.now()));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function runImport(ctx, invocation, session, rawPath) {
|
|
293
|
+
const root = workspaceOf(session);
|
|
294
|
+
const { input, file } = resolveInputFile(rawPath, session, root);
|
|
295
|
+
if (!input || !file) {
|
|
296
|
+
return notify(session, 'error', '用法:/fancy-material import <文件.jsonl>');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let stat = null;
|
|
300
|
+
try { stat = fs.statSync(file); } catch { /* reported below */ }
|
|
301
|
+
if (!stat || !stat.isFile()) {
|
|
302
|
+
return notify(session, 'error', '❌ 未找到素材文件,或目标不是普通文件:' + file);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const checked = await checkFile(file);
|
|
306
|
+
if (checked.code !== 0) {
|
|
307
|
+
return notify(session, 'error', validationFailedText({ file, detail: scriptDetail(checked) }));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
await ensureMaterialDatabase(root);
|
|
311
|
+
const database = materialDatabaseName(readWorkspace(root));
|
|
312
|
+
let importedText;
|
|
313
|
+
try {
|
|
314
|
+
importedText = await importFile(root, file, { noPrefetch: true });
|
|
315
|
+
} catch (err) {
|
|
316
|
+
return notify(session, 'error', '❌ 导入失败:' + (err && err.message ? err.message : String(err))
|
|
317
|
+
+ '\n文件:' + file);
|
|
318
|
+
}
|
|
319
|
+
let total;
|
|
320
|
+
try { total = await materialTotal(root); } catch { total = undefined; }
|
|
321
|
+
return notify(session, 'success', importResultText({
|
|
322
|
+
file,
|
|
323
|
+
importedText,
|
|
324
|
+
total,
|
|
325
|
+
database,
|
|
326
|
+
}));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function runInit(ctx, invocation, session) {
|
|
330
|
+
const root = workspaceOf(session);
|
|
331
|
+
const result = await ensureMaterialDatabase(root);
|
|
332
|
+
const tools = deployMaterialTools(root);
|
|
333
|
+
if (!tools.ok) {
|
|
334
|
+
return notify(session, 'error', [
|
|
335
|
+
'❌ 素材库 schema 已初始化,但查询脚本部署失败:',
|
|
336
|
+
...(tools.problems || []),
|
|
337
|
+
'可重试 /fancy-material init。',
|
|
338
|
+
].join('\n'));
|
|
339
|
+
}
|
|
340
|
+
return notify(session, 'success', [
|
|
341
|
+
'✅ 素材库初始化完成',
|
|
342
|
+
'数据库:' + result.database,
|
|
343
|
+
'schema:' + result.schema_version,
|
|
344
|
+
'字段:' + result.fields + ' 个',
|
|
345
|
+
'索引:' + (result.indexes || []).join('、'),
|
|
346
|
+
...(result.pruned ? ['已清理旧类型素材:' + result.pruned + ' 条'] : []),
|
|
347
|
+
'查询脚本:' + tools.queryScript,
|
|
348
|
+
'校验脚本:' + tools.validateScript,
|
|
349
|
+
'',
|
|
350
|
+
'初始化只补齐 schema/索引,不会清空合法素材;已下线的 builtin kind 会被清理。',
|
|
351
|
+
].join('\n'));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function runRemove(ctx, invocation, session) {
|
|
355
|
+
const root = workspaceOf(session);
|
|
356
|
+
const asked = await askUserQuestion(ctx, invocation, [{
|
|
357
|
+
id: 'confirm_material_remove',
|
|
358
|
+
header: '删除素材库',
|
|
359
|
+
question: '确定删除整个素材库吗?\n数据库:' + materialDatabaseName(readWorkspace(root)) + '\n所有已生成素材都会丢失,且不能撤销。',
|
|
360
|
+
options: [
|
|
361
|
+
{ label: '取消', description: '不删除任何素材' },
|
|
362
|
+
{ label: '删除素材库', description: '删除整个 material_library 数据库' },
|
|
363
|
+
],
|
|
364
|
+
}]);
|
|
365
|
+
if (!asked.ok) {
|
|
366
|
+
return notify(session, 'error', asked.failure?.message || '❌ 提问失败');
|
|
367
|
+
}
|
|
368
|
+
const selected = asked.answer?.answers?.[0]?.selected?.[0];
|
|
369
|
+
if (selected !== '删除素材库') return notify(session, 'success', '操作已取消。');
|
|
370
|
+
const result = await removeMaterialDatabase(root);
|
|
371
|
+
return notify(session, 'success', [
|
|
372
|
+
'✅ 已删除素材库:' + result.database,
|
|
373
|
+
'运行 /fancy-material init 可重新创建 schema 和索引。',
|
|
374
|
+
].join('\n'));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function runExport(ctx, invocation, session) {
|
|
378
|
+
const root = workspaceOf(session);
|
|
379
|
+
await ensureMaterialDatabase(root);
|
|
380
|
+
const result = await exportMaterials(root);
|
|
381
|
+
return notify(session, 'success', exportResultText(result));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function runGenerate(ctx, invocation, session, focusRaw = '') {
|
|
385
|
+
const root = workspaceOf(session);
|
|
386
|
+
const focus = resolveFocus(focusRaw);
|
|
387
|
+
if (focusRaw && !focus.kinds.length) {
|
|
388
|
+
return notify(session, 'error', '❌ 未识别素材类型:' + focusRaw
|
|
389
|
+
+ '\n可用示例:地名、人名、姓氏、门派、技能、超能力、武器、颜色、特效、脑洞、人物性格、关系、场景。');
|
|
390
|
+
}
|
|
391
|
+
// No-arg generation asks which topic_decision report to use when several
|
|
392
|
+
// exist. Focused runs keep the previous behavior (latest report) so a
|
|
393
|
+
// targeted one-kind request does not require an extra click.
|
|
394
|
+
const topicFiles = !focusRaw ? listTopicDecisionFiles(root) : [];
|
|
395
|
+
let selectedTopicFile = null;
|
|
396
|
+
if (topicFiles.length > 1) {
|
|
397
|
+
const options = topicFiles.map((file) => {
|
|
398
|
+
let genres = [];
|
|
399
|
+
try { genres = parseTopicDecisionGenres(fs.readFileSync(file, 'utf8')); } catch { /* preview only */ }
|
|
400
|
+
return {
|
|
401
|
+
label: path.basename(file),
|
|
402
|
+
description: genres.length ? genres.join('、') : '未解析到选题方向',
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
const asked = await askUserQuestion(ctx, invocation, [{
|
|
406
|
+
id: 'material-topic-file',
|
|
407
|
+
header: '选择选题方向',
|
|
408
|
+
question: '📊 请选择本次素材库使用的 topic_decision 文件:',
|
|
409
|
+
detail: '只会读取选中文件里的「选题方向」,不会合并多个文件。',
|
|
410
|
+
options,
|
|
411
|
+
}]);
|
|
412
|
+
if (!asked.ok) {
|
|
413
|
+
return notify(session, 'error', asked.failure?.message || '❌ 提问失败');
|
|
414
|
+
}
|
|
415
|
+
const selected = asked.answer?.answers?.[0]?.selected?.[0];
|
|
416
|
+
if (!selected) return notify(session, 'error', '已取消素材生成。');
|
|
417
|
+
selectedTopicFile = topicFiles.find((file) => path.basename(file) === selected) || null;
|
|
418
|
+
if (!selectedTopicFile) {
|
|
419
|
+
return notify(session, 'error', '❌ 未找到选中的选题文件:' + selected);
|
|
420
|
+
}
|
|
421
|
+
} else if (topicFiles.length === 1) {
|
|
422
|
+
selectedTopicFile = topicFiles[0];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const config = generationConfig();
|
|
426
|
+
await ensureMaterialDatabase(root);
|
|
427
|
+
const tools = deployMaterialTools(root);
|
|
428
|
+
if (!tools.ok) {
|
|
429
|
+
return notify(session, 'error', '❌ 部署素材查询脚本失败:\n' + (tools.problems.join('\n') || '未知错误'));
|
|
430
|
+
}
|
|
431
|
+
const database = materialDatabaseName(readWorkspace(root));
|
|
432
|
+
const plan = resolveMaterialGenres(root, { topicFile: selectedTopicFile || undefined });
|
|
433
|
+
if (selectedTopicFile && plan.mode !== 'topic_decision') {
|
|
434
|
+
return notify(session, 'error', '❌ 选中的文件没有解析到「选题方向」:' + path.basename(selectedTopicFile));
|
|
435
|
+
}
|
|
436
|
+
const counts = await countByKind(root, plan.genres);
|
|
437
|
+
const kinds = [...new Set(focus.kinds.length ? focus.kinds : MATERIAL_KINDS)];
|
|
438
|
+
const planned = planMaterialQueue({ kinds, target: config.targetPerKind });
|
|
439
|
+
|
|
440
|
+
const startedAt = Date.now();
|
|
441
|
+
const batchId = batchIdOfNow();
|
|
442
|
+
const stamp = batchId.replace(/^material-/, '');
|
|
443
|
+
const runDir = path.join(workspacePaths(root).tmp, 'fancy_material_' + stamp);
|
|
444
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
445
|
+
const progressFile = path.join(runDir, 'progress.json');
|
|
446
|
+
const batchLog = path.join(runDir, 'batches.jsonl');
|
|
447
|
+
const existing = await buildExistingIndex(root, plan.genres, stamp);
|
|
448
|
+
const base = renderBasePrompt({
|
|
449
|
+
mode: plan.mode,
|
|
450
|
+
genres: plan.genres,
|
|
451
|
+
topicFile: plan.topicFile,
|
|
452
|
+
existingIndex: existing.file,
|
|
453
|
+
existingTotal: existing.count,
|
|
454
|
+
batchId,
|
|
455
|
+
workspace: root,
|
|
456
|
+
focus,
|
|
457
|
+
queryScript: tools.queryScript,
|
|
458
|
+
validateScript: tools.validateScript,
|
|
459
|
+
targetPerKind: config.targetPerKind,
|
|
460
|
+
batchSize: config.batchSize,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const queue = planned.all.map((item) => ({
|
|
464
|
+
kind: item.kind,
|
|
465
|
+
label: KIND_LABELS[item.kind] || item.kind,
|
|
466
|
+
db_existing: Number(counts[item.kind]) || 0,
|
|
467
|
+
generated: item.generated,
|
|
468
|
+
target: item.target,
|
|
469
|
+
remaining: item.remaining,
|
|
470
|
+
status: item.status,
|
|
471
|
+
batches: 0,
|
|
472
|
+
imported: 0,
|
|
473
|
+
reason: '',
|
|
474
|
+
}));
|
|
475
|
+
const pending = queue.filter((item) => item.remaining > 0);
|
|
476
|
+
|
|
477
|
+
const report = {
|
|
478
|
+
status: 'running',
|
|
479
|
+
mode: 'per_run_quota',
|
|
480
|
+
started_at: new Date(startedAt).toISOString(),
|
|
481
|
+
updated_at: new Date(startedAt).toISOString(),
|
|
482
|
+
batch_id: batchId,
|
|
483
|
+
database,
|
|
484
|
+
workspace: root,
|
|
485
|
+
target_per_kind: config.targetPerKind,
|
|
486
|
+
batch_size: config.batchSize,
|
|
487
|
+
genre_mode: plan.mode,
|
|
488
|
+
genres: plan.genres,
|
|
489
|
+
kinds: queue,
|
|
490
|
+
};
|
|
491
|
+
const saveProgress = () => {
|
|
492
|
+
report.updated_at = new Date().toISOString();
|
|
493
|
+
writeProgress(progressFile, report);
|
|
494
|
+
};
|
|
495
|
+
saveProgress();
|
|
496
|
+
|
|
497
|
+
const waitForTurn = makeWaitForTurn(invocation, {
|
|
498
|
+
timeoutMs: GENERATION_TIMEOUT_MS,
|
|
499
|
+
idleGraceMs: GENERATION_IDLE_GRACE_MS,
|
|
500
|
+
signal: invocation.signal,
|
|
501
|
+
changed: (files, before) => {
|
|
502
|
+
try {
|
|
503
|
+
return fs.readFileSync(files[0], 'utf8') !== before;
|
|
504
|
+
} catch {
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const completed = [];
|
|
511
|
+
const incomplete = [];
|
|
512
|
+
let importedTotal = 0;
|
|
513
|
+
let batchCount = 0;
|
|
514
|
+
let stopReason = '';
|
|
515
|
+
let aborted = false;
|
|
516
|
+
let fatal = false;
|
|
517
|
+
let kindFailureStreak = 0;
|
|
518
|
+
const maxBatchesPerKind = Math.max(1, Math.ceil(config.targetPerKind / config.batchSize) * 4);
|
|
519
|
+
|
|
520
|
+
for (let index = 0; index < pending.length; index += 1) {
|
|
521
|
+
const item = pending[index];
|
|
522
|
+
item.status = 'running';
|
|
523
|
+
let stalled = 0;
|
|
524
|
+
let lastReason = '';
|
|
525
|
+
|
|
526
|
+
while (item.generated < config.targetPerKind && item.batches < maxBatchesPerKind) {
|
|
527
|
+
if (abortRequested(invocation)) {
|
|
528
|
+
aborted = true;
|
|
529
|
+
lastReason = '用户中断';
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
const next = nextBatchRequest({
|
|
533
|
+
generated: item.generated,
|
|
534
|
+
target: config.targetPerKind,
|
|
535
|
+
batchSize: config.batchSize,
|
|
536
|
+
});
|
|
537
|
+
if (!next.requestCount) break;
|
|
538
|
+
|
|
539
|
+
const batchNo = item.batches + 1;
|
|
540
|
+
const file = path.join(
|
|
541
|
+
runDir,
|
|
542
|
+
String(index + 1).padStart(3, '0') + '_' + item.kind + '_' + String(batchNo).padStart(2, '0') + '.jsonl',
|
|
543
|
+
);
|
|
544
|
+
const includeBase = batchNo === 1 || batchNo % FULL_PROMPT_EVERY === 1;
|
|
545
|
+
let attemptDetail = '';
|
|
546
|
+
let accepted = false;
|
|
547
|
+
|
|
548
|
+
for (let attempt = 1; attempt <= GENERATION_ATTEMPTS; attempt += 1) {
|
|
549
|
+
if (abortRequested(invocation)) {
|
|
550
|
+
aborted = true;
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
let before = null;
|
|
554
|
+
try { before = fs.readFileSync(file, 'utf8'); } catch { /* first attempt */ }
|
|
555
|
+
|
|
556
|
+
const prompt = generationPrompt({
|
|
557
|
+
base,
|
|
558
|
+
target: file,
|
|
559
|
+
workspace: root,
|
|
560
|
+
batchId,
|
|
561
|
+
attempt,
|
|
562
|
+
failures: attemptDetail ? [attemptDetail] : [],
|
|
563
|
+
mode: plan.mode,
|
|
564
|
+
genres: plan.genres,
|
|
565
|
+
focus,
|
|
566
|
+
targetPerKind: config.targetPerKind,
|
|
567
|
+
batchSize: config.batchSize,
|
|
568
|
+
kind: item.kind,
|
|
569
|
+
kindLabel: item.label,
|
|
570
|
+
kindIndex: index + 1,
|
|
571
|
+
kindTotal: pending.length,
|
|
572
|
+
generated: item.generated,
|
|
573
|
+
dbExisting: item.db_existing,
|
|
574
|
+
requestCount: next.requestCount,
|
|
575
|
+
includeBase,
|
|
576
|
+
validateScript: tools.validateScript,
|
|
577
|
+
queryScript: tools.queryScript,
|
|
578
|
+
existingIndex: existing.file,
|
|
579
|
+
existingTotal: existing.count,
|
|
580
|
+
});
|
|
581
|
+
const sent = await llmFollowup(invocation, prompt, { appendTempRule: false });
|
|
582
|
+
if (!sent || sent.ok !== true) {
|
|
583
|
+
fatal = true;
|
|
584
|
+
stopReason = '提示词发送失败:' + ((sent && sent.error) || '未知错误');
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const waited = await waitForTurn([file], before);
|
|
589
|
+
if (!waited.ok && waited.code === 'ABORTED') {
|
|
590
|
+
aborted = true;
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
if (!waited.ok && waited.code === 'TIMEOUT') {
|
|
594
|
+
if (fs.existsSync(file)) {
|
|
595
|
+
const checked = await checkFile(file);
|
|
596
|
+
const genreProblem = checked.code === 0 ? genreCoverageProblem(file, plan.genres) : '';
|
|
597
|
+
const kindProblem = checked.code === 0 ? kindCoverageProblem(file, item.kind) : '';
|
|
598
|
+
const sourceProblem = checked.code === 0 ? sourceCoverageProblem(file) : '';
|
|
599
|
+
if (checked.code === 0 && !genreProblem && !kindProblem && !sourceProblem) {
|
|
600
|
+
try {
|
|
601
|
+
await importFile(root, file, { noPrefetch: true });
|
|
602
|
+
} catch (err) {
|
|
603
|
+
fatal = true;
|
|
604
|
+
stopReason = '等待超时后导入数据库失败:' + (err && err.message ? err.message : String(err));
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
const beforeCount = item.db_existing;
|
|
608
|
+
const afterCount = await countKind(root, item.kind, plan.genres);
|
|
609
|
+
const imported = Math.max(0, afterCount - beforeCount);
|
|
610
|
+
item.db_existing = afterCount;
|
|
611
|
+
item.generated += imported;
|
|
612
|
+
importedTotal += imported;
|
|
613
|
+
batchCount += 1;
|
|
614
|
+
item.batches = batchNo;
|
|
615
|
+
item.imported += imported;
|
|
616
|
+
item.remaining = Math.max(0, config.targetPerKind - item.generated);
|
|
617
|
+
try {
|
|
618
|
+
fs.appendFileSync(batchLog, JSON.stringify({
|
|
619
|
+
at: new Date().toISOString(),
|
|
620
|
+
kind: item.kind,
|
|
621
|
+
batch: batchNo,
|
|
622
|
+
file,
|
|
623
|
+
request_count: next.requestCount,
|
|
624
|
+
db_before: beforeCount,
|
|
625
|
+
db_after: afterCount,
|
|
626
|
+
imported,
|
|
627
|
+
generated: item.generated,
|
|
628
|
+
attempts: attempt,
|
|
629
|
+
timeout_salvage: true,
|
|
630
|
+
}) + '\n');
|
|
631
|
+
} catch { /* best effort */ }
|
|
632
|
+
accepted = true;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
fatal = true;
|
|
636
|
+
stopReason = '等待 LLM 写入超时' + (accepted ? ',已导入当前批次' : '');
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
if (!waited.ok && waited.code !== 'NO_CHANGE') {
|
|
640
|
+
attemptDetail = waited.reason || waited.code || '等待 LLM 写入文件失败';
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (!waited.ok && waited.code === 'NO_CHANGE') {
|
|
644
|
+
attemptDetail = 'LLM 本轮结束但目标文件没有变化';
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const checked = await checkFile(file);
|
|
649
|
+
if (checked.code !== 0) {
|
|
650
|
+
attemptDetail = scriptDetail(checked);
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
const genreProblem = genreCoverageProblem(file, plan.genres);
|
|
654
|
+
if (genreProblem) {
|
|
655
|
+
attemptDetail = genreProblem;
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
const kindProblem = kindCoverageProblem(file, item.kind);
|
|
659
|
+
if (kindProblem) {
|
|
660
|
+
attemptDetail = kindProblem;
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
const sourceProblem = sourceCoverageProblem(file);
|
|
664
|
+
if (sourceProblem) {
|
|
665
|
+
attemptDetail = sourceProblem;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
await importFile(root, file, { noPrefetch: true });
|
|
671
|
+
} catch (err) {
|
|
672
|
+
fatal = true;
|
|
673
|
+
stopReason = '导入数据库失败:' + (err && err.message ? err.message : String(err));
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
const beforeCount = item.db_existing;
|
|
677
|
+
const afterCount = await countKind(root, item.kind, plan.genres);
|
|
678
|
+
const imported = Math.max(0, afterCount - beforeCount);
|
|
679
|
+
item.db_existing = afterCount;
|
|
680
|
+
item.generated += imported;
|
|
681
|
+
importedTotal += imported;
|
|
682
|
+
batchCount += 1;
|
|
683
|
+
item.batches = batchNo;
|
|
684
|
+
item.imported += imported;
|
|
685
|
+
item.remaining = Math.max(0, config.targetPerKind - item.generated);
|
|
686
|
+
try {
|
|
687
|
+
fs.appendFileSync(batchLog, JSON.stringify({
|
|
688
|
+
at: new Date().toISOString(),
|
|
689
|
+
kind: item.kind,
|
|
690
|
+
batch: batchNo,
|
|
691
|
+
file,
|
|
692
|
+
request_count: next.requestCount,
|
|
693
|
+
db_before: beforeCount,
|
|
694
|
+
db_after: afterCount,
|
|
695
|
+
imported,
|
|
696
|
+
generated: item.generated,
|
|
697
|
+
attempts: attempt,
|
|
698
|
+
}) + '\n');
|
|
699
|
+
} catch { /* best effort */ }
|
|
700
|
+
accepted = true;
|
|
701
|
+
if (imported === 0) {
|
|
702
|
+
stalled += 1;
|
|
703
|
+
attemptDetail = '本批没有新增素材(可能全是重复),必须写库中不存在的新素材';
|
|
704
|
+
} else {
|
|
705
|
+
stalled = 0;
|
|
706
|
+
attemptDetail = '';
|
|
707
|
+
}
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (aborted || fatal) break;
|
|
712
|
+
if (!accepted) {
|
|
713
|
+
lastReason = attemptDetail || '未产出可导入的批次';
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
saveProgress();
|
|
717
|
+
if (item.generated >= config.targetPerKind) break;
|
|
718
|
+
if (stalled >= MAX_STALLED_BATCHES) {
|
|
719
|
+
lastReason = '连续 ' + stalled + ' 批没有新增素材';
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
item.remaining = Math.max(0, config.targetPerKind - item.generated);
|
|
725
|
+
if (item.generated >= config.targetPerKind) {
|
|
726
|
+
item.status = 'done';
|
|
727
|
+
completed.push({ ...item });
|
|
728
|
+
kindFailureStreak = 0;
|
|
729
|
+
} else {
|
|
730
|
+
item.status = aborted ? 'aborted' : 'incomplete';
|
|
731
|
+
item.reason = lastReason || (aborted ? '用户中断' : '未完成本次配额');
|
|
732
|
+
incomplete.push({ ...item });
|
|
733
|
+
kindFailureStreak += 1;
|
|
734
|
+
}
|
|
735
|
+
saveProgress();
|
|
736
|
+
if (aborted || fatal) break;
|
|
737
|
+
if (kindFailureStreak >= KIND_FAILURE_STREAK_LIMIT) {
|
|
738
|
+
stopReason = '连续 ' + kindFailureStreak + ' 个 kind 未完成,已停止以免继续空转';
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
let total;
|
|
744
|
+
try { total = await materialTotal(root); } catch { total = undefined; }
|
|
745
|
+
report.total = total;
|
|
746
|
+
report.imported_total = importedTotal;
|
|
747
|
+
report.batches = batchCount;
|
|
748
|
+
const notStarted = queue.filter((item) => item.status === 'pending' || item.status === 'running');
|
|
749
|
+
report.not_started = notStarted.map((item) => item.kind);
|
|
750
|
+
report.status = aborted
|
|
751
|
+
? 'aborted'
|
|
752
|
+
: ((incomplete.length || notStarted.length || fatal || stopReason) ? 'incomplete' : 'complete');
|
|
753
|
+
report.stop_reason = stopReason;
|
|
754
|
+
saveProgress();
|
|
755
|
+
|
|
756
|
+
return notify(session, (aborted || incomplete.length || notStarted.length || stopReason) ? 'error' : 'success', generationRunText({
|
|
757
|
+
database,
|
|
758
|
+
targetPerKind: config.targetPerKind,
|
|
759
|
+
batchSize: config.batchSize,
|
|
760
|
+
elapsedMs: Date.now() - startedAt,
|
|
761
|
+
queue,
|
|
762
|
+
completed,
|
|
763
|
+
incomplete,
|
|
764
|
+
notStarted,
|
|
765
|
+
importedTotal,
|
|
766
|
+
batches: batchCount,
|
|
767
|
+
total,
|
|
768
|
+
progressFile,
|
|
769
|
+
aborted,
|
|
770
|
+
stopReason,
|
|
771
|
+
}));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export async function apply(ctx) {
|
|
775
|
+
ctx.commands.register({
|
|
776
|
+
name: 'fancy-material',
|
|
777
|
+
description: '🧩 素材库 ( usage: /fancy-material [素材类型] | import <文件> | init | remove | prompt | export | help )',
|
|
778
|
+
input: { hint: '[素材类型] | import <文件> | init | remove | prompt | export' },
|
|
779
|
+
handler: async (invocation) => {
|
|
780
|
+
const session = invocation.agent.session;
|
|
781
|
+
const raw = String(invocation.rawInput || '').trim();
|
|
782
|
+
const tokens = raw.split(/\s+/).filter(Boolean);
|
|
783
|
+
const command = (tokens[0] || '').toLowerCase();
|
|
784
|
+
|
|
785
|
+
if (command === 'help' || command === '?') return notify(session, 'success', helpText());
|
|
786
|
+
|
|
787
|
+
if (command === 'prompt') {
|
|
788
|
+
if (tokens.length > 1) return notify(session, 'error', '用法:/fancy-material prompt(不接受额外参数)');
|
|
789
|
+
try {
|
|
790
|
+
let root = null;
|
|
791
|
+
try { root = workspaceOf(session); } catch { /* prompt also works outside a workspace */ }
|
|
792
|
+
const genres = allKnownGenres(root);
|
|
793
|
+
const rendered = renderBasePrompt({
|
|
794
|
+
mode: 'all_genres',
|
|
795
|
+
genres,
|
|
796
|
+
topicFile: '(prompt 子命令固定输出全题材)',
|
|
797
|
+
existingIndex: '(运行 /fancy-material 时由插件提供)',
|
|
798
|
+
existingTotal: undefined,
|
|
799
|
+
batchId: 'material-YYYYMMDD-01',
|
|
800
|
+
workspace: root || '(运行时提供)',
|
|
801
|
+
focus: null,
|
|
802
|
+
validateScript: VALIDATE_SCRIPT,
|
|
803
|
+
});
|
|
804
|
+
return notify(session, 'success', promptDisplayText(rendered));
|
|
805
|
+
} catch (err) {
|
|
806
|
+
return notify(session, 'error', '❌ ' + (err && err.message ? err.message : String(err)));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
try {
|
|
811
|
+
if (!command) return await runGenerate(ctx, invocation, session, '');
|
|
812
|
+
if (command === 'import') {
|
|
813
|
+
if (tokens.length < 2) return notify(session, 'error', '用法:/fancy-material import <文件.jsonl>');
|
|
814
|
+
return await runImport(ctx, invocation, session, tokens.slice(1).join(' '));
|
|
815
|
+
}
|
|
816
|
+
if (command === 'export') {
|
|
817
|
+
if (tokens.length > 1) return notify(session, 'error', '用法:/fancy-material export(不接受额外参数)');
|
|
818
|
+
return await runExport(ctx, invocation, session);
|
|
819
|
+
}
|
|
820
|
+
if (command === 'init') {
|
|
821
|
+
if (tokens.length > 1) return notify(session, 'error', '用法:/fancy-material init(不接受额外参数)');
|
|
822
|
+
return await runInit(ctx, invocation, session);
|
|
823
|
+
}
|
|
824
|
+
if (command === 'remove') {
|
|
825
|
+
if (tokens.length > 1) return notify(session, 'error', '用法:/fancy-material remove(不接受额外参数)');
|
|
826
|
+
return await runRemove(ctx, invocation, session);
|
|
827
|
+
}
|
|
828
|
+
// 其它参数视为素材类型重点,例如 /fancy-material 地名
|
|
829
|
+
return await runGenerate(ctx, invocation, session, raw);
|
|
830
|
+
} catch (err) {
|
|
831
|
+
return notify(session, 'error', '❌ ' + (err && err.message ? err.message : String(err)));
|
|
832
|
+
}
|
|
833
|
+
},
|
|
834
|
+
});
|
|
835
|
+
}
|