feat: local model, narrative health, engineering polish#180
feat: local model, narrative health, engineering polish#1802265514998-hash wants to merge 12 commits into
Conversation
## 新增功能 ### 本地 LLM / Workspace 支持 - 新增 LocalWorkspaceService,支持本地模型连接与管理 - 新增 LocalModelStore / LocalModelConnectPanel / LocalConnectPanel - 新增 local_llm_utils,统一本地模型探测与工具函数 - 新增 local_routes API 路由,暴露本地模型相关接口 - 新增 probe_local_models / test_local_import 脚本 ### 叙事健康监控 (Narrative Health) - 新增 narrative_health API 路由(302 行) - 新增 NarrativeHealthPanel 面板组件(259 行) - 新增 narrativeHealthStore 状态管理 - 新增 narrativeHealth API 客户端 ### Tiptap 富文本编辑器 - 新增 TiptapEditor.vue 组件(300 行),替换旧编辑器 - 新增 AIWritingAssist.vue 写作辅助组件(173 行) ### 自动驾驶面板 (Autopilot Cockpit) - 新增 AutopilotCockpitLayout 整体布局(290 行) - 新增 AutopilotCockpitChapterRail 章节轨道(110 行) - 新增 StoryPipelineBar 故事流水线栏(211 行) ### DAG 引擎 - 新增 domain/engine/dag/models.py(285 行),完善 DAG 领域模型 - 新增 state_bootstrap.py 状态初始化服务(57 行) ### SSE 流式处理 - 新增 sseStream.ts 工具(166 行),统一 SSE 事件处理 ## 改进与修复 - 更新 scene_generation_service 场景生成服务 - 更新 CI/CD workflow(backend/frontend) - 新增 ruff.toml 代码规范配置 - 优化前端多个 API 客户端与路由 - 更新依赖版本(package.json / package-lock.json) - 修复 Cast/Chapter/Home 视图交互问题 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
## 变更 ### pyproject.toml — 项目可 pip install - 添加 [build-system] (hatchling) - 添加 [project]: 名称 plotpilot、版本 3.0.0-alpha、Python 3.11+ - 添加 [project.optional-dependencies]: dev, local, export - 添加 [project.scripts]: plotpilot 和 plotpilot-server 入口 - 合并 ruff.toml 内容,统一配置 ### Docker 支持 - 新增 Dockerfile (python:3.11-slim 多阶段构建) - 新增 docker-compose.yml (后端 + ChromaDB) - 新增 .dockerignore ### 开发自动化 - 新增 Makefile: install, run, test, lint, format, clean, docker-* 等目标 - 新增 .pre-commit-config.yaml: ruff check + format + 基础检查 ### 测试目录清理 - tests/test_prop_lifecycle_syncer.py → tests/unit/application/prop/ - tests/test_tension_score_integration.py → tests/integration/application/analyst/ - tests/dag/ (9 files) → tests/unit/application/engine/dag/ ### 文档 - 新增 docs/SETUP.md: clone → run 完整三平台指南 ## 测试验证 878 passed, 44 pre-existing failures (API keys/external services), 0 regressions Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds local workspace scan/import and local-LLM probe/connect endpoints and UI; introduces narrative-health API, stores, and panel; migrates editor to TipTap with related UI components and SSE utility; adds DAG models, storage/persistence hardening, provider/key normalization, Docker/CI/tooling, and many frontend API/type cleanups. ChangesCore feature integration and platform hardening
Estimated code review effort Possibly related PRs
Suggested reviewers
Poem
✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
infrastructure/persistence/database/connection.py (1)
353-379:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftDo not mark a multi-statement migration as applied after a partial failure.
executescript()stops at the first failing statement. If an earlier statement succeeded and a later one hitsduplicate column/already exists, this code records the entire file inmigrations_applied, so the remaining statements are never retried. That leaves the schema permanently half-migrated on that database.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@infrastructure/persistence/database/connection.py` around lines 353 - 379, The current handler marks migration_file as applied on sqlite3.OperationalError (e.g., "duplicate column") which can hide partial failures from executescript(); fix by making migration application atomic and only record migrations_applied after the whole script succeeds: either wrap the executescript call in an explicit transaction (BEGIN; executescript; COMMIT) so any failing statement rolls back, or split the script and execute each statement in a single transaction and only INSERT into migrations_applied when all statements complete without exception; update the error handling around conn.executescript / sqlite3.OperationalError and the use of migration_file / conn so that duplicate/unsupported-function errors do not cause a premature INSERT into migrations_applied.frontend/src/components/workbench/WorkArea.vue (1)
282-294:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDefine
onSidebarChapterSelectbefore wiring the event.Line 293 references a handler that does not exist in this
<script setup>. As written, cockpit chapter clicks never propagate anywhere, and the template now depends on an undefined symbol.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/workbench/WorkArea.vue` around lines 282 - 294, Define the missing onSidebarChapterSelect handler inside the <script setup>; implement a function onSidebarChapterSelect(chapterId) that updates the component's reactive currentChapterId/ref to the selected chapter and then forwards the selection to the existing logic (for example call an existing handler such as handleChapterContentUpdate or emit an upward event) so clicks from AutopilotWorkspace actually change state and trigger the same downstream behavior as the other handlers.
🟠 Major comments (23)
.github/workflows/backend-ci.yml-15-15 (1)
15-15:⚠️ Potential issue | 🟠 Major | ⚡ Quick winAdd
persist-credentials: falseto checkout actions.The
actions/checkoutsteps should setpersist-credentials: falseto prevent GitHub credentials from persisting in the repository checkout, reducing the attack surface if subsequent steps are compromised.🔒 Proposed fix
- - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + persist-credentials: falseApply the same change to line 52.
Also applies to: 52-52
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/backend-ci.yml at line 15, Update both actions/checkout@v4 steps to include persist-credentials: false so GitHub credentials are not persisted in the workspace; locate the two occurrences of "uses: actions/checkout@v4" and add a corresponding "with: persist-credentials: false" block under each occurrence (also apply the same change to the second checkout at the other occurrence mentioned)..github/workflows/frontend-ci.yml-48-49 (1)
48-49:⚠️ Potential issue | 🟠 Major | ⚡ Quick winRemove
|| trueto fail the build on high-severity vulnerabilities.The
npm audit --audit-level=high || truecommand will pass even when high or critical vulnerabilities are detected. This silently allows vulnerable dependencies into production. Either remove|| trueto fail on vulnerabilities, or explicitly document why known vulnerabilities are acceptable.🔒 Proposed fix to fail on vulnerabilities
- - name: Audit dependencies - run: npm audit --audit-level=high || true + - name: Audit dependencies + run: npm audit --audit-level=high🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/frontend-ci.yml around lines 48 - 49, The CI step named "Audit dependencies" currently runs "npm audit --audit-level=high || true" which masks high-severity findings; modify the command in that step (the run line containing npm audit --audit-level=high || true) to remove the "|| true" so the job fails on high/critical vulnerabilities, or alternatively replace it with a documented exception mechanism if specific vulnerabilities are accepted..github/workflows/frontend-ci.yml-16-16 (1)
16-16:⚠️ Potential issue | 🟠 Major | ⚡ Quick winAdd
persist-credentials: falseto checkout action.The
actions/checkoutstep should setpersist-credentials: falseto prevent GitHub credentials from persisting in the repository checkout.🔒 Proposed fix
- - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + persist-credentials: falseApply the same change to line 37.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/frontend-ci.yml at line 16, Add persist-credentials: false to the actions/checkout steps to avoid persisting GitHub credentials; locate the two occurrences of "uses: actions/checkout@v4" in the workflow and under each checkout step add the key "persist-credentials: false" (keeping existing keys like with: or fetch-depth intact) so both checkout actions use the non-persistent credentials configuration.Dockerfile-42-42 (1)
42-42:⚠️ Potential issue | 🟠 Major | ⚡ Quick winRun the application as a non-root user.
The container currently runs as root, which violates security best practices. If the application is compromised, an attacker would have root privileges inside the container.
🔒 Proposed fix to add non-root user
+# Create non-root user +RUN useradd -m -u 1000 plotpilot && \ + chown -R plotpilot:plotpilot /app + +USER plotpilot + # 启动 CMD ["python", "-m", "uvicorn", "interfaces.main:app", "--host", "0.0.0.0", "--port", "8005"]🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Dockerfile` at line 42, The Dockerfile currently runs the app as root via the CMD line; add a non-root user and switch to it before the CMD: create a user/group (e.g., appuser) with RUN useradd/groupadd or addgroup/adduser, ensure application directories and files referenced by the image (including the package install location and any runtime writable dirs) are owned by that user via chown, set proper permissions, and add a USER appuser instruction so the final CMD ["python", "-m", "uvicorn", "interfaces.main:app", "--host", "0.0.0.0", "--port", "8005"] runs as the non-root user.application/workspace/local_workspace_service.py-245-253 (1)
245-253:⚠️ Potential issue | 🟠 Major | ⚡ Quick winFilter blank chapters before freezing import numbers.
This block assigns
1..nacross every scanned file, but lines 278-280 can still skip empty content later. If the second file is blank, the third file keeps number3; that reintroduces the gap this method is explicitly trying to avoid, so lateradd_chapter()calls can fail their continuity check and get skipped too.Also applies to: 277-289
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@application/workspace/local_workspace_service.py` around lines 245 - 253, The loop assigns sequential chapter numbers before checking for blank content, causing gaps when empty files are skipped later; change the logic in the block that uses files, _load_file_content, ScannedChapterFile, _word_count and chapters_to_import so you first load and filter out blank/zero-word contents (use _word_count(content) or content.strip()) and only then enumerate to create ScannedChapterFile entries and assign the number field; this ensures add_chapter() continuity checks won't see missing numbers.interfaces/api/v1/analyst/narrative_health.py-261-287 (1)
261-287:⚠️ Potential issue | 🟠 Major | ⚡ Quick winUnavailable metrics are being scored as failures.
When
foreshadow.total == 0orlen(tension) < 3, this still adds the full weight toweight_sumbut contributes no points. A new novel therefore gets an artificially lowhealth_scoreeven if every available signal is healthy. These weights should be skipped, or the missing dimensions should get a neutral/full baseline.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@interfaces/api/v1/analyst/narrative_health.py` around lines 261 - 287, The foreshadow and tension branches are adding their weights to weight_sum even when metrics are unavailable, causing missing signals to be treated as failures; move the weight_sum += 30 into the foreshadow.total > 0 block and move weight_sum += 25 into the len(tension) >= 3 block (or alternatively set total += baseline_score * weight if you prefer neutral/full baseline) so that weight_sum only increases when that dimension actually contributes; keep the debts handling as-is since it already grants full points when debts.total == 0.application/workspace/local_workspace_service.py-522-525 (1)
522-525:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDo not auto-select a known non-chat model.
When every candidate scores below zero, this still returns
candidates[0]. On a machine exposing only embedding/rerank models, auto-connect will save an unusable chat profile and later completions will fail. Returning an empty result here is safer so the caller can ask for a real chat model.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@application/workspace/local_workspace_service.py` around lines 522 - 525, The current selection logic picks candidates[0] when the top-ranked model has a negative score, which can auto-select a non-chat model; change the branch where self._chat_model_score(best) < 0 to return an empty/no-selection value instead of candidates[0] (e.g., return None or an empty list consistent with the function's return type) so callers can detect "no chat model found" and prompt for a real chat model; update any callers if needed to handle this None/empty result.application/core/services/scene_generation_service.py-73-79 (1)
73-79:⚠️ Potential issue | 🟠 Major | ⚡ Quick winRetrieved context never reaches the model prompt.
_retrieve_relevant_context()now builds vector snippets plus character/location context, but_build_scene_prompt()still only consumesforeshadowingsfrom that dict. So this new retrieval path has no effect on scene generation beyond the foreshadowing list.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@application/core/services/scene_generation_service.py` around lines 73 - 79, The retrieved context from _retrieve_relevant_context (assigned to relevant_context) is not being used by _build_scene_prompt—only foreshadowings are consumed—so update the scene generation flow to pass the full relevant_context into _build_scene_prompt (or update _build_scene_prompt to accept and incorporate the other keys: vector snippets, character context, location context, etc.); locate the call site where relevant_context is created and ensure the call to _build_scene_prompt (and its signature) accepts and includes relevant_context so the vector snippets and character/location data are injected into the model prompt.application/workspace/local_workspace_service.py-37-59 (1)
37-59:⚠️ Potential issue | 🟠 Major | ⚡ Quick winChinese numerals above 99 are misparsed.
Inputs like
一百二十hit the"十"branch first, soparts[0]becomes"一百二"and this returns10instead of120. Becauseconnect_import_manuscript()sorts on this number before renumbering, manuscripts using百/千chapter names can be imported in the wrong order.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@application/workspace/local_workspace_service.py` around lines 37 - 59, The parser misparses numbers like "一百二十" because the early "if '十' in token" branch runs before handling "百"/"千"; update _parse_chinese_numeral to handle higher-order units first by replacing the ad-hoc "十" split with a unit-aware parse that recognizes digits and multipliers (十, 百, 千) using _CN_NUM and multiplier logic, or at minimum check for "百" or "千" and parse through the existing loop before applying the "十" shortcut; keep the function name _parse_chinese_numeral and use _CN_NUM for digit lookups so inputs like "一百二十" return 120 and "一千二百三十四" return 1234.frontend/src/stores/narrativeHealthStore.ts-10-16 (1)
10-16:⚠️ Potential issue | 🟠 Major | ⚡ Quick winCache is shared across all novels.
After the first successful load,
loadHealth()returnsdata.valuefor every laternovelIdunless callers remember to passforce=trueor clear the store first. Because this store is app-scoped, switching books can display the previous novel's narrative health.Suggested fix
export const useNarrativeHealthStore = defineStore('narrativeHealth', () => { const data = ref<NarrativeHealthResponse | null>(null) + const loadedNovelId = ref<string | null>(null) const loading = ref(false) const error = ref<string | null>(null) async function loadHealth(novelId: string, force = false) { - if (!force && data.value !== null) return data.value + if (!force && data.value !== null && loadedNovelId.value === novelId) return data.value loading.value = true error.value = null try { data.value = await narrativeHealthApi.getHealth(novelId) + loadedNovelId.value = novelId return data.value } catch (err) { error.value = err instanceof Error ? err.message : '加载叙事健康数据失败' throw err } finally { loading.value = false } } function clear() { data.value = null + loadedNovelId.value = null error.value = null }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/stores/narrativeHealthStore.ts` around lines 10 - 16, The cache is currently global (data.value) so loadHealth(novelId: string) returns the same cached value for every novel; modify the store to cache per-novel (e.g., replace data.value with a Map keyed by novelId or add a lastLoadedNovelId and per-novel storage) and change loadHealth to check that the cached entry corresponds to the requested novelId before returning early; update writes to set the map entry (or set lastLoadedNovelId and its data) when narrativeHealthApi.getHealth(novelId) succeeds, and ensure callers still get the right value when force=true.docker-compose.yml-25-25 (1)
25-25:⚠️ Potential issue | 🟠 Major | ⚡ Quick winPin ChromaDB to a specific version.
Using the
:latesttag can lead to unexpected breaking changes and makes deployments non-reproducible.📌 Proposed fix
- image: chromadb/chroma:latest + image: chromadb/chroma:0.4.22Check the ChromaDB releases for the latest stable version.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docker-compose.yml` at line 25, The docker-compose image for ChromaDB is pinned to :latest which is unsafe; update the image reference (image: chromadb/chroma:latest) to a fixed release tag (for example image: chromadb/chroma:<stable-version>) by choosing a specific version from the ChromaDB Docker tags/releases and replace :latest with that exact tag to ensure reproducible, stable deployments.docker-compose.yml-12-12 (1)
12-12:⚠️ Potential issue | 🟠 Major | ⚡ Quick winHandle missing
.envfile gracefully.The
.envmount will causedocker-compose upto fail if the file doesn't exist, which affects first-time setup.🛡️ Proposed fix
Option 1: Use
.env.exampleas fallback (recommended):volumes: - plotpilot_data:/app/data - plotpilot_sessions:/app/sessions - - ./.env:/app/.env:ro + - ${PWD}/.env:/app/.env:roAnd create a
.env.examplefile, then updatedocs/SETUP.mdto instruct users to copy it.Option 2: Make the mount conditional using an environment variable:
volumes: - plotpilot_data:/app/data - plotpilot_sessions:/app/sessions - ${ENV_FILE:-.env.example}:/app/.env:roOption 3: Document the requirement in the Makefile/scripts to create
.envbefore runningdocker-compose up.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docker-compose.yml` at line 12, The docker-compose.yml volume mount "- ./.env:/app/.env:ro" will fail if .env is missing; update the compose to fall back to an example file and add guidance: create a .env.example in the repo with defaults, change the volumes entry to use an environment-variable fallback (e.g., ${ENV_FILE:-.env.example}:/app/.env:ro) so first-time setup succeeds, and update docs/SETUP.md to instruct users to copy .env.example to .env or set ENV_FILE; ensure the new .env.example contains all required keys referenced by the app.frontend/src/components/global/LocalModelConnectPanel.vue-143-154 (1)
143-154:⚠️ Potential issue | 🟠 Major | ⚡ Quick winHardcoded service type may mismatch manual URL input.
handleManualConnectalways passes'ollama'as the service type, but users may manually enter URLs for LM Studio, LocalAI, or other OpenAI-compatible services. If the backend differentiates behavior by service type, this could cause connection failures or incorrect configuration.Consider either:
- Adding a service-type selector in the manual-address UI
- Inferring the service type from the base URL pattern
- Using a generic
'openai_compatible'type for manual connections🔧 Option 2: Infer service from URL
async function handleManualConnect() { + const serviceType = manualBaseUrl.value.includes(':11434') ? 'ollama' + : manualBaseUrl.value.includes(':1234') ? 'lmstudio' + : 'openai_compatible' const ok = await store.connectManual( - 'ollama', + serviceType, manualBaseUrl.value.trim(), manualModel.value.trim(), false, )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/global/LocalModelConnectPanel.vue` around lines 143 - 154, The manual connect handler hardcodes the service type 'ollama' in handleManualConnect when calling store.connectManual which can misclassify other services; update handleManualConnect to determine the service type dynamically by either (a) reading a new manualService selector input (e.g., manualService.value) and passing that to store.connectManual, or (b) inferring from manualBaseUrl.value (match known patterns like 'lmstudio', 'localai', 'ollama' or default to 'openai_compatible'), and then call store.connectManual(serviceType, manualBaseUrl.value.trim(), manualModel.value.trim(), false); ensure the chosen branch also updates the UI to add the selector if you pick option (a).interfaces/api/v1/workspace/local_routes.py-298-302 (1)
298-302:⚠️ Potential issue | 🟠 Major | ⚡ Quick winFix incomplete RFC1918 private address range coverage.
Line 299 attempts to allow RFC1918 private addresses but the pattern is incomplete and inconsistent. The current list has entries like
"172.16.","172.17.", etc. with trailing dots, but"172.2"without a dot. This would match addresses starting with172.2(like172.20,172.21, etc.) but is inconsistent with the pattern and misses172.20.through172.29.when matched as prefixes.RFC1918 specifies
172.16.0.0/12, meaning172.16through172.31. The allowlist should consistently cover all these ranges.🔒 Fix the RFC1918 range coverage
# 限制探针范围,防止 SSRF 放大攻击 - _ALLOWED_HOST_PREFIXES = ("127.0.0.1", "localhost", "0.0.0.0", "::1", "192.168.", "10.", "172.16.", "172.17.", "172.18.", "172.19.", "172.2", "172.30.", "172.31.") + _ALLOWED_HOST_PREFIXES = ( + "127.0.0.1", "localhost", "0.0.0.0", "::1", + "192.168.", "10.", + "172.16.", "172.17.", "172.18.", "172.19.", + "172.20.", "172.21.", "172.22.", "172.23.", + "172.24.", "172.25.", "172.26.", "172.27.", + "172.28.", "172.29.", "172.30.", "172.31.", + )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@interfaces/api/v1/workspace/local_routes.py` around lines 298 - 302, The _ALLOWED_HOST_PREFIXES list and the host filtering in host_list are missing and inconsistently specifying the RFC1918 172.16.0.0/12 range (the entry "172.2" is wrong). Update the _ALLOWED_HOST_PREFIXES used by host_list (and any uses of hosts) to consistently cover 172.16. through 172.31. (e.g., "172.16.", "172.17.", ..., "172.31.") or, preferably, replace the prefix-check with an IP-aware check using the ipaddress module to parse each host and test .is_private; ensure trailing dots/prefix logic is consistent so only intended private addresses pass.frontend/src/components/editor/AIWritingAssist.vue-82-114 (1)
82-114:⚠️ Potential issue | 🟠 Major | ⚡ Quick winIncomplete AI action implementation with unused API call.
The
handleActionfunction makes a fetch request to/api/v1/llm-control/models(lines 96-104) but never uses the response. Instead, it always setsaiResultto a hardcoded placeholder string (line 108). This results in:
- Wasted network request: The fetch is fired but the response is discarded
- Misleading UX: Users see placeholder text instead of real AI results
- Dead code: The entire fetch block serves no purpose
🔧 Suggested interim fix
Either remove the unused fetch call until the real implementation is ready:
async function handleAction(action: 'rewrite' | 'expand' | 'shorten' | 'dialogue') { if (!selectedText.value || loading.value) return loading.value = true aiResult.value = '' try { const actionLabels: Record<string, string> = { rewrite: '改写以下文本,保持原意但换一种表达方式', expand: '扩写以下文本,增加细节和描写,使内容更丰富', shorten: '缩写以下文本,保留核心信息,去除冗余', dialogue: '将以下叙述转换为角色对白形式,保持信息完整', } - const prompt = actionLabels[action] - const response = await fetch('/api/v1/llm-control/models', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - provider: 'openai', - api_key: '', - base_url: '', - }), - }) - - // 使用简化的 LLM 调用(通过现有工作台的 AI 能力) - // 这里先用 placeholder,实际接入时替换为真实 LLM 调用 + // TODO: Implement real LLM call aiResult.value = `[${action === 'rewrite' ? '改写' : action === 'expand' ? '扩写' : action === 'shorten' ? '缩写' : '对白化'}结果将在此显示]\n\n原文:${selectedText.value.slice(0, 50)}...`Or wire it up to actually use the API response when the endpoint is ready.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/editor/AIWritingAssist.vue` around lines 82 - 114, The fetch call in handleAction is sent to '/api/v1/llm-control/models' but its response is never used, while aiResult is set to a hardcoded placeholder; either remove the unused fetch block or wire it to use the response: call fetch (or the real LLM API) and await/parse the JSON, extract the generated text and assign it to aiResult.value instead of the placeholder, while preserving error handling and loading state; update references to selectedText, loading, and aiResult so the actual API output is shown to the user (or remove the fetch lines entirely until the real endpoint is implemented).pyproject.toml-83-83 (1)
83-83:⚠️ Potential issue | 🟠 Major | ⚡ Quick winRemove
-p no:warningsto avoid hiding real issues.Disabling the pytest warnings plugin suppresses all warnings, including deprecation warnings, resource leaks, and API migration notices. These warnings often signal real problems (e.g., unclosed database connections, deprecated library calls) that should be addressed rather than hidden.
♻️ Proposed fix to re-enable warnings
-addopts = ["-v", "--strict-markers", "-p", "no:warnings"] +addopts = ["-v", "--strict-markers"]If specific warnings are noisy, filter them selectively using
filterwarningsinstead:[tool.pytest.ini_options] filterwarnings = [ "ignore::DeprecationWarning:some_noisy_library.*", ]🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@pyproject.toml` at line 83, Remove the "-p", "no:warnings" entries from the addopts array in pyproject.toml so pytest warnings are not globally suppressed (locate the addopts line containing "-p" and "no:warnings"); if certain noisy warnings need silencing, add a [tool.pytest.ini_options] filterwarnings array with targeted rules (e.g., "ignore::DeprecationWarning:some_noisy_library.*") instead of disabling the warnings plugin entirely.infrastructure/persistence/storage/file_storage.py-35-39 (1)
35-39: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick winUse
Path.is_relative_to()for path traversal validation.The current string-based prefix check is fragile and may have edge cases. Python 3.9+ provides
Path.is_relative_to()specifically for this use case, which is more robust and handles path normalization correctly.🔒 Recommended fix using `is_relative_to()` (Python 3.9+)
- full_path = (self.base_path / path).resolve() - base_resolved = self.base_path.resolve() - if not (full_path == base_resolved or str(full_path).startswith(str(base_resolved) + '\\') or str(full_path).startswith(str(base_resolved) + '/')): - raise ValueError(f"路径越界: {path}") - return full_path + full_path = (self.base_path / path).resolve() + base_resolved = self.base_path.resolve() + try: + full_path.relative_to(base_resolved) + except ValueError: + raise ValueError(f"路径越界: {path}") + return full_pathOr if Python 3.9+ is available:
full_path = (self.base_path / path).resolve() base_resolved = self.base_path.resolve() if not full_path.is_relative_to(base_resolved): raise ValueError(f"路径越界: {path}") return full_path🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@infrastructure/persistence/storage/file_storage.py` around lines 35 - 39, Replace the fragile string-prefix path validation in the block that computes full_path and base_resolved with Path.is_relative_to(); specifically, after computing full_path = (self.base_path / path).resolve() and base_resolved = self.base_path.resolve(), call full_path.is_relative_to(base_resolved) and if it returns False raise the same ValueError(f"路径越界: {path}") — ensure you keep resolving both paths before the check and note this requires Python 3.9+ (if older Python must keep a robust fallback).interfaces/api/v1/workbench/llm_control.py-166-167 (1)
166-167:⚠️ Potential issue | 🟠 Major | ⚡ Quick winLog the actual exception before raising a generic error message.
Similar to other endpoints, this catch-all handler now returns a generic error without logging the underlying exception, making debugging difficult.
📝 Proposed fix
except Exception as exc: + logger.exception("list_models failed with protocol=%s base_url=%s", api_format, base_url) raise HTTPException(status_code=502, detail="操作失败,请稍后重试") from exc🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@interfaces/api/v1/workbench/llm_control.py` around lines 166 - 167, The catch-all except block that currently does "except Exception as exc: raise HTTPException(...)" should log the actual exception before re-raising the generic HTTPException; update the handler surrounding the except Exception as exc (the block that raises HTTPException(status_code=502, detail="操作失败,请稍后重试")) to call the module logger (e.g., logger.exception(...) or logger.error(..., exc_info=True)) or process logger used elsewhere, passing the exception/traceback and context, then raise the same HTTPException to preserve the external message.interfaces/api/v1/engine/generation.py-397-401 (1)
397-401:⚠️ Potential issue | 🟠 Major | ⚡ Quick winLog the actual exception before raising a generic error message.
This error handler (and several others at lines 455, 556, 613, 638, 684, 738, 928, 969) now returns a generic
"操作失败,请稍后重试"message without logging the underlying exception. While this prevents leaking internal details to clients, it makes debugging impossible when issues occur.📝 Proposed fix to add logging
except Exception as e: + logger.exception("get_storyline_graph_data failed for novel %s", novel_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="操作失败,请稍后重试" )Apply similar logging to all affected handlers (lines 455, 556, 613, 638, 684, 738, 928, 969).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@interfaces/api/v1/engine/generation.py` around lines 397 - 401, The except blocks in interfaces/api/v1/engine/generation.py (e.g., the generic "except Exception as e" handlers around the HTTPException raises) swallow the original exception; update each handler (the blocks at the shown location and the other affected handlers referenced in the comment) to log the caught exception before re-raising the generic HTTPException. Use the module logger (or the existing logger in scope) to call logger.exception(...) or logger.error(..., exc_info=True) with a short contextual message (e.g., "Generation endpoint error") and then raise the same HTTPException(detail="操作失败,请稍后重试") so clients still get the generic message but the real error is recorded for debugging.frontend/src/views/Chapter.vue-285-291 (1)
285-291:⚠️ Potential issue | 🟠 Major | ⚡ Quick winReplace the raw
watch(content)with a user-edit path.This watcher now fires for API hydration as well as typing.
loadChapter()setscontent.value, so untouched chapters get marked dirty and re-saved 30 seconds later. At the same time,updatePreview()still only lives inonInput(), so typing throughTiptapEditorno longer refreshes the Markdown preview. Gate programmatic loads with a hydration flag, or move both autosave and preview refresh into the editor-change path that only runs for real edits.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/views/Chapter.vue` around lines 285 - 291, The watcher on content (watch(content)) is firing for programmatic loads from loadChapter() and marking chapters dirty; change the logic to only treat real user edits as dirty: add a hydration flag (e.g., isHydrating or isProgrammaticUpdate) that loadChapter() sets around assigning content.value, or instead move autosave and preview refresh into the editor-change path (onInput() / TiptapEditor change event) so saveStatus, saveTimer and saveContent() are only triggered by user input and updatePreview() is called from the same user-edit handler; ensure any programmatic set of content.value clears or bypasses the watcher so untouched chapters are not marked unsaved.frontend/src/utils/llmPanelNormalize.ts-71-80 (1)
71-80:⚠️ Potential issue | 🟠 Major | ⚡ Quick winClamp
active_profile_idto the normalized profile list.If the backend returns a stale
active_profile_id, or that profile gets dropped by the filter above, this still preserves the missing id. The normalized config can then point at a profile that no longer exists. Fall back to the first surviving profile unless the requested id is still present.Suggested fix
+ const activeProfileId = + typeof cfgObj.active_profile_id === 'string' && + profiles.some((p) => p.id === cfgObj.active_profile_id) + ? cfgObj.active_profile_id + : profiles[0]?.id ?? null + const config: LLMControlConfig = { version: typeof cfgObj.version === 'number' ? cfgObj.version : 1, - active_profile_id: - typeof cfgObj.active_profile_id === 'string' - ? cfgObj.active_profile_id - : profiles[0]?.id ?? null, + active_profile_id: activeProfileId, endpoint_mode: cfgObj.endpoint_mode === 'independent' ? 'independent' : 'unified', profiles, }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/utils/llmPanelNormalize.ts` around lines 71 - 80, The active_profile_id assignment in the LLMControlConfig may preserve a stale id; update the logic that sets config.active_profile_id to first validate that cfgObj.active_profile_id is a string and that an entry with that id exists in the normalized profiles list (e.g., use profiles.find(p => p.id === cfgObj.active_profile_id)); if the id is missing from profiles, fall back to profiles[0]?.id ?? null. Keep the type checks and the surrounding config object (LLMControlConfig, config, profiles, cfgObj) intact while replacing the current active_profile_id expression with this validation-and-fallback logic.frontend/src/main.ts-33-35 (1)
33-35:⚠️ Potential issue | 🟠 Major | ⚡ Quick winStartup now bypasses the user's persisted auto-connect preference.
autoConnectOnDetectis stored inlocalStorage, but this path always passesforce: true, so users who disabled auto-connect still get probed/reconnected on every reload. Reserveforcefor explicit user actions and let bootstrap respect the saved preference.Suggested fix
- void useLocalModelStore(pinia).connectLocalDirect({ quiet: true, force: true }) + void useLocalModelStore(pinia).connectLocalDirect({ quiet: true })🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/main.ts` around lines 33 - 35, The startup code always forces local model reconnection by calling useLocalModelStore(pinia).connectLocalDirect({ quiet: true, force: true }), which ignores the user's persisted autoConnectOnDetect preference in localStorage; change the call to respect that preference by reading autoConnectOnDetect from localStorage (or from the pinia store) and pass force only when the user explicitly requested it (e.g., from a user action) — remove or set force based on the stored autoConnectOnDetect value and keep quiet: true as before so bootstrap honors the saved preference instead of always probing.frontend/src/stores/localModelStore.ts-132-162 (1)
132-162:⚠️ Potential issue | 🟠 Major | ⚡ Quick winAvoid calling
useMessage()inside Pinia store actions
frontend/src/stores/localModelStore.tscallsuseMessage()directly in store functions/actions (e.g.,openServicePage,markLocalConnectApiMissing). In Naive UI 2.x,useMessage()is provider/inject-based and can fail when executed from non-component code without anNMessageProviderin the component-tree context (“No outer founded.”). Pass amessageinstance into the store/actions from a component/provider context (or use Naive UI’s discrete/app-level message API) instead of callinguseMessage()inside the store.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/stores/localModelStore.ts` around lines 132 - 162, openServicePage, markLocalConnectApiMissing (and other actions like connectLocalLlm) call useMessage() inside the Pinia store which can fail outside a component context; refactor these functions to accept a message parameter (e.g., message: ReturnType<typeof useMessage>) or an optional options object that carries a message instance, remove internal useMessage() calls, and use the passed-in message to call .info/.error; alternatively switch to Naive UI’s discrete/app-level message API and invoke that from the store; update callers (components) to pass the message instance when calling openServicePage, markLocalConnectApiMissing, connectLocalLlm, etc., and keep existing state vars like sessionLocalConnectApiMissing and modelProbeSummary unchanged.
🟡 Minor comments (8)
.dockerignore-5-5 (1)
5-5:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winCorrect the
.envpattern to match environment files.Line 5 uses
.env/(directory pattern), but environment files are typically named.env(file, not directory). This should be.envwithout the trailing slash to properly exclude sensitive environment variable files.🔒 Proposed fix
-.env/ +.env🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.dockerignore at line 5, The .dockerignore currently uses the directory pattern ".env/" which won't match typical environment files named ".env"; update the pattern in the .dockerignore (replace ".env/") to the file pattern ".env" so the actual .env file is excluded from Docker build context and sensitive variables are not included.Makefile-4-4 (1)
4-4:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winComplete the
.PHONYdeclaration with all non-file targets.The
.PHONYdeclaration is missing several targets that are defined later:test-all,serve, andlint-fix. All non-file targets should be declared to ensure Make always runs them regardless of file existence.📝 Proposed fix
-.PHONY: help install dev-install run test lint format clean docker-build docker-up docker-down +.PHONY: help install dev-install run serve test test-all lint format lint-fix clean docker-build docker-up docker-down db-reset🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Makefile` at line 4, The .PHONY line is incomplete: update the .PHONY declaration to include all non-file targets by adding test-all, serve, and lint-fix (alongside existing targets like help, install, dev-install, run, test, lint, format, clean, docker-build, docker-up, docker-down) so Make will always execute those targets regardless of matching filenames; modify the .PHONY declaration in the Makefile to list these additional target names..github/workflows/backend-ci.yml-61-62 (1)
61-62:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winFix the pip-audit failure-semantics concern for backend-ci.yml
In
.github/workflows/backend-ci.yml(lines 61-62), the workflow step runspip-audit -r requirements.txt --desc.pip-auditexits with code 1 when vulnerabilities are found, so this step should already fail the job rather than merely log. The--descflag only controls whether vulnerability descriptions are included in output; it doesn’t change failure behavior or “mask” severity.
- Optional improvement: add
--vulnerability-service osvto broaden coverage beyond the default vulnerability service.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/backend-ci.yml around lines 61 - 62, The "Audit dependencies" workflow step currently runs the command pip-audit -r requirements.txt --desc but may be masking failure semantics; ensure this step does not suppress non-zero exit codes (remove any continue-on-error: true, avoid if: always() that masks failures, and don't redirect or pipe the command output), so pip-audit can fail the job when vulnerabilities are found; optionally enhance the command by adding --vulnerability-service osv to broaden coverage while keeping --desc if you want descriptions.interfaces/api/v1/core/export.py-112-115 (1)
112-115:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winLog the exception before returning a generic error.
The exception is caught and a generic message is returned (which correctly prevents information leakage), but the actual error is not logged. This makes debugging production issues difficult.
📝 Add logging
except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: + logger.exception("章节导出失败 (chapter_id=%s, format=%s)", chapter_id, format) raise HTTPException(status_code=500, detail="导出失败,请稍后重试")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@interfaces/api/v1/core/export.py` around lines 112 - 115, The generic 500 handler currently swallows the exception; before raising HTTPException(status_code=500, detail="导出失败,请稍后重试") log the caught exception (variable e) with a stack trace using the module logger (e.g., logger.exception or logger.error with traceback) so errors are recorded for debugging while still returning the generic message; update the except Exception as e block to call the logger (including context like "export failed") and then raise the same HTTPException.interfaces/api/v1/core/export.py-59-60 (1)
59-60:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winLog the exception before returning a generic error.
The exception is caught and a generic message is returned (which correctly prevents information leakage), but the actual error is not logged. This makes debugging production issues difficult.
📝 Add logging
+import logging + +logger = logging.getLogger(__name__) + # ... existing code ... except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: + logger.exception("导出失败 (novel_id=%s, format=%s)", novel_id, format) raise HTTPException(status_code=500, detail="导出失败,请稍后重试")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@interfaces/api/v1/core/export.py` around lines 59 - 60, In the except block that currently does "except Exception as e: raise HTTPException(...)" in interfaces/api/v1/core/export.py, log the caught exception before raising the generic HTTPException; e.g. obtain or add a logger (or use the module-level logger) and call logger.exception(...) or logger.error(..., exc_info=True) referencing the caught variable "e" to record stack/trace, then re-raise the HTTPException with the same generic detail.interfaces/api/v1/core/novels.py-307-308 (1)
307-308:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winLog the exception before returning a generic error.
The exception is caught and a generic message is returned (which correctly prevents information leakage), but the actual error is not logged. This makes debugging production issues difficult.
📝 Add logging
return { "message": "Bible generation started", "novel_id": novel_id, "status_url": f"/api/v1/bible/novels/{novel_id}/bible/status" } except Exception as e: + logger.exception("启动Bible生成失败 (novel_id=%s, stage=%s)", novel_id, stage) raise HTTPException(status_code=500, detail="操作失败,请稍后重试")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@interfaces/api/v1/core/novels.py` around lines 307 - 308, The except block currently swallows the caught exception and raises a generic HTTPException; log the original exception before raising so failures can be debugged: call logging.exception(...) (or the module's existing logger) inside the except Exception as e: block to record the error and stack trace along with any contextual identifiers, then re-raise the same generic HTTPException as now; reference the except Exception as e block and the HTTPException raise to locate where to insert the logging.frontend/vite.config.ts-32-34 (1)
32-34:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winComment-configuration mismatch.
Line 32 states the development default is 8010 (with 8005 as fallback), but line 34 configures the proxy target to port 8005. Update either the comment to match the actual port 8005, or change the target to 8010 if that's the intended default.
📝 Proposed fix to align comment with actual configuration
- // 代理到后端服务器(开发默认 8010;8005 被占用时可改回) + // 代理到后端服务器(开发默认 8005;8010 被占用时可改用) '/api': { target: 'http://127.0.0.1:8005',🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/vite.config.ts` around lines 32 - 34, The comment near the dev proxy is inconsistent with the configured target: update the proxy configuration in vite.config.ts so the '/api' proxy target matches the comment or vice versa; either change the target value 'http://127.0.0.1:8005' to 'http://127.0.0.1:8010' if 8010 is the intended dev default, or edit the comment text to state that the proxy targets port 8005 (with 8010 as alternative) so the comment and the '/api' proxy target are aligned.frontend/src/utils/sseStream.ts-105-114 (1)
105-114:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winRemove duplicate
signalassignment.The
signalproperty is assigned twice in thefetchoptions. The first assignment at line 106 is immediately overwritten by the spread...opts.requestInitfollowed by the explicit assignment at line 113, making line 106 redundant and potentially confusing.♻️ Proposed fix
const res = await fetch(opts.url, { - signal: ctrl.signal, headers: { 'Accept': 'text/event-stream', 'Cache-Control': 'no-cache', ...opts.requestInit?.headers, }, ...opts.requestInit, signal: opts.requestInit?.signal ?? ctrl.signal, })As per coding guidelines, the static analysis tool (Biome) correctly identified this as a duplicate object key.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/utils/sseStream.ts` around lines 105 - 114, Remove the duplicate `signal` assignment in the fetch options: in the fetch call that uses `opts` and `ctrl` (the one creating `res`), delete the first `signal: ctrl.signal` entry and keep the later explicit `signal: opts.requestInit?.signal ?? ctrl.signal` so the spread `...opts.requestInit` can override the controller when provided; this eliminates the redundant key and preserves the intended precedence between `opts.requestInit.signal` and `ctrl.signal`.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 082464ab-7271-4bcf-b335-bd1127c7fb3b
⛔ Files ignored due to path filters (1)
frontend/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (103)
.dockerignore.github/workflows/backend-ci.yml.github/workflows/frontend-ci.yml.pre-commit-config.yamlDockerfileMakefileapplication/ai/llm_control_service.pyapplication/ai/local_llm_utils.pyapplication/core/services/scene_generation_service.pyapplication/engine/services/shared_state_repository.pyapplication/engine/services/state_bootstrap.pyapplication/workspace/__init__.pyapplication/workspace/local_workspace_service.pydocker-compose.ymldocs/SETUP.mddomain/engine/dag/models.pydomain/engine/dag/repositories/dag_version_repository.pyfrontend/package.jsonfrontend/src/api/book.tsfrontend/src/api/chapterElement.tsfrontend/src/api/dag.tsfrontend/src/api/engineCore.tsfrontend/src/api/knowledge.tsfrontend/src/api/localWorkspace.tsfrontend/src/api/monitor.tsfrontend/src/api/narrativeEngine.tsfrontend/src/api/narrativeHealth.tsfrontend/src/api/planning.tsfrontend/src/api/propApi.tsfrontend/src/api/sandbox.tsfrontend/src/api/stats.tsfrontend/src/api/tools.tsfrontend/src/api/voice.tsfrontend/src/api/voiceDrift.tsfrontend/src/api/workflow.tsfrontend/src/api/worldbuilding.tsfrontend/src/api/worldline.tsfrontend/src/components/autopilot/AutopilotCockpitChapterRail.vuefrontend/src/components/autopilot/AutopilotCockpitLayout.vuefrontend/src/components/autopilot/AutopilotPanel.vuefrontend/src/components/autopilot/AutopilotWorkspace.vuefrontend/src/components/autopilot/StoryPipelineBar.vuefrontend/src/components/autopilot/TensionChart.vuefrontend/src/components/editor/AIWritingAssist.vuefrontend/src/components/editor/TiptapEditor.vuefrontend/src/components/global/GlobalLLMEntryButton.vuefrontend/src/components/global/LocalModelConnectModal.vuefrontend/src/components/global/LocalModelConnectPanel.vuefrontend/src/components/home/LocalConnectPanel.vuefrontend/src/components/stats/StatsSidebar.vuefrontend/src/components/stats/StatsTopBar.vuefrontend/src/components/workbench/LLMControlPanel.vuefrontend/src/components/workbench/NarrativeHealthPanel.vuefrontend/src/components/workbench/WorkArea.vuefrontend/src/composables/useLocalModelProbe.tsfrontend/src/main.tsfrontend/src/plugins/echarts.tsfrontend/src/stores/autopilotWorkspaceStore.tsfrontend/src/stores/localModelStore.tsfrontend/src/stores/narrativeHealthStore.tsfrontend/src/utils/llmPanelNormalize.tsfrontend/src/utils/sseStream.tsfrontend/src/views/Cast.vuefrontend/src/views/Chapter.vuefrontend/src/views/Home.vuefrontend/vite.config.tsinfrastructure/ai/provider_factory.pyinfrastructure/ai/providers/openai_provider.pyinfrastructure/persistence/database/connection.pyinfrastructure/persistence/database/sqlite_pragmas.pyinfrastructure/persistence/database/triple_repository.pyinfrastructure/persistence/storage/file_storage.pyinterfaces/api/dependencies.pyinterfaces/api/middleware/error_handler.pyinterfaces/api/v1/analyst/narrative_health.pyinterfaces/api/v1/core/export.pyinterfaces/api/v1/core/novels.pyinterfaces/api/v1/core/scene_generation_routes.pyinterfaces/api/v1/core/settings.pyinterfaces/api/v1/engine/generation.pyinterfaces/api/v1/workbench/llm_control.pyinterfaces/api/v1/workspace/__init__.pyinterfaces/api/v1/workspace/local_routes.pyinterfaces/api/v1/world/worldbuilding_routes.pyinterfaces/main.pypyproject.tomlruff.tomlscripts/evaluation/macro_planning_evaluator.pyscripts/probe_local_models.pyscripts/test_local_import.pytests/integration/application/analyst/test_tension_score_integration.pytests/unit/application/engine/dag/__init__.pytests/unit/application/engine/dag/test_engine.pytests/unit/application/engine/dag/test_error_classifier.pytests/unit/application/engine/dag/test_event_aggregator.pytests/unit/application/engine/dag/test_ipc_adapter.pytests/unit/application/engine/dag/test_models.pytests/unit/application/engine/dag/test_prompt_validator.pytests/unit/application/engine/dag/test_registry.pytests/unit/application/engine/dag/test_validator.pytests/unit/application/engine/dag/test_version_manager.pytests/unit/application/prop/test_prop_lifecycle_syncer.pytests/unit/application/workspace/test_local_workspace_service.py
💤 Files with no reviewable changes (2)
- frontend/src/plugins/echarts.ts
- infrastructure/ai/providers/openai_provider.py
- 升级 @tauri-apps/api 到 2.11.0,对齐 Rust tauri crate 2.11.2 - 添加 PyInstaller spec,用于将 Python 后端打包为独立 exe - Tauri 构建自动生成的 schema 更新 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
## 修复 ### 前端 TypeScript(17 个错误 → 0) - statsStore: AxiosResponse 解包,使用 .data 提取原始数据 - sseStream: 消除重复 signal 属性,解构分离 headers 和 signal - TiptapEditor: editor 可空传递 (editor ?? null),setContent 选项对象,移除废弃 history 配置 - TensionChart / NarrativeHealthPanel: EChartsOption → EChartsCoreOption - AutopilotCockpitLayout: 模板箭头函数添加显式 any 类型 - LocalConnectPanel: 修复 apiClient 已解包后的重复解构,添加 HealthProbeItem 类型导入 - WorkArea: 补全缺失的 onSidebarChapterSelect 处理函数 ### DAG 测试修复(22/22 通过) - test_validator / test_version_manager: 导入 application.engine.dag.nodes 以触发 @NodeRegistry.register 副作用 ### 新增 - start.bat: Windows 一键启动脚本(后端 + 前端) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
start.bat (1)
1-39:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUse CRLF line endings for this
.batfile.LF-only endings can cause parser edge failures in Windows batch execution; normalize this file to CRLF and enforce it via
.gitattributes.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@start.bat` around lines 1 - 39, The batch script start.bat currently uses LF-only endings which can break Windows batch parsing; convert start.bat to CRLF line endings and commit the normalized file, then create or update a .gitattributes entry to enforce CRLF for *.bat (e.g., add a rule for *.bat text eol=crlf) so future checkouts preserve CRLF; ensure the repository index is refreshed (git add/.gitattributes and recommit) so the enforced line endings take effect for the start.bat file.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/stores/statsStore.ts`:
- Around line 69-70: The code is dereferencing res.data even though
statsApi.getGlobal/getChapter/getProgress already return the payload directly;
update loadGlobalStats, loadChapterStats, and loadProgress to treat the awaited
value as the payload (no .data). Specifically, in loadGlobalStats replace "const
res = await statsApi.getGlobal(); const data = res.data" with a single payload
assignment (e.g., const data = await statsApi.getGlobal()), and do the same for
loadChapterStats and loadProgress so they no longer read res.data and thus avoid
storing undefined into the cache.
In `@frontend/src/utils/sseStream.ts`:
- Around line 105-113: The consumeSseStream helper is merging headers then
spreading ...init after them which allows requestInit.headers to overwrite the
required 'Accept'/'Cache-Control', and when opts.requestInit.signal exists fetch
will use that external signal so the returned AbortController (ctrl) can't
cancel the request; fix by creating a single merged init object where headers =
{ 'Accept': 'text/event-stream', 'Cache-Control': 'no-cache',
...((opts.requestInit ?? {}).headers ?? {}) } and ensure the signal passed to
fetch is ctrl.signal (i.e., ignore or combine opts.requestInit.signal by
listening to it and calling ctrl.abort() if it fires, or prefer ctrl.signal and
wire opts.requestInit.signal to abort ctrl), update consumeSseStream to use this
merged init and to return the AbortController that actually cancels the fetch.
In `@start.bat`:
- Around line 11-19: The startup batch hard-codes ".venv\Scripts\python.exe" so
it fails when .venv isn't present; modify the script to detect if
".venv\Scripts\activate.bat" (or ".venv\Scripts\python.exe") exists, set a
PYTHON variable to ".venv\Scripts\python.exe" when present or to "python"
(system) when not, call the activation only if it exists, and then use %PYTHON%
in the start command (the line starting with start "PlotPilot-API") so the
backend will run using the virtualenv Python when available and fall back to the
system python otherwise.
---
Outside diff comments:
In `@start.bat`:
- Around line 1-39: The batch script start.bat currently uses LF-only endings
which can break Windows batch parsing; convert start.bat to CRLF line endings
and commit the normalized file, then create or update a .gitattributes entry to
enforce CRLF for *.bat (e.g., add a rule for *.bat text eol=crlf) so future
checkouts preserve CRLF; ensure the repository index is refreshed (git
add/.gitattributes and recommit) so the enforced line endings take effect for
the start.bat file.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 352713d1-8845-44a4-a0c1-262f48331940
📒 Files selected for processing (11)
frontend/src/components/autopilot/AutopilotCockpitLayout.vuefrontend/src/components/autopilot/TensionChart.vuefrontend/src/components/editor/TiptapEditor.vuefrontend/src/components/home/LocalConnectPanel.vuefrontend/src/components/workbench/NarrativeHealthPanel.vuefrontend/src/components/workbench/WorkArea.vuefrontend/src/stores/statsStore.tsfrontend/src/utils/sseStream.tsstart.battests/unit/application/engine/dag/test_validator.pytests/unit/application/engine/dag/test_version_manager.py
🚧 Files skipped from review as they are similar to previous changes (6)
- frontend/src/components/workbench/WorkArea.vue
- frontend/src/components/autopilot/TensionChart.vue
- frontend/src/components/workbench/NarrativeHealthPanel.vue
- frontend/src/components/editor/TiptapEditor.vue
- frontend/src/components/autopilot/AutopilotCockpitLayout.vue
- frontend/src/components/home/LocalConnectPanel.vue
| const res = await statsApi.getGlobal() | ||
| const data = res.data |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Verify response-shape contract at source
sed -n '1,240p' frontend/src/api/config.ts
sed -n '1,220p' frontend/src/api/stats.ts
# Verify store dereference points
sed -n '56,170p' frontend/src/stores/statsStore.tsRepository: shenminglinyi/PlotPilot
Length of output: 11549
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== frontend/src/api/stats.ts (relevant) ==="
nl -ba frontend/src/api/stats.ts | sed -n '1,220p'
echo "=== frontend/src/stores/statsStore.ts (line ranges) ==="
nl -ba frontend/src/stores/statsStore.ts | sed -n '1,220p'
echo "=== frontend/src/stores/statsStore.ts (tail beyond 220) ==="
nl -ba frontend/src/stores/statsStore.ts | sed -n '220,420p'Repository: shenminglinyi/PlotPilot
Length of output: 173
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== frontend/src/api/config.ts: legacyStatsHttp interceptor ==="
sed -n '1,140p' frontend/src/api/config.ts
echo "=== frontend/src/api/stats.ts: statsApi get* implementations ==="
sed -n '1,220p' frontend/src/api/stats.ts
echo "=== frontend/src/stores/statsStore.ts: global/chapter/progress dereferences ==="
sed -n '40,110p' frontend/src/stores/statsStore.ts
sed -n '110,190p' frontend/src/stores/statsStore.ts
sed -n '190,260p' frontend/src/stores/statsStore.tsRepository: shenminglinyi/PlotPilot
Length of output: 11289
Fix statsStore dereference: statsApi.get*() doesn’t return an AxiosResponse
frontend/src/api/config.tsunwraps legacy stats responses vialegacyStatsHttp.interceptors.response.use(...)and returnsbody.data(orbody) directly, sostatsApi.getGlobal/getChapter/getProgressresolve to the payload, not{ data: ... }.frontend/src/stores/statsStore.tsdereferencesres.dataanyway (e.g.,const res = await statsApi.getGlobal(); const data = res.data), makingdataundefinedand corrupting the cache inloadGlobalStats(69-70),loadChapterStats(123-124), andloadProgress(152-153).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/stores/statsStore.ts` around lines 69 - 70, The code is
dereferencing res.data even though statsApi.getGlobal/getChapter/getProgress
already return the payload directly; update loadGlobalStats, loadChapterStats,
and loadProgress to treat the awaited value as the payload (no .data).
Specifically, in loadGlobalStats replace "const res = await
statsApi.getGlobal(); const data = res.data" with a single payload assignment
(e.g., const data = await statsApi.getGlobal()), and do the same for
loadChapterStats and loadProgress so they no longer read res.data and thus avoid
storing undefined into the cache.
| const { signal: _unused, ...init } = (opts.requestInit ?? {}) | ||
| const res = await fetch(opts.url, { | ||
| signal: _unused ?? ctrl.signal, | ||
| headers: { | ||
| 'Accept': 'text/event-stream', | ||
| 'Cache-Control': 'no-cache', | ||
| ...init.headers, | ||
| }, | ||
| ...init, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate and print the relevant section with line numbers
FILE="frontend/src/utils/sseStream.ts"
if [ ! -f "$FILE" ]; then
echo "Missing file: $FILE" >&2
exit 1
fi
echo "---- $FILE (lines 70-160) ----"
nl -ba "$FILE" | sed -n '70,160p'
echo
echo "---- grep for abort/signal contract in $FILE ----"
rg -n "abort|signal|requestInit|ctrl\.abort|AbortController" "$FILE" || true
echo
echo "---- grep for sseStream usage / options typing ----"
rg -n "sseStream|requestInit|SSE|event-stream" frontend/src --glob='*.ts' --glob='*.tsx' || trueRepository: shenminglinyi/PlotPilot
Length of output: 186
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="frontend/src/utils/sseStream.ts"
if [ ! -f "$FILE" ]; then
echo "Missing file: $FILE" >&2
exit 1
fi
echo "---- $FILE (lines 70-160) ----"
awk 'NR>=70 && NR<=160 {printf "%5d: %s\n", NR, $0}' "$FILE"
echo
echo "---- grep for abort/signal contract in $FILE ----"
rg -n "abort|signal|requestInit|ctrl\.abort|AbortController" "$FILE" || true
echo
echo "---- grep for sseStream usage / options typing ----"
rg -n "sseStream|requestInit|SSE|event-stream" frontend/src --glob='*.ts' --glob='*.tsx' || trueRepository: shenminglinyi/PlotPilot
Length of output: 11252
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="frontend/src/utils/sseStream.ts"
awk 'NR>=145 && NR<=210 {printf "%5d: %s\n", NR, $0}' "$FILE"
echo
echo "---- find consumeSseStream call sites with requestInit.signal ----"
rg -n "consumeSseStream\\(|requestInit\\s*:\\s*\\{[^}]*signal|signal\\s*:" frontend/src --glob='*.ts' --glob='*.tsx'Repository: shenminglinyi/PlotPilot
Length of output: 1843
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="frontend/src/utils/sseStream.ts"
awk 'NR>=145 && NR<=210 {printf "%5d: %s\n", NR, $0}' "$FILE"
echo
echo "---- find consumeSseStream call sites with requestInit.signal ----"
rg -n "consumeSseStream\\(|requestInit\\s*:\\s*\\{[^}]*signal|signal\\s*:" frontend/src --glob='*.ts' --glob='*.tsx'Repository: shenminglinyi/PlotPilot
Length of output: 1843
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Helper: print a small numbered window
print_window () {
local file="$1" start="$2" end="$3"
echo "---- $file (lines $start-$end) ----"
awk -v s="$start" -v e="$end" 'NR>=s && NR<=e {printf "%5d: %s\n", NR, $0}' "$file"
echo
}
print_window "frontend/src/utils/sseStream.ts" 1 120
print_window "frontend/src/utils/sseStream.ts" 120 190
print_window "frontend/src/api/settings.ts" 60 130
print_window "frontend/src/api/workflow.ts" 250 340
print_window "frontend/src/api/workflow.ts" 390 470
print_window "frontend/src/api/planning.ts" 240 310
print_window "frontend/src/api/planning.ts" 360 430
print_window "frontend/src/api/planning.ts" 520 590
print_window "frontend/src/api/bible.ts" 220 290
print_window "frontend/src/api/config.ts" 120 200
print_window "frontend/src/api/config.ts" 240 310Repository: shenminglinyi/PlotPilot
Length of output: 35191
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n "consumeSseStream\\(" frontend/src --glob='*.ts' --glob='*.tsx'Repository: shenminglinyi/PlotPilot
Length of output: 307
Fix consumeSseStream option merge and abort handling (headers can be lost; returned AbortController may not cancel fetch)
- In
frontend/src/utils/sseStream.ts(around lines 105-113), theheadersdefaults (Accept: text/event-stream,Cache-Control: no-cache) are merged, but are then overwritten by the later...initspread wheneverrequestInit.headersis provided. - When
opts.requestInit.signalis provided,fetch()uses that signal instead of the returnedctrl.signal, so calling the returnedAbortController.abort()may not terminate the request/stream.
Suggested fix
- const { signal: _unused, ...init } = (opts.requestInit ?? {})
+ const { signal: externalSignal, headers: initHeaders, ...init } = (opts.requestInit ?? {})
+ if (externalSignal?.aborted) ctrl.abort()
+ externalSignal?.addEventListener('abort', () => ctrl.abort(), { once: true })
const res = await fetch(opts.url, {
- signal: _unused ?? ctrl.signal,
+ ...init,
+ signal: ctrl.signal,
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
- ...init.headers,
+ ...initHeaders,
},
- ...init,
})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { signal: _unused, ...init } = (opts.requestInit ?? {}) | |
| const res = await fetch(opts.url, { | |
| signal: _unused ?? ctrl.signal, | |
| headers: { | |
| 'Accept': 'text/event-stream', | |
| 'Cache-Control': 'no-cache', | |
| ...init.headers, | |
| }, | |
| ...init, | |
| const { signal: externalSignal, headers: initHeaders, ...init } = (opts.requestInit ?? {}) | |
| if (externalSignal?.aborted) ctrl.abort() | |
| externalSignal?.addEventListener('abort', () => ctrl.abort(), { once: true }) | |
| const res = await fetch(opts.url, { | |
| ...init, | |
| signal: ctrl.signal, | |
| headers: { | |
| 'Accept': 'text/event-stream', | |
| 'Cache-Control': 'no-cache', | |
| ...initHeaders, | |
| }, | |
| }) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/utils/sseStream.ts` around lines 105 - 113, The consumeSseStream
helper is merging headers then spreading ...init after them which allows
requestInit.headers to overwrite the required 'Accept'/'Cache-Control', and when
opts.requestInit.signal exists fetch will use that external signal so the
returned AbortController (ctrl) can't cancel the request; fix by creating a
single merged init object where headers = { 'Accept': 'text/event-stream',
'Cache-Control': 'no-cache', ...((opts.requestInit ?? {}).headers ?? {}) } and
ensure the signal passed to fetch is ctrl.signal (i.e., ignore or combine
opts.requestInit.signal by listening to it and calling ctrl.abort() if it fires,
or prefer ctrl.signal and wire opts.requestInit.signal to abort ctrl), update
consumeSseStream to use this merged init and to return the AbortController that
actually cancels the fetch.
| if exist ".venv\Scripts\activate.bat" ( | ||
| call .venv\Scripts\activate.bat | ||
| echo [√] 已激活虚拟环境 | ||
| ) | ||
|
|
||
| rem Start backend | ||
| echo [>>] 启动后端 API 服务 (8005)... | ||
| start "PlotPilot-API" ".venv\Scripts\python.exe" -m uvicorn interfaces.main:app --host 0.0.0.0 --port 8005 | ||
|
|
There was a problem hiding this comment.
Backend startup is hard-coupled to .venv despite optional activation check.
You conditionally activate .venv, but backend launch always uses .venv\Scripts\python.exe. On machines without .venv, one-click startup fails even if python is available.
Suggested fix
rem Activate venv if exists
+set "PYTHON_CMD=python"
if exist ".venv\Scripts\activate.bat" (
- call .venv\Scripts\activate.bat
+ call ".venv\Scripts\activate.bat"
+ set "PYTHON_CMD=.venv\Scripts\python.exe"
echo [√] 已激活虚拟环境
)
rem Start backend
echo [>>] 启动后端 API 服务 (8005)...
-start "PlotPilot-API" ".venv\Scripts\python.exe" -m uvicorn interfaces.main:app --host 0.0.0.0 --port 8005
+start "PlotPilot-API" "%PYTHON_CMD%" -m uvicorn interfaces.main:app --host 0.0.0.0 --port 8005🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@start.bat` around lines 11 - 19, The startup batch hard-codes
".venv\Scripts\python.exe" so it fails when .venv isn't present; modify the
script to detect if ".venv\Scripts\activate.bat" (or ".venv\Scripts\python.exe")
exists, set a PYTHON variable to ".venv\Scripts\python.exe" when present or to
"python" (system) when not, call the activation only if it exists, and then use
%PYTHON% in the start command (the line starting with start "PlotPilot-API") so
the backend will run using the virtualenv Python when available and fall back to
the system python otherwise.
## 第 1 批:核心编辑器 ### 1.1 Focus Mode(禅模式) - 新建 useFocusMode.ts composable - Ctrl+Shift+F 一键隐藏全部 UI,只留编辑器 - 浮动工具栏 30% 透明度,hover 显示 - 编辑器居中最大宽 780px ### 1.2 斜杠命令菜单 - 新建 SlashCommandMenu.vue - / 触发三类命令:基础(H1/H2/段落/分割线)、小说(场景分隔/POV/时间跳/闪回)、AI(续写/改写/扩写/缩写/变语气) - 键盘上下导航 + Enter 选择 ### 1.3 内联 AI 建议(Ghost Text) - 新建 useAICompletion.ts composable - 停笔 1.5s 自动请求 AI 续写建议 - Tab 接受 / Esc 消失 - SSE 流式获取建议 ### 1.5 卡片式剧情版(Corkboard) - 新建 Corkboard.vue 页面 - 每章一张卡片:标题、字数、POV 角色、状态标签 - POV 颜色编码 + 过滤 - 路由 /book/:slug/corkboard ## 第 2 批:主题 & 视觉 ### 2.1-2.3 设计系统优化 - 新增 Focus Mode CSS、Ghost Text CSS、Splash 空状态 CSS - POV 标签主题配色(亮/暗双模式) - 卡片 hover 上浮效果 ### 2.2 顶部导航栏 - App.vue 新增 AppShell Navbar:Logo、项目名、搜索、主题切换、仪表盘入口 - 全局 Ctrl+K 搜索快捷键 ## 第 3 批:工作台功能 ### 3.3 写作仪表盘 - 新建 Dashboard.vue - 今日字数进度环 + 连续写作天数 + 总字数统计 - 近期活动时间线 ### 3.4 AI 上下文面板 - 新建 AIContextPanel.vue - 选中文字后一键改写/扩写/缩写/变语气 - 6 个预设 AI 动作按钮 - 结果流式展示 + Apply/Discard ### 3.5 一键格式化 - TiptapEditor 新增 formatChapter() 函数 - 工具栏新增「格式化」和「禅模式」按钮 ## 第 4 批:辅助功能 ### 4.1 快捷键面板 - 新建 ShortcutHelp.vue,Ctrl+/ 或 ? 弹出 ### 4.2 全局搜索 - 新建 GlobalSearch.vue,Ctrl+K 弹出 ### 4.3 Toast 优化 - 消息位置统一 bottom-right,最多 3 条 ## 路由新增 - /dashboard — 写作仪表盘 - /book/:slug/corkboard — 剧情卡片版 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 13
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
frontend/src/components/editor/TiptapEditor.vue (2)
183-199:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftPersisting
getText()flattens every rich-text feature added in this component.
onUpdateemits plain text, and the sync watcher compares/resets against plain text as well. That means headings, lists, blockquotes, scene breaks, and POV tags inserted here are lost on the next parent round-trip, so save/reload cannot preserve the document structure this editor creates.Also applies to: 235-238
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/editor/TiptapEditor.vue` around lines 183 - 199, The editor is emitting and syncing plain text (onUpdate uses e.getText() and the watcher compares/sets against getText()), which strips all rich-text structure; change the sync to use a rich format (e.g., editor.getJSON() or editor.getHTML()) when emitting from onUpdate (replace emit('update:modelValue', e.getText()) with emitting the editor's JSON/HTML) and adapt the watcher to compare that same rich-format payload and call editor.commands.setContent(newVal || {}, { emitUpdate: false }) with the matching format; update the corresponding other occurrences where getText() is used (the block referenced at lines ~235-238) so both emit and restore use the same structured format (and ensure props.modelValue is treated as that structured type).
169-181:⚠️ Potential issue | 🟠 Major | ⚡ Quick winMost of the new editor shortcuts are currently dead code.
The only keyboard handler actually registered with TipTap is the save branch on Lines 173-181.
onEditorKeydown()andonSlashKeydown()are never passed to the editor or any DOM listener, so slash-menu opening, Tab-to-accept, Escape-to-dismiss, and Ctrl/⌘+Shift+F never run.Wire the existing handler instead of leaving it detached
editorProps: { attributes: { class: 'tiptap-prose', }, - handleKeyDown: (_view, event) => { - // Ctrl+S 保存 - if ((event.ctrlKey || event.metaKey) && event.key === 's') { - event.preventDefault() - emit('save') - return true - } - return false - }, + handleKeyDown: (view, event) => onEditorKeydown(view, event), }, @@ -// Override editorProps handleKeyDown -if (editor.value) { - // handleKeyDown is set in options; we need to pass it there -}Also applies to: 282-336
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/editor/TiptapEditor.vue` around lines 169 - 181, The TipTap keyboard handlers onEditorKeydown and onSlashKeydown are defined but never registered, so only the Ctrl/⌘+S branch inside editorProps.handleKeyDown runs; fix by wiring those handlers into the editorProps.handleKeyDown (or replacing handleKeyDown with a wrapper that calls onEditorKeydown and onSlashKeydown then falls back to the existing save logic) so that the slash menu, Tab-to-accept, Escape-to-dismiss and Ctrl/⌘+Shift+F branches execute; specifically update the editorProps.handleKeyDown implementation to call onEditorKeydown(event, _view) and onSlashKeydown(event, _view) (or the exported functions with the same names) and return their results when applicable before applying the current save logic.
🧹 Nitpick comments (2)
frontend/src/composables/useFocusMode.ts (1)
37-39: 💤 Low valueConsider checking active element before exiting focus mode on Escape.
The Escape handler exits focus mode unconditionally. If a user is typing in an input field or textarea within focus mode and presses Escape (e.g., to clear autocomplete or cancel a form action), the entire focus mode will exit instead. You may want to check
document.activeElement?.tagNameto allow Escape to work in input contexts first.🔧 Optional refinement
function onKeydown(e: KeyboardEvent) { if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'F') { e.preventDefault() toggle() } - if (e.key === 'Escape' && active.value) { + const el = document.activeElement?.tagName + if (e.key === 'Escape' && active.value && el !== 'INPUT' && el !== 'TEXTAREA') { exit() } }If the design intent is for Escape to always exit focus mode (overriding input behavior), the current code is correct.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/composables/useFocusMode.ts` around lines 37 - 39, The Escape key handler currently calls exit() whenever e.key === 'Escape' and active.value is true; modify the handler in useFocusMode.ts (the block checking e.key === 'Escape') to first inspect document.activeElement (e.g., document.activeElement?.tagName and/or checking contentEditable) and only call exit() if the active element is not an INPUT, TEXTAREA, or contentEditable element (or other interactive elements as needed), preserving normal Escape behavior inside form fields while keeping the existing active.value check.frontend/src/views/Corkboard.vue (1)
91-91: ⚡ Quick winDefine a proper type instead of
any.Using
anyfor the API response disables type checking. Define an interface for the novel response structure to catch shape mismatches at compile time.📘 Suggested type definition
interface NovelChapter { id: number title?: string number: number word_count?: number wordCount?: number summary?: string pov_character?: string } interface NovelResponse { title?: string chapters?: NovelChapter[] }Then use it:
- const data = await apiClient.get<any>(`/novels/${slug.value}`) + const data = await apiClient.get<NovelResponse>(`/novels/${slug.value}`)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/views/Corkboard.vue` at line 91, Replace the usage of apiClient.get<any> with a typed response: define interfaces (e.g., NovelChapter and NovelResponse) that match the API shape and change the call to apiClient.get<NovelResponse>(`/novels/${slug.value}`); update any local variables (e.g., data) and downstream accesses to use the new NovelResponse/NovelChapter properties (wordCount vs word_count, chapters array, etc.) so TypeScript can validate shapes at compile time.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/assets/styles/main.css`:
- Around line 696-753: Replace the undeclared theme tokens with the app's
existing tokens in the CSS rules so they inherit theme values: update
.ai-ghost-text to use --app-text-muted instead of --text-color-muted; update
.empty-state-splash color to --app-text-muted; update .slash-command-item:hover
(background) to use --color-brand-light or a subtle variant instead of
--action-color-hover; and update .slash-command-item__icon background to
--app-surface-subtle instead of --action-color-secondary. Locate and replace
these token references in the .ai-ghost-text, .empty-state-splash,
.slash-command-item:hover, and .slash-command-item__icon rules so the styles use
the declared theme tokens.
In `@frontend/src/components/editor/AIContextPanel.vue`:
- Around line 62-100: The runPreset handler can let an earlier invocation's
finally block (and stream loop) overwrite UI state after a newer request
started; to fix, capture the current controller and preset id into locals (e.g.
const localController = controller and const localPresetId = preset.id) when the
request starts and before updating shared state (appending to result.value or
setting loading.value/activePreset.value) verify the localController (or
localPresetId) still matches the global controller (or activePreset) so only the
active request mutates state; apply this check inside the streaming loop when
appending to result and in the catch/finally where loading.value is toggled.
In `@frontend/src/components/editor/SlashCommandMenu.vue`:
- Around line 26-31: The SlashCommand definition currently uses a generic
action: 'heading' which loses heading level info; update the SlashCommand
contract so heading commands encode their variant (e.g., include a headingLevel
or make action discriminated like action: 'heading' with an additional
headingLevel:number or action: { type: 'heading', level: number }) and update
any producers of SlashCommand (the two heading menu items) to set that level,
ensuring consumers like TiptapEditor.vue read the heading-specific field
(headingLevel or action.level) to apply the correct heading variant; keep other
action strings unchanged.
In `@frontend/src/components/editor/TiptapEditor.vue`:
- Around line 146-154: The editor is missing declarations for the refs used by
TipTap callbacks—declare and initialize isFocused (e.g., ref(false)) and
aiAssistRef (e.g., ref<HTMLElement | null>(null)) alongside the existing refs
like slashOpen and aiPanelOpen, ensure their types match how they’re used in
hooks, and return them from setup so functions such as the useFocusMode-related
handlers (focusActive/toggleFocus/exitFocus) and the useAICompletion helpers
(requestAI/acceptAI/dismissAI) can safely read/update isFocused and aiAssistRef
without runtime errors.
- Around line 267-279: formatChapter mutates the document while iterating
doc.descendants (using editor.value.chain()...deleteSelection()), which
invalidates subsequent pos values; instead first walk doc.descendants and
collect positions and nodeSizes for empty paragraph nodes, then apply deletions
in reverse order (highest pos first) so earlier mutations don't invalidate later
positions (use the same identifiers: formatChapter, doc.descendants, and the
editor.value.chain()/deleteSelection() call to perform the actual removes).
Ensure you only run the edit chain after collecting positions, and keep the
final setTextSelection(0) focus call after the deletions.
- Around line 319-326: The watcher uses editor.value?.getText() which isn’t a
reactive dependency, so create a reactive ref (e.g., editorText) and update it
inside the TipTap onUpdate handler by reading editor.getText() (also
initialize/sync it from props.modelValue when that changes), then replace the
current watch(...) to watch editorText; keep ghostTextTimer, clear/set it and
call requestAI(editorText.slice(-300)) as before so the ghost-text timer
retriggers on live typing (refer to ghostTextTimer, requestAI, editor, onUpdate,
and props.modelValue).
In `@frontend/src/components/global/GlobalSearch.vue`:
- Line 23: The template in GlobalSearch.vue is using v-html="r.excerpt" which
allows raw HTML and can cause XSS; change this to render plain text (e.g., use
{{ r.excerpt }} or a computed/sanitizedExcerpt) or sanitize the HTML before
injecting (e.g., run r.excerpt through DOMPurify or a safeHighlighter that only
wraps matches in <mark> after sanitization). Update the template to stop using
v-html with r.excerpt and adjust any associated methods/computed props (e.g.,
sanitizeExcerpt, highlightMatches) so excerpts are either plain-text-rendered or
safely sanitized/highlighted before insertion.
- Line 68: The conditional in GlobalSearch.vue is inverted: the expression
`'chapter'.includes(q)` checks whether the literal "chapter" contains the user's
query. Change this to test the user's query instead (use q.includes('chapter'))
inside the same if block so the logic in the search/filter code correctly
matches queries that contain the keyword; update the condition where the if
currently reads 'chapter'.includes(q) (in the search handling logic) to
q.includes('chapter').
In `@frontend/src/composables/useAICompletion.ts`:
- Around line 67-72: The accept() function currently clears suggestion.value and
debounceTimer but does not cancel any active streaming request; update accept()
to also abort the in-flight stream by calling abort() on the fetch/stream
AbortController (e.g., streamAbortController or similar), then null out/reset
that controller and any stream-related state (like isStreaming flags) so the
background fetch/parsing stops; keep the existing suggestion.value reset and
debounceTimer clear logic (reference: accept(), debounceTimer, suggestion.value,
and the AbortController variable used for streaming).
- Around line 38-56: Replace the manual SSE parsing loop in useAICompletion (the
reader/read loop using res.body.getReader, TextDecoder, buffer splitting and
JSON.parse) with the shared consumeSseStream utility: import consumeSseStream
and call it with the response body (or reader) and a handler that reads each
parsed frame, JSON.parses the payload and appends data.text to suggestion.value.
Ensure error/malformed-frame handling is delegated to consumeSseStream and
remove the redundant buffer/line-splitting logic so suggestion.value is updated
only from the consumeSseStream callback.
In `@frontend/src/views/Corkboard.vue`:
- Line 104: The empty catch block after the API call that loads corkboard data
is swallowing errors; update that catch to at minimum console.error the caught
error and surface user feedback by either invoking the app's toast API (e.g.,
this.$toast.error(...) or similar) or setting a reactive error state (e.g.,
this.errorMessage or data property) that the template renders as an error
banner; locate the try/catch in Corkboard.vue around the corkboard/boards fetch
and replace the empty catch with these logging and user-notification steps.
- Line 101: The color selection currently compares ch.pov_character to Chinese
display strings (e.g., '主角', '对手'), which couples UI logic to localized labels;
replace this with a mapping and accessor function (e.g., define a POV_COLORS
record and a getPovColor(pov) helper) and use getPovColor(ch.pov_character)
instead of the inline ternary so the code relies on locale-independent POV codes
(protagonist/antagonist/supporting/default) returned by the backend.
In `@frontend/src/views/Dashboard.vue`:
- Around line 70-86: The mounted handler never assigns recentActivity, so update
the onMounted async block (the apiClient.get('/stats/global') response handling)
to read the activity list from the API (e.g., data.recent_activity or
data.recentActivity) and set recentActivity.value to a mapped array of objects
matching the ref type ({ type, title, desc, time }); ensure you provide safe
defaults if the field is missing and normalize timestamps to strings (e.g., ISO
or formatted) so the timeline rendering uses the mapped items instead of the “no
activity” message.
---
Outside diff comments:
In `@frontend/src/components/editor/TiptapEditor.vue`:
- Around line 183-199: The editor is emitting and syncing plain text (onUpdate
uses e.getText() and the watcher compares/sets against getText()), which strips
all rich-text structure; change the sync to use a rich format (e.g.,
editor.getJSON() or editor.getHTML()) when emitting from onUpdate (replace
emit('update:modelValue', e.getText()) with emitting the editor's JSON/HTML) and
adapt the watcher to compare that same rich-format payload and call
editor.commands.setContent(newVal || {}, { emitUpdate: false }) with the
matching format; update the corresponding other occurrences where getText() is
used (the block referenced at lines ~235-238) so both emit and restore use the
same structured format (and ensure props.modelValue is treated as that
structured type).
- Around line 169-181: The TipTap keyboard handlers onEditorKeydown and
onSlashKeydown are defined but never registered, so only the Ctrl/⌘+S branch
inside editorProps.handleKeyDown runs; fix by wiring those handlers into the
editorProps.handleKeyDown (or replacing handleKeyDown with a wrapper that calls
onEditorKeydown and onSlashKeydown then falls back to the existing save logic)
so that the slash menu, Tab-to-accept, Escape-to-dismiss and Ctrl/⌘+Shift+F
branches execute; specifically update the editorProps.handleKeyDown
implementation to call onEditorKeydown(event, _view) and onSlashKeydown(event,
_view) (or the exported functions with the same names) and return their results
when applicable before applying the current save logic.
---
Nitpick comments:
In `@frontend/src/composables/useFocusMode.ts`:
- Around line 37-39: The Escape key handler currently calls exit() whenever
e.key === 'Escape' and active.value is true; modify the handler in
useFocusMode.ts (the block checking e.key === 'Escape') to first inspect
document.activeElement (e.g., document.activeElement?.tagName and/or checking
contentEditable) and only call exit() if the active element is not an INPUT,
TEXTAREA, or contentEditable element (or other interactive elements as needed),
preserving normal Escape behavior inside form fields while keeping the existing
active.value check.
In `@frontend/src/views/Corkboard.vue`:
- Line 91: Replace the usage of apiClient.get<any> with a typed response: define
interfaces (e.g., NovelChapter and NovelResponse) that match the API shape and
change the call to apiClient.get<NovelResponse>(`/novels/${slug.value}`); update
any local variables (e.g., data) and downstream accesses to use the new
NovelResponse/NovelChapter properties (wordCount vs word_count, chapters array,
etc.) so TypeScript can validate shapes at compile time.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 99324b9c-3217-4e61-96ed-b32213931248
📒 Files selected for processing (12)
frontend/src/App.vuefrontend/src/assets/styles/main.cssfrontend/src/components/editor/AIContextPanel.vuefrontend/src/components/editor/SlashCommandMenu.vuefrontend/src/components/editor/TiptapEditor.vuefrontend/src/components/global/GlobalSearch.vuefrontend/src/components/global/ShortcutHelp.vuefrontend/src/composables/useAICompletion.tsfrontend/src/composables/useFocusMode.tsfrontend/src/router/index.tsfrontend/src/views/Corkboard.vuefrontend/src/views/Dashboard.vue
| /* ── Ghost Text ── */ | ||
| .ai-ghost-text { | ||
| color: var(--text-color-muted, #94a3b8); | ||
| font-style: italic; | ||
| opacity: 0.6; | ||
| transition: opacity 0.15s ease; | ||
| } | ||
|
|
||
| /* ── Card hover ── */ | ||
| .card-hover-lift { | ||
| transition: transform 0.2s ease, box-shadow 0.2s ease; | ||
| } | ||
| .card-hover-lift:hover { | ||
| transform: translateY(-2px); | ||
| box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1); | ||
| } | ||
|
|
||
| /* ── Corkboard grid ── */ | ||
| .corkboard-grid { | ||
| display: grid; | ||
| grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); | ||
| gap: 1rem; | ||
| padding: 1rem; | ||
| } | ||
|
|
||
| /* ── Empty state ── */ | ||
| .empty-state-splash { | ||
| display: flex; | ||
| flex-direction: column; | ||
| align-items: center; | ||
| justify-content: center; | ||
| min-height: 300px; | ||
| gap: 1rem; | ||
| color: var(--text-color-muted); | ||
| } | ||
|
|
||
| /* ── Slash command ── */ | ||
| .slash-command-popup { max-height: 320px; overflow-y: auto; padding: 0.25rem; } | ||
| .slash-command-group { padding: 0.25rem 0; } | ||
| .slash-command-group-label { | ||
| padding: 0.25rem 0.75rem; font-size: 0.7rem; font-weight: 600; | ||
| text-transform: uppercase; letter-spacing: 0.06em; | ||
| color: var(--text-color-muted); | ||
| } | ||
| .slash-command-item { | ||
| display: flex; align-items: center; gap: 0.5rem; | ||
| padding: 0.45rem 0.75rem; border-radius: 6px; cursor: pointer; | ||
| transition: background 0.1s ease; | ||
| } | ||
| .slash-command-item:hover, .slash-command-item--selected { | ||
| background: var(--action-color-hover, rgba(0,0,0,0.05)); | ||
| } | ||
| .slash-command-item__icon { | ||
| width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; | ||
| border-radius: 6px; background: var(--action-color-secondary); font-size: 0.875rem; flex-shrink: 0; | ||
| } | ||
| .slash-command-item__label { font-weight: 500; font-size: 0.875rem; } | ||
| .slash-command-item__desc { font-size: 0.75rem; color: var(--text-color-muted); } |
There was a problem hiding this comment.
Use existing theme tokens here instead of undeclared ones.
--text-color-muted, --action-color-hover, and --action-color-secondary are not declared in the theme blocks above, so these new ghost-text / empty-state / slash-menu styles fall back unpredictably or lose their intended contrast. Reusing the existing --app-text-muted, --color-brand-light, and --app-surface-subtle tokens will keep them themed correctly.
Example fix
-.ai-ghost-text {
- color: var(--text-color-muted, `#94a3b8`);
+.ai-ghost-text {
+ color: var(--app-text-muted);
font-style: italic;
opacity: 0.6;
transition: opacity 0.15s ease;
}
@@
.empty-state-splash {
display: flex;
@@
- color: var(--text-color-muted);
+ color: var(--app-text-muted);
}
@@
.slash-command-group-label {
@@
- color: var(--text-color-muted);
+ color: var(--app-text-muted);
}
@@
.slash-command-item:hover, .slash-command-item--selected {
- background: var(--action-color-hover, rgba(0,0,0,0.05));
+ background: var(--color-brand-light);
}
@@
.slash-command-item__icon {
@@
- border-radius: 6px; background: var(--action-color-secondary); font-size: 0.875rem; flex-shrink: 0;
+ border-radius: 6px; background: var(--app-surface-subtle); font-size: 0.875rem; flex-shrink: 0;
}
-.slash-command-item__desc { font-size: 0.75rem; color: var(--text-color-muted); }
+.slash-command-item__desc { font-size: 0.75rem; color: var(--app-text-muted); }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /* ── Ghost Text ── */ | |
| .ai-ghost-text { | |
| color: var(--text-color-muted, #94a3b8); | |
| font-style: italic; | |
| opacity: 0.6; | |
| transition: opacity 0.15s ease; | |
| } | |
| /* ── Card hover ── */ | |
| .card-hover-lift { | |
| transition: transform 0.2s ease, box-shadow 0.2s ease; | |
| } | |
| .card-hover-lift:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1); | |
| } | |
| /* ── Corkboard grid ── */ | |
| .corkboard-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); | |
| gap: 1rem; | |
| padding: 1rem; | |
| } | |
| /* ── Empty state ── */ | |
| .empty-state-splash { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 300px; | |
| gap: 1rem; | |
| color: var(--text-color-muted); | |
| } | |
| /* ── Slash command ── */ | |
| .slash-command-popup { max-height: 320px; overflow-y: auto; padding: 0.25rem; } | |
| .slash-command-group { padding: 0.25rem 0; } | |
| .slash-command-group-label { | |
| padding: 0.25rem 0.75rem; font-size: 0.7rem; font-weight: 600; | |
| text-transform: uppercase; letter-spacing: 0.06em; | |
| color: var(--text-color-muted); | |
| } | |
| .slash-command-item { | |
| display: flex; align-items: center; gap: 0.5rem; | |
| padding: 0.45rem 0.75rem; border-radius: 6px; cursor: pointer; | |
| transition: background 0.1s ease; | |
| } | |
| .slash-command-item:hover, .slash-command-item--selected { | |
| background: var(--action-color-hover, rgba(0,0,0,0.05)); | |
| } | |
| .slash-command-item__icon { | |
| width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; | |
| border-radius: 6px; background: var(--action-color-secondary); font-size: 0.875rem; flex-shrink: 0; | |
| } | |
| .slash-command-item__label { font-weight: 500; font-size: 0.875rem; } | |
| .slash-command-item__desc { font-size: 0.75rem; color: var(--text-color-muted); } | |
| /* ── Ghost Text ── */ | |
| .ai-ghost-text { | |
| color: var(--app-text-muted); | |
| font-style: italic; | |
| opacity: 0.6; | |
| transition: opacity 0.15s ease; | |
| } | |
| /* ── Card hover ── */ | |
| .card-hover-lift { | |
| transition: transform 0.2s ease, box-shadow 0.2s ease; | |
| } | |
| .card-hover-lift:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1); | |
| } | |
| /* ── Corkboard grid ── */ | |
| .corkboard-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); | |
| gap: 1rem; | |
| padding: 1rem; | |
| } | |
| /* ── Empty state ── */ | |
| .empty-state-splash { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 300px; | |
| gap: 1rem; | |
| color: var(--app-text-muted); | |
| } | |
| /* ── Slash command ── */ | |
| .slash-command-popup { max-height: 320px; overflow-y: auto; padding: 0.25rem; } | |
| .slash-command-group { padding: 0.25rem 0; } | |
| .slash-command-group-label { | |
| padding: 0.25rem 0.75rem; font-size: 0.7rem; font-weight: 600; | |
| text-transform: uppercase; letter-spacing: 0.06em; | |
| color: var(--app-text-muted); | |
| } | |
| .slash-command-item { | |
| display: flex; align-items: center; gap: 0.5rem; | |
| padding: 0.45rem 0.75rem; border-radius: 6px; cursor: pointer; | |
| transition: background 0.1s ease; | |
| } | |
| .slash-command-item:hover, .slash-command-item--selected { | |
| background: var(--color-brand-light); | |
| } | |
| .slash-command-item__icon { | |
| width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; | |
| border-radius: 6px; background: var(--app-surface-subtle); font-size: 0.875rem; flex-shrink: 0; | |
| } | |
| .slash-command-item__label { font-weight: 500; font-size: 0.875rem; } | |
| .slash-command-item__desc { font-size: 0.75rem; color: var(--app-text-muted); } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/assets/styles/main.css` around lines 696 - 753, Replace the
undeclared theme tokens with the app's existing tokens in the CSS rules so they
inherit theme values: update .ai-ghost-text to use --app-text-muted instead of
--text-color-muted; update .empty-state-splash color to --app-text-muted; update
.slash-command-item:hover (background) to use --color-brand-light or a subtle
variant instead of --action-color-hover; and update .slash-command-item__icon
background to --app-surface-subtle instead of --action-color-secondary. Locate
and replace these token references in the .ai-ghost-text, .empty-state-splash,
.slash-command-item:hover, and .slash-command-item__icon rules so the styles use
the declared theme tokens.
| async function runPreset(preset: AIPreset) { | ||
| activePreset.value = preset.id | ||
| loading.value = true | ||
| result.value = '' | ||
| if (controller) controller.abort() | ||
| controller = new AbortController() | ||
|
|
||
| try { | ||
| const res = await fetch('/api/v1/ai/assist', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| prompt: preset.prompt, | ||
| text: props.selectedText, | ||
| novel_id: props.novelId, | ||
| chapter_id: props.chapterId, | ||
| }), | ||
| signal: controller.signal, | ||
| }) | ||
|
|
||
| if (res.ok && res.body) { | ||
| const reader = res.body.getReader() | ||
| const decoder = new TextDecoder() | ||
| let buf = '' | ||
| while (true) { | ||
| const { done, value } = await reader.read() | ||
| if (done) break | ||
| buf += decoder.decode(value, { stream: true }) | ||
| const lines = buf.split('\n'); buf = lines.pop() || '' | ||
| for (const line of lines) { | ||
| if (line.startsWith('data: ')) { | ||
| try { result.value += JSON.parse(line.slice(6)).text } catch { /* skip */ } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } catch (e) { | ||
| if ((e as Error).name !== 'AbortError') console.warn('[AIContext]', e) | ||
| } finally { loading.value = false } |
There was a problem hiding this comment.
Guard UI state updates against superseded requests.
Aborting the previous controller does not stop that older invocation's finally from running. If the user clicks a second preset quickly, the first request can flip loading back to false while the newer stream is still active, which re-enables the buttons and desynchronizes the panel state.
One way to pin state to the active request
async function runPreset(preset: AIPreset) {
activePreset.value = preset.id
loading.value = true
result.value = ''
if (controller) controller.abort()
- controller = new AbortController()
+ const currentController = new AbortController()
+ controller = currentController
try {
const res = await fetch('/api/v1/ai/assist', {
@@
- signal: controller.signal,
+ signal: currentController.signal,
})
if (res.ok && res.body) {
@@
- try { result.value += JSON.parse(line.slice(6)).text } catch { /* skip */ }
+ try {
+ if (controller === currentController) {
+ result.value += JSON.parse(line.slice(6)).text
+ }
+ } catch { /* skip */ }
}
}
}
}
} catch (e) {
if ((e as Error).name !== 'AbortError') console.warn('[AIContext]', e)
- } finally { loading.value = false }
+ } finally {
+ if (controller === currentController) {
+ loading.value = false
+ controller = null
+ }
+ }
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/editor/AIContextPanel.vue` around lines 62 - 100, The
runPreset handler can let an earlier invocation's finally block (and stream
loop) overwrite UI state after a newer request started; to fix, capture the
current controller and preset id into locals (e.g. const localController =
controller and const localPresetId = preset.id) when the request starts and
before updating shared state (appending to result.value or setting
loading.value/activePreset.value) verify the localController (or localPresetId)
still matches the global controller (or activePreset) so only the active request
mutates state; apply this check inside the streaming loop when appending to
result and in the catch/finally where loading.value is toggled.
| export interface SlashCommand { | ||
| id: string | ||
| icon: string | ||
| label: string | ||
| desc: string | ||
| action: 'heading' | 'paragraph' | 'divider' | 'scene-break' | 'pov-switch' | 'time-jump' | 'flashback' | 'ai-continue' | 'ai-rewrite' | 'ai-expand' | 'ai-shorten' | 'ai-tone' |
There was a problem hiding this comment.
Encode the heading variant in the command payload.
Both heading entries emit the same action: 'heading', so the consumer has no way to tell which one the user picked. TiptapEditor.vue ends up hard-coding a single heading level, which makes one of these menu items wrong every time.
Suggested contract change
export interface SlashCommand {
id: string
icon: string
label: string
desc: string
action: 'heading' | 'paragraph' | 'divider' | 'scene-break' | 'pov-switch' | 'time-jump' | 'flashback' | 'ai-continue' | 'ai-rewrite' | 'ai-expand' | 'ai-shorten' | 'ai-tone'
+ level?: 1 | 2 | 3
}
@@
- { id: 'h1', icon: 'H1', label: '一级标题', desc: '章标题', action: 'heading' },
- { id: 'h2', icon: 'H2', label: '二级标题', desc: '节标题', action: 'heading' },
+ { id: 'h1', icon: 'H1', label: '一级标题', desc: '章标题', action: 'heading', level: 1 },
+ { id: 'h2', icon: 'H2', label: '二级标题', desc: '节标题', action: 'heading', level: 2 },Also applies to: 47-49
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/editor/SlashCommandMenu.vue` around lines 26 - 31,
The SlashCommand definition currently uses a generic action: 'heading' which
loses heading level info; update the SlashCommand contract so heading commands
encode their variant (e.g., include a headingLevel or make action discriminated
like action: 'heading' with an additional headingLevel:number or action: { type:
'heading', level: number }) and update any producers of SlashCommand (the two
heading menu items) to set that level, ensuring consumers like TiptapEditor.vue
read the heading-specific field (headingLevel or action.level) to apply the
correct heading variant; keep other action strings unchanged.
| function formatChapter() { | ||
| if (!editor.value) return | ||
| const { doc } = editor.value.state | ||
| // Normalize all headings to consistent levels | ||
| doc.descendants((node, pos) => { | ||
| if (node.type.name === 'paragraph' && !node.textContent.trim()) { | ||
| // Remove empty paragraphs | ||
| editor.value?.chain().focus().setTextSelection({ from: pos, to: pos + node.nodeSize }).deleteSelection().run() | ||
| } | ||
| }) | ||
| // Set first-line indent on all paragraphs via CSS (no DOM mutation needed for that) | ||
| editor.value.chain().focus().setTextSelection(0).run() | ||
| } |
There was a problem hiding this comment.
Don't mutate the document while iterating doc.descendants().
The pos values come from the pre-mutation document. After the first empty paragraph is deleted, later positions are stale, so this formatter can skip blocks or delete the wrong ranges when there are multiple empty paragraphs.
Safer pattern
function formatChapter() {
if (!editor.value) return
const { doc } = editor.value.state
- // Normalize all headings to consistent levels
- doc.descendants((node, pos) => {
- if (node.type.name === 'paragraph' && !node.textContent.trim()) {
- // Remove empty paragraphs
- editor.value?.chain().focus().setTextSelection({ from: pos, to: pos + node.nodeSize }).deleteSelection().run()
- }
- })
+ const emptyRanges: Array<{ from: number; to: number }> = []
+ doc.descendants((node, pos) => {
+ if (node.type.name === 'paragraph' && !node.textContent.trim()) {
+ emptyRanges.push({ from: pos, to: pos + node.nodeSize })
+ }
+ })
+ for (const range of emptyRanges.reverse()) {
+ editor.value.chain().focus().setTextSelection(range).deleteSelection().run()
+ }
// Set first-line indent on all paragraphs via CSS (no DOM mutation needed for that)
editor.value.chain().focus().setTextSelection(0).run()
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function formatChapter() { | |
| if (!editor.value) return | |
| const { doc } = editor.value.state | |
| // Normalize all headings to consistent levels | |
| doc.descendants((node, pos) => { | |
| if (node.type.name === 'paragraph' && !node.textContent.trim()) { | |
| // Remove empty paragraphs | |
| editor.value?.chain().focus().setTextSelection({ from: pos, to: pos + node.nodeSize }).deleteSelection().run() | |
| } | |
| }) | |
| // Set first-line indent on all paragraphs via CSS (no DOM mutation needed for that) | |
| editor.value.chain().focus().setTextSelection(0).run() | |
| } | |
| function formatChapter() { | |
| if (!editor.value) return | |
| const { doc } = editor.value.state | |
| const emptyRanges: Array<{ from: number; to: number }> = [] | |
| doc.descendants((node, pos) => { | |
| if (node.type.name === 'paragraph' && !node.textContent.trim()) { | |
| emptyRanges.push({ from: pos, to: pos + node.nodeSize }) | |
| } | |
| }) | |
| for (const range of emptyRanges.reverse()) { | |
| editor.value.chain().focus().setTextSelection(range).deleteSelection().run() | |
| } | |
| // Set first-line indent on all paragraphs via CSS (no DOM mutation needed for that) | |
| editor.value.chain().focus().setTextSelection(0).run() | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/editor/TiptapEditor.vue` around lines 267 - 279,
formatChapter mutates the document while iterating doc.descendants (using
editor.value.chain()...deleteSelection()), which invalidates subsequent pos
values; instead first walk doc.descendants and collect positions and nodeSizes
for empty paragraph nodes, then apply deletions in reverse order (highest pos
first) so earlier mutations don't invalidate later positions (use the same
identifiers: formatChapter, doc.descendants, and the
editor.value.chain()/deleteSelection() call to perform the actual removes).
Ensure you only run the edit chain after collecting positions, and keep the
final setTextSelection(0) focus call after the deletions.
| const reader = res.body.getReader() | ||
| const decoder = new TextDecoder() | ||
| let buffer = '' | ||
|
|
||
| while (true) { | ||
| const { done, value } = await reader.read() | ||
| if (done) break | ||
| buffer += decoder.decode(value, { stream: true }) | ||
| const lines = buffer.split('\n') | ||
| buffer = lines.pop() || '' | ||
| for (const line of lines) { | ||
| if (line.startsWith('data: ')) { | ||
| try { | ||
| const data = JSON.parse(line.slice(6)) | ||
| if (data.text) suggestion.value += data.text | ||
| } catch { /* skip malformed */ } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Prefer the shared consumeSseStream utility.
This manual SSE parsing (reader loop, line splitting, data: extraction, JSON parse) duplicates the logic in frontend/src/utils/sseStream.ts (lines 11-110). Reusing consumeSseStream would eliminate the duplication and ensure consistent frame handling across the codebase.
♻️ Suggested refactor using consumeSseStream
+import { consumeSseStream } from '`@/utils/sseStream`'
+
function request(context: string) {
if (debounceTimer) clearTimeout(debounceTimer)
if (controller) controller.abort()
suggestion.value = ''
debounceTimer = setTimeout(async () => {
if (!context.trim()) return
loading.value = true
controller = new AbortController()
- try {
- const res = await fetch('/api/v1/ai/completion', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ context, max_tokens: 80 }),
- signal: controller.signal,
- })
-
- if (!res.ok || !res.body) { loading.value = false; return }
-
- const reader = res.body.getReader()
- const decoder = new TextDecoder()
- let buffer = ''
-
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- buffer += decoder.decode(value, { stream: true })
- const lines = buffer.split('\n')
- buffer = lines.pop() || ''
- for (const line of lines) {
- if (line.startsWith('data: ')) {
- try {
- const data = JSON.parse(line.slice(6))
- if (data.text) suggestion.value += data.text
- } catch { /* skip malformed */ }
- }
- }
- }
- } catch (e) {
- if ((e as Error).name !== 'AbortError') {
- console.warn('[AICompletion]', e)
- }
- } finally {
+ controller = consumeSseStream({
+ url: '/api/v1/ai/completion',
+ requestInit: {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ context, max_tokens: 80 }),
+ signal: controller.signal,
+ },
+ onFrame({ parsed }) {
+ const data = parsed as { text?: string } | null
+ if (data?.text) suggestion.value += data.text
+ },
+ onDone() { loading.value = false },
+ onError(err) {
+ if (err.name !== 'AbortError') console.warn('[AICompletion]', err)
+ loading.value = false
+ },
+ })
- loading.value = false
- }
}, 1500)
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/composables/useAICompletion.ts` around lines 38 - 56, Replace
the manual SSE parsing loop in useAICompletion (the reader/read loop using
res.body.getReader, TextDecoder, buffer splitting and JSON.parse) with the
shared consumeSseStream utility: import consumeSseStream and call it with the
response body (or reader) and a handler that reads each parsed frame,
JSON.parses the payload and appends data.text to suggestion.value. Ensure
error/malformed-frame handling is delegated to consumeSseStream and remove the
redundant buffer/line-splitting logic so suggestion.value is updated only from
the consumeSseStream callback.
| function accept(): string { | ||
| const text = suggestion.value | ||
| suggestion.value = '' | ||
| if (debounceTimer) clearTimeout(debounceTimer) | ||
| return text | ||
| } |
There was a problem hiding this comment.
Abort in-flight request when accepting suggestion.
accept() does not abort the active streaming request. If a user accepts a partial suggestion while the stream is still running, the request continues fetching and parsing in the background, wasting network and CPU resources.
🔧 Proposed fix
function accept(): string {
const text = suggestion.value
suggestion.value = ''
if (debounceTimer) clearTimeout(debounceTimer)
+ if (controller) controller.abort()
return text
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/composables/useAICompletion.ts` around lines 67 - 72, The
accept() function currently clears suggestion.value and debounceTimer but does
not cancel any active streaming request; update accept() to also abort the
in-flight stream by calling abort() on the fetch/stream AbortController (e.g.,
streamAbortController or similar), then null out/reset that controller and any
stream-related state (like isStreaming flags) so the background fetch/parsing
stops; keep the existing suggestion.value reset and debounceTimer clear logic
(reference: accept(), debounceTimer, suggestion.value, and the AbortController
variable used for streaming).
| wordCount: ch.word_count || ch.wordCount || 0, | ||
| summary: ch.summary || '', | ||
| povLabel: ch.pov_character || '—', | ||
| color: ch.pov_character === '主角' ? '#3b82f6' : ch.pov_character === '对手' ? '#ef4444' : ch.pov_character ? '#f59e0b' : '#9ca3af', |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Avoid hardcoding display strings in business logic.
The color assignment checks ch.pov_character === '主角' and '对手', coupling the logic to Chinese display strings. If the backend returns locale-independent codes, or if you internationalize the UI, this conditional will break.
♻️ Recommended refactor
Define a mapping from POV type codes to colors:
const POV_COLORS: Record<string, string> = {
protagonist: '`#3b82f6`',
antagonist: '`#ef4444`',
supporting: '`#f59e0b`',
default: '`#9ca3af`',
}
function getPovColor(pov: string | undefined): string {
return POV_COLORS[pov ?? 'default'] ?? POV_COLORS.default
}Then use it:
povLabel: ch.pov_character || '—',
- color: ch.pov_character === '主角' ? '`#3b82f6`' : ch.pov_character === '对手' ? '`#ef4444`' : ch.pov_character ? '`#f59e0b`' : '`#9ca3af`',
+ color: getPovColor(ch.pov_character),Coordinate with the backend to ensure pov_character returns type codes ('protagonist', 'antagonist', etc.) rather than display strings.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/views/Corkboard.vue` at line 101, The color selection currently
compares ch.pov_character to Chinese display strings (e.g., '主角', '对手'), which
couples UI logic to localized labels; replace this with a mapping and accessor
function (e.g., define a POV_COLORS record and a getPovColor(pov) helper) and
use getPovColor(ch.pov_character) instead of the inline ternary so the code
relies on locale-independent POV codes
(protagonist/antagonist/supporting/default) returned by the backend.
| color: ch.pov_character === '主角' ? '#3b82f6' : ch.pov_character === '对手' ? '#ef4444' : ch.pov_character ? '#f59e0b' : '#9ca3af', | ||
| })) | ||
| } | ||
| } catch { /* fallback */ } |
There was a problem hiding this comment.
Provide user feedback on error.
The empty catch block silently swallows errors. If the API call fails, users see no feedback and the page remains empty. At minimum, log the error to the console; ideally, show a toast notification or error state.
📢 Proposed fix
- } catch { /* fallback */ }
+ } catch (err) {
+ console.error('[Corkboard] Failed to load novel data:', err)
+ // Optionally show a toast notification to the user
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| } catch { /* fallback */ } | |
| } catch (err) { | |
| console.error('[Corkboard] Failed to load novel data:', err) | |
| // Optionally show a toast notification to the user | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/views/Corkboard.vue` at line 104, The empty catch block after
the API call that loads corkboard data is swallowing errors; update that catch
to at minimum console.error the caught error and surface user feedback by either
invoking the app's toast API (e.g., this.$toast.error(...) or similar) or
setting a reactive error state (e.g., this.errorMessage or data property) that
the template renders as an error banner; locate the try/catch in Corkboard.vue
around the corkboard/boards fetch and replace the empty catch with these logging
and user-notification steps.
| const recentActivity = ref<Array<{ type: string; title: string; desc: string; time: string }>>([]) | ||
|
|
||
| const streakMessage = ref('继续加油!') | ||
|
|
||
| onMounted(async () => { | ||
| try { | ||
| const data = await apiClient.get<any>('/stats/global') | ||
| if (data) { | ||
| totalWords.value = data.total_words || 0 | ||
| totalChapters.value = data.total_chapters || 0 | ||
| totalBooks.value = data.total_books || 0 | ||
| todayWords.value = data.today_words || 0 | ||
| streak.value = data.streak || 0 | ||
| todayPercent.value = Math.min(100, Math.round((todayWords.value / dailyGoal.value) * 100)) | ||
| } | ||
| streakMessage.value = streak.value >= 30 ? '传奇!' : streak.value >= 14 ? '太厉害了' : streak.value >= 7 ? '坚持一周了' : streak.value >= 3 ? '势头不错' : '开始吧' | ||
| } catch { /* offline */ } |
There was a problem hiding this comment.
Populate recentActivity from the API response.
This view renders a timeline, but the mount handler never assigns recentActivity. Even if /stats/global returns activity items, the dashboard will always show “还没有写作活动记录”.
Example mapping
if (data) {
totalWords.value = data.total_words || 0
totalChapters.value = data.total_chapters || 0
totalBooks.value = data.total_books || 0
todayWords.value = data.today_words || 0
streak.value = data.streak || 0
+ recentActivity.value = Array.isArray(data.recent_activity) ? data.recent_activity : []
todayPercent.value = Math.min(100, Math.round((todayWords.value / dailyGoal.value) * 100))
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const recentActivity = ref<Array<{ type: string; title: string; desc: string; time: string }>>([]) | |
| const streakMessage = ref('继续加油!') | |
| onMounted(async () => { | |
| try { | |
| const data = await apiClient.get<any>('/stats/global') | |
| if (data) { | |
| totalWords.value = data.total_words || 0 | |
| totalChapters.value = data.total_chapters || 0 | |
| totalBooks.value = data.total_books || 0 | |
| todayWords.value = data.today_words || 0 | |
| streak.value = data.streak || 0 | |
| todayPercent.value = Math.min(100, Math.round((todayWords.value / dailyGoal.value) * 100)) | |
| } | |
| streakMessage.value = streak.value >= 30 ? '传奇!' : streak.value >= 14 ? '太厉害了' : streak.value >= 7 ? '坚持一周了' : streak.value >= 3 ? '势头不错' : '开始吧' | |
| } catch { /* offline */ } | |
| const recentActivity = ref<Array<{ type: string; title: string; desc: string; time: string }>>([]) | |
| const streakMessage = ref('继续加油!') | |
| onMounted(async () => { | |
| try { | |
| const data = await apiClient.get<any>('/stats/global') | |
| if (data) { | |
| totalWords.value = data.total_words || 0 | |
| totalChapters.value = data.total_chapters || 0 | |
| totalBooks.value = data.total_books || 0 | |
| todayWords.value = data.today_words || 0 | |
| streak.value = data.streak || 0 | |
| recentActivity.value = Array.isArray(data.recent_activity) ? data.recent_activity : [] | |
| todayPercent.value = Math.min(100, Math.round((todayWords.value / dailyGoal.value) * 100)) | |
| } | |
| streakMessage.value = streak.value >= 30 ? '传奇!' : streak.value >= 14 ? '太厉害了' : streak.value >= 7 ? '坚持一周了' : streak.value >= 3 ? '势头不错' : '开始吧' | |
| } catch { /* offline */ } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/views/Dashboard.vue` around lines 70 - 86, The mounted handler
never assigns recentActivity, so update the onMounted async block (the
apiClient.get('/stats/global') response handling) to read the activity list from
the API (e.g., data.recent_activity or data.recentActivity) and set
recentActivity.value to a mapped array of objects matching the ref type ({ type,
title, desc, time }); ensure you provide safe defaults if the field is missing
and normalize timestamps to strings (e.g., ISO or formatted) so the timeline
rendering uses the mapped items instead of the “no activity” message.
## 核心:DAG 智能分析引擎
新建 (400+ 行):
- NarrativeIntelligenceService 统一智能分析服务
- 9 个 NodeMeta 注册到 DAG Registry
- 纯启发式 pacing 分析(零 token 消耗)
### 9 个智能节点
| 节点 | 功能 | 依赖 |
|------|------|------|
| val_structure | 章结构分类(三幕/英雄之旅/七点) | LLM |
| val_character | 角色一致性 OOC 检测 | LLM |
| val_plot_hole | KG 三元组矛盾跨章检测 | LLM |
| val_emotion | 角色情感弧线逐章追踪 | LLM |
| val_pacing | 句长/对话比/动作密度 | 纯启发式 |
| val_theme | 主题关键词频率漂移检测 | 启发式+LLM |
| review_coach | 写作教练提问式建议 | LLM |
| review_beta | 三种读者视角β阅读 | LLM |
| gen_enrich | 五感细节描写增强 | LLM |
### 54 个 prompt 模板(36 旧 + 18 新)
每节点配有 package.yaml + system.md + user.md
### API 路由
新建 路由组:
- POST /ai/coach — 写作教练
- POST /ai/enrich — 细节增强
- POST /ai/beta-read — AI β 读者
- POST /ai/analyze/{novel_id} — 全维度叙事分析
### 前端
- 新建 NarrativeHealth.vue:叙事健康大屏(结构/情感/节奏/漏洞/角色/主题)
- 新建 CoachPanel.vue:实时写作教练面板(采纳/忽略/稍后)
- 新建路由 /book/:slug/narrative-health
- ECharts 多角色情感弧线图
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (1)
frontend/src/views/NarrativeHealth.vue (1)
115-117: ⚡ Quick win建议在卸载时释放图表实例,避免内存泄漏。
当前页面离开后未
dispose(),反复进入会累计实例和监听器。Proposed fix
-import { ref, onMounted, watch, nextTick } from 'vue' +import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue' @@ onMounted(refreshAll) +onBeforeUnmount(() => { + emotionChart?.dispose() + emotionChart = null +})Also applies to: 172-173
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/views/NarrativeHealth.vue` around lines 115 - 117, The emotion chart instance (emotionChart) created from emotionChartRef is never disposed, causing leaks when the component is entered repeatedly; add an onBeforeUnmount/onUnmounted hook to check if emotionChart is non-null, call emotionChart.dispose(), set emotionChart = null, and remove any window or DOM resize/event listeners tied to that instance; apply the same disposal logic for the other chart instance referenced at the later block (lines 172-173) so both charts are properly cleaned up on component teardown.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@application/engine/dag/narrative_intelligence.py`:
- Around line 441-447: register_intelligence_nodes is passing a NodeMeta
instance (from INTELLIGENCE_NODES) into NodeRegistry.register, which expects an
executable node class or a decorator invocation; change the call to register the
actual class (e.g. use the NodeMeta attribute that holds the class such as
meta.node_class or meta.cls) or invoke the decorator form (e.g.
NodeRegistry.register(meta.node_type)(meta.node_class)) so the real node class
is registered for runtime discovery; update the call to
NodeRegistry.register(...) accordingly and keep the logger.warning for
exceptions.
- Around line 321-347: The detect_theme_drift function currently ignores the
threshold parameter by comparing result["drift_score"] to a hard-coded 0.5;
change the comparison to use the provided threshold (and optionally validate
threshold is between 0 and 1) so caller-provided sensitivity takes effect, i.e.,
replace the literal 0.5 check with a check against the threshold variable and
ensure the suggestion logic (the string built from result["missing_themes"])
still executes correctly when drift_score > threshold.
In `@frontend/src/components/workbench/CoachPanel.vue`:
- Around line 64-76: The requestCoach function currently swallows errors in its
empty catch, so change the catch to capture the error (e) from apiClient.post
and surface it: log the error (e.g., console.error or app logger) and set a
user-visible error state instead of failing silently (for example set a
dedicated errorRef or set encouragement.value to a short failure message), so
users see the failed request; keep loading.value toggled in the finally and
ensure you reference requestCoach, apiClient.post, loading.value,
encouragement.value and suggestions.value when making the change.
In `@frontend/src/views/NarrativeHealth.vue`:
- Around line 122-130: The UI is reading fields (structure.value, pacing.value,
plotHoles.value, characterViolations.value, oocScore.value, themeDrift.value,
renderEmotionChart) from the `/novels/${slug.value}/narrative-health` response,
but the backend returns a different contract (foreshadowing, narrative_debts,
tension_curve, props, voice_drift, health_score); update the call to use the
correct analysis endpoint or remap the response: replace the current
apiClient.get usage (the `/novels/${slug.value}/narrative-health` call) with the
endpoint that returns
foreshadowing/narrative_debts/tension_curve/props/voice_drift/health_score, or
change assignments so structure.value/pacing.value/plotHoles.value/... are
populated from the matching backend fields (e.g., map foreshadowing ->
structure.value, narrative_debts -> plotHoles.value, tension_curve ->
pacing.value, voice_drift -> themeDrift.value, health_score -> oocScore.value)
and pass the backend emotions field into renderEmotionChart; ensure all keys use
the backend’s snake_case names.
- Around line 138-143: The code is importing init from the wrong module and
calling init before registering components; change the logic to import
'echarts/core' first, call its use(...) with the imported LineChart,
GridComponent, TooltipComponent and CanvasRenderer to register them, then call
init(emotionChartRef.value) from the core module and assign to emotionChart;
update the sequence around the symbols init, LineChart, GridComponent,
TooltipComponent, CanvasRenderer, emotionChart and emotionChartRef so
registration happens before init and you call init from the core import rather
than from 'echarts/charts'.
In `@infrastructure/ai/prompt_packages/nodes/enrich-describe/system.md`:
- Line 16: The system prompt for the enrich-describe node contains conflicting
output constraints ("strict JSON" vs "directly return enhanced text"); change
the prompt to require a single unambiguous JSON schema and enforce returning
only that JSON object (for example
{"enriched_text":"...","added_senses":[...]}), update the system prompt text in
the enrich-describe template (system.md) to explicitly state that the model must
output only that JSON structure with those keys and no extra commentary so
upstream json.loads will always succeed.
In `@infrastructure/ai/prompt_packages/nodes/writing-coach/package.yaml`:
- Line 7: The package.yaml outputs list is missing the encouragement output used
by the coach route/component; update the outputs array under the outputs key in
infrastructure/ai/prompt_packages/nodes/writing-coach/package.yaml to include
"encouragement" alongside suggestions, priority_tags, and auto_actions so the
prompt contract matches the coach component's expectations.
In `@interfaces/api/v1/analyst/narrative_intelligence_routes.py`:
- Line 8: The /ai router is defined as router = APIRouter(...) in
narrative_intelligence_routes but is never wired into the FastAPI app; import
the narrative_intelligence_routes module where other routers are registered (the
same place that currently imports voice.router, narrative_state.router,
foreshadow_ledger.router, and narrative_health.router) and call
app.include_router(narrative_intelligence_routes.router) so the /ai endpoints
become reachable.
---
Nitpick comments:
In `@frontend/src/views/NarrativeHealth.vue`:
- Around line 115-117: The emotion chart instance (emotionChart) created from
emotionChartRef is never disposed, causing leaks when the component is entered
repeatedly; add an onBeforeUnmount/onUnmounted hook to check if emotionChart is
non-null, call emotionChart.dispose(), set emotionChart = null, and remove any
window or DOM resize/event listeners tied to that instance; apply the same
disposal logic for the other chart instance referenced at the later block (lines
172-173) so both charts are properly cleaned up on component teardown.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 491cd75f-ae3b-4398-97c2-5a749aeefa22
📒 Files selected for processing (28)
application/engine/dag/narrative_intelligence.pyfrontend/src/components/workbench/CoachPanel.vuefrontend/src/router/index.tsfrontend/src/views/NarrativeHealth.vueinfrastructure/ai/prompt_packages/nodes/beta-reader/package.yamlinfrastructure/ai/prompt_packages/nodes/beta-reader/system.mdinfrastructure/ai/prompt_packages/nodes/beta-reader/user.mdinfrastructure/ai/prompt_packages/nodes/character-consistency-check/package.yamlinfrastructure/ai/prompt_packages/nodes/character-consistency-check/system.mdinfrastructure/ai/prompt_packages/nodes/character-consistency-check/user.mdinfrastructure/ai/prompt_packages/nodes/emotion-arc/package.yamlinfrastructure/ai/prompt_packages/nodes/emotion-arc/system.mdinfrastructure/ai/prompt_packages/nodes/emotion-arc/user.mdinfrastructure/ai/prompt_packages/nodes/enrich-describe/package.yamlinfrastructure/ai/prompt_packages/nodes/enrich-describe/system.mdinfrastructure/ai/prompt_packages/nodes/enrich-describe/user.mdinfrastructure/ai/prompt_packages/nodes/plot-hole-detection/package.yamlinfrastructure/ai/prompt_packages/nodes/plot-hole-detection/system.mdinfrastructure/ai/prompt_packages/nodes/plot-hole-detection/user.mdinfrastructure/ai/prompt_packages/nodes/structure-classification/package.yamlinfrastructure/ai/prompt_packages/nodes/structure-classification/system.mdinfrastructure/ai/prompt_packages/nodes/structure-classification/user.mdinfrastructure/ai/prompt_packages/nodes/theme-drift/package.yamlinfrastructure/ai/prompt_packages/nodes/theme-drift/system.mdinfrastructure/ai/prompt_packages/nodes/writing-coach/package.yamlinfrastructure/ai/prompt_packages/nodes/writing-coach/system.mdinfrastructure/ai/prompt_packages/nodes/writing-coach/user.mdinterfaces/api/v1/analyst/narrative_intelligence_routes.py
✅ Files skipped from review due to trivial changes (13)
- infrastructure/ai/prompt_packages/nodes/plot-hole-detection/package.yaml
- infrastructure/ai/prompt_packages/nodes/enrich-describe/user.md
- infrastructure/ai/prompt_packages/nodes/emotion-arc/package.yaml
- infrastructure/ai/prompt_packages/nodes/enrich-describe/package.yaml
- infrastructure/ai/prompt_packages/nodes/character-consistency-check/user.md
- infrastructure/ai/prompt_packages/nodes/structure-classification/package.yaml
- infrastructure/ai/prompt_packages/nodes/plot-hole-detection/system.md
- infrastructure/ai/prompt_packages/nodes/beta-reader/user.md
- infrastructure/ai/prompt_packages/nodes/theme-drift/package.yaml
- infrastructure/ai/prompt_packages/nodes/emotion-arc/system.md
- infrastructure/ai/prompt_packages/nodes/character-consistency-check/package.yaml
- infrastructure/ai/prompt_packages/nodes/writing-coach/user.md
- infrastructure/ai/prompt_packages/nodes/structure-classification/user.md
🚧 Files skipped from review as they are similar to previous changes (1)
- frontend/src/router/index.ts
| async def detect_theme_drift( | ||
| theme_keywords: List[str], | ||
| chapter_content: str, | ||
| threshold: float = 0.3, | ||
| llm_invoke=None, | ||
| ) -> Dict[str, Any]: | ||
| """主题漂移检测""" | ||
| result = {"drift_score": 0.0, "missing_themes": [], "weakened_themes": [], "suggestion": ""} | ||
| if not chapter_content or not theme_keywords: | ||
| return result | ||
|
|
||
| # 启发式层:关键词频率检测 | ||
| content_lower = chapter_content.lower() | ||
| for kw in theme_keywords: | ||
| count = content_lower.count(kw.lower()) | ||
| if count == 0: | ||
| result["missing_themes"].append(kw) | ||
| elif count < 2: | ||
| result["weakened_themes"].append(kw) | ||
|
|
||
| total_themes = len(theme_keywords) | ||
| if total_themes > 0: | ||
| missing_ratio = len(result["missing_themes"]) / total_themes | ||
| result["drift_score"] = round(missing_ratio, 2) | ||
|
|
||
| if result["drift_score"] > 0.5: | ||
| result["suggestion"] = f"主题漂移严重:{', '.join(result['missing_themes'][:3])} 在本章中未出现" |
There was a problem hiding this comment.
threshold is ignored in theme drift scoring.
Line 324 accepts threshold, but Line 346 compares against hard-coded 0.5. This makes caller-provided sensitivity ineffective.
💡 Proposed fix
- if result["drift_score"] > 0.5:
+ if result["drift_score"] > threshold:
result["suggestion"] = f"主题漂移严重:{', '.join(result['missing_themes'][:3])} 在本章中未出现"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@application/engine/dag/narrative_intelligence.py` around lines 321 - 347, The
detect_theme_drift function currently ignores the threshold parameter by
comparing result["drift_score"] to a hard-coded 0.5; change the comparison to
use the provided threshold (and optionally validate threshold is between 0 and
1) so caller-provided sensitivity takes effect, i.e., replace the literal 0.5
check with a check against the threshold variable and ensure the suggestion
logic (the string built from result["missing_themes"]) still executes correctly
when drift_score > threshold.
| def register_intelligence_nodes(): | ||
| """将智能节点注册到 DAG 节点注册表""" | ||
| for meta in INTELLIGENCE_NODES: | ||
| try: | ||
| NodeRegistry.register(meta) | ||
| except Exception as e: | ||
| logger.warning(f"[IntelligenceNodes] 注册 {meta.node_type} 失败: {e}") |
There was a problem hiding this comment.
register_intelligence_nodes is using the registry API in a non-registering way.
At Line 445, NodeRegistry.register(meta) passes a NodeMeta instance into a decorator-style API. This does not register an executable node class and can leave these node types undiscoverable at runtime while still logging success.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@application/engine/dag/narrative_intelligence.py` around lines 441 - 447,
register_intelligence_nodes is passing a NodeMeta instance (from
INTELLIGENCE_NODES) into NodeRegistry.register, which expects an executable node
class or a decorator invocation; change the call to register the actual class
(e.g. use the NodeMeta attribute that holds the class such as meta.node_class or
meta.cls) or invoke the decorator form (e.g.
NodeRegistry.register(meta.node_type)(meta.node_class)) so the real node class
is registered for runtime discovery; update the call to
NodeRegistry.register(...) accordingly and keep the logger.warning for
exceptions.
| async function requestCoach() { | ||
| loading.value = true | ||
| encouragement.value = '' | ||
| suggestions.value = [] | ||
| try { | ||
| const res = await apiClient.post<any>('/ai/coach', { | ||
| chapter_content: props.chapterContent?.slice(0, 6000) || '', | ||
| chapter_number: props.chapterNumber || 0, | ||
| }) | ||
| encouragement.value = res?.encouragement || '' | ||
| suggestions.value = res?.suggestions || [] | ||
| } catch { /* fallback */ } finally { loading.value = false } | ||
| } |
There was a problem hiding this comment.
Request failures are silently swallowed.
At Line 75, the empty catch hides API/provider errors; users only see empty content, not a failed request state.
💡 Proposed fix
async function requestCoach() {
loading.value = true
encouragement.value = ''
suggestions.value = []
try {
@@
encouragement.value = res?.encouragement || ''
suggestions.value = res?.suggestions || []
- } catch { /* fallback */ } finally { loading.value = false }
+ } catch {
+ encouragement.value = '教练建议请求失败,请稍后重试。'
+ suggestions.value = []
+ } finally {
+ loading.value = false
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async function requestCoach() { | |
| loading.value = true | |
| encouragement.value = '' | |
| suggestions.value = [] | |
| try { | |
| const res = await apiClient.post<any>('/ai/coach', { | |
| chapter_content: props.chapterContent?.slice(0, 6000) || '', | |
| chapter_number: props.chapterNumber || 0, | |
| }) | |
| encouragement.value = res?.encouragement || '' | |
| suggestions.value = res?.suggestions || [] | |
| } catch { /* fallback */ } finally { loading.value = false } | |
| } | |
| async function requestCoach() { | |
| loading.value = true | |
| encouragement.value = '' | |
| suggestions.value = [] | |
| try { | |
| const res = await apiClient.post<any>('/ai/coach', { | |
| chapter_content: props.chapterContent?.slice(0, 6000) || '', | |
| chapter_number: props.chapterNumber || 0, | |
| }) | |
| encouragement.value = res?.encouragement || '' | |
| suggestions.value = res?.suggestions || [] | |
| } catch { | |
| encouragement.value = '教练建议请求失败,请稍后重试。' | |
| suggestions.value = [] | |
| } finally { | |
| loading.value = false | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/workbench/CoachPanel.vue` around lines 64 - 76, The
requestCoach function currently swallows errors in its empty catch, so change
the catch to capture the error (e) from apiClient.post and surface it: log the
error (e.g., console.error or app logger) and set a user-visible error state
instead of failing silently (for example set a dedicated errorRef or set
encouragement.value to a short failure message), so users see the failed
request; keep loading.value toggled in the finally and ensure you reference
requestCoach, apiClient.post, loading.value, encouragement.value and
suggestions.value when making the change.
| - 保持原文的语气和节奏,不改变情节 | ||
| - 中文网络文学风格,避免翻译腔 | ||
|
|
||
| 请输出严格的 JSON 格式。直接返回增强后的文本。 |
There was a problem hiding this comment.
输出约束自相矛盾,易导致 JSON 解析失败。
“严格 JSON”与“直接返回增强后的文本”冲突,模型容易返回纯文本;上游 json.loads 失败后会走降级路径,导致增强结果丢失。建议明确唯一格式(例如固定 {"enriched_text":"...", "added_senses":[...]})。
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@infrastructure/ai/prompt_packages/nodes/enrich-describe/system.md` at line
16, The system prompt for the enrich-describe node contains conflicting output
constraints ("strict JSON" vs "directly return enhanced text"); change the
prompt to require a single unambiguous JSON schema and enforce returning only
that JSON object (for example {"enriched_text":"...","added_senses":[...]}),
update the system prompt text in the enrich-describe template (system.md) to
explicitly state that the model must output only that JSON structure with those
keys and no extra commentary so upstream json.loads will always succeed.
| category: review | ||
| description: 不代替写作,以提问式引导方式给出2-3条可执行建议,覆盖感官描写、节奏、角色深度、冲突强度 | ||
| inputs: [chapter_content, tension_scores, drift_score, character_states, beat_focus, chapter_number] | ||
| outputs: [suggestions, priority_tags, auto_actions] |
There was a problem hiding this comment.
Declared prompt outputs don’t match the API/UI response contract.
Line 7 omits encouragement, but the coach route/component in this PR consumes encouragement with suggestions. This cross-layer mismatch can produce incomplete responses in schema-driven flows.
💡 Proposed fix
-outputs: [suggestions, priority_tags, auto_actions]
+outputs: [encouragement, suggestions]📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| outputs: [suggestions, priority_tags, auto_actions] | |
| outputs: [encouragement, suggestions] |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@infrastructure/ai/prompt_packages/nodes/writing-coach/package.yaml` at line
7, The package.yaml outputs list is missing the encouragement output used by the
coach route/component; update the outputs array under the outputs key in
infrastructure/ai/prompt_packages/nodes/writing-coach/package.yaml to include
"encouragement" alongside suggestions, priority_tags, and auto_actions so the
prompt contract matches the coach component's expectations.
| from pydantic import BaseModel, Field | ||
| from typing import Any, Dict, List, Optional | ||
|
|
||
| router = APIRouter(prefix="/ai", tags=["Narrative Intelligence"]) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Check whether narrative intelligence router is imported and included
rg -n "narrative_intelligence|include_router\\(" interfaces/main.py -C2
rg -n "narrative_intelligence" interfaces/api/v1/analyst -C2Repository: shenminglinyi/PlotPilot
Length of output: 7083
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Search for this router/module being included anywhere
rg -n "narrative_intelligence_routes|narrative_intelligence_routes\.router|include_router\(\s*.*narrative_intelligence" . -S
# Search for the "/ai" prefix being wired
rg -n "prefix\s*=\s*[\"']?/ai[\"']?|\"/ai\"" interfaces -S
# Search for any include_router with tags including "Narrative Intelligence"
rg -n "include_router\([^)]*Narrative Intelligence|Narrative Intelligence[^)]*include_router" interfaces -S
# Show analyst routers included in interfaces/main.py around the analyst block
rg -n "Analyst:|voice\.router|narrative_state\.router|foreshadow_ledger\.router|narrative_health\.router" interfaces/main.py -n -C3Repository: shenminglinyi/PlotPilot
Length of output: 49
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find the file to ensure correct path/name
fd -i "narrative_intelligence_routes\.py" interfaces || true
fd -i "narrative_intelligence" interfaces || true
# Look for any import of this module (direct or via alias)
rg -n "narrative_intelligence_routes" interfaces -S || true
rg -n "from .*narrative_intelligence_routes import .*router|import .*narrative_intelligence_routes" interfaces -S || true
# Look specifically for include_router passing that router object
rg -n "include_router\(\s*.*narrative_intelligence_routes.*router|include_router\(\s*.*narrative_intelligence.*router" interfaces -S || true
# Also search for include_router(...) that uses variable named like narrative_intelligence_router
rg -n "include_router\([^)]*narrative_intelligence[^)]*\)" interfaces -S || trueRepository: shenminglinyi/PlotPilot
Length of output: 185
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Any mention of the specific router module or its router variable
echo "== narrative_intelligence_routes imports/references =="
rg -n "narrative_intelligence_routes" interfaces -S || true
echo "== from ... import ...router from that module =="
rg -n "from .*narrative_intelligence_routes import .*router" interfaces -S || true
echo "== include_router(...) that includes that router object/module =="
rg -n "include_router\\([^\\)]*narrative_intelligence_routes[^\\)]*\\)" interfaces -S || true
# Search for the "/ai" prefix being wired into FastAPI routers
echo "== APIRouter prefix \"/ai\" =="
rg -n "APIRouter\\([^\\)]*prefix\\s*=\\s*[\"']/ai[\"']" interfaces -S || true
echo "== include_router(...) with prefix \"/ai\" =="
rg -n "include_router\\([^\\)]*\\+?\\s*[\"']/ai[\"']|include_router\\([^\\)]*prefix\\s*=\\s*[\"']/ai[\"']" interfaces -S || true
# Search for the tag string (likely only in this module, but helps find inclusion)
echo "== \"Narrative Intelligence\" occurrences =="
rg -n "\"Narrative Intelligence\"" interfaces -S || true
# Check analyst package exports
echo "== analyst package files =="
fd -i "interfaces/api/v1/analyst/__init__\\.py" . || trueRepository: shenminglinyi/PlotPilot
Length of output: 635
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect analyst __init__.py if it exists
if [ -f interfaces/api/v1/analyst/__init__.py ]; then
echo "---- interfaces/api/v1/analyst/__init__.py ----"
sed -n '1,200p' interfaces/api/v1/analyst/__init__.py
else
echo "No interfaces/api/v1/analyst/__init__.py found"
fi
# Inspect the relevant analyst wiring region in interfaces/main.py for nearby context
echo "---- analyst wiring region in interfaces/main.py ----"
rg -n "Analyst:|Narrative Intelligence|voice\.router|narrative_state\.router|foreshadow_ledger\.router|narrative_health\.router" interfaces/main.py -n -C5 || trueRepository: shenminglinyi/PlotPilot
Length of output: 976
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Search whole repo for any reference/import/include of this module
rg -n "narrative_intelligence_routes" . -S || true
rg -n "from .*narrative_intelligence_routes import .*router|import .*narrative_intelligence_routes" . -S || true
rg -n "include_router\\([^\\)]*narrative_intelligence[^\\)]*\\)" . -S || true
rg -n "\"Narrative Intelligence\"" . -S || trueRepository: shenminglinyi/PlotPilot
Length of output: 196
🏁 Script executed:
rg -n "include_router\\([^\\)]*prefix\\s*=\\s*[\"']/ai[\"']|include_router\\(.*\\)\\s*#.*\\/ai|prefix\\s*=\\s*[\"']/ai[\"']" . -S || trueRepository: shenminglinyi/PlotPilot
Length of output: 309
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n 'prefix\s*=\s*["'\'']\/ai["'\'']' . -S || true
rg -n 'include_router\(' . -S | rg -n '\/ai|prefix\s*=\s*["'\'']\/ai["'\'']' || trueRepository: shenminglinyi/PlotPilot
Length of output: 273
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n 'prefix\s*=\s*["'\'']\/ai["'\'']' . -S || true
rg -n 'include_router\(' . -S | rg -n '/ai|prefix\s*=\s*["'\'']/ai["'\'']' || true
rg -n '"/ai"' . -S || trueRepository: shenminglinyi/PlotPilot
Length of output: 323
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# List analyst directory files
ls -la interfaces/api/v1/analyst || true
# Show any imports from analyst package in interfaces/main.py
rg -n "interfaces\.api\.v1\.analyst|from .*analyst import|import .*analyst" interfaces/main.py -S || true
# Show all include_router lines in interfaces/main.py that use _V1 prefix (for completeness)
rg -n "app\.include_router\(" interfaces/main.py -S || trueRepository: shenminglinyi/PlotPilot
Length of output: 4674
Add missing FastAPI include_router wiring for /ai narrative intelligence endpoints.
interfaces/api/v1/analyst/narrative_intelligence_routes.py defines router = APIRouter(prefix="/ai", tags=["Narrative Intelligence"]), but interfaces/main.py imports/includes only voice.router, narrative_state.router, foreshadow_ledger.router, and narrative_health.router—it never imports or includes narrative_intelligence_routes.router, so the /ai routes won’t be reachable.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@interfaces/api/v1/analyst/narrative_intelligence_routes.py` at line 8, The
/ai router is defined as router = APIRouter(...) in
narrative_intelligence_routes but is never wired into the FastAPI app; import
the narrative_intelligence_routes module where other routers are registered (the
same place that currently imports voice.router, narrative_state.router,
foreshadow_ledger.router, and narrative_health.router) and call
app.include_router(narrative_intelligence_routes.router) so the /ai endpoints
become reachable.
## 5 个新服务 ### ReflectivePlanner (反思式规划器) Plan → Critique → Refine 三轮反思循环 - quick: 1轮直接生成 - standard: 2轮 (Plan → Critique → Refine) - deep: 3轮 (Plan → Critique → Refine → Critique → Re-refine) - 内部评分阈值 0.75,达标即停止 ### IterativeWriter (迭代式写作) Write → Review → Rewrite 质量闭环 - 5 维度审查(连贯性/文笔/角色/展示vs告知/可读性) - 评分 < 3.0 触发重写 - 最多 2 轮 rewrite,避免无限循环 ### SmartRouter (智能模型路由) 按任务类型选择最优模型和参数 - 规划/审查/分类/摘要 → fast 模型(便宜快模型) - 创意写作/重写/增强 → creative 模型(强模型) - 自动推荐温度 + max_tokens - 支持降级:主模型失败 → 备选模型 ### ParallelAuditor (并行审计引擎) 6 维度同时审计(asyncio.gather) - 每维度独立超时(30s)和重试(2次) - 失败维度不影响其他 - 结果聚合为统一报告 ### AdaptiveRetry (自适应重试) 替代硬编码 max 3 次 — 智能决策 - 按错误类型分类:临时/永久/模型/溢出 - 指数退避 + 随机 jitter - 模型降级支持 - 熔断器状态感知 ## DAG 引擎默认启用 ENABLE_DAG_ENGINE: false → true Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
## 对标最新论文 ### SuperWriter 四步流水线 (arXiv 2506.04180, 2025-06) - Plan → Think → Write → Check → Edit 五阶段认知流水线 - Plan: 双 agent Story Workshop 对话式策划 - Think: 落笔前结构化思考(关键创新) - Check→Edit: 迭代精炼循环(最多 2 轮) - 对标论文结果:7B 模型通过此流水线击败 GPT-4o (8.51 vs 8.16) ### MCTS 层级质量评分 (Hierarchical DPO) - 6 维度评分(连贯/逻辑/吸引/角色/文笔/创新) - 分数层级回传:Edit → Write → Think → Plan(衰减因子) - 追踪多条路径找最优组合 - 对标 SuperWriter 的 MCTS-based hierarchical DPO ### 知识图谱交叉验证 (SAGA Graph Healing) - 新章三元组 vs 现有 KG 差异比对 - 自动分类:已确认/新增/冲突/更新/遗留/需人工 - 可变谓词自动解决,不可变谓词标记需人工 - 健康分数计算 ### 工作流编排工厂 (统一入口) - 三种深度:fast(单次) / standard(反思) / deep(SuperWriter+MCTS) - 自动选择最优 Planner + Writer + Auditor 组合 - 可配置化:每步可独立开关 ## 现有工作流生态(10 个服务) | 服务 | 对标 | |------|------| | ReflectivePlanner | CogWriter Planning Agent | | IterativeWriter | Reflection Agent × LangGraph | | SmartRouter | 任务路由层 | | ParallelAuditor | 并行评估 | | AdaptiveRetry | 自适应恢复 | | SuperWriterPipeline | SuperWriter 论文 | | MCTSScorer | Hierarchical DPO | | KGCrossValidator | SAGA Graph Healing | | WorkflowOrchestrator | 统一入口 | | NarrativeIntelligenceService | 9 DAG 智能节点 | Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… 多分辨率 + 编排v2 ## 对标 10 篇 2026 最新论文的全面落地 ### BiT-MCTS 双向搜索 (arXiv:2603.14410) 高潮优先 → 双向 MCTS 展开 (Freytag 金字塔五阶段) - UCB1 平衡探索与利用 - 网文优化的 6 种高潮类型 ### Writer-R1 MRPO (arXiv:2603.15061) 记忆增强回放策略 — 零训练自我反思 - 多智能体对话生成动态评价标准 - 记忆回放 + 择优输出 - 4B 模型通过此方法超越 100B+ 开源模型 ### Octopus 熵控制 (AAAI 2026 + EPIC arXiv:2601.01714 + Top-H NeurIPS) 动态熵调节 + 长度泛化 (POLARIS) - 7 种叙事阶段自动调节温度/top_p/top_k - Top-H 熵约束采样 - 3x 训练长度泛化 ### ForeshadowPool 伏笔投资池 (arXiv:2601.07033) 编码 Foreshadow-Trigger-Payoff 三元组 - 5 状态有限状态机 (SET→ACTIVE→TRIGGERED→PAID_OFF/EXPIRED) - 容量上限 + 优先级驱逐 - 回收率追踪 ### DiversityBranching 多样性分叉 (arXiv:2601.09609) 群组多样性奖励驱动的规划分叉 - Jaccard 距离计算群组多样性 - 质量×0.7 + 多样性×0.3 综合选择 ### StyleVariationGuard 反扁平化 (arXiv:2605.27878) 五维度风格多样性巡检 - 句长/情感/形容词/用词/语气 - 自动注入变化提示到下一章生成 ### MultiResolutionPlanner 全书骨架 (arXiv:2605.17064) 4 级分辨率: Book→Part→Volume→Act→Chapter - 按网文章节规范自动计算层级分配 - 用人类散文风格指令远离 AI 助手风格 ### WorkflowOrchestratorV2 (编排 v2) 第四层: supreme 模式 — 集成上述全部服务 fast → standard → deep → supreme 四档可配 ## 工作流服务全景 (18 个专业服务) | 年份 | 服务数 | 代表 | |------|--------|------| | 2025 | 6 | SuperWriter, MCTS, KG验证, ReflectivePlanner | | 2026 | 8 | BiT-MCTS, Writer-R1, Octopus, ForeshadowPool 等 | | 基础 | 4 | SmartRouter, AdaptiveRetry, ParallelAuditor, DAG | Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ChapterTreePanel.vue: 可折叠树形+拖拽排序+右键菜单+状态图标 - ChapterTabs.vue: 卡片式标签栏+自动跟踪打开章节 - SplitEditor.vue: 左右分屏(25%-75%可拖拽)+写作/参考双模式 - ChapterList 新增「大纲树」视图模式 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tiveHealth, restore build script - App.vue: toggleTheme → setTheme inline toggle - TiptapEditor: restore isFocused + aiAssistRef refs - WorkArea: emit chapter-select instead of mutating prop - Dashboard: cast ternary type - Router: remove NarrativeHealth lazy import - package.json: restore vue-tsc build step Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- state_publisher.py: 补全 PersistenceQueue import - DB: active profile 从损坏 local-ollama 切到 DeepSeek v4-pro Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 commits, 104 files changed.
Commit 1 (011f3a5): Local LLM workspace, narrative health, Tiptap editor, autopilot cockpit, DAG engine, SSE streaming.
Commit 2 (a1e8500): pip-installable pyproject.toml, Docker support, Makefile, pre-commit, test cleanup, setup docs.
Summary by CodeRabbit
New Features
Chores
Documentation
Tests