@zwbigi/ink-xy 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +2 -2
- package/.next/build-manifest.json +2 -2
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +1 -1
- package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/api/inkos/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/install/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/search/route.js.nft.json +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +1 -1
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/server/app-paths-manifest.json +2 -2
- package/.next/server/chunks/162.js +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/trace +4 -4
- package/.next/trace-build +1 -1
- package/bin/pi-web.js +4 -1
- package/inkos/.env.example +20 -0
- package/inkos/.node-version +1 -0
- package/inkos/.nvmrc +1 -0
- package/inkos/CHANGELOG.md +787 -0
- package/inkos/CONTRIBUTING.md +89 -0
- package/inkos/LICENSE +661 -0
- package/inkos/README.en.md +483 -0
- package/inkos/README.ja.md +461 -0
- package/inkos/README.md +272 -0
- package/inkos/assets/15qun.jpg +0 -0
- package/inkos/assets/41777702961_.pic.jpg +0 -0
- package/inkos/assets/inkos-short-demo-cover.png +0 -0
- package/inkos/assets/inkos-text.svg +40 -0
- package/inkos/assets/logo.svg +47 -0
- package/inkos/assets/screenshot-chapters.png +0 -0
- package/inkos/assets/screenshot-pipeline.png +0 -0
- package/inkos/assets/screenshot-state.png +0 -0
- package/inkos/assets/screenshot-terminal.png +0 -0
- package/inkos/assets/wechat-group-v8.jpg +0 -0
- package/inkos/package.json +42 -0
- package/inkos/packages/cli/package.json +74 -0
- package/inkos/packages/cli/src/__tests__/analytics.test.ts +154 -0
- package/inkos/packages/cli/src/__tests__/cli-integration.test.ts +1031 -0
- package/inkos/packages/cli/src/__tests__/daemon.test.ts +93 -0
- package/inkos/packages/cli/src/__tests__/doctor.test.ts +36 -0
- package/inkos/packages/cli/src/__tests__/interact-command.test.ts +142 -0
- package/inkos/packages/cli/src/__tests__/interaction-tools.test.ts +107 -0
- package/inkos/packages/cli/src/__tests__/llm-overrides.test.ts +25 -0
- package/inkos/packages/cli/src/__tests__/localization.test.ts +121 -0
- package/inkos/packages/cli/src/__tests__/progress-text.test.ts +92 -0
- package/inkos/packages/cli/src/__tests__/project-bootstrap.test.ts +71 -0
- package/inkos/packages/cli/src/__tests__/publish-package.test.ts +272 -0
- package/inkos/packages/cli/src/__tests__/revision-command.test.ts +82 -0
- package/inkos/packages/cli/src/__tests__/runtime-requirements.test.ts +89 -0
- package/inkos/packages/cli/src/__tests__/short-fiction-command.test.ts +48 -0
- package/inkos/packages/cli/src/__tests__/studio-runtime.test.ts +142 -0
- package/inkos/packages/cli/src/__tests__/studio.test.ts +87 -0
- package/inkos/packages/cli/src/__tests__/tui-activity-state.test.ts +20 -0
- package/inkos/packages/cli/src/__tests__/tui-agent-session.test.ts +213 -0
- package/inkos/packages/cli/src/__tests__/tui-chat-depth.test.ts +27 -0
- package/inkos/packages/cli/src/__tests__/tui-chat-draft.test.ts +44 -0
- package/inkos/packages/cli/src/__tests__/tui-command.test.ts +86 -0
- package/inkos/packages/cli/src/__tests__/tui-composer-caret.test.ts +46 -0
- package/inkos/packages/cli/src/__tests__/tui-composer-display.test.ts +40 -0
- package/inkos/packages/cli/src/__tests__/tui-dashboard.test.tsx +219 -0
- package/inkos/packages/cli/src/__tests__/tui-effects-i18n.test.ts +29 -0
- package/inkos/packages/cli/src/__tests__/tui-i18n.test.ts +22 -0
- package/inkos/packages/cli/src/__tests__/tui-input-chrome.test.ts +10 -0
- package/inkos/packages/cli/src/__tests__/tui-input-history.test.ts +40 -0
- package/inkos/packages/cli/src/__tests__/tui-layout.test.ts +55 -0
- package/inkos/packages/cli/src/__tests__/tui-local-commands.test.ts +47 -0
- package/inkos/packages/cli/src/__tests__/tui-session-store.test.ts +81 -0
- package/inkos/packages/cli/src/__tests__/tui-setup-i18n.test.ts +31 -0
- package/inkos/packages/cli/src/__tests__/tui-slash-autocomplete.test.ts +33 -0
- package/inkos/packages/cli/src/commands/agent.ts +65 -0
- package/inkos/packages/cli/src/commands/analytics.ts +77 -0
- package/inkos/packages/cli/src/commands/audit.ts +52 -0
- package/inkos/packages/cli/src/commands/book.ts +260 -0
- package/inkos/packages/cli/src/commands/compose.ts +50 -0
- package/inkos/packages/cli/src/commands/config.ts +328 -0
- package/inkos/packages/cli/src/commands/consolidate.ts +50 -0
- package/inkos/packages/cli/src/commands/daemon.ts +121 -0
- package/inkos/packages/cli/src/commands/detect.ts +125 -0
- package/inkos/packages/cli/src/commands/doctor.ts +391 -0
- package/inkos/packages/cli/src/commands/draft.ts +43 -0
- package/inkos/packages/cli/src/commands/eval.ts +217 -0
- package/inkos/packages/cli/src/commands/export.ts +45 -0
- package/inkos/packages/cli/src/commands/fanfic.ts +183 -0
- package/inkos/packages/cli/src/commands/genre.ts +160 -0
- package/inkos/packages/cli/src/commands/import.ts +158 -0
- package/inkos/packages/cli/src/commands/init.ts +47 -0
- package/inkos/packages/cli/src/commands/interact.ts +109 -0
- package/inkos/packages/cli/src/commands/plan.ts +54 -0
- package/inkos/packages/cli/src/commands/radar.ts +60 -0
- package/inkos/packages/cli/src/commands/review.ts +253 -0
- package/inkos/packages/cli/src/commands/revise.ts +58 -0
- package/inkos/packages/cli/src/commands/short-fiction.ts +294 -0
- package/inkos/packages/cli/src/commands/status.ts +138 -0
- package/inkos/packages/cli/src/commands/studio.ts +194 -0
- package/inkos/packages/cli/src/commands/style.ts +99 -0
- package/inkos/packages/cli/src/commands/tui.ts +18 -0
- package/inkos/packages/cli/src/commands/update.ts +45 -0
- package/inkos/packages/cli/src/commands/write.ts +324 -0
- package/inkos/packages/cli/src/index.ts +5 -0
- package/inkos/packages/cli/src/interaction/tools.ts +49 -0
- package/inkos/packages/cli/src/localization.ts +215 -0
- package/inkos/packages/cli/src/program.ts +106 -0
- package/inkos/packages/cli/src/progress-text.ts +85 -0
- package/inkos/packages/cli/src/project-bootstrap.ts +175 -0
- package/inkos/packages/cli/src/runtime-requirements.ts +135 -0
- package/inkos/packages/cli/src/tui/__tests__/markdown.test.ts +64 -0
- package/inkos/packages/cli/src/tui/activity-state.ts +41 -0
- package/inkos/packages/cli/src/tui/agent-input.ts +264 -0
- package/inkos/packages/cli/src/tui/ansi.ts +72 -0
- package/inkos/packages/cli/src/tui/app.ts +130 -0
- package/inkos/packages/cli/src/tui/chat-depth.ts +22 -0
- package/inkos/packages/cli/src/tui/chat-draft.ts +42 -0
- package/inkos/packages/cli/src/tui/composer-caret.ts +22 -0
- package/inkos/packages/cli/src/tui/composer-display.ts +26 -0
- package/inkos/packages/cli/src/tui/dashboard-model.ts +164 -0
- package/inkos/packages/cli/src/tui/dashboard.tsx +544 -0
- package/inkos/packages/cli/src/tui/effects.ts +542 -0
- package/inkos/packages/cli/src/tui/i18n.ts +278 -0
- package/inkos/packages/cli/src/tui/input-history.ts +69 -0
- package/inkos/packages/cli/src/tui/local-commands.ts +55 -0
- package/inkos/packages/cli/src/tui/markdown.ts +64 -0
- package/inkos/packages/cli/src/tui/session-store.ts +6 -0
- package/inkos/packages/cli/src/tui/setup.ts +397 -0
- package/inkos/packages/cli/src/tui/slash-autocomplete.ts +62 -0
- package/inkos/packages/cli/src/tui/theme.ts +17 -0
- package/inkos/packages/cli/src/utils.ts +222 -0
- package/inkos/packages/cli/tsconfig.json +9 -0
- package/inkos/packages/core/genres/cozy.md +43 -0
- package/inkos/packages/core/genres/cultivation.md +42 -0
- package/inkos/packages/core/genres/dungeon-core.md +40 -0
- package/inkos/packages/core/genres/horror.md +51 -0
- package/inkos/packages/core/genres/isekai.md +43 -0
- package/inkos/packages/core/genres/litrpg.md +43 -0
- package/inkos/packages/core/genres/other.md +24 -0
- package/inkos/packages/core/genres/progression.md +41 -0
- package/inkos/packages/core/genres/romantasy.md +45 -0
- package/inkos/packages/core/genres/sci-fi.md +42 -0
- package/inkos/packages/core/genres/system-apocalypse.md +40 -0
- package/inkos/packages/core/genres/tower-climber.md +41 -0
- package/inkos/packages/core/genres/urban.md +53 -0
- package/inkos/packages/core/genres/xianxia.md +46 -0
- package/inkos/packages/core/genres/xuanhuan.md +64 -0
- package/inkos/packages/core/package.json +61 -0
- package/inkos/packages/core/src/__tests__/agent-max-tokens-policy.test.ts +29 -0
- package/inkos/packages/core/src/__tests__/agent-session.test.ts +866 -0
- package/inkos/packages/core/src/__tests__/agent-system-prompt.test.ts +167 -0
- package/inkos/packages/core/src/__tests__/agent-tools-params.test.ts +197 -0
- package/inkos/packages/core/src/__tests__/agent-tools.test.ts +421 -0
- package/inkos/packages/core/src/__tests__/ai-tells.test.ts +90 -0
- package/inkos/packages/core/src/__tests__/architect-phase5-consolidated.test.ts +445 -0
- package/inkos/packages/core/src/__tests__/architect-phase5.test.ts +455 -0
- package/inkos/packages/core/src/__tests__/architect-phase7.test.ts +210 -0
- package/inkos/packages/core/src/__tests__/architect.test.ts +859 -0
- package/inkos/packages/core/src/__tests__/audit-parse.test.ts +78 -0
- package/inkos/packages/core/src/__tests__/book-id.test.ts +26 -0
- package/inkos/packages/core/src/__tests__/book-session-store.test.ts +447 -0
- package/inkos/packages/core/src/__tests__/book-session.test.ts +113 -0
- package/inkos/packages/core/src/__tests__/chapter-analyzer.test.ts +574 -0
- package/inkos/packages/core/src/__tests__/chapter-memo-parser.test.ts +247 -0
- package/inkos/packages/core/src/__tests__/chapter-persistence.test.ts +198 -0
- package/inkos/packages/core/src/__tests__/chapter-review-cycle.test.ts +294 -0
- package/inkos/packages/core/src/__tests__/chapter-splitter.test.ts +156 -0
- package/inkos/packages/core/src/__tests__/chapter-state-recovery.test.ts +235 -0
- package/inkos/packages/core/src/__tests__/chapter-truth-validation.test.ts +253 -0
- package/inkos/packages/core/src/__tests__/composer.test.ts +627 -0
- package/inkos/packages/core/src/__tests__/config-loader.test.ts +325 -0
- package/inkos/packages/core/src/__tests__/config-migration.test.ts +102 -0
- package/inkos/packages/core/src/__tests__/consolidator.test.ts +32 -0
- package/inkos/packages/core/src/__tests__/context-filter.test.ts +60 -0
- package/inkos/packages/core/src/__tests__/context-transform.test.ts +108 -0
- package/inkos/packages/core/src/__tests__/continuity.test.ts +391 -0
- package/inkos/packages/core/src/__tests__/detection-insights.test.ts +59 -0
- package/inkos/packages/core/src/__tests__/detector.test.ts +86 -0
- package/inkos/packages/core/src/__tests__/draft-directive-parser.test.ts +386 -0
- package/inkos/packages/core/src/__tests__/edit-controller.test.ts +190 -0
- package/inkos/packages/core/src/__tests__/effective-llm-config.test.ts +486 -0
- package/inkos/packages/core/src/__tests__/fanfic-dimensions.test.ts +58 -0
- package/inkos/packages/core/src/__tests__/fanfic-models.test.ts +69 -0
- package/inkos/packages/core/src/__tests__/governed-working-set.test.ts +155 -0
- package/inkos/packages/core/src/__tests__/hook-arbiter.test.ts +124 -0
- package/inkos/packages/core/src/__tests__/hook-governance.test.ts +228 -0
- package/inkos/packages/core/src/__tests__/hook-health.test.ts +166 -0
- package/inkos/packages/core/src/__tests__/hook-ledger-validator.test.ts +236 -0
- package/inkos/packages/core/src/__tests__/hook-promotion.test.ts +192 -0
- package/inkos/packages/core/src/__tests__/hook-stale-detection.test.ts +136 -0
- package/inkos/packages/core/src/__tests__/index-notify-lazy.test.ts +20 -0
- package/inkos/packages/core/src/__tests__/interaction-chat-tokens.test.ts +170 -0
- package/inkos/packages/core/src/__tests__/interaction-models.test.ts +155 -0
- package/inkos/packages/core/src/__tests__/interaction-nl-router.test.ts +223 -0
- package/inkos/packages/core/src/__tests__/interaction-runtime.test.ts +633 -0
- package/inkos/packages/core/src/__tests__/interaction-tools.test.ts +343 -0
- package/inkos/packages/core/src/__tests__/length-metrics.test.ts +82 -0
- package/inkos/packages/core/src/__tests__/length-normalizer.test.ts +331 -0
- package/inkos/packages/core/src/__tests__/list-models.test.ts +109 -0
- package/inkos/packages/core/src/__tests__/llm-env.test.ts +31 -0
- package/inkos/packages/core/src/__tests__/logger.test.ts +175 -0
- package/inkos/packages/core/src/__tests__/long-span-fatigue.test.ts +160 -0
- package/inkos/packages/core/src/__tests__/memory-retrieval.test.ts +1303 -0
- package/inkos/packages/core/src/__tests__/models.test.ts +918 -0
- package/inkos/packages/core/src/__tests__/outline-paths.test.ts +97 -0
- package/inkos/packages/core/src/__tests__/path-safety.test.ts +22 -0
- package/inkos/packages/core/src/__tests__/persisted-governed-plan.test.ts +134 -0
- package/inkos/packages/core/src/__tests__/phase5-cleanup.test.ts +393 -0
- package/inkos/packages/core/src/__tests__/phase5-hotfix.test.ts +288 -0
- package/inkos/packages/core/src/__tests__/phase7-hotfix.test.ts +614 -0
- package/inkos/packages/core/src/__tests__/pipeline-agent.test.ts +354 -0
- package/inkos/packages/core/src/__tests__/pipeline-runner-memory-sync.test.ts +317 -0
- package/inkos/packages/core/src/__tests__/pipeline-runner.test.ts +5200 -0
- package/inkos/packages/core/src/__tests__/planner-context.test.ts +137 -0
- package/inkos/packages/core/src/__tests__/planner-prompts-ratio.test.ts +11 -0
- package/inkos/packages/core/src/__tests__/planner-prompts.test.ts +171 -0
- package/inkos/packages/core/src/__tests__/planner.test.ts +362 -0
- package/inkos/packages/core/src/__tests__/planning-materials.test.ts +90 -0
- package/inkos/packages/core/src/__tests__/polisher.test.ts +189 -0
- package/inkos/packages/core/src/__tests__/post-write-validator.test.ts +291 -0
- package/inkos/packages/core/src/__tests__/probe.test.ts +77 -0
- package/inkos/packages/core/src/__tests__/project-interaction.test.ts +241 -0
- package/inkos/packages/core/src/__tests__/provider.test.ts +953 -0
- package/inkos/packages/core/src/__tests__/providers-group.test.ts +34 -0
- package/inkos/packages/core/src/__tests__/providers-lookup.test.ts +81 -0
- package/inkos/packages/core/src/__tests__/providers-schema.test.ts +158 -0
- package/inkos/packages/core/src/__tests__/proxy-fetch.test.ts +75 -0
- package/inkos/packages/core/src/__tests__/revise-foundation.test.ts +514 -0
- package/inkos/packages/core/src/__tests__/reviser.test.ts +859 -0
- package/inkos/packages/core/src/__tests__/runtime-state-store.test.ts +388 -0
- package/inkos/packages/core/src/__tests__/scheduler.test.ts +123 -0
- package/inkos/packages/core/src/__tests__/secrets-migration.test.ts +71 -0
- package/inkos/packages/core/src/__tests__/secrets.test.ts +95 -0
- package/inkos/packages/core/src/__tests__/sensitive-words.test.ts +88 -0
- package/inkos/packages/core/src/__tests__/service-presets-regression.test.ts +73 -0
- package/inkos/packages/core/src/__tests__/service-resolver-regression.test.ts +75 -0
- package/inkos/packages/core/src/__tests__/service-resolver.test.ts +228 -0
- package/inkos/packages/core/src/__tests__/session-transcript-restore.test.ts +1311 -0
- package/inkos/packages/core/src/__tests__/session-transcript.test.ts +195 -0
- package/inkos/packages/core/src/__tests__/settler-delta-parser.test.ts +133 -0
- package/inkos/packages/core/src/__tests__/short-fiction-public.test.ts +241 -0
- package/inkos/packages/core/src/__tests__/spot-fix-patches.test.ts +104 -0
- package/inkos/packages/core/src/__tests__/state-manager.test.ts +1298 -0
- package/inkos/packages/core/src/__tests__/state-projections.test.ts +130 -0
- package/inkos/packages/core/src/__tests__/state-reducer.test.ts +372 -0
- package/inkos/packages/core/src/__tests__/state-validator-agent.test.ts +165 -0
- package/inkos/packages/core/src/__tests__/state-validator.test.ts +122 -0
- package/inkos/packages/core/src/__tests__/style-analyzer.test.ts +61 -0
- package/inkos/packages/core/src/__tests__/temperature-constraints.test.ts +57 -0
- package/inkos/packages/core/src/__tests__/v13-hotfix-round4.test.ts +343 -0
- package/inkos/packages/core/src/__tests__/verify-service.test.ts +77 -0
- package/inkos/packages/core/src/__tests__/webhook.test.ts +91 -0
- package/inkos/packages/core/src/__tests__/writer-parser.test.ts +348 -0
- package/inkos/packages/core/src/__tests__/writer-prompts.test.ts +269 -0
- package/inkos/packages/core/src/__tests__/writer.test.ts +1360 -0
- package/inkos/packages/core/src/agent/agent-session.ts +737 -0
- package/inkos/packages/core/src/agent/agent-system-prompt.ts +199 -0
- package/inkos/packages/core/src/agent/agent-tools.ts +835 -0
- package/inkos/packages/core/src/agent/context-transform.ts +85 -0
- package/inkos/packages/core/src/agent/index.ts +14 -0
- package/inkos/packages/core/src/agents/ai-tells.ts +161 -0
- package/inkos/packages/core/src/agents/architect.ts +1291 -0
- package/inkos/packages/core/src/agents/base.ts +100 -0
- package/inkos/packages/core/src/agents/chapter-analyzer.ts +634 -0
- package/inkos/packages/core/src/agents/composer.ts +469 -0
- package/inkos/packages/core/src/agents/consolidator.ts +218 -0
- package/inkos/packages/core/src/agents/continuity.ts +824 -0
- package/inkos/packages/core/src/agents/detection-insights.ts +72 -0
- package/inkos/packages/core/src/agents/detector.ts +224 -0
- package/inkos/packages/core/src/agents/en-prompt-sections.ts +129 -0
- package/inkos/packages/core/src/agents/fanfic-canon-importer.ts +146 -0
- package/inkos/packages/core/src/agents/fanfic-dimensions.ts +87 -0
- package/inkos/packages/core/src/agents/fanfic-prompt-sections.ts +109 -0
- package/inkos/packages/core/src/agents/foundation-reviewer.ts +204 -0
- package/inkos/packages/core/src/agents/length-normalizer.ts +218 -0
- package/inkos/packages/core/src/agents/observer-prompts.ts +127 -0
- package/inkos/packages/core/src/agents/planner-context.ts +297 -0
- package/inkos/packages/core/src/agents/planner-prompts.ts +404 -0
- package/inkos/packages/core/src/agents/planner.ts +783 -0
- package/inkos/packages/core/src/agents/polisher.ts +153 -0
- package/inkos/packages/core/src/agents/post-write-validator.ts +873 -0
- package/inkos/packages/core/src/agents/radar-source.ts +123 -0
- package/inkos/packages/core/src/agents/radar.ts +120 -0
- package/inkos/packages/core/src/agents/reviser.ts +701 -0
- package/inkos/packages/core/src/agents/rules-reader.ts +155 -0
- package/inkos/packages/core/src/agents/sensitive-words.ts +142 -0
- package/inkos/packages/core/src/agents/settler-delta-parser.ts +53 -0
- package/inkos/packages/core/src/agents/settler-parser.ts +38 -0
- package/inkos/packages/core/src/agents/settler-prompts.ts +230 -0
- package/inkos/packages/core/src/agents/short-fiction.ts +429 -0
- package/inkos/packages/core/src/agents/state-validator.ts +322 -0
- package/inkos/packages/core/src/agents/style-analyzer.ts +93 -0
- package/inkos/packages/core/src/agents/writer-parser.ts +178 -0
- package/inkos/packages/core/src/agents/writer-prompts.ts +899 -0
- package/inkos/packages/core/src/agents/writer.ts +1450 -0
- package/inkos/packages/core/src/index.ts +392 -0
- package/inkos/packages/core/src/interaction/book-session-store.ts +226 -0
- package/inkos/packages/core/src/interaction/draft-directive-parser.ts +266 -0
- package/inkos/packages/core/src/interaction/edit-controller.ts +270 -0
- package/inkos/packages/core/src/interaction/events.ts +41 -0
- package/inkos/packages/core/src/interaction/export-artifact.ts +151 -0
- package/inkos/packages/core/src/interaction/intents.ts +63 -0
- package/inkos/packages/core/src/interaction/modes.ts +13 -0
- package/inkos/packages/core/src/interaction/nl-router.ts +258 -0
- package/inkos/packages/core/src/interaction/project-control.ts +150 -0
- package/inkos/packages/core/src/interaction/project-session-store.ts +81 -0
- package/inkos/packages/core/src/interaction/project-tools.ts +704 -0
- package/inkos/packages/core/src/interaction/request-router.ts +5 -0
- package/inkos/packages/core/src/interaction/runtime.ts +1167 -0
- package/inkos/packages/core/src/interaction/session-transcript-legacy.ts +113 -0
- package/inkos/packages/core/src/interaction/session-transcript-restore.ts +607 -0
- package/inkos/packages/core/src/interaction/session-transcript-schema.ts +76 -0
- package/inkos/packages/core/src/interaction/session-transcript.ts +189 -0
- package/inkos/packages/core/src/interaction/session.ts +226 -0
- package/inkos/packages/core/src/interaction/truth-authority.ts +45 -0
- package/inkos/packages/core/src/llm/config-migration.ts +58 -0
- package/inkos/packages/core/src/llm/cover-providers.ts +45 -0
- package/inkos/packages/core/src/llm/provider.ts +1331 -0
- package/inkos/packages/core/src/llm/providers/endpoints/ai360.ts +42 -0
- package/inkos/packages/core/src/llm/providers/endpoints/anthropic.ts +82 -0
- package/inkos/packages/core/src/llm/providers/endpoints/astronCodingPlan.ts +30 -0
- package/inkos/packages/core/src/llm/providers/endpoints/baichuan.ts +28 -0
- package/inkos/packages/core/src/llm/providers/endpoints/bailian.ts +65 -0
- package/inkos/packages/core/src/llm/providers/endpoints/bailianCodingPlan.ts +30 -0
- package/inkos/packages/core/src/llm/providers/endpoints/custom.ts +22 -0
- package/inkos/packages/core/src/llm/providers/endpoints/deepseek.ts +35 -0
- package/inkos/packages/core/src/llm/providers/endpoints/giteeai.ts +41 -0
- package/inkos/packages/core/src/llm/providers/endpoints/githubCopilot.ts +43 -0
- package/inkos/packages/core/src/llm/providers/endpoints/glmCodingPlan.ts +28 -0
- package/inkos/packages/core/src/llm/providers/endpoints/google.ts +51 -0
- package/inkos/packages/core/src/llm/providers/endpoints/hunyuan.ts +42 -0
- package/inkos/packages/core/src/llm/providers/endpoints/infiniai.ts +72 -0
- package/inkos/packages/core/src/llm/providers/endpoints/internlm.ts +28 -0
- package/inkos/packages/core/src/llm/providers/endpoints/kimiCode.ts +23 -0
- package/inkos/packages/core/src/llm/providers/endpoints/kimiCodingPlan.ts +24 -0
- package/inkos/packages/core/src/llm/providers/endpoints/kkaiapi.ts +56 -0
- package/inkos/packages/core/src/llm/providers/endpoints/longcat.ts +25 -0
- package/inkos/packages/core/src/llm/providers/endpoints/minimax.ts +39 -0
- package/inkos/packages/core/src/llm/providers/endpoints/minimaxCodingPlan.ts +28 -0
- package/inkos/packages/core/src/llm/providers/endpoints/mistral.ts +40 -0
- package/inkos/packages/core/src/llm/providers/endpoints/modelscope.ts +30 -0
- package/inkos/packages/core/src/llm/providers/endpoints/moonshot.ts +39 -0
- package/inkos/packages/core/src/llm/providers/endpoints/newapi.ts +21 -0
- package/inkos/packages/core/src/llm/providers/endpoints/ollama.ts +73 -0
- package/inkos/packages/core/src/llm/providers/endpoints/openai.ts +77 -0
- package/inkos/packages/core/src/llm/providers/endpoints/opencodeCodingPlan.ts +30 -0
- package/inkos/packages/core/src/llm/providers/endpoints/openrouter.ts +87 -0
- package/inkos/packages/core/src/llm/providers/endpoints/ppio.ts +86 -0
- package/inkos/packages/core/src/llm/providers/endpoints/qiniu.ts +32 -0
- package/inkos/packages/core/src/llm/providers/endpoints/sensenova.ts +45 -0
- package/inkos/packages/core/src/llm/providers/endpoints/siliconcloud.ts +126 -0
- package/inkos/packages/core/src/llm/providers/endpoints/spark.ts +33 -0
- package/inkos/packages/core/src/llm/providers/endpoints/stepfun.ts +35 -0
- package/inkos/packages/core/src/llm/providers/endpoints/tencentcloud.ts +25 -0
- package/inkos/packages/core/src/llm/providers/endpoints/volcengine.ts +52 -0
- package/inkos/packages/core/src/llm/providers/endpoints/volcengineCodingPlan.ts +42 -0
- package/inkos/packages/core/src/llm/providers/endpoints/wenxin.ts +106 -0
- package/inkos/packages/core/src/llm/providers/endpoints/xai.ts +34 -0
- package/inkos/packages/core/src/llm/providers/endpoints/xiaomimimo.ts +26 -0
- package/inkos/packages/core/src/llm/providers/endpoints/zeroone.ts +34 -0
- package/inkos/packages/core/src/llm/providers/endpoints/zhipu.ts +61 -0
- package/inkos/packages/core/src/llm/providers/index.ts +71 -0
- package/inkos/packages/core/src/llm/providers/lookup.ts +70 -0
- package/inkos/packages/core/src/llm/providers/probe.ts +35 -0
- package/inkos/packages/core/src/llm/providers/types.ts +89 -0
- package/inkos/packages/core/src/llm/providers/verify.ts +104 -0
- package/inkos/packages/core/src/llm/secrets.ts +77 -0
- package/inkos/packages/core/src/llm/service-presets.ts +215 -0
- package/inkos/packages/core/src/llm/service-resolver.ts +91 -0
- package/inkos/packages/core/src/models/book-rules.ts +126 -0
- package/inkos/packages/core/src/models/book.ts +70 -0
- package/inkos/packages/core/src/models/chapter.ts +42 -0
- package/inkos/packages/core/src/models/detection.ts +25 -0
- package/inkos/packages/core/src/models/genre-profile.ts +36 -0
- package/inkos/packages/core/src/models/input-governance.ts +99 -0
- package/inkos/packages/core/src/models/length-governance.ts +46 -0
- package/inkos/packages/core/src/models/project.ts +161 -0
- package/inkos/packages/core/src/models/runtime-state.ts +144 -0
- package/inkos/packages/core/src/models/state.ts +52 -0
- package/inkos/packages/core/src/models/style-profile.ts +15 -0
- package/inkos/packages/core/src/notify/dispatcher.ts +96 -0
- package/inkos/packages/core/src/notify/feishu.ts +34 -0
- package/inkos/packages/core/src/notify/telegram.ts +25 -0
- package/inkos/packages/core/src/notify/webhook.ts +58 -0
- package/inkos/packages/core/src/notify/wechat-work.ts +22 -0
- package/inkos/packages/core/src/pipeline/agent.ts +691 -0
- package/inkos/packages/core/src/pipeline/chapter-persistence.ts +79 -0
- package/inkos/packages/core/src/pipeline/chapter-review-cycle.ts +324 -0
- package/inkos/packages/core/src/pipeline/chapter-state-recovery.ts +236 -0
- package/inkos/packages/core/src/pipeline/chapter-truth-validation.ts +145 -0
- package/inkos/packages/core/src/pipeline/detection-runner.ts +164 -0
- package/inkos/packages/core/src/pipeline/persisted-governed-plan.ts +216 -0
- package/inkos/packages/core/src/pipeline/runner.ts +3438 -0
- package/inkos/packages/core/src/pipeline/scheduler.ts +411 -0
- package/inkos/packages/core/src/pipeline/short-fiction-runner.ts +801 -0
- package/inkos/packages/core/src/prompts/index.ts +1 -0
- package/inkos/packages/core/src/prompts/short-fiction.ts +273 -0
- package/inkos/packages/core/src/state/manager.ts +560 -0
- package/inkos/packages/core/src/state/memory-db.ts +359 -0
- package/inkos/packages/core/src/state/runtime-state-store.ts +164 -0
- package/inkos/packages/core/src/state/state-bootstrap.ts +657 -0
- package/inkos/packages/core/src/state/state-projections.ts +255 -0
- package/inkos/packages/core/src/state/state-reducer.ts +260 -0
- package/inkos/packages/core/src/state/state-validator.ts +117 -0
- package/inkos/packages/core/src/utils/analytics.ts +92 -0
- package/inkos/packages/core/src/utils/book-id.ts +31 -0
- package/inkos/packages/core/src/utils/cadence-policy.ts +46 -0
- package/inkos/packages/core/src/utils/chapter-cadence.ts +211 -0
- package/inkos/packages/core/src/utils/chapter-memo-parser.ts +157 -0
- package/inkos/packages/core/src/utils/chapter-splitter.ts +80 -0
- package/inkos/packages/core/src/utils/config-loader.ts +29 -0
- package/inkos/packages/core/src/utils/context-assembly.ts +98 -0
- package/inkos/packages/core/src/utils/context-filter.ts +190 -0
- package/inkos/packages/core/src/utils/effective-llm-config.ts +529 -0
- package/inkos/packages/core/src/utils/governed-context.ts +101 -0
- package/inkos/packages/core/src/utils/governed-working-set.ts +395 -0
- package/inkos/packages/core/src/utils/hook-arbiter.ts +332 -0
- package/inkos/packages/core/src/utils/hook-governance.ts +199 -0
- package/inkos/packages/core/src/utils/hook-health.ts +189 -0
- package/inkos/packages/core/src/utils/hook-ledger-validator.ts +277 -0
- package/inkos/packages/core/src/utils/hook-lifecycle.ts +224 -0
- package/inkos/packages/core/src/utils/hook-policy.ts +115 -0
- package/inkos/packages/core/src/utils/hook-promotion.ts +313 -0
- package/inkos/packages/core/src/utils/hook-stale-detection.ts +168 -0
- package/inkos/packages/core/src/utils/length-metrics.ts +123 -0
- package/inkos/packages/core/src/utils/llm-endpoint-auth.ts +40 -0
- package/inkos/packages/core/src/utils/llm-env.ts +74 -0
- package/inkos/packages/core/src/utils/logger.ts +123 -0
- package/inkos/packages/core/src/utils/long-span-fatigue.ts +545 -0
- package/inkos/packages/core/src/utils/memory-retrieval.ts +527 -0
- package/inkos/packages/core/src/utils/narrative-control.ts +177 -0
- package/inkos/packages/core/src/utils/outline-paths.ts +275 -0
- package/inkos/packages/core/src/utils/path-safety.ts +11 -0
- package/inkos/packages/core/src/utils/planning-materials.ts +185 -0
- package/inkos/packages/core/src/utils/pov-filter.ts +149 -0
- package/inkos/packages/core/src/utils/proxy-fetch.ts +44 -0
- package/inkos/packages/core/src/utils/runtime-writer.ts +41 -0
- package/inkos/packages/core/src/utils/spot-fix-patches.ts +189 -0
- package/inkos/packages/core/src/utils/story-markdown.ts +346 -0
- package/inkos/packages/core/src/utils/web-search.ts +82 -0
- package/inkos/packages/core/src/utils/writing-methodology.ts +164 -0
- package/inkos/packages/core/tsconfig.json +8 -0
- package/inkos/packages/core/vitest.config.ts +7 -0
- package/inkos/packages/studio/components.json +25 -0
- package/inkos/packages/studio/index.html +13 -0
- package/inkos/packages/studio/package.json +72 -0
- package/inkos/packages/studio/postcss.config.js +3 -0
- package/inkos/packages/studio/src/App.test.ts +25 -0
- package/inkos/packages/studio/src/App.tsx +280 -0
- package/inkos/packages/studio/src/api/__tests__/normalize-base-url.test.ts +40 -0
- package/inkos/packages/studio/src/api/book-create.test.ts +104 -0
- package/inkos/packages/studio/src/api/book-create.ts +94 -0
- package/inkos/packages/studio/src/api/errors.ts +17 -0
- package/inkos/packages/studio/src/api/index.ts +30 -0
- package/inkos/packages/studio/src/api/lib/run-store.ts +177 -0
- package/inkos/packages/studio/src/api/lib/sse.ts +50 -0
- package/inkos/packages/studio/src/api/phase5-hotfix.test.ts +335 -0
- package/inkos/packages/studio/src/api/safety.ts +6 -0
- package/inkos/packages/studio/src/api/server.test.ts +3162 -0
- package/inkos/packages/studio/src/api/server.ts +3666 -0
- package/inkos/packages/studio/src/api/v13-hotfix-round4.test.ts +226 -0
- package/inkos/packages/studio/src/app-state.test.ts +8 -0
- package/inkos/packages/studio/src/app-state.ts +1 -0
- package/inkos/packages/studio/src/components/ConfirmDialog.tsx +95 -0
- package/inkos/packages/studio/src/components/ServiceConfigSourceCard.tsx +139 -0
- package/inkos/packages/studio/src/components/ServiceQuickLinks.tsx +65 -0
- package/inkos/packages/studio/src/components/Sidebar.tsx +652 -0
- package/inkos/packages/studio/src/components/ai-elements/code-block.tsx +562 -0
- package/inkos/packages/studio/src/components/ai-elements/confirmation.tsx +174 -0
- package/inkos/packages/studio/src/components/ai-elements/message.tsx +360 -0
- package/inkos/packages/studio/src/components/ai-elements/prompt-input.tsx +1457 -0
- package/inkos/packages/studio/src/components/ai-elements/reasoning.tsx +226 -0
- package/inkos/packages/studio/src/components/ai-elements/shimmer.tsx +77 -0
- package/inkos/packages/studio/src/components/ai-elements/tool.tsx +173 -0
- package/inkos/packages/studio/src/components/chat/BookSidebar.tsx +291 -0
- package/inkos/packages/studio/src/components/chat/ChatMessage.tsx +39 -0
- package/inkos/packages/studio/src/components/chat/QuickActions.tsx +73 -0
- package/inkos/packages/studio/src/components/chat/ToolExecutionSteps.tsx +320 -0
- package/inkos/packages/studio/src/components/chat/__tests__/ToolExecutionSteps.test.ts +114 -0
- package/inkos/packages/studio/src/components/chat-utils.ts +56 -0
- package/inkos/packages/studio/src/components/chatbar-state.test.ts +69 -0
- package/inkos/packages/studio/src/components/sidebar/ChaptersSection.tsx +66 -0
- package/inkos/packages/studio/src/components/sidebar/CharacterSection.tsx +129 -0
- package/inkos/packages/studio/src/components/sidebar/FoundationSection.tsx +61 -0
- package/inkos/packages/studio/src/components/sidebar/ProgressSection.tsx +124 -0
- package/inkos/packages/studio/src/components/sidebar/SidebarCard.tsx +30 -0
- package/inkos/packages/studio/src/components/sidebar/SummarySection.tsx +89 -0
- package/inkos/packages/studio/src/components/ui/alert.tsx +76 -0
- package/inkos/packages/studio/src/components/ui/badge.tsx +52 -0
- package/inkos/packages/studio/src/components/ui/button-group.tsx +87 -0
- package/inkos/packages/studio/src/components/ui/button.tsx +58 -0
- package/inkos/packages/studio/src/components/ui/collapsible.tsx +19 -0
- package/inkos/packages/studio/src/components/ui/command.tsx +194 -0
- package/inkos/packages/studio/src/components/ui/dialog.tsx +158 -0
- package/inkos/packages/studio/src/components/ui/dropdown-menu.tsx +266 -0
- package/inkos/packages/studio/src/components/ui/hover-card.tsx +51 -0
- package/inkos/packages/studio/src/components/ui/input-group.tsx +158 -0
- package/inkos/packages/studio/src/components/ui/input.tsx +20 -0
- package/inkos/packages/studio/src/components/ui/select.tsx +199 -0
- package/inkos/packages/studio/src/components/ui/separator.tsx +23 -0
- package/inkos/packages/studio/src/components/ui/spinner.tsx +10 -0
- package/inkos/packages/studio/src/components/ui/textarea.tsx +18 -0
- package/inkos/packages/studio/src/components/ui/tooltip.tsx +66 -0
- package/inkos/packages/studio/src/constants/service-groups.ts +29 -0
- package/inkos/packages/studio/src/hooks/use-api.test.ts +93 -0
- package/inkos/packages/studio/src/hooks/use-api.ts +189 -0
- package/inkos/packages/studio/src/hooks/use-book-activity.test.ts +129 -0
- package/inkos/packages/studio/src/hooks/use-book-activity.ts +180 -0
- package/inkos/packages/studio/src/hooks/use-colors.ts +27 -0
- package/inkos/packages/studio/src/hooks/use-hash-route.test.ts +101 -0
- package/inkos/packages/studio/src/hooks/use-hash-route.ts +89 -0
- package/inkos/packages/studio/src/hooks/use-i18n.ts +289 -0
- package/inkos/packages/studio/src/hooks/use-session-events.ts +64 -0
- package/inkos/packages/studio/src/hooks/use-sse.test.ts +52 -0
- package/inkos/packages/studio/src/hooks/use-sse.ts +92 -0
- package/inkos/packages/studio/src/hooks/use-theme.test.ts +31 -0
- package/inkos/packages/studio/src/hooks/use-theme.ts +75 -0
- package/inkos/packages/studio/src/index.css +323 -0
- package/inkos/packages/studio/src/lib/error-copy.test.ts +34 -0
- package/inkos/packages/studio/src/lib/error-copy.ts +37 -0
- package/inkos/packages/studio/src/lib/utils.ts +6 -0
- package/inkos/packages/studio/src/main.tsx +10 -0
- package/inkos/packages/studio/src/pages/Analytics.tsx +80 -0
- package/inkos/packages/studio/src/pages/BookCreate.tsx +895 -0
- package/inkos/packages/studio/src/pages/BookDetail.tsx +652 -0
- package/inkos/packages/studio/src/pages/ChapterReader.tsx +266 -0
- package/inkos/packages/studio/src/pages/ChatPage.tsx +521 -0
- package/inkos/packages/studio/src/pages/DaemonControl.tsx +116 -0
- package/inkos/packages/studio/src/pages/Dashboard.tsx +379 -0
- package/inkos/packages/studio/src/pages/DoctorView.tsx +82 -0
- package/inkos/packages/studio/src/pages/GenreManager.tsx +464 -0
- package/inkos/packages/studio/src/pages/ImportManager.tsx +216 -0
- package/inkos/packages/studio/src/pages/LanguageSelector.tsx +74 -0
- package/inkos/packages/studio/src/pages/LogViewer.tsx +82 -0
- package/inkos/packages/studio/src/pages/RadarView.tsx +157 -0
- package/inkos/packages/studio/src/pages/ServiceDetailPage.tsx +393 -0
- package/inkos/packages/studio/src/pages/ServiceListPage.tsx +463 -0
- package/inkos/packages/studio/src/pages/StyleManager.tsx +225 -0
- package/inkos/packages/studio/src/pages/TruthFiles.tsx +194 -0
- package/inkos/packages/studio/src/pages/chat-page-state.test.ts +206 -0
- package/inkos/packages/studio/src/pages/chat-page-state.ts +112 -0
- package/inkos/packages/studio/src/pages/page-state.test.ts +258 -0
- package/inkos/packages/studio/src/pages/service-detail-state.test.ts +294 -0
- package/inkos/packages/studio/src/pages/service-detail-state.ts +234 -0
- package/inkos/packages/studio/src/pages/style-manager-state.test.ts +22 -0
- package/inkos/packages/studio/src/pages/truth-files-state.test.ts +61 -0
- package/inkos/packages/studio/src/shared/contracts.ts +143 -0
- package/inkos/packages/studio/src/store/chat/__tests__/message-parts.test.ts +172 -0
- package/inkos/packages/studio/src/store/chat/index.ts +3 -0
- package/inkos/packages/studio/src/store/chat/initialState.ts +8 -0
- package/inkos/packages/studio/src/store/chat/message-policy.test.ts +16 -0
- package/inkos/packages/studio/src/store/chat/message-policy.ts +5 -0
- package/inkos/packages/studio/src/store/chat/parts-builder.ts +187 -0
- package/inkos/packages/studio/src/store/chat/selectors.ts +13 -0
- package/inkos/packages/studio/src/store/chat/slices/create/action.ts +10 -0
- package/inkos/packages/studio/src/store/chat/slices/create/initialState.ts +9 -0
- package/inkos/packages/studio/src/store/chat/slices/message/action.ts +417 -0
- package/inkos/packages/studio/src/store/chat/slices/message/initialState.ts +10 -0
- package/inkos/packages/studio/src/store/chat/slices/message/runtime.test.ts +21 -0
- package/inkos/packages/studio/src/store/chat/slices/message/runtime.ts +233 -0
- package/inkos/packages/studio/src/store/chat/slices/message/stream-events.ts +272 -0
- package/inkos/packages/studio/src/store/chat/store.ts +11 -0
- package/inkos/packages/studio/src/store/chat/types.ts +169 -0
- package/inkos/packages/studio/src/store/service/index.ts +2 -0
- package/inkos/packages/studio/src/store/service/store.ts +123 -0
- package/inkos/packages/studio/src/store/service/types.ts +50 -0
- package/inkos/packages/studio/tsconfig.json +24 -0
- package/inkos/packages/studio/tsconfig.server.json +11 -0
- package/inkos/packages/studio/vite.config.ts +34 -0
- package/inkos/packages/studio/vitest.config.ts +14 -0
- package/inkos/pnpm-lock.yaml +9569 -0
- package/inkos/pnpm-workspace.yaml +2 -0
- package/inkos/scripts/prepare-package-for-publish.mjs +135 -0
- package/inkos/scripts/restore-package-json.mjs +31 -0
- package/inkos/scripts/set-package-versions.mjs +74 -0
- package/inkos/scripts/verify-no-workspace-protocol.mjs +140 -0
- package/inkos/skills/SKILL.md +654 -0
- package/inkos/tsconfig.json +19 -0
- package/package.json +4 -3
- /package/.next/static/{F2hMZMf1IyCVAWpkbtRz7 → -3vIrBZXdQ0rp7Wa3Kz40}/_buildManifest.js +0 -0
- /package/.next/static/{F2hMZMf1IyCVAWpkbtRz7 → -3vIrBZXdQ0rp7Wa3Kz40}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,3666 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { cors } from "hono/cors";
|
|
3
|
+
import { streamSSE } from "hono/streaming";
|
|
4
|
+
import { serve } from "@hono/node-server";
|
|
5
|
+
import {
|
|
6
|
+
StateManager,
|
|
7
|
+
PipelineRunner,
|
|
8
|
+
createLLMClient,
|
|
9
|
+
createLogger,
|
|
10
|
+
createInteractionToolsFromDeps,
|
|
11
|
+
computeAnalytics,
|
|
12
|
+
loadProjectConfig,
|
|
13
|
+
loadProjectSession,
|
|
14
|
+
processProjectInteractionRequest,
|
|
15
|
+
resolveSessionActiveBook,
|
|
16
|
+
listBookSessions,
|
|
17
|
+
loadBookSession,
|
|
18
|
+
appendManualSessionMessages,
|
|
19
|
+
createAndPersistBookSession,
|
|
20
|
+
renameBookSession,
|
|
21
|
+
deleteBookSession,
|
|
22
|
+
migrateBookSession,
|
|
23
|
+
SessionAlreadyMigratedError,
|
|
24
|
+
runAgentSession,
|
|
25
|
+
buildAgentSystemPrompt,
|
|
26
|
+
resolveServicePreset,
|
|
27
|
+
resolveServiceProviderFamily,
|
|
28
|
+
resolveServiceModelsBaseUrl,
|
|
29
|
+
resolveServiceModel,
|
|
30
|
+
loadSecrets,
|
|
31
|
+
saveSecrets,
|
|
32
|
+
listModelsForService,
|
|
33
|
+
isApiKeyOptionalForEndpoint,
|
|
34
|
+
getAllEndpoints,
|
|
35
|
+
probeModelsFromUpstream,
|
|
36
|
+
fetchWithProxy,
|
|
37
|
+
chatCompletion,
|
|
38
|
+
buildExportArtifact,
|
|
39
|
+
GLOBAL_ENV_PATH,
|
|
40
|
+
COVER_PROVIDER_PRESETS,
|
|
41
|
+
Scheduler,
|
|
42
|
+
coverSecretKey,
|
|
43
|
+
resolveCoverProviderPreset,
|
|
44
|
+
type ResolvedModel,
|
|
45
|
+
type PipelineConfig,
|
|
46
|
+
type ProjectConfig,
|
|
47
|
+
type LogSink,
|
|
48
|
+
type LogEntry,
|
|
49
|
+
} from "@actalk/inkos-core";
|
|
50
|
+
import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
51
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
52
|
+
import { isSafeBookId } from "./safety.js";
|
|
53
|
+
import { ApiError } from "./errors.js";
|
|
54
|
+
import { buildStudioBookConfig } from "./book-create.js";
|
|
55
|
+
|
|
56
|
+
// -- Pipeline stage definitions per agent type --
|
|
57
|
+
|
|
58
|
+
const PIPELINE_STAGES: Record<string, string[]> = {
|
|
59
|
+
writer: [
|
|
60
|
+
"准备章节输入", "撰写章节草稿", "落盘最终章节",
|
|
61
|
+
"生成最终真相文件", "校验真相文件变更", "同步记忆索引",
|
|
62
|
+
"更新章节索引与快照",
|
|
63
|
+
],
|
|
64
|
+
architect: [
|
|
65
|
+
"生成基础设定", "保存书籍配置", "写入基础设定文件",
|
|
66
|
+
"初始化控制文档", "创建初始快照",
|
|
67
|
+
],
|
|
68
|
+
reviser: [
|
|
69
|
+
"加载修订上下文", "修订章节", "落盘修订结果",
|
|
70
|
+
"更新索引与快照",
|
|
71
|
+
],
|
|
72
|
+
auditor: ["审计章节"],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const AGENT_LABELS: Record<string, string> = {
|
|
76
|
+
architect: "建书", writer: "写作", auditor: "审计",
|
|
77
|
+
reviser: "修订", exporter: "导出",
|
|
78
|
+
};
|
|
79
|
+
const TOOL_LABELS: Record<string, string> = {
|
|
80
|
+
read: "读取文件", edit: "编辑文件", grep: "搜索", ls: "列目录",
|
|
81
|
+
short_fiction_run: "短篇生产",
|
|
82
|
+
generate_cover: "生成封面",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function resolveToolLabel(tool: string, agent?: string): string {
|
|
86
|
+
if (tool === "sub_agent" && agent) return AGENT_LABELS[agent] ?? agent;
|
|
87
|
+
return TOOL_LABELS[tool] ?? tool;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function summarizeResult(result: unknown): string {
|
|
91
|
+
if (typeof result === "string") return result.slice(0, 200);
|
|
92
|
+
if (result && typeof result === "object") {
|
|
93
|
+
const r = result as Record<string, unknown>;
|
|
94
|
+
if (typeof r.content === "string") return r.content.slice(0, 200);
|
|
95
|
+
if (typeof r.text === "string") return r.text.slice(0, 200);
|
|
96
|
+
}
|
|
97
|
+
return String(result).slice(0, 200);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function compareServiceListItems(
|
|
101
|
+
left: { readonly service: string },
|
|
102
|
+
right: { readonly service: string },
|
|
103
|
+
): number {
|
|
104
|
+
const priority = ["kkaiapi", "openrouter", "newapi", "siliconcloud"];
|
|
105
|
+
const leftPriority = priority.indexOf(left.service);
|
|
106
|
+
const rightPriority = priority.indexOf(right.service);
|
|
107
|
+
if (leftPriority !== -1 || rightPriority !== -1) {
|
|
108
|
+
return (leftPriority === -1 ? 999 : leftPriority) - (rightPriority === -1 ? 999 : rightPriority);
|
|
109
|
+
}
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isHeaderSafeApiKey(value: string): boolean {
|
|
114
|
+
if (!value) return true;
|
|
115
|
+
return /^[\x21-\x7E]+$/.test(value);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const NON_TEXT_MODEL_ID_PARTS = [
|
|
119
|
+
"image",
|
|
120
|
+
"embedding",
|
|
121
|
+
"embed",
|
|
122
|
+
"rerank",
|
|
123
|
+
"tts",
|
|
124
|
+
"speech",
|
|
125
|
+
"audio",
|
|
126
|
+
"moderation",
|
|
127
|
+
] as const;
|
|
128
|
+
|
|
129
|
+
const SERVICE_MODELS_PROBE_TIMEOUT_MS = 4_000;
|
|
130
|
+
const SERVICE_CHAT_PROBE_TIMEOUT_MS = 8_000;
|
|
131
|
+
const MAX_DISCOVERED_MODELS_TO_PING = 2;
|
|
132
|
+
const MAX_GENERIC_FALLBACK_MODELS_TO_PING = 2;
|
|
133
|
+
|
|
134
|
+
function isTextChatModelId(modelId: string): boolean {
|
|
135
|
+
const normalized = modelId.trim().toLowerCase();
|
|
136
|
+
if (!normalized) return false;
|
|
137
|
+
return !NON_TEXT_MODEL_ID_PARTS.some((part) => normalized.includes(part));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function filterTextChatModels<T extends { readonly id: string }>(models: ReadonlyArray<T>): T[] {
|
|
141
|
+
return models.filter((model) => isTextChatModelId(model.id));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeApiBookId(value: unknown, fieldName: string): string | null {
|
|
145
|
+
if (value === undefined || value === null) return null;
|
|
146
|
+
if (typeof value !== "string") {
|
|
147
|
+
throw new ApiError(400, "INVALID_BOOK_ID", `${fieldName} must be a string`);
|
|
148
|
+
}
|
|
149
|
+
const bookId = value.trim();
|
|
150
|
+
if (!bookId) {
|
|
151
|
+
throw new ApiError(400, "INVALID_BOOK_ID", `${fieldName} cannot be blank`);
|
|
152
|
+
}
|
|
153
|
+
if (!isSafeBookId(bookId)) {
|
|
154
|
+
throw new ApiError(400, "INVALID_BOOK_ID", `Invalid ${fieldName}: "${bookId}"`);
|
|
155
|
+
}
|
|
156
|
+
return bookId;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function nonTextModelMessage(modelId: string): string {
|
|
160
|
+
return `模型 ${modelId} 不适合文本聊天/写作。请在模型选择器中改用文本模型,例如 gemini-2.5-flash、gemini-2.5-pro 或对应服务的 chat 模型。`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function extractToolError(result: unknown): string {
|
|
164
|
+
if (typeof result === "string") return result.slice(0, 500);
|
|
165
|
+
if (result && typeof result === "object") {
|
|
166
|
+
const r = result as Record<string, unknown>;
|
|
167
|
+
if (typeof r.content === "string") return r.content.slice(0, 500);
|
|
168
|
+
if (r.content && Array.isArray(r.content)) {
|
|
169
|
+
const textPart = r.content.find((c: any) => c.type === "text");
|
|
170
|
+
if (textPart) return (textPart as any).text?.slice(0, 500) ?? "";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return String(result).slice(0, 500);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resolveProjectImageFile(root: string, rawPath: string): { readonly resolved: string; readonly contentType: string } {
|
|
177
|
+
let relPath: string;
|
|
178
|
+
try {
|
|
179
|
+
relPath = decodeURIComponent(rawPath).replace(/^\/+/u, "");
|
|
180
|
+
} catch {
|
|
181
|
+
throw new ApiError(400, "INVALID_PROJECT_FILE_PATH", "Invalid project file path");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (
|
|
185
|
+
!relPath
|
|
186
|
+
|| relPath.includes("\0")
|
|
187
|
+
|| isAbsolute(relPath)
|
|
188
|
+
|| relPath.split(/[\\/]+/u).includes("..")
|
|
189
|
+
) {
|
|
190
|
+
throw new ApiError(400, "INVALID_PROJECT_FILE_PATH", "Invalid project file path");
|
|
191
|
+
}
|
|
192
|
+
if (!relPath.startsWith("shorts/") && !relPath.startsWith("covers/")) {
|
|
193
|
+
throw new ApiError(400, "INVALID_PROJECT_FILE_PATH", "Only generated shorts/ and covers/ images can be previewed");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const ext = relPath.split(".").pop()?.toLowerCase() ?? "";
|
|
197
|
+
const contentTypes: Record<string, string> = {
|
|
198
|
+
png: "image/png",
|
|
199
|
+
jpg: "image/jpeg",
|
|
200
|
+
jpeg: "image/jpeg",
|
|
201
|
+
webp: "image/webp",
|
|
202
|
+
};
|
|
203
|
+
const contentType = contentTypes[ext];
|
|
204
|
+
if (!contentType) {
|
|
205
|
+
throw new ApiError(415, "UNSUPPORTED_PROJECT_FILE_TYPE", "Unsupported project file type");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const resolved = resolve(root, relPath);
|
|
209
|
+
const rel = relative(root, resolved);
|
|
210
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
|
211
|
+
throw new ApiError(400, "INVALID_PROJECT_FILE_PATH", "Invalid project file path");
|
|
212
|
+
}
|
|
213
|
+
return { resolved, contentType };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isLikelyFailedToolResult(exec: CollectedToolExec): boolean {
|
|
217
|
+
if (exec.status === "error") return true;
|
|
218
|
+
const text = `${exec.error ?? ""}\n${exec.result ?? ""}`.toLowerCase();
|
|
219
|
+
return /\bfailed\b|\berror\b|失败|异常|出错/.test(text);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function hasSuccessfulSubAgentExec(
|
|
223
|
+
execs: ReadonlyArray<CollectedToolExec>,
|
|
224
|
+
agent: string,
|
|
225
|
+
): boolean {
|
|
226
|
+
return execs.some((exec) =>
|
|
227
|
+
exec.tool === "sub_agent"
|
|
228
|
+
&& exec.agent === agent
|
|
229
|
+
&& exec.status === "completed"
|
|
230
|
+
&& !isLikelyFailedToolResult(exec)
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isWriteNextInstruction(instruction: string): boolean {
|
|
235
|
+
const trimmed = instruction.trim();
|
|
236
|
+
return /^(continue|继续|继续写|写下一章|write next|下一章|再来一章)$/i.test(trimmed)
|
|
237
|
+
|| /(继续写|写下一章|下一章|再来一章|write\s+next)/i.test(trimmed);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
type ExternalChatEditResult = {
|
|
241
|
+
readonly responseText: string;
|
|
242
|
+
readonly activeBookId?: string;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const CHAT_EDIT_WARNING = "[warning] Chat external edit requires review before continuation.";
|
|
246
|
+
const CHAT_EDIT_TEXT_EXTENSIONS = /\.(md|txt|json|ya?ml)$/i;
|
|
247
|
+
const CHAT_EDIT_ALLOWED_ROOTS = new Set(["books", "shorts", "covers", "genres"]);
|
|
248
|
+
|
|
249
|
+
function parseReplacementInstruction(instruction: string): { oldText: string; newText: string } | null {
|
|
250
|
+
const inFileQuoted = instruction.match(/(?:里|里的|中|中的|里面)\s*[「“"]([\s\S]+?)[」”"]\s*(?:改成|替换成|换成)\s*[「“"]([\s\S]+?)[」”"]/);
|
|
251
|
+
if (inFileQuoted?.[1] && inFileQuoted[2] !== undefined) {
|
|
252
|
+
return { oldText: inFileQuoted[1], newText: inFileQuoted[2] };
|
|
253
|
+
}
|
|
254
|
+
const quoted = instruction.match(/(?:把|将)\s*[「“"]([\s\S]+?)[」”"]\s*(?:改成|替换成|换成)\s*[「“"]([\s\S]+?)[」”"]/);
|
|
255
|
+
if (quoted?.[1] && quoted[2] !== undefined) {
|
|
256
|
+
return { oldText: quoted[1], newText: quoted[2] };
|
|
257
|
+
}
|
|
258
|
+
const plain = instruction.match(/(?:把|将)\s+([^\s,。;;]+)\s*(?:改成|替换成|换成)\s+([^\n,。;;]+)/);
|
|
259
|
+
if (plain?.[1] && plain[2] !== undefined) {
|
|
260
|
+
return { oldText: plain[1], newText: plain[2].trim() };
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parseChapterNumberForEdit(instruction: string): number | null {
|
|
266
|
+
const match = instruction.match(/第\s*(\d{1,4})\s*章/);
|
|
267
|
+
if (!match?.[1]) return null;
|
|
268
|
+
const chapterNumber = Number.parseInt(match[1], 10);
|
|
269
|
+
return Number.isInteger(chapterNumber) && chapterNumber > 0 ? chapterNumber : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseExplicitEditPath(instruction: string): string | null {
|
|
273
|
+
const match = instruction.match(/(?:把|将)\s+([^「“"\s,。;;]+?\.[A-Za-z0-9]+)\s*(?:里|里的|中|中的|里面)/);
|
|
274
|
+
return match?.[1]?.trim() ?? null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function countContentUnits(content: string): number {
|
|
278
|
+
const stripped = content
|
|
279
|
+
.replace(/^#{1,6}\s+.*$/gm, "")
|
|
280
|
+
.trim();
|
|
281
|
+
if (!stripped) return 0;
|
|
282
|
+
if (/[\u3400-\u9fff]/.test(stripped)) {
|
|
283
|
+
return stripped.replace(/\s/g, "").length;
|
|
284
|
+
}
|
|
285
|
+
return stripped.split(/\s+/).filter(Boolean).length;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function resolveExternalChatEditPath(root: string, requestedPath: string): { path: string; rel: string } {
|
|
289
|
+
if (isAbsolute(requestedPath)) {
|
|
290
|
+
throw new ApiError(400, "UNSUPPORTED_CHAT_EDIT_TARGET", "Chat external edits only support project-relative content paths.");
|
|
291
|
+
}
|
|
292
|
+
const projectRoot = resolve(root);
|
|
293
|
+
const resolved = resolve(projectRoot, requestedPath);
|
|
294
|
+
const rel = relative(projectRoot, resolved).replace(/\\/g, "/");
|
|
295
|
+
if (!rel || rel.startsWith("../") || rel === "..") {
|
|
296
|
+
throw new ApiError(400, "UNSUPPORTED_CHAT_EDIT_TARGET", "Chat external edit path escapes the project root.");
|
|
297
|
+
}
|
|
298
|
+
const first = rel.split("/")[0] ?? "";
|
|
299
|
+
if (!CHAT_EDIT_ALLOWED_ROOTS.has(first)) {
|
|
300
|
+
throw new ApiError(400, "UNSUPPORTED_CHAT_EDIT_TARGET", "Chat external edits cannot modify source code, config, or arbitrary project files.");
|
|
301
|
+
}
|
|
302
|
+
if (rel.includes("/.inkos/") || rel.endsWith("/.inkos") || rel.includes("/secrets") || rel.endsWith(".env")) {
|
|
303
|
+
throw new ApiError(400, "UNSUPPORTED_CHAT_EDIT_TARGET", "Chat external edits cannot modify secrets or runtime internals.");
|
|
304
|
+
}
|
|
305
|
+
if (!CHAT_EDIT_TEXT_EXTENSIONS.test(rel)) {
|
|
306
|
+
throw new ApiError(400, "UNSUPPORTED_CHAT_EDIT_TARGET", "Chat external edits only support text content files.");
|
|
307
|
+
}
|
|
308
|
+
return { path: resolved, rel };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function findChapterFile(root: string, bookId: string, chapterNumber: number): Promise<string | null> {
|
|
312
|
+
const chaptersDir = join(root, "books", bookId, "chapters");
|
|
313
|
+
const padded = String(chapterNumber).padStart(4, "0");
|
|
314
|
+
const files = await readdir(chaptersDir).catch(() => []);
|
|
315
|
+
const match = files.find((file) => file.startsWith(`${padded}_`) && file.endsWith(".md"));
|
|
316
|
+
return match ? join(chaptersDir, match) : null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function parseBookChapterFromRelativePath(rel: string): { bookId: string; chapterNumber: number } | null {
|
|
320
|
+
const match = rel.match(/^books\/([^/]+)\/chapters\/(\d{4})_[^/]+\.md$/);
|
|
321
|
+
if (!match?.[1] || !match[2]) return null;
|
|
322
|
+
const chapterNumber = Number.parseInt(match[2], 10);
|
|
323
|
+
return Number.isInteger(chapterNumber) ? { bookId: match[1], chapterNumber } : null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function syncExternalChapterEdit(params: {
|
|
327
|
+
readonly state: StateManager;
|
|
328
|
+
readonly root: string;
|
|
329
|
+
readonly bookId: string;
|
|
330
|
+
readonly chapterNumber: number;
|
|
331
|
+
readonly content: string;
|
|
332
|
+
}): Promise<void> {
|
|
333
|
+
const now = new Date().toISOString();
|
|
334
|
+
const index = [...(await params.state.loadChapterIndex(params.bookId))];
|
|
335
|
+
const updated = index.map((chapter) => chapter.number === params.chapterNumber
|
|
336
|
+
? {
|
|
337
|
+
...chapter,
|
|
338
|
+
status: "audit-failed" as const,
|
|
339
|
+
wordCount: countContentUnits(params.content),
|
|
340
|
+
updatedAt: now,
|
|
341
|
+
auditIssues: [
|
|
342
|
+
...chapter.auditIssues.filter((issue) => issue !== CHAT_EDIT_WARNING),
|
|
343
|
+
CHAT_EDIT_WARNING,
|
|
344
|
+
],
|
|
345
|
+
}
|
|
346
|
+
: chapter);
|
|
347
|
+
if (updated.length > 0) {
|
|
348
|
+
await params.state.saveChapterIndex(params.bookId, updated);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const runtimeDir = join(params.root, "books", params.bookId, "story", "runtime");
|
|
352
|
+
const padded = String(params.chapterNumber).padStart(4, "0");
|
|
353
|
+
const runtimeFiles = await readdir(runtimeDir).catch(() => []);
|
|
354
|
+
await Promise.all(
|
|
355
|
+
runtimeFiles
|
|
356
|
+
.filter((file) => file.startsWith(`chapter-${padded}.`))
|
|
357
|
+
.map((file) => rm(join(runtimeDir, file), { force: true })),
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function tryHandleExternalChatEdit(params: {
|
|
362
|
+
readonly root: string;
|
|
363
|
+
readonly state: StateManager;
|
|
364
|
+
readonly instruction: string;
|
|
365
|
+
readonly activeBookId: string | null;
|
|
366
|
+
}): Promise<ExternalChatEditResult | null> {
|
|
367
|
+
const replacement = parseReplacementInstruction(params.instruction);
|
|
368
|
+
if (!replacement) return null;
|
|
369
|
+
|
|
370
|
+
const explicitPath = parseExplicitEditPath(params.instruction);
|
|
371
|
+
if (explicitPath) {
|
|
372
|
+
const target = resolveExternalChatEditPath(params.root, explicitPath);
|
|
373
|
+
const content = await readFile(target.path, "utf-8").catch((error) => {
|
|
374
|
+
throw new ApiError(404, "CHAT_EDIT_TARGET_NOT_FOUND", error instanceof Error ? error.message : String(error));
|
|
375
|
+
});
|
|
376
|
+
const first = content.indexOf(replacement.oldText);
|
|
377
|
+
if (first === -1) {
|
|
378
|
+
throw new ApiError(400, "EDIT_TARGET_NOT_FOUND", "要替换的原文没有在目标文件中找到。");
|
|
379
|
+
}
|
|
380
|
+
if (content.indexOf(replacement.oldText, first + replacement.oldText.length) !== -1) {
|
|
381
|
+
throw new ApiError(400, "EDIT_TARGET_AMBIGUOUS", "要替换的原文出现多次,请给出更具体的一段。");
|
|
382
|
+
}
|
|
383
|
+
const updated = content.slice(0, first) + replacement.newText + content.slice(first + replacement.oldText.length);
|
|
384
|
+
await writeFile(target.path, updated, "utf-8");
|
|
385
|
+
|
|
386
|
+
const chapterTarget = parseBookChapterFromRelativePath(target.rel);
|
|
387
|
+
if (chapterTarget) {
|
|
388
|
+
await syncExternalChapterEdit({
|
|
389
|
+
state: params.state,
|
|
390
|
+
root: params.root,
|
|
391
|
+
bookId: chapterTarget.bookId,
|
|
392
|
+
chapterNumber: chapterTarget.chapterNumber,
|
|
393
|
+
content: updated,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
activeBookId: chapterTarget?.bookId ?? params.activeBookId ?? undefined,
|
|
399
|
+
responseText: `已直接编辑 ${target.rel}${chapterTarget ? ",并标记为需要复核" : ""}。`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!params.activeBookId) return null;
|
|
404
|
+
const chapterNumber = parseChapterNumberForEdit(params.instruction);
|
|
405
|
+
if (!replacement || !chapterNumber) return null;
|
|
406
|
+
|
|
407
|
+
const chapterPath = await findChapterFile(params.root, params.activeBookId, chapterNumber);
|
|
408
|
+
if (!chapterPath) {
|
|
409
|
+
throw new ApiError(404, "CHAPTER_NOT_FOUND", `Chapter ${chapterNumber} not found in ${params.activeBookId}`);
|
|
410
|
+
}
|
|
411
|
+
if (!CHAT_EDIT_TEXT_EXTENSIONS.test(chapterPath)) {
|
|
412
|
+
throw new ApiError(400, "UNSUPPORTED_EDIT_TARGET", "Chat external edits only support text files.");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const content = await readFile(chapterPath, "utf-8");
|
|
416
|
+
const first = content.indexOf(replacement.oldText);
|
|
417
|
+
if (first === -1) {
|
|
418
|
+
throw new ApiError(400, "EDIT_TARGET_NOT_FOUND", "要替换的原文没有在目标章节中找到。");
|
|
419
|
+
}
|
|
420
|
+
if (content.indexOf(replacement.oldText, first + replacement.oldText.length) !== -1) {
|
|
421
|
+
throw new ApiError(400, "EDIT_TARGET_AMBIGUOUS", "要替换的原文出现多次,请给出更具体的一段。");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const updated = content.slice(0, first) + replacement.newText + content.slice(first + replacement.oldText.length);
|
|
425
|
+
await writeFile(chapterPath, updated, "utf-8");
|
|
426
|
+
await syncExternalChapterEdit({
|
|
427
|
+
state: params.state,
|
|
428
|
+
root: params.root,
|
|
429
|
+
bookId: params.activeBookId,
|
|
430
|
+
chapterNumber,
|
|
431
|
+
content: updated,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
activeBookId: params.activeBookId,
|
|
436
|
+
responseText: `已直接编辑 ${params.activeBookId} 第 ${chapterNumber} 章,并标记为需要复核。`,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function looksLikeBookCreatedClaim(responseText: string): boolean {
|
|
441
|
+
return /(?:已|已经|成功).{0,12}(?:创建|建书|初始化|保存).{0,12}(?:作品|书|书籍|文件夹)?/.test(responseText)
|
|
442
|
+
|| /\b(?:created|initiali[sz]ed|saved)\b.{0,40}\b(?:book|project|novel)\b/i.test(responseText);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function validateAgentActionExecution(args: {
|
|
446
|
+
readonly instruction: string;
|
|
447
|
+
readonly agentBookId: string | null | undefined;
|
|
448
|
+
readonly responseText: string;
|
|
449
|
+
readonly collectedToolExecs: ReadonlyArray<CollectedToolExec>;
|
|
450
|
+
}): string | undefined {
|
|
451
|
+
const failedExec = args.collectedToolExecs.find(isLikelyFailedToolResult);
|
|
452
|
+
if (failedExec) {
|
|
453
|
+
return `${failedExec.label} 执行失败:${failedExec.error ?? failedExec.result ?? "未知错误"}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (
|
|
457
|
+
args.agentBookId
|
|
458
|
+
&& isWriteNextInstruction(args.instruction)
|
|
459
|
+
&& !hasSuccessfulSubAgentExec(args.collectedToolExecs, "writer")
|
|
460
|
+
) {
|
|
461
|
+
return "模型声称已完成下一章,但没有实际调用写作工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (
|
|
465
|
+
!args.agentBookId
|
|
466
|
+
&& looksLikeBookCreatedClaim(args.responseText)
|
|
467
|
+
&& !resolveCreatedBookIdFromToolExecs(args.collectedToolExecs)
|
|
468
|
+
) {
|
|
469
|
+
return "模型声称已创建作品,但没有实际调用建书工具,也没有生成作品文件。请补充书名/题材后重试,或换用支持工具调用的模型。";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
interface CollectedToolExec {
|
|
476
|
+
id: string;
|
|
477
|
+
tool: string;
|
|
478
|
+
agent?: string;
|
|
479
|
+
label: string;
|
|
480
|
+
status: "running" | "completed" | "error";
|
|
481
|
+
args?: Record<string, unknown>;
|
|
482
|
+
result?: string;
|
|
483
|
+
details?: unknown;
|
|
484
|
+
error?: string;
|
|
485
|
+
stages?: Array<{ label: string; status: "pending" | "completed" }>;
|
|
486
|
+
startedAt: number;
|
|
487
|
+
completedAt?: number;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
interface StudioBookListSummary {
|
|
491
|
+
readonly id: string;
|
|
492
|
+
readonly title: string;
|
|
493
|
+
readonly genre: string;
|
|
494
|
+
readonly status: string;
|
|
495
|
+
readonly chaptersWritten: number;
|
|
496
|
+
readonly [key: string]: unknown;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// --- Event bus for SSE ---
|
|
500
|
+
|
|
501
|
+
type EventHandler = (event: string, data: unknown) => void;
|
|
502
|
+
const subscribers = new Set<EventHandler>();
|
|
503
|
+
const bookCreateStatus = new Map<string, { status: "creating" | "error"; error?: string }>();
|
|
504
|
+
|
|
505
|
+
// 内存缓存:service -> 模型列表 + 更新时间戳;避免每次 sidebar 挂载时都打真实 LLM /models
|
|
506
|
+
const modelListCache = new Map<string, { models: Array<{ id: string; name: string }>; at: number }>();
|
|
507
|
+
|
|
508
|
+
interface ServiceConfigEntry {
|
|
509
|
+
service: string;
|
|
510
|
+
name?: string;
|
|
511
|
+
baseUrl?: string;
|
|
512
|
+
temperature?: number;
|
|
513
|
+
apiFormat?: "chat" | "responses";
|
|
514
|
+
stream?: boolean;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
type LLMConfigSource = "env" | "studio";
|
|
518
|
+
|
|
519
|
+
interface EnvConfigSummary {
|
|
520
|
+
detected: boolean;
|
|
521
|
+
provider: string | null;
|
|
522
|
+
baseUrl: string | null;
|
|
523
|
+
model: string | null;
|
|
524
|
+
hasApiKey: boolean;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
interface EnvConfigStatus {
|
|
528
|
+
project: EnvConfigSummary;
|
|
529
|
+
global: EnvConfigSummary;
|
|
530
|
+
effectiveSource: "project" | "global" | null;
|
|
531
|
+
runtimeUsesEnv: false;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
interface ServiceProbeResult {
|
|
535
|
+
ok: boolean;
|
|
536
|
+
models: Array<{ id: string; name: string }>;
|
|
537
|
+
selectedModel?: string;
|
|
538
|
+
apiFormat?: "chat" | "responses";
|
|
539
|
+
stream?: boolean;
|
|
540
|
+
baseUrl?: string;
|
|
541
|
+
modelsSource?: "api" | "fallback";
|
|
542
|
+
error?: string;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function broadcast(event: string, data: unknown): void {
|
|
546
|
+
for (const handler of subscribers) {
|
|
547
|
+
handler(event, data);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function deriveBookIdFromTitle(title: string): string {
|
|
552
|
+
return title
|
|
553
|
+
.trim()
|
|
554
|
+
.toLowerCase()
|
|
555
|
+
.replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
|
|
556
|
+
.replace(/-+/g, "-")
|
|
557
|
+
.replace(/^-+|-+$/g, "")
|
|
558
|
+
.slice(0, 30);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function resolveArchitectBookIdFromArgs(args?: Record<string, unknown>): string | null {
|
|
562
|
+
if (!args || args.agent !== "architect" || args.revise === true) return null;
|
|
563
|
+
if (typeof args.bookId === "string" && args.bookId.trim()) return args.bookId.trim();
|
|
564
|
+
if (typeof args.title === "string" && args.title.trim()) {
|
|
565
|
+
return deriveBookIdFromTitle(args.title) || null;
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function resolveCreatedBookIdFromToolExecs(execs: ReadonlyArray<CollectedToolExec>): string | null {
|
|
571
|
+
for (let i = execs.length - 1; i >= 0; i -= 1) {
|
|
572
|
+
const exec = execs[i];
|
|
573
|
+
if (exec.tool !== "sub_agent" || exec.agent !== "architect" || exec.status !== "completed") continue;
|
|
574
|
+
|
|
575
|
+
const details = exec.details as { kind?: unknown; bookId?: unknown } | undefined;
|
|
576
|
+
if (details?.kind === "book_created" && typeof details.bookId === "string" && details.bookId.trim()) {
|
|
577
|
+
return details.bookId.trim();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const fromArgs = resolveArchitectBookIdFromArgs(exec.args);
|
|
581
|
+
if (fromArgs) return fromArgs;
|
|
582
|
+
}
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function loadStudioBookListSummary(
|
|
587
|
+
state: StateManager,
|
|
588
|
+
bookId: string,
|
|
589
|
+
): Promise<StudioBookListSummary> {
|
|
590
|
+
const book = await state.loadBookConfig(bookId);
|
|
591
|
+
const nextChapter = await state.getNextChapterNumber(bookId);
|
|
592
|
+
return { ...book, chaptersWritten: nextChapter - 1 };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function isCustomServiceId(serviceId: string): boolean {
|
|
596
|
+
return serviceId === "custom" || serviceId.startsWith("custom:");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function serviceConfigKey(entry: ServiceConfigEntry): string {
|
|
600
|
+
return entry.service === "custom" ? `custom:${entry.name ?? "Custom"}` : entry.service;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>): ServiceConfigEntry {
|
|
604
|
+
if (serviceId.startsWith("custom:")) {
|
|
605
|
+
return {
|
|
606
|
+
service: "custom",
|
|
607
|
+
name: decodeURIComponent(serviceId.slice("custom:".length)),
|
|
608
|
+
...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
|
|
609
|
+
...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
|
|
610
|
+
...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
|
|
611
|
+
...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (serviceId === "custom") {
|
|
616
|
+
return {
|
|
617
|
+
service: "custom",
|
|
618
|
+
...(typeof value.name === "string" && value.name.length > 0 ? { name: value.name } : {}),
|
|
619
|
+
...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
|
|
620
|
+
...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
|
|
621
|
+
...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
|
|
622
|
+
...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
service: serviceId,
|
|
628
|
+
...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
|
|
629
|
+
...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
|
|
630
|
+
...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function normalizeConfigSource(value: unknown): LLMConfigSource {
|
|
635
|
+
return value === "studio" ? "studio" : "env";
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function normalizeServiceConfig(raw: unknown): ServiceConfigEntry[] {
|
|
639
|
+
if (Array.isArray(raw)) {
|
|
640
|
+
return raw
|
|
641
|
+
.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
|
|
642
|
+
.map((entry) => ({
|
|
643
|
+
service: typeof entry.service === "string" && entry.service.length > 0 ? entry.service : "custom",
|
|
644
|
+
...(typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {}),
|
|
645
|
+
...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
|
|
646
|
+
...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
|
|
647
|
+
...(entry.apiFormat === "chat" || entry.apiFormat === "responses" ? { apiFormat: entry.apiFormat } : {}),
|
|
648
|
+
...(typeof entry.stream === "boolean" ? { stream: entry.stream } : {}),
|
|
649
|
+
}));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (raw && typeof raw === "object") {
|
|
653
|
+
return Object.entries(raw as Record<string, unknown>)
|
|
654
|
+
.filter(([, value]) => value && typeof value === "object")
|
|
655
|
+
.map(([serviceId, value]) => normalizeServiceEntry(serviceId, value as Record<string, unknown>));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return [];
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function mergeServiceConfig(existing: ServiceConfigEntry[], updates: ServiceConfigEntry[]): ServiceConfigEntry[] {
|
|
662
|
+
const merged = new Map(existing.map((entry) => [serviceConfigKey(entry), entry]));
|
|
663
|
+
for (const update of updates) {
|
|
664
|
+
merged.set(serviceConfigKey(update), update);
|
|
665
|
+
}
|
|
666
|
+
return [...merged.values()];
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function normalizeCoverConfig(raw: unknown): { service: string; model: string } | undefined {
|
|
670
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
671
|
+
const record = raw as Record<string, unknown>;
|
|
672
|
+
const service = typeof record.service === "string" ? record.service : "";
|
|
673
|
+
const preset = resolveCoverProviderPreset(service);
|
|
674
|
+
if (!preset) return undefined;
|
|
675
|
+
const requestedModel = typeof record.model === "string" ? record.model.trim() : "";
|
|
676
|
+
const model = requestedModel && preset.models.includes(requestedModel)
|
|
677
|
+
? requestedModel
|
|
678
|
+
: preset.defaultModel;
|
|
679
|
+
return { service: preset.service, model };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function syncTopLevelLlmMirror(llm: Record<string, unknown>): void {
|
|
683
|
+
const selectedService = typeof llm.service === "string" ? llm.service : undefined;
|
|
684
|
+
if (!selectedService) return;
|
|
685
|
+
|
|
686
|
+
const services = normalizeServiceConfig(llm.services);
|
|
687
|
+
const selectedEntry = services.find((entry) => serviceConfigKey(entry) === selectedService)
|
|
688
|
+
?? (!isCustomServiceId(selectedService) ? { service: selectedService } : undefined);
|
|
689
|
+
if (!selectedEntry) return;
|
|
690
|
+
|
|
691
|
+
const preset = resolveServicePreset(selectedEntry.service);
|
|
692
|
+
llm.provider = resolveServiceProviderFamily(selectedEntry.service) ?? "openai";
|
|
693
|
+
llm.baseUrl = selectedEntry.baseUrl ?? preset?.baseUrl ?? "";
|
|
694
|
+
|
|
695
|
+
const defaultModel = typeof llm.defaultModel === "string" ? llm.defaultModel.trim() : "";
|
|
696
|
+
if (defaultModel) llm.model = defaultModel;
|
|
697
|
+
if (selectedEntry.temperature !== undefined) llm.temperature = selectedEntry.temperature;
|
|
698
|
+
if (selectedEntry.apiFormat !== undefined) llm.apiFormat = selectedEntry.apiFormat;
|
|
699
|
+
if (selectedEntry.stream !== undefined) llm.stream = selectedEntry.stream;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function loadRawConfig(root: string): Promise<Record<string, unknown>> {
|
|
703
|
+
const configPath = join(root, "inkos.json");
|
|
704
|
+
const raw = await readFile(configPath, "utf-8");
|
|
705
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function saveRawConfig(root: string, config: Record<string, unknown>): Promise<void> {
|
|
709
|
+
await writeFile(join(root, "inkos.json"), JSON.stringify(config, null, 2), "utf-8");
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function readEnvConfigSummary(path: string): Promise<EnvConfigSummary> {
|
|
713
|
+
try {
|
|
714
|
+
const raw = await readFile(path, "utf-8");
|
|
715
|
+
const values = new Map<string, string>();
|
|
716
|
+
|
|
717
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
718
|
+
const trimmed = line.trim();
|
|
719
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
720
|
+
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
721
|
+
if (!match) continue;
|
|
722
|
+
const [, key, value] = match;
|
|
723
|
+
values.set(key, value.trim());
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const provider = values.get("INKOS_LLM_PROVIDER") ?? null;
|
|
727
|
+
const baseUrl = values.get("INKOS_LLM_BASE_URL") ?? null;
|
|
728
|
+
const model = values.get("INKOS_LLM_MODEL") ?? null;
|
|
729
|
+
const apiKey = values.get("INKOS_LLM_API_KEY") ?? "";
|
|
730
|
+
const detected = Boolean(provider || baseUrl || model || apiKey);
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
detected,
|
|
734
|
+
provider,
|
|
735
|
+
baseUrl,
|
|
736
|
+
model,
|
|
737
|
+
hasApiKey: apiKey.length > 0,
|
|
738
|
+
};
|
|
739
|
+
} catch {
|
|
740
|
+
return {
|
|
741
|
+
detected: false,
|
|
742
|
+
provider: null,
|
|
743
|
+
baseUrl: null,
|
|
744
|
+
model: null,
|
|
745
|
+
hasApiKey: false,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async function readEnvConfigStatus(root: string): Promise<EnvConfigStatus> {
|
|
751
|
+
const project = await readEnvConfigSummary(join(root, ".env"));
|
|
752
|
+
const global = await readEnvConfigSummary(GLOBAL_ENV_PATH);
|
|
753
|
+
return {
|
|
754
|
+
project,
|
|
755
|
+
global,
|
|
756
|
+
effectiveSource: project.detected ? "project" : global.detected ? "global" : null,
|
|
757
|
+
runtimeUsesEnv: false,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function resolveConfiguredServiceBaseUrl(root: string, serviceId: string, inlineBaseUrl?: string): Promise<string | undefined> {
|
|
762
|
+
if (inlineBaseUrl?.trim()) return inlineBaseUrl.trim();
|
|
763
|
+
|
|
764
|
+
if (!isCustomServiceId(serviceId)) {
|
|
765
|
+
return resolveServicePreset(serviceId)?.baseUrl;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
try {
|
|
769
|
+
const config = await loadRawConfig(root);
|
|
770
|
+
const services = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services);
|
|
771
|
+
const matched = services.find((entry) => serviceConfigKey(entry) === serviceId);
|
|
772
|
+
return matched?.baseUrl;
|
|
773
|
+
} catch {
|
|
774
|
+
return undefined;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function resolveConfiguredServiceEntry(root: string, serviceId: string): Promise<ServiceConfigEntry | undefined> {
|
|
779
|
+
try {
|
|
780
|
+
const config = await loadRawConfig(root);
|
|
781
|
+
const services = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services);
|
|
782
|
+
return services.find((entry) => serviceConfigKey(entry) === serviceId);
|
|
783
|
+
} catch {
|
|
784
|
+
return undefined;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function buildProbePlans(
|
|
789
|
+
preferredApiFormat: "chat" | "responses" | undefined,
|
|
790
|
+
preferredStream: boolean | undefined,
|
|
791
|
+
): Array<{ apiFormat: "chat" | "responses"; stream: boolean }> {
|
|
792
|
+
const candidates: Array<{ apiFormat: "chat" | "responses"; stream: boolean }> = [];
|
|
793
|
+
const seen = new Set<string>();
|
|
794
|
+
const push = (apiFormat: "chat" | "responses", stream: boolean) => {
|
|
795
|
+
const key = `${apiFormat}:${stream ? "1" : "0"}`;
|
|
796
|
+
if (seen.has(key)) return;
|
|
797
|
+
seen.add(key);
|
|
798
|
+
candidates.push({ apiFormat, stream });
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
if (preferredApiFormat) {
|
|
802
|
+
push(preferredApiFormat, preferredStream ?? false);
|
|
803
|
+
if (preferredStream) push(preferredApiFormat, false);
|
|
804
|
+
return candidates;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
push("chat", false);
|
|
808
|
+
push("responses", false);
|
|
809
|
+
return candidates;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function buildModelCandidates(args: {
|
|
813
|
+
preferredModel?: string;
|
|
814
|
+
configModel?: string;
|
|
815
|
+
envModel?: string | null;
|
|
816
|
+
discoveredModels: Array<{ id: string; name: string }>;
|
|
817
|
+
includeGenericFallbacks?: boolean;
|
|
818
|
+
}): string[] {
|
|
819
|
+
const seen = new Set<string>();
|
|
820
|
+
const candidates: string[] = [];
|
|
821
|
+
const push = (value: string | null | undefined) => {
|
|
822
|
+
if (!value || value.trim().length === 0) return;
|
|
823
|
+
const id = value.trim();
|
|
824
|
+
if (seen.has(id)) return;
|
|
825
|
+
seen.add(id);
|
|
826
|
+
candidates.push(id);
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
push(args.preferredModel);
|
|
830
|
+
push(args.configModel);
|
|
831
|
+
push(args.envModel ?? undefined);
|
|
832
|
+
for (const model of args.discoveredModels.slice(0, MAX_DISCOVERED_MODELS_TO_PING)) push(model.id);
|
|
833
|
+
if (args.includeGenericFallbacks === false) return candidates;
|
|
834
|
+
for (const fallback of [
|
|
835
|
+
"gpt-5.4",
|
|
836
|
+
"gpt-4o",
|
|
837
|
+
"claude-sonnet-4-6",
|
|
838
|
+
"MiniMax-M2.7",
|
|
839
|
+
"kimi-k2.5",
|
|
840
|
+
].slice(0, MAX_GENERIC_FALLBACK_MODELS_TO_PING)) {
|
|
841
|
+
push(fallback);
|
|
842
|
+
}
|
|
843
|
+
return candidates;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function yamlScalar(value: unknown): string {
|
|
847
|
+
return JSON.stringify(String(value ?? ""));
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function radarTimestampForFilename(value: string | undefined): string {
|
|
851
|
+
const date = value ? new Date(value) : new Date();
|
|
852
|
+
const safeDate = Number.isNaN(date.getTime()) ? new Date() : date;
|
|
853
|
+
return safeDate.toISOString().replace(/[:.]/g, "-");
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function saveRadarScan(root: string, result: unknown): Promise<string> {
|
|
857
|
+
const radarDir = join(root, "radar");
|
|
858
|
+
await mkdir(radarDir, { recursive: true });
|
|
859
|
+
const timestamp = typeof result === "object" && result !== null && "timestamp" in result
|
|
860
|
+
? String((result as { timestamp?: unknown }).timestamp ?? "")
|
|
861
|
+
: "";
|
|
862
|
+
const fileName = `scan-${radarTimestampForFilename(timestamp)}.json`;
|
|
863
|
+
const filePath = join(radarDir, fileName);
|
|
864
|
+
await writeFile(filePath, JSON.stringify(result, null, 2), "utf-8");
|
|
865
|
+
return filePath;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async function loadRadarHistory(root: string): Promise<Array<{
|
|
869
|
+
readonly file: string;
|
|
870
|
+
readonly timestamp: string;
|
|
871
|
+
readonly marketSummary: string;
|
|
872
|
+
readonly summaryPreview: string;
|
|
873
|
+
readonly result: unknown;
|
|
874
|
+
}>> {
|
|
875
|
+
const radarDir = join(root, "radar");
|
|
876
|
+
let files: string[] = [];
|
|
877
|
+
try {
|
|
878
|
+
files = await readdir(radarDir);
|
|
879
|
+
} catch {
|
|
880
|
+
return [];
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const scans = await Promise.all(
|
|
884
|
+
files
|
|
885
|
+
.filter((file) => /^scan-.+\.json$/.test(file))
|
|
886
|
+
.map(async (file) => {
|
|
887
|
+
try {
|
|
888
|
+
const raw = await readFile(join(radarDir, file), "utf-8");
|
|
889
|
+
const result = JSON.parse(raw) as { timestamp?: unknown; marketSummary?: unknown };
|
|
890
|
+
const timestamp = typeof result.timestamp === "string"
|
|
891
|
+
? result.timestamp
|
|
892
|
+
: file.replace(/^scan-/, "").replace(/\.json$/, "");
|
|
893
|
+
const marketSummary = typeof result.marketSummary === "string" ? result.marketSummary : "";
|
|
894
|
+
return {
|
|
895
|
+
file,
|
|
896
|
+
timestamp,
|
|
897
|
+
marketSummary,
|
|
898
|
+
summaryPreview: marketSummary.slice(0, 100),
|
|
899
|
+
result,
|
|
900
|
+
};
|
|
901
|
+
} catch {
|
|
902
|
+
return null;
|
|
903
|
+
}
|
|
904
|
+
}),
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
return scans
|
|
908
|
+
.filter((item): item is NonNullable<typeof item> => item !== null)
|
|
909
|
+
.sort((a, b) => b.file.localeCompare(a.file));
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function fallbackTextModelsForEndpoint(
|
|
913
|
+
endpoint: ReturnType<typeof getAllEndpoints>[number] | undefined,
|
|
914
|
+
preset: ReturnType<typeof resolveServicePreset> | undefined,
|
|
915
|
+
): Array<{ id: string; name: string }> {
|
|
916
|
+
const endpointModels = endpoint?.models
|
|
917
|
+
.filter((model) => model.enabled !== false)
|
|
918
|
+
.filter((model) => isTextChatModelId(model.id))
|
|
919
|
+
.map((model) => ({ id: model.id, name: model.id }))
|
|
920
|
+
?? [];
|
|
921
|
+
if (endpointModels.length > 0) return endpointModels;
|
|
922
|
+
return preset?.knownModels?.map((id) => ({ id, name: id })) ?? [];
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function shouldTrustStaticModelsWhenLiveListUnavailable(endpoint: ReturnType<typeof getAllEndpoints>[number] | undefined): boolean {
|
|
926
|
+
return endpoint?.group === "aggregator";
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
930
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
931
|
+
try {
|
|
932
|
+
return await Promise.race([
|
|
933
|
+
promise,
|
|
934
|
+
new Promise<never>((_, reject) => {
|
|
935
|
+
timeout = setTimeout(() => reject(new Error(`${label} 超时(${timeoutMs}ms)`)), timeoutMs);
|
|
936
|
+
}),
|
|
937
|
+
]);
|
|
938
|
+
} finally {
|
|
939
|
+
if (timeout) clearTimeout(timeout);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function formatServiceProbeError(args: {
|
|
944
|
+
readonly service: string;
|
|
945
|
+
readonly label?: string;
|
|
946
|
+
readonly baseUrl: string;
|
|
947
|
+
readonly model?: string;
|
|
948
|
+
readonly apiFormat?: "chat" | "responses";
|
|
949
|
+
readonly stream?: boolean;
|
|
950
|
+
readonly error: string;
|
|
951
|
+
}): string {
|
|
952
|
+
const rawDetail = args.error
|
|
953
|
+
.replace(/\n\s*\(baseUrl:[\s\S]*?\)$/m, "")
|
|
954
|
+
.trim();
|
|
955
|
+
const upstreamDetail = rawDetail.includes("上游详情:")
|
|
956
|
+
? rawDetail
|
|
957
|
+
: "";
|
|
958
|
+
const context = [
|
|
959
|
+
`服务商:${args.label ?? args.service}`,
|
|
960
|
+
`测试模型:${args.model ?? "未确定"}`,
|
|
961
|
+
`协议:${args.apiFormat === "responses" ? "Responses" : "Chat / Completions"}${typeof args.stream === "boolean" ? `,${args.stream ? "流式" : "非流式"}` : ""}`,
|
|
962
|
+
`Base URL:${args.baseUrl}`,
|
|
963
|
+
].join("\n");
|
|
964
|
+
|
|
965
|
+
if (args.service === "google") {
|
|
966
|
+
return [
|
|
967
|
+
"Google Gemini 测试连接失败。",
|
|
968
|
+
context,
|
|
969
|
+
"",
|
|
970
|
+
"请优先检查:",
|
|
971
|
+
"1. API Key 是否来自 Google AI Studio 的 Gemini API key,而不是 OAuth、Vertex AI 或其它 Google 服务凭据。",
|
|
972
|
+
"2. 该 key 所属项目是否已启用 Gemini API,并且没有被限制到其它 API、来源或服务。",
|
|
973
|
+
"3. 当前地区/账号是否允许访问 Gemini API。",
|
|
974
|
+
"4. 如果 key 曾经泄露,请在 AI Studio 重新生成后再保存。",
|
|
975
|
+
upstreamDetail ? `\n上游返回:${upstreamDetail}` : "",
|
|
976
|
+
].filter(Boolean).join("\n");
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (args.service === "moonshot" || args.service === "kimiCodingPlan" || args.service === "kimicode") {
|
|
980
|
+
return [
|
|
981
|
+
`${args.label ?? args.service} 测试连接失败。`,
|
|
982
|
+
context,
|
|
983
|
+
"",
|
|
984
|
+
"请优先检查模型是否可用,以及 kimi-k2.x 这类模型是否需要 temperature=1。",
|
|
985
|
+
rawDetail ? `\n上游返回:${rawDetail}` : "",
|
|
986
|
+
].filter(Boolean).join("\n");
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return [
|
|
990
|
+
`${args.label ?? args.service} 测试连接失败。`,
|
|
991
|
+
context,
|
|
992
|
+
"",
|
|
993
|
+
"请检查 API Key、模型可用性、账号额度,以及协议类型是否匹配该服务商。",
|
|
994
|
+
rawDetail ? `\n上游返回:${rawDetail}` : "",
|
|
995
|
+
].filter(Boolean).join("\n");
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
async function fetchModelsFromServiceBaseUrl(
|
|
999
|
+
serviceId: string,
|
|
1000
|
+
baseUrl: string,
|
|
1001
|
+
apiKey: string,
|
|
1002
|
+
proxyUrl?: string,
|
|
1003
|
+
): Promise<{ models: Array<{ id: string; name: string }>; error?: string; authFailed?: boolean }> {
|
|
1004
|
+
const endpoint = isCustomServiceId(serviceId)
|
|
1005
|
+
? undefined
|
|
1006
|
+
: getAllEndpoints().find((ep) => ep.id === serviceId);
|
|
1007
|
+
const modelsBaseUrl = isCustomServiceId(serviceId)
|
|
1008
|
+
? baseUrl
|
|
1009
|
+
: endpoint?.modelsBaseUrl ?? (endpoint ? baseUrl : resolveServiceModelsBaseUrl(serviceId) ?? baseUrl);
|
|
1010
|
+
const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
|
|
1011
|
+
try {
|
|
1012
|
+
const res = await fetchWithProxy(modelsUrl, {
|
|
1013
|
+
headers: buildBearerAuthHeaders(apiKey),
|
|
1014
|
+
signal: AbortSignal.timeout(SERVICE_MODELS_PROBE_TIMEOUT_MS),
|
|
1015
|
+
}, proxyUrl);
|
|
1016
|
+
if (!res.ok) {
|
|
1017
|
+
const body = await res.text().catch(() => "");
|
|
1018
|
+
return {
|
|
1019
|
+
models: [],
|
|
1020
|
+
error: `服务商返回 ${res.status}: ${body.slice(0, 200)}`,
|
|
1021
|
+
authFailed: res.status === 401 || res.status === 403,
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
const json = await res.json() as { data?: Array<{ id: string }> };
|
|
1025
|
+
return {
|
|
1026
|
+
models: (json.data ?? []).map((m) => ({ id: m.id, name: m.id })),
|
|
1027
|
+
};
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
return {
|
|
1030
|
+
models: [],
|
|
1031
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function buildBearerAuthHeaders(apiKey: string | undefined): Record<string, string> {
|
|
1037
|
+
const trimmed = apiKey?.trim() ?? "";
|
|
1038
|
+
if (!trimmed) return {};
|
|
1039
|
+
if (!/^[\x20-\x7e]+$/.test(trimmed)) {
|
|
1040
|
+
throw new Error("API Key 只能包含英文、数字和常见 ASCII 符号,请检查是否误粘贴了中文说明。");
|
|
1041
|
+
}
|
|
1042
|
+
return { Authorization: `Bearer ${trimmed}` };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
async function probeServiceCapabilities(args: {
|
|
1046
|
+
root: string;
|
|
1047
|
+
service: string;
|
|
1048
|
+
apiKey: string;
|
|
1049
|
+
baseUrl: string;
|
|
1050
|
+
preferredApiFormat?: "chat" | "responses";
|
|
1051
|
+
preferredStream?: boolean;
|
|
1052
|
+
preferredModel?: string;
|
|
1053
|
+
proxyUrl?: string;
|
|
1054
|
+
}): Promise<ServiceProbeResult> {
|
|
1055
|
+
const rawConfig = await loadRawConfig(args.root).catch(() => ({} as Record<string, unknown>));
|
|
1056
|
+
const llm = (rawConfig.llm as Record<string, unknown> | undefined) ?? {};
|
|
1057
|
+
const envConfig = await readEnvConfigStatus(args.root);
|
|
1058
|
+
const envModel = envConfig.effectiveSource === "project"
|
|
1059
|
+
? envConfig.project.model
|
|
1060
|
+
: envConfig.effectiveSource === "global"
|
|
1061
|
+
? envConfig.global.model
|
|
1062
|
+
: null;
|
|
1063
|
+
|
|
1064
|
+
const baseService = isCustomServiceId(args.service) ? "custom" : args.service;
|
|
1065
|
+
const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey, args.proxyUrl);
|
|
1066
|
+
if (modelsResponse.authFailed) {
|
|
1067
|
+
return {
|
|
1068
|
+
ok: false,
|
|
1069
|
+
models: [],
|
|
1070
|
+
error: modelsResponse.error ?? "API Key 无效或无权访问模型列表。",
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
const discoveredModels = modelsResponse.models;
|
|
1074
|
+
const endpoint = getAllEndpoints().find((ep) => ep.id === baseService);
|
|
1075
|
+
const preset = resolveServicePreset(baseService);
|
|
1076
|
+
const discoveredFirstModel =
|
|
1077
|
+
discoveredModels.find((model) => isTextChatModelId(model.id))?.id
|
|
1078
|
+
?? discoveredModels[0]?.id;
|
|
1079
|
+
if (discoveredModels.length > 0) {
|
|
1080
|
+
if (!discoveredFirstModel || !isTextChatModelId(discoveredFirstModel)) {
|
|
1081
|
+
return {
|
|
1082
|
+
ok: false,
|
|
1083
|
+
models: discoveredModels,
|
|
1084
|
+
error: "模型列表可访问,但没有发现可用于文本对话的模型。",
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
return {
|
|
1088
|
+
ok: true,
|
|
1089
|
+
models: discoveredModels,
|
|
1090
|
+
selectedModel: discoveredFirstModel,
|
|
1091
|
+
apiFormat: args.preferredApiFormat ?? "chat",
|
|
1092
|
+
stream: args.preferredStream ?? false,
|
|
1093
|
+
baseUrl: args.baseUrl,
|
|
1094
|
+
modelsSource: "api",
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
if (shouldTrustStaticModelsWhenLiveListUnavailable(endpoint)) {
|
|
1098
|
+
const models = fallbackTextModelsForEndpoint(endpoint, preset);
|
|
1099
|
+
const selectedModel =
|
|
1100
|
+
endpoint?.checkModel && models.some((model) => model.id === endpoint.checkModel)
|
|
1101
|
+
? endpoint.checkModel
|
|
1102
|
+
: models[0]?.id;
|
|
1103
|
+
if (selectedModel) {
|
|
1104
|
+
return {
|
|
1105
|
+
ok: true,
|
|
1106
|
+
models,
|
|
1107
|
+
selectedModel,
|
|
1108
|
+
apiFormat: args.preferredApiFormat ?? "chat",
|
|
1109
|
+
stream: args.preferredStream ?? false,
|
|
1110
|
+
baseUrl: args.baseUrl,
|
|
1111
|
+
modelsSource: "fallback",
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
// Prefer live /models results; if unavailable, probe with the service's own check model before global defaults.
|
|
1116
|
+
const serviceFirstModel =
|
|
1117
|
+
endpoint?.checkModel
|
|
1118
|
+
?? preset?.knownModels?.[0]
|
|
1119
|
+
?? endpoint?.models.find((model) => model.enabled !== false)?.id;
|
|
1120
|
+
const useDynamicLocalModels = baseService === "ollama";
|
|
1121
|
+
const useEndpointCheckModel = !useDynamicLocalModels
|
|
1122
|
+
&& !isCustomServiceId(args.service)
|
|
1123
|
+
&& discoveredModels.length === 0
|
|
1124
|
+
&& Boolean(endpoint?.checkModel);
|
|
1125
|
+
const configService = typeof llm.service === "string" ? llm.service : undefined;
|
|
1126
|
+
const configModel = !useEndpointCheckModel && configService === args.service
|
|
1127
|
+
? typeof llm.defaultModel === "string"
|
|
1128
|
+
? llm.defaultModel
|
|
1129
|
+
: typeof llm.model === "string"
|
|
1130
|
+
? llm.model
|
|
1131
|
+
: undefined
|
|
1132
|
+
: undefined;
|
|
1133
|
+
const useCustomFallbacks = false;
|
|
1134
|
+
const modelCandidates = buildModelCandidates({
|
|
1135
|
+
preferredModel: args.preferredModel ?? serviceFirstModel,
|
|
1136
|
+
configModel,
|
|
1137
|
+
envModel: useCustomFallbacks ? envModel : undefined,
|
|
1138
|
+
discoveredModels: useEndpointCheckModel ? [] : discoveredModels,
|
|
1139
|
+
includeGenericFallbacks: useCustomFallbacks,
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
if (modelCandidates.length === 0) {
|
|
1143
|
+
return {
|
|
1144
|
+
ok: false,
|
|
1145
|
+
models: [],
|
|
1146
|
+
error: "无法自动确定模型,请先填写可用模型或提供支持 /models 的服务端点。",
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
let lastError = modelsResponse.error ?? "自动探测失败";
|
|
1151
|
+
|
|
1152
|
+
for (const model of modelCandidates) {
|
|
1153
|
+
for (const plan of buildProbePlans(args.preferredApiFormat, args.preferredStream)) {
|
|
1154
|
+
const client = createLLMClient({
|
|
1155
|
+
provider: resolveServiceProviderFamily(baseService) ?? "openai",
|
|
1156
|
+
service: baseService,
|
|
1157
|
+
configSource: "studio",
|
|
1158
|
+
baseUrl: args.baseUrl,
|
|
1159
|
+
apiKey: args.apiKey.trim(),
|
|
1160
|
+
model,
|
|
1161
|
+
temperature: 0.7,
|
|
1162
|
+
maxTokens: 16,
|
|
1163
|
+
thinkingBudget: 0,
|
|
1164
|
+
proxyUrl: args.proxyUrl,
|
|
1165
|
+
apiFormat: plan.apiFormat,
|
|
1166
|
+
stream: plan.stream,
|
|
1167
|
+
} as ProjectConfig["llm"]);
|
|
1168
|
+
|
|
1169
|
+
try {
|
|
1170
|
+
await withTimeout(
|
|
1171
|
+
chatCompletion(client, model, [{ role: "user", content: "Reply with OK only." }], { maxTokens: 16 }),
|
|
1172
|
+
SERVICE_CHAT_PROBE_TIMEOUT_MS,
|
|
1173
|
+
"service connection test",
|
|
1174
|
+
);
|
|
1175
|
+
const models = discoveredModels.length > 0
|
|
1176
|
+
? discoveredModels
|
|
1177
|
+
: fallbackTextModelsForEndpoint(endpoint, preset);
|
|
1178
|
+
return {
|
|
1179
|
+
ok: true,
|
|
1180
|
+
models: models.length > 0 ? models : [{ id: model, name: model }],
|
|
1181
|
+
selectedModel: model,
|
|
1182
|
+
apiFormat: plan.apiFormat,
|
|
1183
|
+
stream: plan.stream,
|
|
1184
|
+
baseUrl: args.baseUrl,
|
|
1185
|
+
modelsSource: discoveredModels.length > 0 ? "api" : "fallback",
|
|
1186
|
+
};
|
|
1187
|
+
} catch (error) {
|
|
1188
|
+
lastError = formatServiceProbeError({
|
|
1189
|
+
service: baseService,
|
|
1190
|
+
label: endpoint?.label ?? preset?.label,
|
|
1191
|
+
baseUrl: args.baseUrl,
|
|
1192
|
+
model,
|
|
1193
|
+
apiFormat: plan.apiFormat,
|
|
1194
|
+
stream: plan.stream,
|
|
1195
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
return {
|
|
1202
|
+
ok: false,
|
|
1203
|
+
models: discoveredModels,
|
|
1204
|
+
error: lastError,
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// --- Server factory ---
|
|
1209
|
+
|
|
1210
|
+
export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
1211
|
+
const app = new Hono();
|
|
1212
|
+
const state = new StateManager(root);
|
|
1213
|
+
let cachedConfig = initialConfig;
|
|
1214
|
+
|
|
1215
|
+
app.use("/*", cors());
|
|
1216
|
+
|
|
1217
|
+
// Structured error handler — ApiError returns typed JSON, others return 500
|
|
1218
|
+
app.onError((error, c) => {
|
|
1219
|
+
if (error instanceof ApiError) {
|
|
1220
|
+
return c.json({ error: { code: error.code, message: error.message } }, error.status as 400);
|
|
1221
|
+
}
|
|
1222
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1223
|
+
if (message.includes("LLM API key not set") || message.includes("INKOS_LLM_API_KEY not set")) {
|
|
1224
|
+
return c.json({ error: { code: "LLM_CONFIG_ERROR", message } }, 400);
|
|
1225
|
+
}
|
|
1226
|
+
console.error("[studio] Unexpected server error", error);
|
|
1227
|
+
return c.json(
|
|
1228
|
+
{ error: { code: "INTERNAL_ERROR", message: "Unexpected server error." } },
|
|
1229
|
+
500,
|
|
1230
|
+
);
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
// BookId validation middleware — blocks path traversal on all book routes
|
|
1234
|
+
app.use("/api/v1/books/:id/*", async (c, next) => {
|
|
1235
|
+
const bookId = c.req.param("id");
|
|
1236
|
+
if (!isSafeBookId(bookId)) {
|
|
1237
|
+
throw new ApiError(400, "INVALID_BOOK_ID", `Invalid book ID: "${bookId}"`);
|
|
1238
|
+
}
|
|
1239
|
+
await next();
|
|
1240
|
+
});
|
|
1241
|
+
app.use("/api/v1/books/:id", async (c, next) => {
|
|
1242
|
+
const bookId = c.req.param("id");
|
|
1243
|
+
if (!isSafeBookId(bookId)) {
|
|
1244
|
+
throw new ApiError(400, "INVALID_BOOK_ID", `Invalid book ID: "${bookId}"`);
|
|
1245
|
+
}
|
|
1246
|
+
await next();
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
// Logger sink that broadcasts to SSE
|
|
1250
|
+
const sseSink: LogSink = {
|
|
1251
|
+
write(entry: LogEntry): void {
|
|
1252
|
+
broadcast("log", { level: entry.level, tag: entry.tag, message: entry.message });
|
|
1253
|
+
},
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
// Logger sink that prints to server terminal
|
|
1257
|
+
const consoleSink: LogSink = {
|
|
1258
|
+
write(entry: LogEntry): void {
|
|
1259
|
+
const prefix = `[${entry.tag}]`;
|
|
1260
|
+
if (entry.level === "warn") console.warn(prefix, entry.message);
|
|
1261
|
+
else if (entry.level === "error") console.error(prefix, entry.message);
|
|
1262
|
+
else console.log(prefix, entry.message);
|
|
1263
|
+
},
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
async function loadCurrentProjectConfig(
|
|
1267
|
+
options?: { readonly requireApiKey?: boolean },
|
|
1268
|
+
): Promise<ProjectConfig> {
|
|
1269
|
+
const freshConfig = await loadProjectConfig(root, { ...options, consumer: "studio" });
|
|
1270
|
+
cachedConfig = freshConfig;
|
|
1271
|
+
return freshConfig;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
async function buildPipelineConfig(
|
|
1275
|
+
overrides?: Partial<Pick<PipelineConfig, "externalContext" | "client" | "model">> & {
|
|
1276
|
+
readonly currentConfig?: ProjectConfig;
|
|
1277
|
+
readonly sessionIdForSSE?: string;
|
|
1278
|
+
},
|
|
1279
|
+
): Promise<PipelineConfig> {
|
|
1280
|
+
const currentConfig = overrides?.currentConfig ?? await loadCurrentProjectConfig();
|
|
1281
|
+
const scopedSseSink: LogSink = overrides?.sessionIdForSSE
|
|
1282
|
+
? {
|
|
1283
|
+
write(entry) {
|
|
1284
|
+
broadcast("log", {
|
|
1285
|
+
sessionId: overrides.sessionIdForSSE,
|
|
1286
|
+
level: entry.level,
|
|
1287
|
+
tag: entry.tag,
|
|
1288
|
+
message: entry.message,
|
|
1289
|
+
});
|
|
1290
|
+
},
|
|
1291
|
+
}
|
|
1292
|
+
: sseSink;
|
|
1293
|
+
const logger = createLogger({ tag: "studio", sinks: [scopedSseSink, consoleSink] });
|
|
1294
|
+
return {
|
|
1295
|
+
client: overrides?.client ?? createLLMClient(currentConfig.llm),
|
|
1296
|
+
model: overrides?.model ?? currentConfig.llm.model,
|
|
1297
|
+
projectRoot: root,
|
|
1298
|
+
defaultLLMConfig: currentConfig.llm,
|
|
1299
|
+
foundationReviewRetries: currentConfig.foundation?.reviewRetries ?? 2,
|
|
1300
|
+
writingReviewRetries: currentConfig.writing?.reviewRetries ?? 1,
|
|
1301
|
+
modelOverrides: currentConfig.modelOverrides,
|
|
1302
|
+
notifyChannels: currentConfig.notify,
|
|
1303
|
+
logger,
|
|
1304
|
+
onStreamProgress: (progress) => {
|
|
1305
|
+
broadcast("llm:progress", {
|
|
1306
|
+
...(overrides?.sessionIdForSSE ? { sessionId: overrides.sessionIdForSSE } : {}),
|
|
1307
|
+
status: progress.status,
|
|
1308
|
+
elapsedMs: progress.elapsedMs,
|
|
1309
|
+
totalChars: progress.totalChars,
|
|
1310
|
+
chineseChars: progress.chineseChars,
|
|
1311
|
+
});
|
|
1312
|
+
},
|
|
1313
|
+
externalContext: overrides?.externalContext,
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// --- Books ---
|
|
1318
|
+
|
|
1319
|
+
app.get("/api/v1/books", async (c) => {
|
|
1320
|
+
const bookIds = await state.listBooks();
|
|
1321
|
+
const books = await Promise.all(bookIds.map((id) => loadStudioBookListSummary(state, id)));
|
|
1322
|
+
return c.json({ books });
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
app.get("/api/v1/books/:id", async (c) => {
|
|
1326
|
+
const id = c.req.param("id");
|
|
1327
|
+
try {
|
|
1328
|
+
const book = await state.loadBookConfig(id);
|
|
1329
|
+
const chapters = await state.loadChapterIndex(id);
|
|
1330
|
+
const nextChapter = await state.getNextChapterNumber(id);
|
|
1331
|
+
return c.json({ book, chapters, nextChapter });
|
|
1332
|
+
} catch {
|
|
1333
|
+
return c.json({ error: `Book "${id}" not found` }, 404);
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
// --- Genres ---
|
|
1338
|
+
|
|
1339
|
+
app.get("/api/v1/genres", async (c) => {
|
|
1340
|
+
const { listAvailableGenres, readGenreProfile } = await import("@actalk/inkos-core");
|
|
1341
|
+
const rawGenres = await listAvailableGenres(root);
|
|
1342
|
+
const genres = await Promise.all(
|
|
1343
|
+
rawGenres.map(async (g) => {
|
|
1344
|
+
try {
|
|
1345
|
+
const { profile } = await readGenreProfile(root, g.id);
|
|
1346
|
+
return { ...g, language: profile.language ?? "zh" };
|
|
1347
|
+
} catch {
|
|
1348
|
+
return { ...g, language: "zh" };
|
|
1349
|
+
}
|
|
1350
|
+
}),
|
|
1351
|
+
);
|
|
1352
|
+
return c.json({ genres });
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
// --- Book Create ---
|
|
1356
|
+
|
|
1357
|
+
app.post("/api/v1/books/create", async (c) => {
|
|
1358
|
+
const body = await c.req.json<{
|
|
1359
|
+
title: string;
|
|
1360
|
+
genre: string;
|
|
1361
|
+
language?: string;
|
|
1362
|
+
platform?: string;
|
|
1363
|
+
chapterWordCount?: number;
|
|
1364
|
+
targetChapters?: number;
|
|
1365
|
+
blurb?: string;
|
|
1366
|
+
}>();
|
|
1367
|
+
|
|
1368
|
+
const now = new Date().toISOString();
|
|
1369
|
+
const bookConfig = buildStudioBookConfig(body, now);
|
|
1370
|
+
const bookId = bookConfig.id;
|
|
1371
|
+
const bookDir = state.bookDir(bookId);
|
|
1372
|
+
|
|
1373
|
+
try {
|
|
1374
|
+
await access(join(bookDir, "book.json"));
|
|
1375
|
+
await access(join(bookDir, "story", "story_bible.md"));
|
|
1376
|
+
return c.json({ error: `Book "${bookId}" already exists` }, 409);
|
|
1377
|
+
} catch {
|
|
1378
|
+
// The target book is not fully initialized yet, so creation can continue.
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
broadcast("book:creating", { bookId, title: body.title });
|
|
1382
|
+
bookCreateStatus.set(bookId, { status: "creating" });
|
|
1383
|
+
|
|
1384
|
+
const pipeline = new PipelineRunner(await buildPipelineConfig());
|
|
1385
|
+
const tools = createInteractionToolsFromDeps(pipeline, state);
|
|
1386
|
+
processProjectInteractionRequest({
|
|
1387
|
+
projectRoot: root,
|
|
1388
|
+
request: {
|
|
1389
|
+
intent: "create_book",
|
|
1390
|
+
title: body.title,
|
|
1391
|
+
genre: body.genre,
|
|
1392
|
+
language: body.language === "en" ? "en" : body.language === "zh" ? "zh" : undefined,
|
|
1393
|
+
platform: body.platform,
|
|
1394
|
+
chapterWordCount: body.chapterWordCount,
|
|
1395
|
+
targetChapters: body.targetChapters,
|
|
1396
|
+
blurb: body.blurb,
|
|
1397
|
+
},
|
|
1398
|
+
tools,
|
|
1399
|
+
}).then(
|
|
1400
|
+
async (result: {
|
|
1401
|
+
readonly session: { readonly activeBookId?: string };
|
|
1402
|
+
readonly details?: Readonly<Record<string, unknown>>;
|
|
1403
|
+
}) => {
|
|
1404
|
+
const createdBookId = (result.details?.bookId as string | undefined) ?? result.session.activeBookId ?? bookId;
|
|
1405
|
+
const book = await loadStudioBookListSummary(state, createdBookId).catch(() => undefined);
|
|
1406
|
+
bookCreateStatus.delete(createdBookId);
|
|
1407
|
+
broadcast("book:created", { bookId: createdBookId, ...(book ? { book } : {}) });
|
|
1408
|
+
},
|
|
1409
|
+
(e: unknown) => {
|
|
1410
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
1411
|
+
bookCreateStatus.set(bookId, { status: "error", error });
|
|
1412
|
+
broadcast("book:error", { bookId, error });
|
|
1413
|
+
},
|
|
1414
|
+
);
|
|
1415
|
+
|
|
1416
|
+
return c.json({ status: "creating", bookId });
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
app.get("/api/v1/books/:id/create-status", async (c) => {
|
|
1420
|
+
const id = c.req.param("id");
|
|
1421
|
+
const status = bookCreateStatus.get(id);
|
|
1422
|
+
if (!status) {
|
|
1423
|
+
return c.json({ status: "missing" }, 404);
|
|
1424
|
+
}
|
|
1425
|
+
return c.json(status);
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
// --- Chapters ---
|
|
1429
|
+
|
|
1430
|
+
app.get("/api/v1/books/:id/chapters/:num", async (c) => {
|
|
1431
|
+
const id = c.req.param("id");
|
|
1432
|
+
const num = parseInt(c.req.param("num"), 10);
|
|
1433
|
+
const bookDir = state.bookDir(id);
|
|
1434
|
+
const chaptersDir = join(bookDir, "chapters");
|
|
1435
|
+
|
|
1436
|
+
try {
|
|
1437
|
+
const files = await readdir(chaptersDir);
|
|
1438
|
+
const paddedNum = String(num).padStart(4, "0");
|
|
1439
|
+
const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
|
|
1440
|
+
if (!match) return c.json({ error: "Chapter not found" }, 404);
|
|
1441
|
+
const content = await readFile(join(chaptersDir, match), "utf-8");
|
|
1442
|
+
return c.json({ chapterNumber: num, filename: match, content });
|
|
1443
|
+
} catch {
|
|
1444
|
+
return c.json({ error: "Chapter not found" }, 404);
|
|
1445
|
+
}
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
// --- Chapter Save ---
|
|
1449
|
+
|
|
1450
|
+
app.put("/api/v1/books/:id/chapters/:num", async (c) => {
|
|
1451
|
+
const id = c.req.param("id");
|
|
1452
|
+
const num = parseInt(c.req.param("num"), 10);
|
|
1453
|
+
const bookDir = state.bookDir(id);
|
|
1454
|
+
const chaptersDir = join(bookDir, "chapters");
|
|
1455
|
+
const { content } = await c.req.json<{ content: string }>();
|
|
1456
|
+
|
|
1457
|
+
try {
|
|
1458
|
+
const files = await readdir(chaptersDir);
|
|
1459
|
+
const paddedNum = String(num).padStart(4, "0");
|
|
1460
|
+
const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
|
|
1461
|
+
if (!match) return c.json({ error: "Chapter not found" }, 404);
|
|
1462
|
+
|
|
1463
|
+
const { writeFile: writeFileFs } = await import("node:fs/promises");
|
|
1464
|
+
await writeFileFs(join(chaptersDir, match), content, "utf-8");
|
|
1465
|
+
return c.json({ ok: true, chapterNumber: num });
|
|
1466
|
+
} catch (e) {
|
|
1467
|
+
return c.json({ error: String(e) }, 500);
|
|
1468
|
+
}
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
// --- Truth files ---
|
|
1472
|
+
|
|
1473
|
+
// Flat-file whitelist — the pre-Phase-5 story root files plus dev's legacy
|
|
1474
|
+
// editor targets (author_intent / current_focus / volume_outline).
|
|
1475
|
+
//
|
|
1476
|
+
// Phase 5 cleanup #3 moved the authoritative YAML frontmatter + outline prose
|
|
1477
|
+
// into story/outline/ and character sheets into story/roles/. `story_bible.md`
|
|
1478
|
+
// and `book_rules.md` now exist only as compat pointer shims — we still allow
|
|
1479
|
+
// reading them so legacy books keep rendering, but the server-side writer
|
|
1480
|
+
// (write_truth_file) no longer accepts them as edit targets.
|
|
1481
|
+
const TRUTH_FLAT_FILES = [
|
|
1482
|
+
"author_intent.md", "current_focus.md",
|
|
1483
|
+
"story_bible.md", "book_rules.md", "volume_outline.md", "current_state.md",
|
|
1484
|
+
"particle_ledger.md", "pending_hooks.md", "chapter_summaries.md",
|
|
1485
|
+
"subplot_board.md", "emotional_arcs.md", "character_matrix.md",
|
|
1486
|
+
"style_guide.md", "parent_canon.md", "fanfic_canon.md",
|
|
1487
|
+
];
|
|
1488
|
+
|
|
1489
|
+
// Authoritative Phase 5 paths — prose outline + role sheets live under
|
|
1490
|
+
// dedicated subdirectories of story/. The full path (relative to story/) is
|
|
1491
|
+
// matched literally here. `节奏原则.md` / `rhythm_principles.md` is optional
|
|
1492
|
+
// after Phase 5 consolidation (rhythm lives in volume_map's closing paragraph);
|
|
1493
|
+
// the entries stay whitelisted for legacy books and manual overrides.
|
|
1494
|
+
const TRUTH_OUTLINE_FILES = [
|
|
1495
|
+
"outline/story_frame.md",
|
|
1496
|
+
"outline/volume_map.md",
|
|
1497
|
+
"outline/节奏原则.md",
|
|
1498
|
+
"outline/rhythm_principles.md",
|
|
1499
|
+
];
|
|
1500
|
+
|
|
1501
|
+
// Pointer shims that the runtime no longer treats as authoritative. The
|
|
1502
|
+
// GET handler tags them with `legacy: true` so the UI can surface that the
|
|
1503
|
+
// edits won't land where the user expects.
|
|
1504
|
+
const LEGACY_SHIM_FILES = new Set(["story_bible.md", "book_rules.md"]);
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* Validate a requested truth-file path:
|
|
1508
|
+
* 1. Must be one of the declared flat files, an outline/* allow-listed
|
|
1509
|
+
* entry, or a roles/**\/*.md file under 主要角色/ | 次要角色/.
|
|
1510
|
+
* 2. Must resolve to a path inside bookDir/story/ (no `..`, no absolute
|
|
1511
|
+
* paths, no traversal via the tier-name segment).
|
|
1512
|
+
*/
|
|
1513
|
+
function resolveTruthFilePath(bookDir: string, file: string): string | null {
|
|
1514
|
+
// Reject absolute paths, traversal, null bytes outright.
|
|
1515
|
+
if (!file || file.includes("\0") || isAbsolute(file) || file.includes("..")) {
|
|
1516
|
+
return null;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// Phase hotfix 3: accept both Chinese and English locale role dirs so
|
|
1520
|
+
// English-layout books (roles/major, roles/minor) are reachable through
|
|
1521
|
+
// Studio. The runtime reader (utils/outline-paths.ts:75) already scans
|
|
1522
|
+
// both — Studio used to drop English books to read-only.
|
|
1523
|
+
const allowed =
|
|
1524
|
+
TRUTH_FLAT_FILES.includes(file)
|
|
1525
|
+
|| TRUTH_OUTLINE_FILES.includes(file)
|
|
1526
|
+
|| /^roles\/(主要角色|次要角色|major|minor)\/[^/]+\.md$/.test(file);
|
|
1527
|
+
|
|
1528
|
+
if (!allowed) return null;
|
|
1529
|
+
|
|
1530
|
+
const storyDir = resolve(bookDir, "story");
|
|
1531
|
+
const resolved = resolve(storyDir, file);
|
|
1532
|
+
const relativePath = relative(storyDir, resolved);
|
|
1533
|
+
if (relativePath === "" || relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
|
1534
|
+
return null;
|
|
1535
|
+
}
|
|
1536
|
+
return resolved;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
1540
|
+
try {
|
|
1541
|
+
await access(path);
|
|
1542
|
+
return true;
|
|
1543
|
+
} catch {
|
|
1544
|
+
return false;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// Use `:file{.+}` wildcard so nested paths (outline/..., roles/.../...) match.
|
|
1549
|
+
app.get("/api/v1/books/:id/truth/:file{.+}", async (c) => {
|
|
1550
|
+
const file = c.req.param("file");
|
|
1551
|
+
const id = c.req.param("id");
|
|
1552
|
+
|
|
1553
|
+
const bookDir = state.bookDir(id);
|
|
1554
|
+
const resolved = resolveTruthFilePath(bookDir, file);
|
|
1555
|
+
if (!resolved) {
|
|
1556
|
+
return c.json({ error: "Invalid truth file" }, 400);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// Phase 5: new-layout books keep the authoritative prose under outline/.
|
|
1560
|
+
// A legacy book may only have story_bible.md / book_rules.md on disk —
|
|
1561
|
+
// we still serve those for read-only display, but flag them so the UI
|
|
1562
|
+
// can warn users their edits won't reach the runtime.
|
|
1563
|
+
// Hotfix: only tag as legacy when the book actually HAS the new layout.
|
|
1564
|
+
// Pre-Phase-5 books use story_bible/book_rules as the authoritative source.
|
|
1565
|
+
const { isNewLayoutBook } = await import("@actalk/inkos-core");
|
|
1566
|
+
const legacy = LEGACY_SHIM_FILES.has(file) && await isNewLayoutBook(bookDir);
|
|
1567
|
+
|
|
1568
|
+
try {
|
|
1569
|
+
const content = await readFile(resolved, "utf-8");
|
|
1570
|
+
return c.json({ file, content, ...(legacy ? { legacy: true } : {}) });
|
|
1571
|
+
} catch {
|
|
1572
|
+
return c.json({ file, content: null, ...(legacy ? { legacy: true } : {}) });
|
|
1573
|
+
}
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
// --- Analytics ---
|
|
1577
|
+
|
|
1578
|
+
app.get("/api/v1/books/:id/analytics", async (c) => {
|
|
1579
|
+
const id = c.req.param("id");
|
|
1580
|
+
try {
|
|
1581
|
+
const chapters = await state.loadChapterIndex(id);
|
|
1582
|
+
return c.json(computeAnalytics(id, chapters));
|
|
1583
|
+
} catch {
|
|
1584
|
+
return c.json({ error: `Book "${id}" not found` }, 404);
|
|
1585
|
+
}
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
// --- Actions ---
|
|
1589
|
+
|
|
1590
|
+
app.post("/api/v1/books/:id/write-next", async (c) => {
|
|
1591
|
+
const id = c.req.param("id");
|
|
1592
|
+
const body = await c.req.json<{ wordCount?: number }>().catch(() => ({ wordCount: undefined }));
|
|
1593
|
+
|
|
1594
|
+
broadcast("write:start", { bookId: id });
|
|
1595
|
+
|
|
1596
|
+
// Fire and forget — progress/completion/errors pushed via SSE
|
|
1597
|
+
const pipeline = new PipelineRunner(await buildPipelineConfig());
|
|
1598
|
+
pipeline.writeNextChapter(id, body.wordCount).then(
|
|
1599
|
+
(result) => {
|
|
1600
|
+
broadcast("write:complete", { bookId: id, chapterNumber: result.chapterNumber, status: result.status, title: result.title, wordCount: result.wordCount });
|
|
1601
|
+
},
|
|
1602
|
+
(e) => {
|
|
1603
|
+
broadcast("write:error", { bookId: id, error: e instanceof Error ? e.message : String(e) });
|
|
1604
|
+
},
|
|
1605
|
+
);
|
|
1606
|
+
|
|
1607
|
+
return c.json({ status: "writing", bookId: id });
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1610
|
+
app.post("/api/v1/books/:id/draft", async (c) => {
|
|
1611
|
+
const id = c.req.param("id");
|
|
1612
|
+
const body = await c.req.json<{ wordCount?: number; context?: string }>().catch(() => ({ wordCount: undefined, context: undefined }));
|
|
1613
|
+
|
|
1614
|
+
broadcast("draft:start", { bookId: id });
|
|
1615
|
+
|
|
1616
|
+
const pipeline = new PipelineRunner(await buildPipelineConfig());
|
|
1617
|
+
pipeline.writeDraft(id, body.context, body.wordCount).then(
|
|
1618
|
+
(result) => {
|
|
1619
|
+
broadcast("draft:complete", { bookId: id, chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount });
|
|
1620
|
+
},
|
|
1621
|
+
(e) => {
|
|
1622
|
+
broadcast("draft:error", { bookId: id, error: e instanceof Error ? e.message : String(e) });
|
|
1623
|
+
},
|
|
1624
|
+
);
|
|
1625
|
+
|
|
1626
|
+
return c.json({ status: "drafting", bookId: id });
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
app.post("/api/v1/books/:id/chapters/:num/approve", async (c) => {
|
|
1630
|
+
const id = c.req.param("id");
|
|
1631
|
+
const num = parseInt(c.req.param("num"), 10);
|
|
1632
|
+
|
|
1633
|
+
try {
|
|
1634
|
+
const index = await state.loadChapterIndex(id);
|
|
1635
|
+
const updated = index.map((ch) =>
|
|
1636
|
+
ch.number === num ? { ...ch, status: "approved" as const } : ch,
|
|
1637
|
+
);
|
|
1638
|
+
await state.saveChapterIndex(id, updated);
|
|
1639
|
+
return c.json({ ok: true, chapterNumber: num, status: "approved" });
|
|
1640
|
+
} catch (e) {
|
|
1641
|
+
return c.json({ error: String(e) }, 500);
|
|
1642
|
+
}
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
app.post("/api/v1/books/:id/chapters/:num/reject", async (c) => {
|
|
1646
|
+
const id = c.req.param("id");
|
|
1647
|
+
const num = parseInt(c.req.param("num"), 10);
|
|
1648
|
+
|
|
1649
|
+
try {
|
|
1650
|
+
const index = await state.loadChapterIndex(id);
|
|
1651
|
+
const target = index.find((ch) => ch.number === num);
|
|
1652
|
+
if (!target) {
|
|
1653
|
+
return c.json({ error: `Chapter ${num} not found` }, 404);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
const rollbackTarget = num - 1;
|
|
1657
|
+
const discarded = await state.rollbackToChapter(id, rollbackTarget);
|
|
1658
|
+
return c.json({
|
|
1659
|
+
ok: true,
|
|
1660
|
+
chapterNumber: num,
|
|
1661
|
+
status: "rejected",
|
|
1662
|
+
rolledBackTo: rollbackTarget,
|
|
1663
|
+
discarded,
|
|
1664
|
+
});
|
|
1665
|
+
} catch (e) {
|
|
1666
|
+
return c.json({ error: String(e) }, 500);
|
|
1667
|
+
}
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
// --- SSE ---
|
|
1671
|
+
|
|
1672
|
+
app.get("/api/v1/events", (c) => {
|
|
1673
|
+
return streamSSE(c, async (stream) => {
|
|
1674
|
+
const handler: EventHandler = (event, data) => {
|
|
1675
|
+
stream.writeSSE({ event, data: JSON.stringify(data) });
|
|
1676
|
+
};
|
|
1677
|
+
subscribers.add(handler);
|
|
1678
|
+
await stream.writeSSE({ event: "ping", data: "" });
|
|
1679
|
+
|
|
1680
|
+
// Keep alive
|
|
1681
|
+
const keepAlive = setInterval(() => {
|
|
1682
|
+
stream.writeSSE({ event: "ping", data: "" });
|
|
1683
|
+
}, 30000);
|
|
1684
|
+
|
|
1685
|
+
stream.onAbort(() => {
|
|
1686
|
+
subscribers.delete(handler);
|
|
1687
|
+
clearInterval(keepAlive);
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
// Block until aborted
|
|
1691
|
+
await new Promise(() => {});
|
|
1692
|
+
});
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
// --- Model discovery ---
|
|
1696
|
+
|
|
1697
|
+
app.get("/api/v1/services", async (c) => {
|
|
1698
|
+
const secrets = await loadSecrets(root);
|
|
1699
|
+
const endpoints = getAllEndpoints().filter((ep) => ep.id !== "custom");
|
|
1700
|
+
|
|
1701
|
+
// Fast: only check connection status from secrets, no external API calls.
|
|
1702
|
+
const services = endpoints.map((ep) => ({
|
|
1703
|
+
service: ep.id,
|
|
1704
|
+
label: ep.label,
|
|
1705
|
+
group: ep.group,
|
|
1706
|
+
connected: Boolean(secrets.services[ep.id]?.apiKey),
|
|
1707
|
+
})).sort(compareServiceListItems);
|
|
1708
|
+
|
|
1709
|
+
// Add custom services from inkos.json
|
|
1710
|
+
try {
|
|
1711
|
+
const config = await loadRawConfig(root);
|
|
1712
|
+
for (const svc of normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services)) {
|
|
1713
|
+
if (svc.service === "custom") {
|
|
1714
|
+
const secretKey = `custom:${svc.name}`;
|
|
1715
|
+
services.push({
|
|
1716
|
+
service: secretKey,
|
|
1717
|
+
label: svc.name ?? "Custom",
|
|
1718
|
+
group: undefined,
|
|
1719
|
+
connected: Boolean(secrets.services[secretKey]?.apiKey),
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
} catch { /* no config file */ }
|
|
1724
|
+
|
|
1725
|
+
return c.json({ services });
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
app.get("/api/v1/services/config", async (c) => {
|
|
1729
|
+
const config = await loadRawConfig(root);
|
|
1730
|
+
const llm = (config.llm as Record<string, unknown> | undefined) ?? {};
|
|
1731
|
+
const services = normalizeServiceConfig(llm.services);
|
|
1732
|
+
const envConfig = await readEnvConfigStatus(root);
|
|
1733
|
+
return c.json({
|
|
1734
|
+
services,
|
|
1735
|
+
service: typeof llm.service === "string" ? llm.service : null,
|
|
1736
|
+
defaultModel: llm.defaultModel ?? null,
|
|
1737
|
+
configSource: "studio" satisfies LLMConfigSource,
|
|
1738
|
+
storedConfigSource: normalizeConfigSource(llm.configSource),
|
|
1739
|
+
envConfig,
|
|
1740
|
+
});
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
app.put("/api/v1/services/config", async (c) => {
|
|
1744
|
+
const body = await c.req.json<{ services?: unknown; defaultModel?: string; configSource?: LLMConfigSource; service?: string }>();
|
|
1745
|
+
const config = await loadRawConfig(root);
|
|
1746
|
+
config.llm = config.llm ?? {};
|
|
1747
|
+
const llm = config.llm as Record<string, unknown>;
|
|
1748
|
+
if (body.services !== undefined) {
|
|
1749
|
+
const existingServices = normalizeServiceConfig(llm.services);
|
|
1750
|
+
const incomingServices = normalizeServiceConfig(body.services);
|
|
1751
|
+
llm.services = mergeServiceConfig(existingServices, incomingServices);
|
|
1752
|
+
}
|
|
1753
|
+
if (body.defaultModel !== undefined) {
|
|
1754
|
+
llm.defaultModel = body.defaultModel;
|
|
1755
|
+
}
|
|
1756
|
+
if (body.configSource === "env") {
|
|
1757
|
+
return c.json({
|
|
1758
|
+
error: "Studio 运行时不支持切换到 env;env 只在 CLI/daemon/部署运行时作为覆盖层使用。",
|
|
1759
|
+
}, 400);
|
|
1760
|
+
}
|
|
1761
|
+
if (body.configSource !== undefined) {
|
|
1762
|
+
llm.configSource = normalizeConfigSource(body.configSource);
|
|
1763
|
+
}
|
|
1764
|
+
if (body.service !== undefined) {
|
|
1765
|
+
llm.service = body.service;
|
|
1766
|
+
}
|
|
1767
|
+
syncTopLevelLlmMirror(llm);
|
|
1768
|
+
await saveRawConfig(root, config);
|
|
1769
|
+
return c.json({ ok: true });
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
app.get("/api/v1/cover/config", async (c) => {
|
|
1773
|
+
const config = await loadRawConfig(root);
|
|
1774
|
+
const llm = (config.llm as Record<string, unknown> | undefined) ?? {};
|
|
1775
|
+
const cover = normalizeCoverConfig(llm.cover);
|
|
1776
|
+
const secrets = await loadSecrets(root);
|
|
1777
|
+
return c.json({
|
|
1778
|
+
service: cover?.service ?? null,
|
|
1779
|
+
model: cover?.model ?? null,
|
|
1780
|
+
providers: COVER_PROVIDER_PRESETS.map((provider) => ({
|
|
1781
|
+
service: provider.service,
|
|
1782
|
+
label: provider.label,
|
|
1783
|
+
baseUrl: provider.baseUrl,
|
|
1784
|
+
defaultModel: provider.defaultModel,
|
|
1785
|
+
models: provider.models,
|
|
1786
|
+
connected: Boolean(secrets.services[coverSecretKey(provider.service)]?.apiKey || secrets.services[provider.service]?.apiKey),
|
|
1787
|
+
})),
|
|
1788
|
+
});
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
app.put("/api/v1/cover/config", async (c) => {
|
|
1792
|
+
const body = await c.req.json<{ service?: string; model?: string }>();
|
|
1793
|
+
const preset = resolveCoverProviderPreset(body.service);
|
|
1794
|
+
if (!preset) {
|
|
1795
|
+
return c.json({ error: "Unsupported cover service" }, 400);
|
|
1796
|
+
}
|
|
1797
|
+
const model = typeof body.model === "string" && preset.models.includes(body.model)
|
|
1798
|
+
? body.model
|
|
1799
|
+
: preset.defaultModel;
|
|
1800
|
+
|
|
1801
|
+
const config = await loadRawConfig(root);
|
|
1802
|
+
config.llm = config.llm ?? {};
|
|
1803
|
+
const llm = config.llm as Record<string, unknown>;
|
|
1804
|
+
llm.cover = {
|
|
1805
|
+
service: preset.service,
|
|
1806
|
+
model,
|
|
1807
|
+
};
|
|
1808
|
+
await saveRawConfig(root, config);
|
|
1809
|
+
return c.json({ ok: true, service: preset.service, model });
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
app.get("/api/v1/cover/secret/:service", async (c) => {
|
|
1813
|
+
const service = c.req.param("service");
|
|
1814
|
+
if (!resolveCoverProviderPreset(service)) {
|
|
1815
|
+
return c.json({ error: "Unsupported cover service" }, 400);
|
|
1816
|
+
}
|
|
1817
|
+
const secrets = await loadSecrets(root);
|
|
1818
|
+
return c.json({ apiKey: secrets.services[coverSecretKey(service)]?.apiKey ?? "" });
|
|
1819
|
+
});
|
|
1820
|
+
|
|
1821
|
+
app.put("/api/v1/cover/secret/:service", async (c) => {
|
|
1822
|
+
const service = c.req.param("service");
|
|
1823
|
+
if (!resolveCoverProviderPreset(service)) {
|
|
1824
|
+
return c.json({ error: "Unsupported cover service" }, 400);
|
|
1825
|
+
}
|
|
1826
|
+
const body = await c.req.json<{ apiKey?: string }>();
|
|
1827
|
+
const trimmedKey = body.apiKey?.trim() ?? "";
|
|
1828
|
+
if (trimmedKey && !isHeaderSafeApiKey(trimmedKey)) {
|
|
1829
|
+
return c.json({ error: "API Key 包含不能放入 HTTP Authorization header 的字符,请只粘贴原始密钥。" }, 400);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
const secrets = await loadSecrets(root);
|
|
1833
|
+
const key = coverSecretKey(service);
|
|
1834
|
+
if (trimmedKey) {
|
|
1835
|
+
secrets.services[key] = { apiKey: trimmedKey };
|
|
1836
|
+
} else {
|
|
1837
|
+
delete secrets.services[key];
|
|
1838
|
+
}
|
|
1839
|
+
await saveSecrets(root, secrets);
|
|
1840
|
+
return c.json({ ok: true, service });
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
app.delete("/api/v1/services/:service", async (c) => {
|
|
1844
|
+
const service = c.req.param("service");
|
|
1845
|
+
const config = await loadRawConfig(root);
|
|
1846
|
+
const llm = (config.llm as Record<string, unknown> | undefined) ?? {};
|
|
1847
|
+
const existingServices = normalizeServiceConfig(llm.services);
|
|
1848
|
+
const nextServices = existingServices.filter((entry) => serviceConfigKey(entry) !== service);
|
|
1849
|
+
|
|
1850
|
+
if (!config.llm) config.llm = {};
|
|
1851
|
+
const nextLlm = config.llm as Record<string, unknown>;
|
|
1852
|
+
nextLlm.services = nextServices;
|
|
1853
|
+
if (nextLlm.service === service) {
|
|
1854
|
+
delete nextLlm.service;
|
|
1855
|
+
delete nextLlm.defaultModel;
|
|
1856
|
+
}
|
|
1857
|
+
await saveRawConfig(root, config);
|
|
1858
|
+
|
|
1859
|
+
const secrets = await loadSecrets(root);
|
|
1860
|
+
delete secrets.services[service];
|
|
1861
|
+
await saveSecrets(root, secrets);
|
|
1862
|
+
modelListCache.clear();
|
|
1863
|
+
return c.json({ ok: true, service });
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
app.post("/api/v1/services/:service/test", async (c) => {
|
|
1867
|
+
const service = c.req.param("service");
|
|
1868
|
+
const { apiKey, baseUrl, apiFormat, stream } = await c.req.json<{
|
|
1869
|
+
apiKey: string;
|
|
1870
|
+
baseUrl?: string;
|
|
1871
|
+
apiFormat?: "chat" | "responses";
|
|
1872
|
+
stream?: boolean;
|
|
1873
|
+
}>();
|
|
1874
|
+
|
|
1875
|
+
const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service, baseUrl);
|
|
1876
|
+
if (!resolvedBaseUrl) {
|
|
1877
|
+
return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
const baseService = isCustomServiceId(service) ? "custom" : service;
|
|
1881
|
+
const apiKeyOptional = isApiKeyOptionalForEndpoint({
|
|
1882
|
+
provider: resolveServiceProviderFamily(baseService) ?? "openai",
|
|
1883
|
+
baseUrl: resolvedBaseUrl,
|
|
1884
|
+
});
|
|
1885
|
+
if (!apiKey?.trim() && !apiKeyOptional) {
|
|
1886
|
+
return c.json({
|
|
1887
|
+
ok: false,
|
|
1888
|
+
error: "API Key 不能为空",
|
|
1889
|
+
}, 400);
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
const rawConfig = await loadRawConfig(root).catch(() => ({} as Record<string, unknown>));
|
|
1893
|
+
const llm = (rawConfig.llm as Record<string, unknown> | undefined) ?? {};
|
|
1894
|
+
const probe = await probeServiceCapabilities({
|
|
1895
|
+
root,
|
|
1896
|
+
service,
|
|
1897
|
+
apiKey: apiKey?.trim() ?? "",
|
|
1898
|
+
baseUrl: resolvedBaseUrl,
|
|
1899
|
+
preferredApiFormat: apiFormat,
|
|
1900
|
+
preferredStream: stream,
|
|
1901
|
+
proxyUrl: typeof llm.proxyUrl === "string" ? llm.proxyUrl : undefined,
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
// B12: 升级响应 shape 为 { probe, chat, ... },同时保留老字段供 UI 过渡期兼容
|
|
1905
|
+
const probeStatus = {
|
|
1906
|
+
ok: probe.ok,
|
|
1907
|
+
models: probe.models?.length ?? 0,
|
|
1908
|
+
...(probe.ok ? {} : { error: probe.error ?? "连接失败" }),
|
|
1909
|
+
};
|
|
1910
|
+
|
|
1911
|
+
if (!probe.ok) {
|
|
1912
|
+
return c.json({
|
|
1913
|
+
ok: false,
|
|
1914
|
+
error: probe.error ?? "连接失败",
|
|
1915
|
+
probe: probeStatus,
|
|
1916
|
+
chat: null,
|
|
1917
|
+
}, 400);
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
return c.json({
|
|
1921
|
+
ok: true,
|
|
1922
|
+
modelCount: probe.models.length,
|
|
1923
|
+
models: probe.models,
|
|
1924
|
+
selectedModel: probe.selectedModel,
|
|
1925
|
+
detected: {
|
|
1926
|
+
apiFormat: probe.apiFormat,
|
|
1927
|
+
stream: probe.stream,
|
|
1928
|
+
baseUrl: probe.baseUrl,
|
|
1929
|
+
modelsSource: probe.modelsSource,
|
|
1930
|
+
},
|
|
1931
|
+
// B12 新字段:两步验证状态
|
|
1932
|
+
probe: probeStatus,
|
|
1933
|
+
chat: null, // probeServiceCapabilities 本身只做 probe,chat hello 在 Studio 的 follow-up 调用里单独触发
|
|
1934
|
+
});
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
app.put("/api/v1/services/:service/secret", async (c) => {
|
|
1938
|
+
const service = c.req.param("service");
|
|
1939
|
+
const { apiKey } = await c.req.json<{ apiKey: string }>();
|
|
1940
|
+
const secrets = await loadSecrets(root);
|
|
1941
|
+
const trimmedKey = apiKey?.trim() ?? "";
|
|
1942
|
+
if (trimmedKey) {
|
|
1943
|
+
if (!isHeaderSafeApiKey(trimmedKey)) {
|
|
1944
|
+
return c.json({
|
|
1945
|
+
ok: false,
|
|
1946
|
+
error: "API Key 只能包含可放进 HTTP Authorization header 的非空白 ASCII 字符;请不要粘贴连接失败提示或诊断文本。",
|
|
1947
|
+
}, 400);
|
|
1948
|
+
}
|
|
1949
|
+
secrets.services[service] = { apiKey: trimmedKey };
|
|
1950
|
+
} else {
|
|
1951
|
+
delete secrets.services[service];
|
|
1952
|
+
}
|
|
1953
|
+
await saveSecrets(root, secrets);
|
|
1954
|
+
return c.json({ ok: true });
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
app.get("/api/v1/services/:service/secret", async (c) => {
|
|
1958
|
+
const service = c.req.param("service");
|
|
1959
|
+
const secrets = await loadSecrets(root);
|
|
1960
|
+
return c.json({
|
|
1961
|
+
apiKey: secrets.services[service]?.apiKey ?? "",
|
|
1962
|
+
});
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
app.get("/api/v1/services/models", async (c) => {
|
|
1966
|
+
const secrets = await loadSecrets(root);
|
|
1967
|
+
const endpoints = getAllEndpoints()
|
|
1968
|
+
.filter((ep) => ep.id !== "custom" && Boolean(secrets.services[ep.id]?.apiKey));
|
|
1969
|
+
|
|
1970
|
+
const groups = endpoints.map((ep) => ({
|
|
1971
|
+
service: ep.id,
|
|
1972
|
+
label: ep.label,
|
|
1973
|
+
models: ep.models
|
|
1974
|
+
.filter((m) => m.enabled !== false)
|
|
1975
|
+
.filter((m) => isTextChatModelId(m.id))
|
|
1976
|
+
.map((m) => ({
|
|
1977
|
+
id: m.id,
|
|
1978
|
+
name: m.id,
|
|
1979
|
+
...(typeof m.maxOutput === "number" ? { maxOutput: m.maxOutput } : {}),
|
|
1980
|
+
...(m.contextWindowTokens > 0 ? { contextWindow: m.contextWindowTokens } : {}),
|
|
1981
|
+
})),
|
|
1982
|
+
}));
|
|
1983
|
+
|
|
1984
|
+
return c.json({ groups });
|
|
1985
|
+
});
|
|
1986
|
+
|
|
1987
|
+
app.get("/api/v1/services/models/custom", async (c) => {
|
|
1988
|
+
const secrets = await loadSecrets(root);
|
|
1989
|
+
let config: Record<string, unknown> = {};
|
|
1990
|
+
try {
|
|
1991
|
+
config = await loadRawConfig(root);
|
|
1992
|
+
} catch {
|
|
1993
|
+
// no config file
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
const customs = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services)
|
|
1997
|
+
.filter((s) => s.service === "custom")
|
|
1998
|
+
.map((s) => ({
|
|
1999
|
+
id: `custom:${s.name ?? "Custom"}`,
|
|
2000
|
+
baseUrl: s.baseUrl ?? "",
|
|
2001
|
+
label: s.name ?? "Custom",
|
|
2002
|
+
}))
|
|
2003
|
+
.filter((s) => s.baseUrl && Boolean(secrets.services[s.id]?.apiKey));
|
|
2004
|
+
|
|
2005
|
+
const groups = await Promise.all(customs.map(async (s) => ({
|
|
2006
|
+
service: s.id,
|
|
2007
|
+
label: s.label,
|
|
2008
|
+
models: filterTextChatModels(
|
|
2009
|
+
await probeModelsFromUpstream(s.baseUrl, secrets.services[s.id].apiKey, 10_000),
|
|
2010
|
+
),
|
|
2011
|
+
})));
|
|
2012
|
+
|
|
2013
|
+
return c.json({ groups });
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
app.get("/api/v1/services/:service/models", async (c) => {
|
|
2017
|
+
const service = c.req.param("service");
|
|
2018
|
+
const refresh = c.req.query("refresh") === "1";
|
|
2019
|
+
const secrets = await loadSecrets(root);
|
|
2020
|
+
const apiKey = c.req.query("apiKey") || secrets.services[service]?.apiKey || "";
|
|
2021
|
+
|
|
2022
|
+
const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service);
|
|
2023
|
+
const baseService = isCustomServiceId(service) ? "custom" : service;
|
|
2024
|
+
const apiKeyOptional = isApiKeyOptionalForEndpoint({
|
|
2025
|
+
provider: resolveServiceProviderFamily(baseService) ?? "openai",
|
|
2026
|
+
baseUrl: resolvedBaseUrl,
|
|
2027
|
+
});
|
|
2028
|
+
|
|
2029
|
+
// No key = no models, except local/self-hosted endpoints such as Ollama.
|
|
2030
|
+
if (!apiKey && !apiKeyOptional) return c.json({ models: [] });
|
|
2031
|
+
|
|
2032
|
+
// Cache by service + resolved baseUrl + apiKey fingerprint; valid for 10 min unless ?refresh=1
|
|
2033
|
+
const cacheKey = `${service}::${resolvedBaseUrl ?? ""}::${apiKey.slice(-8)}`;
|
|
2034
|
+
if (!refresh) {
|
|
2035
|
+
const cached = modelListCache.get(cacheKey);
|
|
2036
|
+
if (cached && Date.now() - cached.at < 10 * 60 * 1000) {
|
|
2037
|
+
return c.json({ models: cached.models });
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// B13: 走 listModelsForService 走 live probe + bank 交叉,返回带元数据的 models
|
|
2042
|
+
const enriched = await listModelsForService(
|
|
2043
|
+
isCustomServiceId(service) ? "custom" : service,
|
|
2044
|
+
apiKey,
|
|
2045
|
+
isCustomServiceId(service) ? resolvedBaseUrl ?? undefined : undefined,
|
|
2046
|
+
);
|
|
2047
|
+
const models = filterTextChatModels(enriched).map((m) => ({
|
|
2048
|
+
id: m.id,
|
|
2049
|
+
name: m.name,
|
|
2050
|
+
...(m.maxOutput !== undefined ? { maxOutput: m.maxOutput } : {}),
|
|
2051
|
+
...(m.contextWindow > 0 ? { contextWindow: m.contextWindow } : {}),
|
|
2052
|
+
}));
|
|
2053
|
+
modelListCache.set(cacheKey, { models, at: Date.now() });
|
|
2054
|
+
return c.json({ models });
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
// --- Project info ---
|
|
2058
|
+
|
|
2059
|
+
app.get("/api/v1/project", async (c) => {
|
|
2060
|
+
const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false });
|
|
2061
|
+
// Check if language was explicitly set in inkos.json (not just the schema default)
|
|
2062
|
+
const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
|
|
2063
|
+
const languageExplicit = "language" in raw && raw.language !== "";
|
|
2064
|
+
|
|
2065
|
+
return c.json({
|
|
2066
|
+
name: currentConfig.name,
|
|
2067
|
+
language: currentConfig.language,
|
|
2068
|
+
languageExplicit,
|
|
2069
|
+
model: currentConfig.llm.model,
|
|
2070
|
+
provider: currentConfig.llm.provider,
|
|
2071
|
+
baseUrl: currentConfig.llm.baseUrl,
|
|
2072
|
+
stream: currentConfig.llm.stream,
|
|
2073
|
+
temperature: currentConfig.llm.temperature,
|
|
2074
|
+
});
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
app.get("/api/v1/project/files/:file{.+}", async (c) => {
|
|
2078
|
+
const file = resolveProjectImageFile(root, c.req.param("file"));
|
|
2079
|
+
|
|
2080
|
+
try {
|
|
2081
|
+
const content = await readFile(file.resolved);
|
|
2082
|
+
return new Response(content, {
|
|
2083
|
+
headers: {
|
|
2084
|
+
"Content-Type": file.contentType,
|
|
2085
|
+
"Cache-Control": "no-store",
|
|
2086
|
+
},
|
|
2087
|
+
});
|
|
2088
|
+
} catch {
|
|
2089
|
+
return c.notFound();
|
|
2090
|
+
}
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
// --- Config editing ---
|
|
2094
|
+
|
|
2095
|
+
app.put("/api/v1/project", async (c) => {
|
|
2096
|
+
const updates = await c.req.json<Record<string, unknown>>();
|
|
2097
|
+
const configPath = join(root, "inkos.json");
|
|
2098
|
+
try {
|
|
2099
|
+
const raw = await readFile(configPath, "utf-8");
|
|
2100
|
+
const existing = JSON.parse(raw);
|
|
2101
|
+
// Merge LLM settings
|
|
2102
|
+
if (updates.temperature !== undefined) {
|
|
2103
|
+
existing.llm.temperature = updates.temperature;
|
|
2104
|
+
}
|
|
2105
|
+
if (updates.stream !== undefined) {
|
|
2106
|
+
existing.llm.stream = updates.stream;
|
|
2107
|
+
}
|
|
2108
|
+
if (updates.language === "zh" || updates.language === "en") {
|
|
2109
|
+
existing.language = updates.language;
|
|
2110
|
+
}
|
|
2111
|
+
const { writeFile: writeFileFs } = await import("node:fs/promises");
|
|
2112
|
+
await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
|
|
2113
|
+
return c.json({ ok: true });
|
|
2114
|
+
} catch (e) {
|
|
2115
|
+
return c.json({ error: String(e) }, 500);
|
|
2116
|
+
}
|
|
2117
|
+
});
|
|
2118
|
+
|
|
2119
|
+
// --- Truth files browser ---
|
|
2120
|
+
|
|
2121
|
+
app.get("/api/v1/books/:id/truth", async (c) => {
|
|
2122
|
+
const id = c.req.param("id");
|
|
2123
|
+
const bookDir = state.bookDir(id);
|
|
2124
|
+
const storyDir = join(bookDir, "story");
|
|
2125
|
+
|
|
2126
|
+
async function listDir(subdir: string): Promise<string[]> {
|
|
2127
|
+
try {
|
|
2128
|
+
const entries = await readdir(join(storyDir, subdir));
|
|
2129
|
+
return entries.filter((f) => f.endsWith(".md") || f.endsWith(".json"));
|
|
2130
|
+
} catch {
|
|
2131
|
+
return [];
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// Hotfix: only tag shim files as legacy when the book has the new layout.
|
|
2136
|
+
const { isNewLayoutBook } = await import("@actalk/inkos-core");
|
|
2137
|
+
const newLayout = await isNewLayoutBook(bookDir);
|
|
2138
|
+
|
|
2139
|
+
async function describe(relPath: string): Promise<{ readonly name: string; readonly size: number; readonly preview: string; readonly legacy?: true } | null> {
|
|
2140
|
+
try {
|
|
2141
|
+
const content = await readFile(join(storyDir, relPath), "utf-8");
|
|
2142
|
+
const isShim = LEGACY_SHIM_FILES.has(relPath) && newLayout;
|
|
2143
|
+
const entry: { readonly name: string; readonly size: number; readonly preview: string; readonly legacy?: true } =
|
|
2144
|
+
isShim
|
|
2145
|
+
? { name: relPath, size: content.length, preview: content.slice(0, 200), legacy: true }
|
|
2146
|
+
: { name: relPath, size: content.length, preview: content.slice(0, 200) };
|
|
2147
|
+
return entry;
|
|
2148
|
+
} catch {
|
|
2149
|
+
return null;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
try {
|
|
2154
|
+
// Flat story/ files (legacy + runtime logs)
|
|
2155
|
+
const flatFiles = (await listDir(".")).filter((f) => !f.startsWith("outline") && !f.startsWith("roles"));
|
|
2156
|
+
// Phase 5 outline/ files
|
|
2157
|
+
const outlineFiles = (await listDir("outline")).map((f) => `outline/${f}`);
|
|
2158
|
+
// Phase 5 roles/主要角色 + roles/次要角色, plus Phase hotfix 3
|
|
2159
|
+
// English-locale equivalents so en-language books are visible.
|
|
2160
|
+
const majorRolesZh = (await listDir("roles/主要角色")).map((f) => `roles/主要角色/${f}`);
|
|
2161
|
+
const minorRolesZh = (await listDir("roles/次要角色")).map((f) => `roles/次要角色/${f}`);
|
|
2162
|
+
const majorRolesEn = (await listDir("roles/major")).map((f) => `roles/major/${f}`);
|
|
2163
|
+
const minorRolesEn = (await listDir("roles/minor")).map((f) => `roles/minor/${f}`);
|
|
2164
|
+
|
|
2165
|
+
const all = [
|
|
2166
|
+
...flatFiles,
|
|
2167
|
+
...outlineFiles,
|
|
2168
|
+
...majorRolesZh,
|
|
2169
|
+
...minorRolesZh,
|
|
2170
|
+
...majorRolesEn,
|
|
2171
|
+
...minorRolesEn,
|
|
2172
|
+
];
|
|
2173
|
+
const described = await Promise.all(all.map(describe));
|
|
2174
|
+
const result = described.filter((x): x is NonNullable<typeof x> => x !== null);
|
|
2175
|
+
return c.json({ files: result });
|
|
2176
|
+
} catch {
|
|
2177
|
+
return c.json({ files: [] });
|
|
2178
|
+
}
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2181
|
+
// --- Daemon control ---
|
|
2182
|
+
|
|
2183
|
+
let schedulerInstance: Scheduler | null = null;
|
|
2184
|
+
|
|
2185
|
+
app.get("/api/v1/daemon", (c) => {
|
|
2186
|
+
return c.json({
|
|
2187
|
+
running: schedulerInstance?.isRunning ?? false,
|
|
2188
|
+
});
|
|
2189
|
+
});
|
|
2190
|
+
|
|
2191
|
+
app.post("/api/v1/daemon/start", async (c) => {
|
|
2192
|
+
if (schedulerInstance?.isRunning) {
|
|
2193
|
+
return c.json({ error: "Daemon already running" }, 400);
|
|
2194
|
+
}
|
|
2195
|
+
try {
|
|
2196
|
+
const currentConfig = await loadCurrentProjectConfig();
|
|
2197
|
+
const scheduler = new Scheduler({
|
|
2198
|
+
...(await buildPipelineConfig()),
|
|
2199
|
+
radarCron: currentConfig.daemon.schedule.radarCron,
|
|
2200
|
+
writeCron: currentConfig.daemon.schedule.writeCron,
|
|
2201
|
+
maxConcurrentBooks: currentConfig.daemon.maxConcurrentBooks,
|
|
2202
|
+
chaptersPerCycle: currentConfig.daemon.chaptersPerCycle,
|
|
2203
|
+
retryDelayMs: currentConfig.daemon.retryDelayMs,
|
|
2204
|
+
cooldownAfterChapterMs: currentConfig.daemon.cooldownAfterChapterMs,
|
|
2205
|
+
maxChaptersPerDay: currentConfig.daemon.maxChaptersPerDay,
|
|
2206
|
+
onChapterComplete: (bookId, chapter, status) => {
|
|
2207
|
+
broadcast("daemon:chapter", { bookId, chapter, status });
|
|
2208
|
+
},
|
|
2209
|
+
onError: (bookId, error) => {
|
|
2210
|
+
broadcast("daemon:error", { bookId, error: error.message });
|
|
2211
|
+
},
|
|
2212
|
+
});
|
|
2213
|
+
schedulerInstance = scheduler;
|
|
2214
|
+
broadcast("daemon:started", {});
|
|
2215
|
+
void scheduler.start().catch((e) => {
|
|
2216
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
2217
|
+
if (schedulerInstance === scheduler) {
|
|
2218
|
+
scheduler.stop();
|
|
2219
|
+
schedulerInstance = null;
|
|
2220
|
+
broadcast("daemon:stopped", {});
|
|
2221
|
+
}
|
|
2222
|
+
broadcast("daemon:error", { bookId: "scheduler", error: error.message });
|
|
2223
|
+
});
|
|
2224
|
+
return c.json({ ok: true, running: true });
|
|
2225
|
+
} catch (e) {
|
|
2226
|
+
return c.json({ error: String(e) }, 500);
|
|
2227
|
+
}
|
|
2228
|
+
});
|
|
2229
|
+
|
|
2230
|
+
app.post("/api/v1/daemon/stop", (c) => {
|
|
2231
|
+
if (!schedulerInstance?.isRunning) {
|
|
2232
|
+
return c.json({ error: "Daemon not running" }, 400);
|
|
2233
|
+
}
|
|
2234
|
+
schedulerInstance.stop();
|
|
2235
|
+
schedulerInstance = null;
|
|
2236
|
+
broadcast("daemon:stopped", {});
|
|
2237
|
+
return c.json({ ok: true, running: false });
|
|
2238
|
+
});
|
|
2239
|
+
|
|
2240
|
+
// --- Logs ---
|
|
2241
|
+
|
|
2242
|
+
app.get("/api/v1/logs", async (c) => {
|
|
2243
|
+
const logPath = join(root, "inkos.log");
|
|
2244
|
+
try {
|
|
2245
|
+
const content = await readFile(logPath, "utf-8");
|
|
2246
|
+
const lines = content.trim().split("\n").slice(-100);
|
|
2247
|
+
const entries = lines.map((line) => {
|
|
2248
|
+
try { return JSON.parse(line); } catch { return { message: line }; }
|
|
2249
|
+
});
|
|
2250
|
+
return c.json({ entries });
|
|
2251
|
+
} catch {
|
|
2252
|
+
return c.json({ entries: [] });
|
|
2253
|
+
}
|
|
2254
|
+
});
|
|
2255
|
+
|
|
2256
|
+
// --- Agent chat ---
|
|
2257
|
+
|
|
2258
|
+
app.get("/api/v1/interaction/session", async (c) => {
|
|
2259
|
+
const session = await loadProjectSession(root);
|
|
2260
|
+
const activeBookId = await resolveSessionActiveBook(root, session);
|
|
2261
|
+
return c.json({
|
|
2262
|
+
session: activeBookId && session.activeBookId !== activeBookId
|
|
2263
|
+
? { ...session, activeBookId }
|
|
2264
|
+
: session,
|
|
2265
|
+
activeBookId,
|
|
2266
|
+
});
|
|
2267
|
+
});
|
|
2268
|
+
|
|
2269
|
+
// -- Per-book session endpoints --
|
|
2270
|
+
|
|
2271
|
+
app.get("/api/v1/sessions", async (c) => {
|
|
2272
|
+
const bookId = c.req.query("bookId");
|
|
2273
|
+
const sessions = await listBookSessions(root, bookId === undefined ? null : bookId === "null" ? null : bookId);
|
|
2274
|
+
return c.json({ sessions });
|
|
2275
|
+
});
|
|
2276
|
+
|
|
2277
|
+
app.get("/api/v1/sessions/:sessionId", async (c) => {
|
|
2278
|
+
const session = await loadBookSession(root, c.req.param("sessionId"));
|
|
2279
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
2280
|
+
return c.json({ session });
|
|
2281
|
+
});
|
|
2282
|
+
|
|
2283
|
+
app.post("/api/v1/sessions", async (c) => {
|
|
2284
|
+
const body = await c.req.json<{ bookId?: string | null; sessionId?: string }>().catch(() => ({}));
|
|
2285
|
+
const bookId = normalizeApiBookId((body as { bookId?: unknown }).bookId, "bookId");
|
|
2286
|
+
const sessionId = (body as { sessionId?: string }).sessionId;
|
|
2287
|
+
// sessionId 只允许 timestamp-random 格式;防止注入任意文件名
|
|
2288
|
+
const safeSessionId = sessionId && /^[0-9]+-[a-z0-9]+$/.test(sessionId) ? sessionId : undefined;
|
|
2289
|
+
const session = await createAndPersistBookSession(root, bookId, safeSessionId);
|
|
2290
|
+
return c.json({ session });
|
|
2291
|
+
});
|
|
2292
|
+
|
|
2293
|
+
app.put("/api/v1/sessions/:sessionId", async (c) => {
|
|
2294
|
+
const sessionId = c.req.param("sessionId");
|
|
2295
|
+
const body = await c.req.json<{ title?: string }>().catch(() => ({}) as { title?: string });
|
|
2296
|
+
const title = body.title?.trim();
|
|
2297
|
+
if (!title) {
|
|
2298
|
+
throw new ApiError(400, "INVALID_SESSION_TITLE", "Session title is required");
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
const session = await renameBookSession(root, sessionId, title);
|
|
2302
|
+
if (!session) {
|
|
2303
|
+
return c.json({ error: "Session not found" }, 404);
|
|
2304
|
+
}
|
|
2305
|
+
return c.json({ session });
|
|
2306
|
+
});
|
|
2307
|
+
|
|
2308
|
+
app.delete("/api/v1/sessions/:sessionId", async (c) => {
|
|
2309
|
+
await deleteBookSession(root, c.req.param("sessionId"));
|
|
2310
|
+
return c.json({ ok: true });
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
app.post("/api/v1/agent", async (c) => {
|
|
2314
|
+
const { instruction, activeBookId, sessionId: reqSessionId, model: reqModel, service: reqService } = await c.req.json<{
|
|
2315
|
+
instruction: string;
|
|
2316
|
+
activeBookId?: string;
|
|
2317
|
+
sessionId?: string;
|
|
2318
|
+
model?: string;
|
|
2319
|
+
service?: string;
|
|
2320
|
+
}>();
|
|
2321
|
+
const sessionId = reqSessionId;
|
|
2322
|
+
if (!instruction?.trim()) {
|
|
2323
|
+
return c.json({ error: "No instruction provided" }, 400);
|
|
2324
|
+
}
|
|
2325
|
+
if (!sessionId?.trim()) {
|
|
2326
|
+
throw new ApiError(400, "SESSION_ID_REQUIRED", "sessionId is required");
|
|
2327
|
+
}
|
|
2328
|
+
if (reqModel && !isTextChatModelId(reqModel)) {
|
|
2329
|
+
const message = nonTextModelMessage(reqModel);
|
|
2330
|
+
return c.json({ error: message, response: message }, 400);
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
broadcast("agent:start", { instruction, activeBookId, sessionId });
|
|
2334
|
+
|
|
2335
|
+
try {
|
|
2336
|
+
// Load config + create LLM client (pipeline created after model resolution)
|
|
2337
|
+
const config = await loadCurrentProjectConfig({ requireApiKey: false });
|
|
2338
|
+
const client = createLLMClient(config.llm);
|
|
2339
|
+
|
|
2340
|
+
const loadedBookSession = await loadBookSession(root, sessionId);
|
|
2341
|
+
if (!loadedBookSession) {
|
|
2342
|
+
throw new ApiError(404, "SESSION_NOT_FOUND", `Session not found: ${sessionId}`);
|
|
2343
|
+
}
|
|
2344
|
+
let bookSession = loadedBookSession;
|
|
2345
|
+
const requestedActiveBookId = normalizeApiBookId(activeBookId, "activeBookId");
|
|
2346
|
+
const persistedBookId = normalizeApiBookId(bookSession.bookId, "session.bookId");
|
|
2347
|
+
if (
|
|
2348
|
+
requestedActiveBookId
|
|
2349
|
+
&& persistedBookId
|
|
2350
|
+
&& persistedBookId !== requestedActiveBookId
|
|
2351
|
+
) {
|
|
2352
|
+
throw new ApiError(
|
|
2353
|
+
409,
|
|
2354
|
+
"SESSION_BOOK_MISMATCH",
|
|
2355
|
+
`Session ${bookSession.sessionId} is bound to ${persistedBookId}, not ${requestedActiveBookId}`,
|
|
2356
|
+
);
|
|
2357
|
+
}
|
|
2358
|
+
const agentBookId = requestedActiveBookId ?? persistedBookId;
|
|
2359
|
+
if (agentBookId) {
|
|
2360
|
+
try {
|
|
2361
|
+
await state.loadBookConfig(agentBookId);
|
|
2362
|
+
} catch {
|
|
2363
|
+
throw new ApiError(404, "BOOK_NOT_FOUND", `Book not found: ${agentBookId}`);
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
const streamSessionId = loadedBookSession.sessionId;
|
|
2367
|
+
const titleBeforeRun = bookSession.title;
|
|
2368
|
+
let sessionTitleBroadcasted = false;
|
|
2369
|
+
const refreshBookSessionFromTranscript = async (): Promise<void> => {
|
|
2370
|
+
const refreshed = await loadBookSession(root, bookSession.sessionId);
|
|
2371
|
+
if (refreshed) {
|
|
2372
|
+
bookSession = refreshed;
|
|
2373
|
+
}
|
|
2374
|
+
if (!sessionTitleBroadcasted && titleBeforeRun === null && bookSession.title) {
|
|
2375
|
+
broadcast("session:title", { sessionId: bookSession.sessionId, title: bookSession.title });
|
|
2376
|
+
sessionTitleBroadcasted = true;
|
|
2377
|
+
}
|
|
2378
|
+
};
|
|
2379
|
+
|
|
2380
|
+
const externalEdit = await tryHandleExternalChatEdit({
|
|
2381
|
+
root,
|
|
2382
|
+
state,
|
|
2383
|
+
instruction,
|
|
2384
|
+
activeBookId: agentBookId,
|
|
2385
|
+
});
|
|
2386
|
+
if (externalEdit) {
|
|
2387
|
+
await appendManualSessionMessages(root, bookSession.sessionId, [{
|
|
2388
|
+
role: "assistant",
|
|
2389
|
+
content: [{ type: "text", text: externalEdit.responseText }],
|
|
2390
|
+
api: "anthropic-messages",
|
|
2391
|
+
provider: config.llm.provider,
|
|
2392
|
+
model: config.llm.model,
|
|
2393
|
+
usage: {
|
|
2394
|
+
input: 0,
|
|
2395
|
+
output: 0,
|
|
2396
|
+
cacheRead: 0,
|
|
2397
|
+
cacheWrite: 0,
|
|
2398
|
+
totalTokens: 0,
|
|
2399
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
2400
|
+
},
|
|
2401
|
+
stopReason: "stop",
|
|
2402
|
+
timestamp: Date.now(),
|
|
2403
|
+
}], instruction);
|
|
2404
|
+
await refreshBookSessionFromTranscript();
|
|
2405
|
+
broadcast("agent:complete", { instruction, activeBookId: externalEdit.activeBookId, sessionId: bookSession.sessionId });
|
|
2406
|
+
return c.json({
|
|
2407
|
+
response: externalEdit.responseText,
|
|
2408
|
+
session: {
|
|
2409
|
+
sessionId: bookSession.sessionId,
|
|
2410
|
+
...(externalEdit.activeBookId ? { activeBookId: externalEdit.activeBookId } : {}),
|
|
2411
|
+
},
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
// Resolve model — multi-service resolution
|
|
2416
|
+
let resolvedModel: ResolvedModel["model"] | undefined;
|
|
2417
|
+
let resolvedApiKey: string | undefined;
|
|
2418
|
+
|
|
2419
|
+
if (reqService && reqModel) {
|
|
2420
|
+
// 1. Frontend explicitly selected a service+model — fail loudly if no key
|
|
2421
|
+
try {
|
|
2422
|
+
const configuredEntry = await resolveConfiguredServiceEntry(root, reqService);
|
|
2423
|
+
const resolved = await resolveServiceModel(
|
|
2424
|
+
reqService,
|
|
2425
|
+
reqModel,
|
|
2426
|
+
root,
|
|
2427
|
+
await resolveConfiguredServiceBaseUrl(root, reqService),
|
|
2428
|
+
configuredEntry?.apiFormat,
|
|
2429
|
+
);
|
|
2430
|
+
resolvedModel = resolved.model;
|
|
2431
|
+
resolvedApiKey = resolved.apiKey;
|
|
2432
|
+
} catch (e: any) {
|
|
2433
|
+
const msg = e?.message ?? String(e);
|
|
2434
|
+
if (/API key/i.test(msg)) {
|
|
2435
|
+
return c.json({
|
|
2436
|
+
error: `请先为 ${reqService} 配置 API Key`,
|
|
2437
|
+
response: `请先在模型配置中为 ${reqService} 填写 API Key,然后再试。`,
|
|
2438
|
+
}, 400);
|
|
2439
|
+
}
|
|
2440
|
+
throw e;
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
if (!resolvedModel) {
|
|
2445
|
+
// 2. Try defaultModel from new config format
|
|
2446
|
+
const rawConfig = config.llm as unknown as Record<string, unknown>;
|
|
2447
|
+
const defaultModel = rawConfig.defaultModel as string | undefined;
|
|
2448
|
+
const servicesArr = normalizeServiceConfig(rawConfig.services);
|
|
2449
|
+
const firstService = servicesArr[0];
|
|
2450
|
+
if (firstService?.service && defaultModel && isTextChatModelId(defaultModel)) {
|
|
2451
|
+
try {
|
|
2452
|
+
const resolved = await resolveServiceModel(
|
|
2453
|
+
serviceConfigKey(firstService),
|
|
2454
|
+
defaultModel,
|
|
2455
|
+
root,
|
|
2456
|
+
firstService.baseUrl,
|
|
2457
|
+
firstService.apiFormat,
|
|
2458
|
+
);
|
|
2459
|
+
resolvedModel = resolved.model;
|
|
2460
|
+
resolvedApiKey = resolved.apiKey;
|
|
2461
|
+
} catch { /* fall through */ }
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
if (!resolvedModel) {
|
|
2466
|
+
// 3. Try first connected service from secrets
|
|
2467
|
+
const secrets = await loadSecrets(root);
|
|
2468
|
+
for (const [svcName, svcData] of Object.entries(secrets.services)) {
|
|
2469
|
+
if (svcData?.apiKey) {
|
|
2470
|
+
try {
|
|
2471
|
+
const models = await listModelsForService(svcName, svcData.apiKey);
|
|
2472
|
+
const textModels = filterTextChatModels(models);
|
|
2473
|
+
if (textModels.length > 0) {
|
|
2474
|
+
const configuredEntry = await resolveConfiguredServiceEntry(root, svcName);
|
|
2475
|
+
const resolved = await resolveServiceModel(
|
|
2476
|
+
svcName,
|
|
2477
|
+
textModels[0].id,
|
|
2478
|
+
root,
|
|
2479
|
+
await resolveConfiguredServiceBaseUrl(root, svcName),
|
|
2480
|
+
configuredEntry?.apiFormat,
|
|
2481
|
+
);
|
|
2482
|
+
resolvedModel = resolved.model;
|
|
2483
|
+
resolvedApiKey = resolved.apiKey;
|
|
2484
|
+
break;
|
|
2485
|
+
}
|
|
2486
|
+
} catch { /* try next */ }
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
if (!resolvedModel) {
|
|
2492
|
+
// 4. Legacy fallback: use createLLMClient
|
|
2493
|
+
resolvedModel = client._piModel
|
|
2494
|
+
? client._piModel
|
|
2495
|
+
: { provider: config.llm.provider ?? "anthropic", modelId: config.llm.model } as any;
|
|
2496
|
+
resolvedApiKey = client._apiKey;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
const model = resolvedModel!;
|
|
2500
|
+
const agentApiKey = resolvedApiKey;
|
|
2501
|
+
const configuredEntry = reqService ? await resolveConfiguredServiceEntry(root, reqService) : undefined;
|
|
2502
|
+
|
|
2503
|
+
// Create pipeline with resolved model (so sub_agent tools use the frontend-selected model)
|
|
2504
|
+
// Don't spread config.llm — its baseUrl/provider belong to the old service.
|
|
2505
|
+
// Let createLLMClient resolve baseUrl from the service preset.
|
|
2506
|
+
const pipelineClient = (reqService && reqModel && resolvedModel)
|
|
2507
|
+
? createLLMClient({
|
|
2508
|
+
...config.llm,
|
|
2509
|
+
service: configuredEntry?.service ?? reqService,
|
|
2510
|
+
model: reqModel,
|
|
2511
|
+
apiKey: resolvedApiKey ?? "",
|
|
2512
|
+
...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
|
|
2513
|
+
...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
|
|
2514
|
+
baseUrl: configuredEntry?.baseUrl ?? "",
|
|
2515
|
+
} as any)
|
|
2516
|
+
: client;
|
|
2517
|
+
const pipeline = new PipelineRunner(await buildPipelineConfig({
|
|
2518
|
+
client: pipelineClient,
|
|
2519
|
+
model: reqModel ?? config.llm.model,
|
|
2520
|
+
currentConfig: config,
|
|
2521
|
+
sessionIdForSSE: bookSession.sessionId,
|
|
2522
|
+
}));
|
|
2523
|
+
|
|
2524
|
+
if (agentBookId && isWriteNextInstruction(instruction)) {
|
|
2525
|
+
const toolCallId = `direct-writer-${Date.now().toString(36)}`;
|
|
2526
|
+
const toolArgs = { agent: "writer", bookId: agentBookId };
|
|
2527
|
+
broadcast("tool:start", {
|
|
2528
|
+
sessionId: streamSessionId,
|
|
2529
|
+
id: toolCallId,
|
|
2530
|
+
tool: "sub_agent",
|
|
2531
|
+
args: toolArgs,
|
|
2532
|
+
stages: PIPELINE_STAGES.writer,
|
|
2533
|
+
});
|
|
2534
|
+
|
|
2535
|
+
try {
|
|
2536
|
+
const writeResult = await pipeline.writeNextChapter(agentBookId);
|
|
2537
|
+
const responseText = [
|
|
2538
|
+
`已为 ${agentBookId} 完成第 ${writeResult.chapterNumber} 章`,
|
|
2539
|
+
writeResult.title ? `《${writeResult.title}》` : "",
|
|
2540
|
+
`,字数 ${writeResult.wordCount},状态 ${writeResult.status}。`,
|
|
2541
|
+
].join("");
|
|
2542
|
+
const toolResult = {
|
|
2543
|
+
content: [{ type: "text", text: responseText }],
|
|
2544
|
+
details: {
|
|
2545
|
+
kind: "chapter_written",
|
|
2546
|
+
bookId: agentBookId,
|
|
2547
|
+
chapterNumber: writeResult.chapterNumber,
|
|
2548
|
+
title: writeResult.title,
|
|
2549
|
+
wordCount: writeResult.wordCount,
|
|
2550
|
+
status: writeResult.status,
|
|
2551
|
+
},
|
|
2552
|
+
};
|
|
2553
|
+
broadcast("tool:end", {
|
|
2554
|
+
sessionId: streamSessionId,
|
|
2555
|
+
id: toolCallId,
|
|
2556
|
+
tool: "sub_agent",
|
|
2557
|
+
result: toolResult,
|
|
2558
|
+
details: toolResult.details,
|
|
2559
|
+
isError: false,
|
|
2560
|
+
});
|
|
2561
|
+
await appendManualSessionMessages(root, bookSession.sessionId, [{
|
|
2562
|
+
role: "assistant",
|
|
2563
|
+
content: [{ type: "text", text: responseText }],
|
|
2564
|
+
api: "anthropic-messages",
|
|
2565
|
+
provider: configuredEntry?.service ?? reqService ?? config.llm.provider,
|
|
2566
|
+
model: reqModel ?? config.llm.model,
|
|
2567
|
+
usage: {
|
|
2568
|
+
input: 0,
|
|
2569
|
+
output: 0,
|
|
2570
|
+
cacheRead: 0,
|
|
2571
|
+
cacheWrite: 0,
|
|
2572
|
+
totalTokens: 0,
|
|
2573
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
2574
|
+
},
|
|
2575
|
+
stopReason: "toolUse",
|
|
2576
|
+
timestamp: Date.now(),
|
|
2577
|
+
}], instruction);
|
|
2578
|
+
await refreshBookSessionFromTranscript();
|
|
2579
|
+
broadcast("agent:complete", { instruction, activeBookId: agentBookId, sessionId: bookSession.sessionId });
|
|
2580
|
+
return c.json({
|
|
2581
|
+
response: responseText,
|
|
2582
|
+
session: {
|
|
2583
|
+
sessionId: bookSession.sessionId,
|
|
2584
|
+
activeBookId: agentBookId,
|
|
2585
|
+
},
|
|
2586
|
+
});
|
|
2587
|
+
} catch (error) {
|
|
2588
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2589
|
+
const toolResult = { content: [{ type: "text", text: message }] };
|
|
2590
|
+
broadcast("tool:end", {
|
|
2591
|
+
sessionId: streamSessionId,
|
|
2592
|
+
id: toolCallId,
|
|
2593
|
+
tool: "sub_agent",
|
|
2594
|
+
result: toolResult,
|
|
2595
|
+
isError: true,
|
|
2596
|
+
});
|
|
2597
|
+
broadcast("agent:error", { instruction, activeBookId: agentBookId, sessionId: bookSession.sessionId, error: message });
|
|
2598
|
+
return c.json({
|
|
2599
|
+
error: { code: "AGENT_ACTION_FAILED", message },
|
|
2600
|
+
response: message,
|
|
2601
|
+
}, 502);
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
// Run pi-agent session
|
|
2606
|
+
const collectedToolExecs: CollectedToolExec[] = [];
|
|
2607
|
+
const result = await runAgentSession(
|
|
2608
|
+
{
|
|
2609
|
+
model,
|
|
2610
|
+
apiKey: agentApiKey,
|
|
2611
|
+
pipeline,
|
|
2612
|
+
projectRoot: root,
|
|
2613
|
+
bookId: agentBookId,
|
|
2614
|
+
sessionId: bookSession.sessionId,
|
|
2615
|
+
language: config.language ?? "zh",
|
|
2616
|
+
onEvent: (event) => {
|
|
2617
|
+
if (event.type === "message_update") {
|
|
2618
|
+
const ame = event.assistantMessageEvent;
|
|
2619
|
+
if (ame.type === "text_delta") {
|
|
2620
|
+
broadcast("draft:delta", { sessionId: streamSessionId, text: ame.delta });
|
|
2621
|
+
} else if (ame.type === "thinking_delta") {
|
|
2622
|
+
broadcast("thinking:delta", { sessionId: streamSessionId, text: (ame as any).delta });
|
|
2623
|
+
} else if (ame.type === "thinking_start") {
|
|
2624
|
+
broadcast("thinking:start", { sessionId: streamSessionId });
|
|
2625
|
+
} else if (ame.type === "thinking_end") {
|
|
2626
|
+
broadcast("thinking:end", { sessionId: streamSessionId });
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
if (event.type === "tool_execution_start") {
|
|
2630
|
+
const args = event.args as Record<string, unknown> | undefined;
|
|
2631
|
+
const agent = event.toolName === "sub_agent" ? (args?.agent as string | undefined) : undefined;
|
|
2632
|
+
const stages = agent ? (PIPELINE_STAGES[agent] ?? []) : [];
|
|
2633
|
+
|
|
2634
|
+
collectedToolExecs.push({
|
|
2635
|
+
id: event.toolCallId,
|
|
2636
|
+
tool: event.toolName,
|
|
2637
|
+
agent,
|
|
2638
|
+
label: resolveToolLabel(event.toolName, agent),
|
|
2639
|
+
status: "running",
|
|
2640
|
+
args,
|
|
2641
|
+
stages: stages.length > 0
|
|
2642
|
+
? stages.map(l => ({ label: l, status: "pending" as const }))
|
|
2643
|
+
: undefined,
|
|
2644
|
+
startedAt: Date.now(),
|
|
2645
|
+
});
|
|
2646
|
+
|
|
2647
|
+
if (!agentBookId && event.toolName === "sub_agent" && agent === "architect") {
|
|
2648
|
+
const bookId = resolveArchitectBookIdFromArgs(args);
|
|
2649
|
+
if (bookId) {
|
|
2650
|
+
const title = typeof args?.title === "string" && args.title.trim()
|
|
2651
|
+
? args.title.trim()
|
|
2652
|
+
: bookId;
|
|
2653
|
+
bookCreateStatus.set(bookId, { status: "creating" });
|
|
2654
|
+
broadcast("book:creating", { bookId, title, sessionId: streamSessionId });
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
broadcast("tool:start", {
|
|
2659
|
+
sessionId: streamSessionId,
|
|
2660
|
+
id: event.toolCallId,
|
|
2661
|
+
tool: event.toolName,
|
|
2662
|
+
args,
|
|
2663
|
+
stages,
|
|
2664
|
+
});
|
|
2665
|
+
}
|
|
2666
|
+
if (event.type === "tool_execution_update") {
|
|
2667
|
+
broadcast("tool:update", {
|
|
2668
|
+
sessionId: streamSessionId,
|
|
2669
|
+
tool: event.toolName,
|
|
2670
|
+
partialResult: event.partialResult,
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
if (event.type === "tool_execution_end") {
|
|
2674
|
+
const exec = collectedToolExecs.find(t => t.id === event.toolCallId);
|
|
2675
|
+
if (exec) {
|
|
2676
|
+
exec.status = event.isError ? "error" : "completed";
|
|
2677
|
+
exec.completedAt = Date.now();
|
|
2678
|
+
exec.stages = exec.stages?.map(s => ({ ...s, status: "completed" as const }));
|
|
2679
|
+
if (event.isError) exec.error = extractToolError(event.result);
|
|
2680
|
+
else exec.result = summarizeResult(event.result);
|
|
2681
|
+
exec.details = (event.result as { details?: unknown } | undefined)?.details;
|
|
2682
|
+
if (
|
|
2683
|
+
event.isError &&
|
|
2684
|
+
!agentBookId &&
|
|
2685
|
+
exec.tool === "sub_agent" &&
|
|
2686
|
+
exec.agent === "architect"
|
|
2687
|
+
) {
|
|
2688
|
+
const bookId = resolveArchitectBookIdFromArgs(exec.args);
|
|
2689
|
+
if (bookId) {
|
|
2690
|
+
const error = exec.error ?? "Book creation failed";
|
|
2691
|
+
bookCreateStatus.set(bookId, { status: "error", error });
|
|
2692
|
+
broadcast("book:error", { bookId, sessionId: streamSessionId, error });
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
broadcast("tool:end", {
|
|
2697
|
+
sessionId: streamSessionId,
|
|
2698
|
+
id: event.toolCallId,
|
|
2699
|
+
tool: event.toolName,
|
|
2700
|
+
result: event.result,
|
|
2701
|
+
details: exec?.details,
|
|
2702
|
+
isError: event.isError,
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
},
|
|
2706
|
+
},
|
|
2707
|
+
instruction,
|
|
2708
|
+
);
|
|
2709
|
+
|
|
2710
|
+
if (result.responseText) {
|
|
2711
|
+
const actionExecutionError = validateAgentActionExecution({
|
|
2712
|
+
instruction,
|
|
2713
|
+
agentBookId,
|
|
2714
|
+
responseText: result.responseText,
|
|
2715
|
+
collectedToolExecs,
|
|
2716
|
+
});
|
|
2717
|
+
if (actionExecutionError) {
|
|
2718
|
+
return c.json({
|
|
2719
|
+
error: { code: "AGENT_ACTION_NOT_EXECUTED", message: actionExecutionError },
|
|
2720
|
+
response: actionExecutionError,
|
|
2721
|
+
}, 502);
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
let broadcastedCreatedBookId: string | null = null;
|
|
2726
|
+
const finalizeCreatedBook = async (): Promise<string | null> => {
|
|
2727
|
+
if (agentBookId) return null;
|
|
2728
|
+
const createdBookId = resolveCreatedBookIdFromToolExecs(collectedToolExecs);
|
|
2729
|
+
if (!createdBookId) return null;
|
|
2730
|
+
if (broadcastedCreatedBookId === createdBookId) return createdBookId;
|
|
2731
|
+
|
|
2732
|
+
try {
|
|
2733
|
+
const migratedSession = await migrateBookSession(root, bookSession.sessionId, createdBookId);
|
|
2734
|
+
if (migratedSession) {
|
|
2735
|
+
bookSession = migratedSession;
|
|
2736
|
+
}
|
|
2737
|
+
} catch (e) {
|
|
2738
|
+
if (!(e instanceof SessionAlreadyMigratedError)) {
|
|
2739
|
+
throw e;
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
const book = await loadStudioBookListSummary(state, createdBookId).catch(() => undefined);
|
|
2744
|
+
bookCreateStatus.delete(createdBookId);
|
|
2745
|
+
broadcast("book:created", {
|
|
2746
|
+
bookId: createdBookId,
|
|
2747
|
+
sessionId: bookSession.sessionId,
|
|
2748
|
+
...(book ? { book } : {}),
|
|
2749
|
+
});
|
|
2750
|
+
broadcastedCreatedBookId = createdBookId;
|
|
2751
|
+
return createdBookId;
|
|
2752
|
+
};
|
|
2753
|
+
|
|
2754
|
+
if (!result.responseText) {
|
|
2755
|
+
if (result.errorMessage) {
|
|
2756
|
+
if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
|
|
2757
|
+
await finalizeCreatedBook();
|
|
2758
|
+
}
|
|
2759
|
+
return c.json({
|
|
2760
|
+
error: { code: "AGENT_LLM_ERROR", message: result.errorMessage },
|
|
2761
|
+
response: result.errorMessage,
|
|
2762
|
+
}, 502);
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
try {
|
|
2766
|
+
const fallbackClient = createLLMClient({
|
|
2767
|
+
...config.llm,
|
|
2768
|
+
service: configuredEntry?.service ?? reqService ?? config.llm.service,
|
|
2769
|
+
model: reqModel ?? config.llm.model,
|
|
2770
|
+
apiKey: agentApiKey ?? config.llm.apiKey,
|
|
2771
|
+
baseUrl: configuredEntry?.baseUrl ?? "",
|
|
2772
|
+
...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
|
|
2773
|
+
...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
|
|
2774
|
+
} as ProjectConfig["llm"]);
|
|
2775
|
+
const fallback = await chatCompletion(
|
|
2776
|
+
fallbackClient,
|
|
2777
|
+
reqModel ?? config.llm.model,
|
|
2778
|
+
[
|
|
2779
|
+
{ role: "system", content: buildAgentSystemPrompt(agentBookId, config.language ?? "zh") },
|
|
2780
|
+
{ role: "user", content: instruction },
|
|
2781
|
+
],
|
|
2782
|
+
{ maxTokens: 256 },
|
|
2783
|
+
);
|
|
2784
|
+
if (fallback.content?.trim()) {
|
|
2785
|
+
const actionExecutionError = validateAgentActionExecution({
|
|
2786
|
+
instruction,
|
|
2787
|
+
agentBookId,
|
|
2788
|
+
responseText: fallback.content,
|
|
2789
|
+
collectedToolExecs,
|
|
2790
|
+
});
|
|
2791
|
+
if (actionExecutionError) {
|
|
2792
|
+
return c.json({
|
|
2793
|
+
error: { code: "AGENT_ACTION_NOT_EXECUTED", message: actionExecutionError },
|
|
2794
|
+
response: actionExecutionError,
|
|
2795
|
+
}, 502);
|
|
2796
|
+
}
|
|
2797
|
+
await appendManualSessionMessages(root, bookSession.sessionId, [{
|
|
2798
|
+
role: "assistant",
|
|
2799
|
+
content: [{ type: "text", text: fallback.content }],
|
|
2800
|
+
api: "anthropic-messages",
|
|
2801
|
+
provider: configuredEntry?.service ?? reqService ?? config.llm.provider,
|
|
2802
|
+
model: reqModel ?? config.llm.model,
|
|
2803
|
+
usage: {
|
|
2804
|
+
input: 0,
|
|
2805
|
+
output: 0,
|
|
2806
|
+
cacheRead: 0,
|
|
2807
|
+
cacheWrite: 0,
|
|
2808
|
+
totalTokens: 0,
|
|
2809
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
2810
|
+
},
|
|
2811
|
+
stopReason: "stop",
|
|
2812
|
+
timestamp: Date.now(),
|
|
2813
|
+
}], instruction);
|
|
2814
|
+
await refreshBookSessionFromTranscript();
|
|
2815
|
+
const createdBookId = await finalizeCreatedBook();
|
|
2816
|
+
return c.json({
|
|
2817
|
+
response: fallback.content,
|
|
2818
|
+
session: {
|
|
2819
|
+
sessionId: bookSession.sessionId,
|
|
2820
|
+
...(createdBookId ? { activeBookId: createdBookId } : {}),
|
|
2821
|
+
},
|
|
2822
|
+
});
|
|
2823
|
+
}
|
|
2824
|
+
} catch {
|
|
2825
|
+
// fall through to probe-based diagnosis below
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
try {
|
|
2829
|
+
const probeClient = createLLMClient({
|
|
2830
|
+
...config.llm,
|
|
2831
|
+
service: configuredEntry?.service ?? reqService ?? config.llm.service,
|
|
2832
|
+
model: reqModel ?? config.llm.model,
|
|
2833
|
+
apiKey: agentApiKey ?? config.llm.apiKey,
|
|
2834
|
+
baseUrl: configuredEntry?.baseUrl ?? "",
|
|
2835
|
+
...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
|
|
2836
|
+
...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
|
|
2837
|
+
} as ProjectConfig["llm"]);
|
|
2838
|
+
await chatCompletion(
|
|
2839
|
+
probeClient,
|
|
2840
|
+
reqModel ?? config.llm.model,
|
|
2841
|
+
[{ role: "user", content: "ping" }],
|
|
2842
|
+
{ maxTokens: 5 },
|
|
2843
|
+
);
|
|
2844
|
+
} catch (probeError) {
|
|
2845
|
+
const probeMessage = probeError instanceof Error ? probeError.message : String(probeError);
|
|
2846
|
+
if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
|
|
2847
|
+
await finalizeCreatedBook();
|
|
2848
|
+
}
|
|
2849
|
+
return c.json({
|
|
2850
|
+
error: { code: "AGENT_EMPTY_RESPONSE", message: probeMessage },
|
|
2851
|
+
response: probeMessage,
|
|
2852
|
+
}, 502);
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
|
|
2856
|
+
if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
|
|
2857
|
+
await finalizeCreatedBook();
|
|
2858
|
+
}
|
|
2859
|
+
return c.json({
|
|
2860
|
+
error: { code: "AGENT_EMPTY_RESPONSE", message: emptyMessage },
|
|
2861
|
+
response: emptyMessage,
|
|
2862
|
+
}, 502);
|
|
2863
|
+
}
|
|
2864
|
+
await refreshBookSessionFromTranscript();
|
|
2865
|
+
await finalizeCreatedBook();
|
|
2866
|
+
|
|
2867
|
+
broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId });
|
|
2868
|
+
|
|
2869
|
+
return c.json({
|
|
2870
|
+
response: result.responseText,
|
|
2871
|
+
session: {
|
|
2872
|
+
sessionId: bookSession.sessionId,
|
|
2873
|
+
...(bookSession.bookId ? { activeBookId: bookSession.bookId } : {}),
|
|
2874
|
+
},
|
|
2875
|
+
});
|
|
2876
|
+
} catch (e) {
|
|
2877
|
+
if (e instanceof ApiError) {
|
|
2878
|
+
throw e;
|
|
2879
|
+
}
|
|
2880
|
+
if (e instanceof SessionAlreadyMigratedError) {
|
|
2881
|
+
const migratedMessage = e instanceof Error ? e.message : String(e);
|
|
2882
|
+
throw new ApiError(409, "SESSION_ALREADY_MIGRATED", migratedMessage);
|
|
2883
|
+
}
|
|
2884
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2885
|
+
broadcast("agent:error", { instruction, activeBookId, sessionId, error: msg });
|
|
2886
|
+
|
|
2887
|
+
// Agent busy — return 429 with user-friendly message
|
|
2888
|
+
if (/already processing|prompt.*queue/i.test(msg)) {
|
|
2889
|
+
return c.json({
|
|
2890
|
+
error: { code: "AGENT_BUSY", message: "正在处理中,请等待当前操作完成" },
|
|
2891
|
+
response: "正在处理中,请等待当前操作完成后再发送。",
|
|
2892
|
+
}, 429);
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
return c.json(
|
|
2896
|
+
{ error: { code: "AGENT_ERROR", message: msg } },
|
|
2897
|
+
500,
|
|
2898
|
+
);
|
|
2899
|
+
}
|
|
2900
|
+
});
|
|
2901
|
+
|
|
2902
|
+
// --- Language setup ---
|
|
2903
|
+
|
|
2904
|
+
app.post("/api/v1/project/language", async (c) => {
|
|
2905
|
+
const { language } = await c.req.json<{ language: "zh" | "en" }>();
|
|
2906
|
+
const configPath = join(root, "inkos.json");
|
|
2907
|
+
try {
|
|
2908
|
+
const raw = await readFile(configPath, "utf-8");
|
|
2909
|
+
const existing = JSON.parse(raw);
|
|
2910
|
+
existing.language = language;
|
|
2911
|
+
const { writeFile: writeFileFs } = await import("node:fs/promises");
|
|
2912
|
+
await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
|
|
2913
|
+
return c.json({ ok: true, language });
|
|
2914
|
+
} catch (e) {
|
|
2915
|
+
return c.json({ error: String(e) }, 500);
|
|
2916
|
+
}
|
|
2917
|
+
});
|
|
2918
|
+
|
|
2919
|
+
// --- Audit ---
|
|
2920
|
+
|
|
2921
|
+
app.post("/api/v1/books/:id/audit/:chapter", async (c) => {
|
|
2922
|
+
const id = c.req.param("id");
|
|
2923
|
+
const chapterNum = parseInt(c.req.param("chapter"), 10);
|
|
2924
|
+
const bookDir = state.bookDir(id);
|
|
2925
|
+
|
|
2926
|
+
broadcast("audit:start", { bookId: id, chapter: chapterNum });
|
|
2927
|
+
try {
|
|
2928
|
+
const book = await state.loadBookConfig(id);
|
|
2929
|
+
const chaptersDir = join(bookDir, "chapters");
|
|
2930
|
+
const files = await readdir(chaptersDir);
|
|
2931
|
+
const paddedNum = String(chapterNum).padStart(4, "0");
|
|
2932
|
+
const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
|
|
2933
|
+
if (!match) return c.json({ error: "Chapter not found" }, 404);
|
|
2934
|
+
|
|
2935
|
+
const content = await readFile(join(chaptersDir, match), "utf-8");
|
|
2936
|
+
const currentConfig = await loadCurrentProjectConfig();
|
|
2937
|
+
const { ContinuityAuditor } = await import("@actalk/inkos-core");
|
|
2938
|
+
const auditor = new ContinuityAuditor({
|
|
2939
|
+
client: createLLMClient(currentConfig.llm),
|
|
2940
|
+
model: currentConfig.llm.model,
|
|
2941
|
+
projectRoot: root,
|
|
2942
|
+
bookId: id,
|
|
2943
|
+
});
|
|
2944
|
+
const result = await auditor.auditChapter(bookDir, content, chapterNum, book.genre);
|
|
2945
|
+
broadcast("audit:complete", { bookId: id, chapter: chapterNum, passed: result.passed });
|
|
2946
|
+
return c.json(result);
|
|
2947
|
+
} catch (e) {
|
|
2948
|
+
broadcast("audit:error", { bookId: id, error: String(e) });
|
|
2949
|
+
return c.json({ error: String(e) }, 500);
|
|
2950
|
+
}
|
|
2951
|
+
});
|
|
2952
|
+
|
|
2953
|
+
// --- Revise ---
|
|
2954
|
+
|
|
2955
|
+
app.post("/api/v1/books/:id/revise/:chapter", async (c) => {
|
|
2956
|
+
const id = c.req.param("id");
|
|
2957
|
+
const chapterNum = parseInt(c.req.param("chapter"), 10);
|
|
2958
|
+
const bookDir = state.bookDir(id);
|
|
2959
|
+
const body = await c.req
|
|
2960
|
+
.json<{ mode?: string; brief?: string }>()
|
|
2961
|
+
.catch(() => ({ mode: "spot-fix", brief: undefined }));
|
|
2962
|
+
|
|
2963
|
+
broadcast("revise:start", { bookId: id, chapter: chapterNum });
|
|
2964
|
+
try {
|
|
2965
|
+
const book = await state.loadBookConfig(id);
|
|
2966
|
+
const chaptersDir = join(bookDir, "chapters");
|
|
2967
|
+
const files = await readdir(chaptersDir);
|
|
2968
|
+
const paddedNum = String(chapterNum).padStart(4, "0");
|
|
2969
|
+
const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
|
|
2970
|
+
if (!match) return c.json({ error: "Chapter not found" }, 404);
|
|
2971
|
+
|
|
2972
|
+
const pipeline = new PipelineRunner(await buildPipelineConfig({
|
|
2973
|
+
externalContext: body.brief,
|
|
2974
|
+
}));
|
|
2975
|
+
const normalizedMode = body.mode ?? "spot-fix";
|
|
2976
|
+
const result = await pipeline.reviseDraft(
|
|
2977
|
+
id,
|
|
2978
|
+
chapterNum,
|
|
2979
|
+
normalizedMode as "polish" | "rewrite" | "rework" | "spot-fix" | "anti-detect",
|
|
2980
|
+
);
|
|
2981
|
+
broadcast("revise:complete", { bookId: id, chapter: chapterNum });
|
|
2982
|
+
return c.json(result);
|
|
2983
|
+
} catch (e) {
|
|
2984
|
+
broadcast("revise:error", { bookId: id, error: String(e) });
|
|
2985
|
+
return c.json({ error: String(e) }, 500);
|
|
2986
|
+
}
|
|
2987
|
+
});
|
|
2988
|
+
|
|
2989
|
+
// --- Export ---
|
|
2990
|
+
|
|
2991
|
+
app.get("/api/v1/books/:id/export", async (c) => {
|
|
2992
|
+
const id = c.req.param("id");
|
|
2993
|
+
const format = (c.req.query("format") ?? "txt") as string;
|
|
2994
|
+
const approvedOnly = c.req.query("approvedOnly") === "true";
|
|
2995
|
+
|
|
2996
|
+
try {
|
|
2997
|
+
const artifact = await buildExportArtifact(state, id, {
|
|
2998
|
+
format: format as "txt" | "md" | "epub",
|
|
2999
|
+
approvedOnly,
|
|
3000
|
+
});
|
|
3001
|
+
const responseBody = typeof artifact.payload === "string"
|
|
3002
|
+
? artifact.payload
|
|
3003
|
+
: new Uint8Array(artifact.payload);
|
|
3004
|
+
return new Response(responseBody, {
|
|
3005
|
+
headers: {
|
|
3006
|
+
"Content-Type": artifact.contentType,
|
|
3007
|
+
"Content-Disposition": `attachment; filename="${artifact.fileName}"`,
|
|
3008
|
+
},
|
|
3009
|
+
});
|
|
3010
|
+
} catch {
|
|
3011
|
+
return c.json({ error: "Export failed" }, 500);
|
|
3012
|
+
}
|
|
3013
|
+
});
|
|
3014
|
+
|
|
3015
|
+
// --- Export to file (save to project dir) ---
|
|
3016
|
+
|
|
3017
|
+
app.post("/api/v1/books/:id/export-save", async (c) => {
|
|
3018
|
+
const id = c.req.param("id");
|
|
3019
|
+
const { format, approvedOnly } = await c.req.json<{ format?: string; approvedOnly?: boolean }>().catch(() => ({ format: "txt", approvedOnly: false }));
|
|
3020
|
+
const fmt = format ?? "txt";
|
|
3021
|
+
|
|
3022
|
+
try {
|
|
3023
|
+
const pipeline = new PipelineRunner(await buildPipelineConfig());
|
|
3024
|
+
const tools = createInteractionToolsFromDeps(pipeline, state);
|
|
3025
|
+
const bookDir = state.bookDir(id);
|
|
3026
|
+
const outputPath = join(bookDir, `${id}.${fmt === "epub" ? "epub" : fmt}`);
|
|
3027
|
+
const result = await processProjectInteractionRequest({
|
|
3028
|
+
projectRoot: root,
|
|
3029
|
+
request: {
|
|
3030
|
+
intent: "export_book",
|
|
3031
|
+
bookId: id,
|
|
3032
|
+
format: fmt as "txt" | "md" | "epub",
|
|
3033
|
+
approvedOnly,
|
|
3034
|
+
outputPath,
|
|
3035
|
+
},
|
|
3036
|
+
tools,
|
|
3037
|
+
activeBookId: id,
|
|
3038
|
+
});
|
|
3039
|
+
return c.json({
|
|
3040
|
+
ok: true,
|
|
3041
|
+
path: (result.details?.outputPath as string | undefined) ?? outputPath,
|
|
3042
|
+
format: fmt,
|
|
3043
|
+
chapters: (result.details?.chaptersExported as number | undefined) ?? 0,
|
|
3044
|
+
});
|
|
3045
|
+
} catch (e) {
|
|
3046
|
+
return c.json({ error: String(e) }, 500);
|
|
3047
|
+
}
|
|
3048
|
+
});
|
|
3049
|
+
|
|
3050
|
+
// --- Genre detail + copy ---
|
|
3051
|
+
|
|
3052
|
+
app.get("/api/v1/genres/:id", async (c) => {
|
|
3053
|
+
const genreId = c.req.param("id");
|
|
3054
|
+
try {
|
|
3055
|
+
const { readGenreProfile } = await import("@actalk/inkos-core");
|
|
3056
|
+
const { profile, body } = await readGenreProfile(root, genreId);
|
|
3057
|
+
return c.json({ profile, body });
|
|
3058
|
+
} catch (e) {
|
|
3059
|
+
return c.json({ error: String(e) }, 404);
|
|
3060
|
+
}
|
|
3061
|
+
});
|
|
3062
|
+
|
|
3063
|
+
app.post("/api/v1/genres/:id/copy", async (c) => {
|
|
3064
|
+
const genreId = c.req.param("id");
|
|
3065
|
+
if (/[/\\\0]/.test(genreId) || genreId.includes("..")) {
|
|
3066
|
+
throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${genreId}"`);
|
|
3067
|
+
}
|
|
3068
|
+
try {
|
|
3069
|
+
const { getBuiltinGenresDir } = await import("@actalk/inkos-core");
|
|
3070
|
+
const { mkdir: mkdirFs, copyFile } = await import("node:fs/promises");
|
|
3071
|
+
const builtinDir = getBuiltinGenresDir();
|
|
3072
|
+
const projectGenresDir = join(root, "genres");
|
|
3073
|
+
await mkdirFs(projectGenresDir, { recursive: true });
|
|
3074
|
+
await copyFile(join(builtinDir, `${genreId}.md`), join(projectGenresDir, `${genreId}.md`));
|
|
3075
|
+
return c.json({ ok: true, path: `genres/${genreId}.md` });
|
|
3076
|
+
} catch (e) {
|
|
3077
|
+
return c.json({ error: String(e) }, 500);
|
|
3078
|
+
}
|
|
3079
|
+
});
|
|
3080
|
+
|
|
3081
|
+
// --- Model overrides ---
|
|
3082
|
+
|
|
3083
|
+
app.get("/api/v1/project/model-overrides", async (c) => {
|
|
3084
|
+
const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
|
|
3085
|
+
return c.json({ overrides: raw.modelOverrides ?? {} });
|
|
3086
|
+
});
|
|
3087
|
+
|
|
3088
|
+
app.put("/api/v1/project/model-overrides", async (c) => {
|
|
3089
|
+
const { overrides } = await c.req.json<{ overrides: Record<string, unknown> }>();
|
|
3090
|
+
const configPath = join(root, "inkos.json");
|
|
3091
|
+
const raw = JSON.parse(await readFile(configPath, "utf-8"));
|
|
3092
|
+
raw.modelOverrides = overrides;
|
|
3093
|
+
const { writeFile: writeFileFs } = await import("node:fs/promises");
|
|
3094
|
+
await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
|
|
3095
|
+
return c.json({ ok: true });
|
|
3096
|
+
});
|
|
3097
|
+
|
|
3098
|
+
// --- Notify channels ---
|
|
3099
|
+
|
|
3100
|
+
app.get("/api/v1/project/notify", async (c) => {
|
|
3101
|
+
const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
|
|
3102
|
+
return c.json({ channels: raw.notify ?? [] });
|
|
3103
|
+
});
|
|
3104
|
+
|
|
3105
|
+
app.put("/api/v1/project/notify", async (c) => {
|
|
3106
|
+
const { channels } = await c.req.json<{ channels: unknown[] }>();
|
|
3107
|
+
const configPath = join(root, "inkos.json");
|
|
3108
|
+
const raw = JSON.parse(await readFile(configPath, "utf-8"));
|
|
3109
|
+
raw.notify = channels;
|
|
3110
|
+
const { writeFile: writeFileFs } = await import("node:fs/promises");
|
|
3111
|
+
await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
|
|
3112
|
+
return c.json({ ok: true });
|
|
3113
|
+
});
|
|
3114
|
+
|
|
3115
|
+
// --- AIGC Detection ---
|
|
3116
|
+
|
|
3117
|
+
app.post("/api/v1/books/:id/detect/:chapter", async (c) => {
|
|
3118
|
+
const id = c.req.param("id");
|
|
3119
|
+
const chapterNum = parseInt(c.req.param("chapter"), 10);
|
|
3120
|
+
const bookDir = state.bookDir(id);
|
|
3121
|
+
|
|
3122
|
+
try {
|
|
3123
|
+
const chaptersDir = join(bookDir, "chapters");
|
|
3124
|
+
const files = await readdir(chaptersDir);
|
|
3125
|
+
const paddedNum = String(chapterNum).padStart(4, "0");
|
|
3126
|
+
const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
|
|
3127
|
+
if (!match) return c.json({ error: "Chapter not found" }, 404);
|
|
3128
|
+
|
|
3129
|
+
const content = await readFile(join(chaptersDir, match), "utf-8");
|
|
3130
|
+
const { analyzeAITells } = await import("@actalk/inkos-core");
|
|
3131
|
+
const result = analyzeAITells(content);
|
|
3132
|
+
return c.json({ chapterNumber: chapterNum, ...result });
|
|
3133
|
+
} catch (e) {
|
|
3134
|
+
return c.json({ error: String(e) }, 500);
|
|
3135
|
+
}
|
|
3136
|
+
});
|
|
3137
|
+
|
|
3138
|
+
// --- Truth file edit ---
|
|
3139
|
+
|
|
3140
|
+
app.put("/api/v1/books/:id/truth/:file{.+}", async (c) => {
|
|
3141
|
+
const id = c.req.param("id");
|
|
3142
|
+
const file = c.req.param("file");
|
|
3143
|
+
const bookDir = state.bookDir(id);
|
|
3144
|
+
const resolved = resolveTruthFilePath(bookDir, file);
|
|
3145
|
+
if (!resolved) {
|
|
3146
|
+
return c.json({ error: "Invalid truth file" }, 400);
|
|
3147
|
+
}
|
|
3148
|
+
// Legacy pointer shims are read-only in new-layout books: writing
|
|
3149
|
+
// story_bible.md or book_rules.md does nothing at runtime (the pipeline
|
|
3150
|
+
// reads outline/ instead). For pre-Phase-5 books these ARE authoritative.
|
|
3151
|
+
if (LEGACY_SHIM_FILES.has(file)) {
|
|
3152
|
+
const { isNewLayoutBook } = await import("@actalk/inkos-core");
|
|
3153
|
+
if (await isNewLayoutBook(bookDir)) {
|
|
3154
|
+
return c.json(
|
|
3155
|
+
{ error: "Legacy compat shim; edit outline/story_frame.md instead" },
|
|
3156
|
+
400,
|
|
3157
|
+
);
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
const { content } = await c.req.json<{ content: string }>();
|
|
3161
|
+
const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
|
|
3162
|
+
const { dirname: dirnameFs } = await import("node:path");
|
|
3163
|
+
await mkdirFs(dirnameFs(resolved), { recursive: true });
|
|
3164
|
+
await writeFileFs(resolved, content, "utf-8");
|
|
3165
|
+
return c.json({ ok: true });
|
|
3166
|
+
});
|
|
3167
|
+
|
|
3168
|
+
// =============================================
|
|
3169
|
+
// NEW ENDPOINTS — CLI parity
|
|
3170
|
+
// =============================================
|
|
3171
|
+
|
|
3172
|
+
// --- Book Delete ---
|
|
3173
|
+
|
|
3174
|
+
app.delete("/api/v1/books/:id", async (c) => {
|
|
3175
|
+
const id = c.req.param("id");
|
|
3176
|
+
const bookDir = state.bookDir(id);
|
|
3177
|
+
try {
|
|
3178
|
+
const { rm } = await import("node:fs/promises");
|
|
3179
|
+
await rm(bookDir, { recursive: true, force: true });
|
|
3180
|
+
broadcast("book:deleted", { bookId: id });
|
|
3181
|
+
return c.json({ ok: true, bookId: id });
|
|
3182
|
+
} catch (e) {
|
|
3183
|
+
return c.json({ error: String(e) }, 500);
|
|
3184
|
+
}
|
|
3185
|
+
});
|
|
3186
|
+
|
|
3187
|
+
// --- Book Update ---
|
|
3188
|
+
|
|
3189
|
+
app.put("/api/v1/books/:id", async (c) => {
|
|
3190
|
+
const id = c.req.param("id");
|
|
3191
|
+
const updates = await c.req.json<{
|
|
3192
|
+
chapterWordCount?: number;
|
|
3193
|
+
targetChapters?: number;
|
|
3194
|
+
status?: string;
|
|
3195
|
+
language?: string;
|
|
3196
|
+
}>();
|
|
3197
|
+
try {
|
|
3198
|
+
const book = await state.loadBookConfig(id);
|
|
3199
|
+
const updated = {
|
|
3200
|
+
...book,
|
|
3201
|
+
...(updates.chapterWordCount !== undefined ? { chapterWordCount: Number(updates.chapterWordCount) } : {}),
|
|
3202
|
+
...(updates.targetChapters !== undefined ? { targetChapters: Number(updates.targetChapters) } : {}),
|
|
3203
|
+
...(updates.status !== undefined ? { status: updates.status as typeof book.status } : {}),
|
|
3204
|
+
...(updates.language !== undefined ? { language: updates.language as "zh" | "en" } : {}),
|
|
3205
|
+
updatedAt: new Date().toISOString(),
|
|
3206
|
+
};
|
|
3207
|
+
await state.saveBookConfig(id, updated);
|
|
3208
|
+
return c.json({ ok: true, book: updated });
|
|
3209
|
+
} catch (e) {
|
|
3210
|
+
return c.json({ error: String(e) }, 500);
|
|
3211
|
+
}
|
|
3212
|
+
});
|
|
3213
|
+
|
|
3214
|
+
// --- Write Rewrite (specific chapter) ---
|
|
3215
|
+
|
|
3216
|
+
app.post("/api/v1/books/:id/rewrite/:chapter", async (c) => {
|
|
3217
|
+
const id = c.req.param("id");
|
|
3218
|
+
const chapterNum = parseInt(c.req.param("chapter"), 10);
|
|
3219
|
+
const body: { brief?: string } = await c.req
|
|
3220
|
+
.json<{ brief?: string }>()
|
|
3221
|
+
.catch(() => ({}));
|
|
3222
|
+
|
|
3223
|
+
broadcast("rewrite:start", { bookId: id, chapter: chapterNum });
|
|
3224
|
+
try {
|
|
3225
|
+
const rollbackTarget = chapterNum - 1;
|
|
3226
|
+
const discarded = await state.rollbackToChapter(id, rollbackTarget);
|
|
3227
|
+
const pipeline = new PipelineRunner(await buildPipelineConfig({
|
|
3228
|
+
externalContext: body.brief,
|
|
3229
|
+
}));
|
|
3230
|
+
pipeline.writeNextChapter(id).then(
|
|
3231
|
+
(result) => broadcast("rewrite:complete", { bookId: id, chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount }),
|
|
3232
|
+
(e) => broadcast("rewrite:error", { bookId: id, error: e instanceof Error ? e.message : String(e) }),
|
|
3233
|
+
);
|
|
3234
|
+
return c.json({ status: "rewriting", bookId: id, chapter: chapterNum, rolledBackTo: rollbackTarget, discarded });
|
|
3235
|
+
} catch (e) {
|
|
3236
|
+
broadcast("rewrite:error", { bookId: id, error: String(e) });
|
|
3237
|
+
return c.json({ error: String(e) }, 500);
|
|
3238
|
+
}
|
|
3239
|
+
});
|
|
3240
|
+
|
|
3241
|
+
app.post("/api/v1/books/:id/resync/:chapter", async (c) => {
|
|
3242
|
+
const id = c.req.param("id");
|
|
3243
|
+
const chapterNum = parseInt(c.req.param("chapter"), 10);
|
|
3244
|
+
const body: { brief?: string } = await c.req
|
|
3245
|
+
.json<{ brief?: string }>()
|
|
3246
|
+
.catch(() => ({}));
|
|
3247
|
+
|
|
3248
|
+
try {
|
|
3249
|
+
const pipeline = new PipelineRunner(await buildPipelineConfig({
|
|
3250
|
+
externalContext: body.brief,
|
|
3251
|
+
}));
|
|
3252
|
+
const result = await pipeline.resyncChapterArtifacts(id, chapterNum);
|
|
3253
|
+
return c.json(result);
|
|
3254
|
+
} catch (e) {
|
|
3255
|
+
return c.json({ error: String(e) }, 500);
|
|
3256
|
+
}
|
|
3257
|
+
});
|
|
3258
|
+
|
|
3259
|
+
// --- Detect All chapters ---
|
|
3260
|
+
|
|
3261
|
+
app.post("/api/v1/books/:id/detect-all", async (c) => {
|
|
3262
|
+
const id = c.req.param("id");
|
|
3263
|
+
const bookDir = state.bookDir(id);
|
|
3264
|
+
|
|
3265
|
+
try {
|
|
3266
|
+
const chaptersDir = join(bookDir, "chapters");
|
|
3267
|
+
const files = await readdir(chaptersDir);
|
|
3268
|
+
const mdFiles = files.filter((f) => f.endsWith(".md") && /^\d{4}/.test(f)).sort();
|
|
3269
|
+
const { analyzeAITells } = await import("@actalk/inkos-core");
|
|
3270
|
+
|
|
3271
|
+
const results = await Promise.all(
|
|
3272
|
+
mdFiles.map(async (f) => {
|
|
3273
|
+
const num = parseInt(f.slice(0, 4), 10);
|
|
3274
|
+
const content = await readFile(join(chaptersDir, f), "utf-8");
|
|
3275
|
+
const result = analyzeAITells(content);
|
|
3276
|
+
return { chapterNumber: num, filename: f, ...result };
|
|
3277
|
+
}),
|
|
3278
|
+
);
|
|
3279
|
+
return c.json({ bookId: id, results });
|
|
3280
|
+
} catch (e) {
|
|
3281
|
+
return c.json({ error: String(e) }, 500);
|
|
3282
|
+
}
|
|
3283
|
+
});
|
|
3284
|
+
|
|
3285
|
+
// --- Detect Stats ---
|
|
3286
|
+
|
|
3287
|
+
app.get("/api/v1/books/:id/detect/stats", async (c) => {
|
|
3288
|
+
const id = c.req.param("id");
|
|
3289
|
+
try {
|
|
3290
|
+
const { loadDetectionHistory, analyzeDetectionInsights } = await import("@actalk/inkos-core");
|
|
3291
|
+
const bookDir = state.bookDir(id);
|
|
3292
|
+
const history = await loadDetectionHistory(bookDir);
|
|
3293
|
+
const insights = analyzeDetectionInsights(history);
|
|
3294
|
+
return c.json(insights);
|
|
3295
|
+
} catch (e) {
|
|
3296
|
+
return c.json({ error: String(e) }, 500);
|
|
3297
|
+
}
|
|
3298
|
+
});
|
|
3299
|
+
|
|
3300
|
+
// --- Genre Create ---
|
|
3301
|
+
|
|
3302
|
+
app.post("/api/v1/genres/create", async (c) => {
|
|
3303
|
+
const body = await c.req.json<{
|
|
3304
|
+
id: string; name: string; language?: string;
|
|
3305
|
+
chapterTypes?: string[]; fatigueWords?: string[];
|
|
3306
|
+
numericalSystem?: boolean; powerScaling?: boolean; eraResearch?: boolean;
|
|
3307
|
+
pacingRule?: string; satisfactionTypes?: string[]; auditDimensions?: number[];
|
|
3308
|
+
body?: string;
|
|
3309
|
+
}>();
|
|
3310
|
+
|
|
3311
|
+
if (!body.id || !body.name) {
|
|
3312
|
+
return c.json({ error: "id and name are required" }, 400);
|
|
3313
|
+
}
|
|
3314
|
+
if (/[/\\\0]/.test(body.id) || body.id.includes("..")) {
|
|
3315
|
+
throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${body.id}"`);
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
|
|
3319
|
+
const genresDir = join(root, "genres");
|
|
3320
|
+
await mkdirFs(genresDir, { recursive: true });
|
|
3321
|
+
|
|
3322
|
+
const frontmatter = [
|
|
3323
|
+
"---",
|
|
3324
|
+
`name: ${yamlScalar(body.name)}`,
|
|
3325
|
+
`id: ${yamlScalar(body.id)}`,
|
|
3326
|
+
`language: ${yamlScalar(body.language ?? "zh")}`,
|
|
3327
|
+
`chapterTypes: ${JSON.stringify(body.chapterTypes ?? [])}`,
|
|
3328
|
+
`fatigueWords: ${JSON.stringify(body.fatigueWords ?? [])}`,
|
|
3329
|
+
`numericalSystem: ${body.numericalSystem ?? false}`,
|
|
3330
|
+
`powerScaling: ${body.powerScaling ?? false}`,
|
|
3331
|
+
`eraResearch: ${body.eraResearch ?? false}`,
|
|
3332
|
+
`pacingRule: ${yamlScalar(body.pacingRule ?? "")}`,
|
|
3333
|
+
`satisfactionTypes: ${JSON.stringify(body.satisfactionTypes ?? [])}`,
|
|
3334
|
+
`auditDimensions: ${JSON.stringify(body.auditDimensions ?? [])}`,
|
|
3335
|
+
"---",
|
|
3336
|
+
"",
|
|
3337
|
+
body.body ?? "",
|
|
3338
|
+
].join("\n");
|
|
3339
|
+
|
|
3340
|
+
await writeFileFs(join(genresDir, `${body.id}.md`), frontmatter, "utf-8");
|
|
3341
|
+
return c.json({ ok: true, id: body.id });
|
|
3342
|
+
});
|
|
3343
|
+
|
|
3344
|
+
// --- Genre Edit ---
|
|
3345
|
+
|
|
3346
|
+
app.put("/api/v1/genres/:id", async (c) => {
|
|
3347
|
+
const genreId = c.req.param("id");
|
|
3348
|
+
if (/[/\\\0]/.test(genreId) || genreId.includes("..")) {
|
|
3349
|
+
throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${genreId}"`);
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
const body = await c.req.json<{ profile: Record<string, unknown>; body: string }>();
|
|
3353
|
+
const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
|
|
3354
|
+
const genresDir = join(root, "genres");
|
|
3355
|
+
await mkdirFs(genresDir, { recursive: true });
|
|
3356
|
+
|
|
3357
|
+
const p = body.profile;
|
|
3358
|
+
const frontmatter = [
|
|
3359
|
+
"---",
|
|
3360
|
+
`name: ${yamlScalar(p.name ?? genreId)}`,
|
|
3361
|
+
`id: ${yamlScalar(p.id ?? genreId)}`,
|
|
3362
|
+
`language: ${yamlScalar(p.language ?? "zh")}`,
|
|
3363
|
+
`chapterTypes: ${JSON.stringify(p.chapterTypes ?? [])}`,
|
|
3364
|
+
`fatigueWords: ${JSON.stringify(p.fatigueWords ?? [])}`,
|
|
3365
|
+
`numericalSystem: ${p.numericalSystem ?? false}`,
|
|
3366
|
+
`powerScaling: ${p.powerScaling ?? false}`,
|
|
3367
|
+
`eraResearch: ${p.eraResearch ?? false}`,
|
|
3368
|
+
`pacingRule: ${yamlScalar(p.pacingRule ?? "")}`,
|
|
3369
|
+
`satisfactionTypes: ${JSON.stringify(p.satisfactionTypes ?? [])}`,
|
|
3370
|
+
`auditDimensions: ${JSON.stringify(p.auditDimensions ?? [])}`,
|
|
3371
|
+
"---",
|
|
3372
|
+
"",
|
|
3373
|
+
body.body ?? "",
|
|
3374
|
+
].join("\n");
|
|
3375
|
+
|
|
3376
|
+
await writeFileFs(join(genresDir, `${genreId}.md`), frontmatter, "utf-8");
|
|
3377
|
+
return c.json({ ok: true, id: genreId });
|
|
3378
|
+
});
|
|
3379
|
+
|
|
3380
|
+
// --- Genre Delete (project-level only) ---
|
|
3381
|
+
|
|
3382
|
+
app.delete("/api/v1/genres/:id", async (c) => {
|
|
3383
|
+
const genreId = c.req.param("id");
|
|
3384
|
+
if (/[/\\\0]/.test(genreId) || genreId.includes("..")) {
|
|
3385
|
+
throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${genreId}"`);
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
const filePath = join(root, "genres", `${genreId}.md`);
|
|
3389
|
+
try {
|
|
3390
|
+
const { rm } = await import("node:fs/promises");
|
|
3391
|
+
await rm(filePath);
|
|
3392
|
+
return c.json({ ok: true, id: genreId });
|
|
3393
|
+
} catch (e) {
|
|
3394
|
+
return c.json({ error: `Genre "${genreId}" not found in project` }, 404);
|
|
3395
|
+
}
|
|
3396
|
+
});
|
|
3397
|
+
|
|
3398
|
+
// --- Style Analyze ---
|
|
3399
|
+
|
|
3400
|
+
app.post("/api/v1/style/analyze", async (c) => {
|
|
3401
|
+
const { text, sourceName } = await c.req.json<{ text: string; sourceName: string }>();
|
|
3402
|
+
if (!text?.trim()) return c.json({ error: "text is required" }, 400);
|
|
3403
|
+
|
|
3404
|
+
try {
|
|
3405
|
+
const { analyzeStyle } = await import("@actalk/inkos-core");
|
|
3406
|
+
const profile = analyzeStyle(text, sourceName ?? "unknown");
|
|
3407
|
+
return c.json(profile);
|
|
3408
|
+
} catch (e) {
|
|
3409
|
+
return c.json({ error: String(e) }, 500);
|
|
3410
|
+
}
|
|
3411
|
+
});
|
|
3412
|
+
|
|
3413
|
+
// --- Style Import to Book ---
|
|
3414
|
+
|
|
3415
|
+
app.post("/api/v1/books/:id/style/import", async (c) => {
|
|
3416
|
+
const id = c.req.param("id");
|
|
3417
|
+
const { text, sourceName } = await c.req.json<{ text: string; sourceName: string }>();
|
|
3418
|
+
if (!text?.trim()) return c.json({ error: "text is required" }, 400);
|
|
3419
|
+
|
|
3420
|
+
broadcast("style:start", { bookId: id });
|
|
3421
|
+
try {
|
|
3422
|
+
const pipeline = new PipelineRunner(await buildPipelineConfig());
|
|
3423
|
+
const result = await pipeline.generateStyleGuide(id, text, sourceName ?? "unknown");
|
|
3424
|
+
broadcast("style:complete", { bookId: id });
|
|
3425
|
+
return c.json({ ok: true, result });
|
|
3426
|
+
} catch (e) {
|
|
3427
|
+
broadcast("style:error", { bookId: id, error: String(e) });
|
|
3428
|
+
return c.json({ error: String(e) }, 500);
|
|
3429
|
+
}
|
|
3430
|
+
});
|
|
3431
|
+
|
|
3432
|
+
// --- Import Chapters ---
|
|
3433
|
+
|
|
3434
|
+
app.post("/api/v1/books/:id/import/chapters", async (c) => {
|
|
3435
|
+
const id = c.req.param("id");
|
|
3436
|
+
const { text, splitRegex } = await c.req.json<{ text: string; splitRegex?: string }>();
|
|
3437
|
+
if (!text?.trim()) return c.json({ error: "text is required" }, 400);
|
|
3438
|
+
|
|
3439
|
+
broadcast("import:start", { bookId: id, type: "chapters" });
|
|
3440
|
+
try {
|
|
3441
|
+
const { splitChapters } = await import("@actalk/inkos-core");
|
|
3442
|
+
const chapters = [...splitChapters(text, splitRegex)];
|
|
3443
|
+
|
|
3444
|
+
const pipeline = new PipelineRunner(await buildPipelineConfig());
|
|
3445
|
+
const result = await pipeline.importChapters({ bookId: id, chapters });
|
|
3446
|
+
broadcast("import:complete", { bookId: id, type: "chapters", count: result.importedCount });
|
|
3447
|
+
return c.json(result);
|
|
3448
|
+
} catch (e) {
|
|
3449
|
+
broadcast("import:error", { bookId: id, error: String(e) });
|
|
3450
|
+
return c.json({ error: String(e) }, 500);
|
|
3451
|
+
}
|
|
3452
|
+
});
|
|
3453
|
+
|
|
3454
|
+
// --- Import Canon ---
|
|
3455
|
+
|
|
3456
|
+
app.post("/api/v1/books/:id/import/canon", async (c) => {
|
|
3457
|
+
const id = c.req.param("id");
|
|
3458
|
+
const { fromBookId } = await c.req.json<{ fromBookId: string }>();
|
|
3459
|
+
if (!fromBookId) return c.json({ error: "fromBookId is required" }, 400);
|
|
3460
|
+
|
|
3461
|
+
broadcast("import:start", { bookId: id, type: "canon" });
|
|
3462
|
+
try {
|
|
3463
|
+
const pipeline = new PipelineRunner(await buildPipelineConfig());
|
|
3464
|
+
await pipeline.importCanon(id, fromBookId);
|
|
3465
|
+
broadcast("import:complete", { bookId: id, type: "canon" });
|
|
3466
|
+
return c.json({ ok: true });
|
|
3467
|
+
} catch (e) {
|
|
3468
|
+
broadcast("import:error", { bookId: id, error: String(e) });
|
|
3469
|
+
return c.json({ error: String(e) }, 500);
|
|
3470
|
+
}
|
|
3471
|
+
});
|
|
3472
|
+
|
|
3473
|
+
// --- Fanfic Init ---
|
|
3474
|
+
|
|
3475
|
+
app.post("/api/v1/fanfic/init", async (c) => {
|
|
3476
|
+
const body = await c.req.json<{
|
|
3477
|
+
title: string; sourceText: string; sourceName?: string;
|
|
3478
|
+
mode?: string; genre?: string; platform?: string;
|
|
3479
|
+
targetChapters?: number; chapterWordCount?: number; language?: string;
|
|
3480
|
+
}>();
|
|
3481
|
+
if (!body.title || !body.sourceText) {
|
|
3482
|
+
return c.json({ error: "title and sourceText are required" }, 400);
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
const now = new Date().toISOString();
|
|
3486
|
+
const bookId = body.title.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, "-").replace(/-+/g, "-").slice(0, 30);
|
|
3487
|
+
|
|
3488
|
+
const bookConfig = {
|
|
3489
|
+
id: bookId,
|
|
3490
|
+
title: body.title,
|
|
3491
|
+
platform: (body.platform ?? "other") as "other",
|
|
3492
|
+
genre: (body.genre ?? "other") as "xuanhuan",
|
|
3493
|
+
status: "outlining" as const,
|
|
3494
|
+
targetChapters: body.targetChapters ?? 100,
|
|
3495
|
+
chapterWordCount: body.chapterWordCount ?? 3000,
|
|
3496
|
+
fanficMode: (body.mode ?? "canon") as "canon",
|
|
3497
|
+
...(body.language ? { language: body.language as "zh" | "en" } : {}),
|
|
3498
|
+
createdAt: now,
|
|
3499
|
+
updatedAt: now,
|
|
3500
|
+
};
|
|
3501
|
+
|
|
3502
|
+
broadcast("fanfic:start", { bookId, title: body.title });
|
|
3503
|
+
try {
|
|
3504
|
+
const pipeline = new PipelineRunner(await buildPipelineConfig());
|
|
3505
|
+
await pipeline.initFanficBook(bookConfig, body.sourceText, body.sourceName ?? "source", (body.mode ?? "canon") as "canon");
|
|
3506
|
+
broadcast("fanfic:complete", { bookId });
|
|
3507
|
+
return c.json({ ok: true, bookId });
|
|
3508
|
+
} catch (e) {
|
|
3509
|
+
broadcast("fanfic:error", { bookId, error: String(e) });
|
|
3510
|
+
return c.json({ error: String(e) }, 500);
|
|
3511
|
+
}
|
|
3512
|
+
});
|
|
3513
|
+
|
|
3514
|
+
// --- Fanfic Show (read canon) ---
|
|
3515
|
+
|
|
3516
|
+
app.get("/api/v1/books/:id/fanfic", async (c) => {
|
|
3517
|
+
const id = c.req.param("id");
|
|
3518
|
+
const bookDir = state.bookDir(id);
|
|
3519
|
+
try {
|
|
3520
|
+
const content = await readFile(join(bookDir, "story", "fanfic_canon.md"), "utf-8");
|
|
3521
|
+
return c.json({ bookId: id, content });
|
|
3522
|
+
} catch {
|
|
3523
|
+
return c.json({ bookId: id, content: null });
|
|
3524
|
+
}
|
|
3525
|
+
});
|
|
3526
|
+
|
|
3527
|
+
// --- Fanfic Refresh ---
|
|
3528
|
+
|
|
3529
|
+
app.post("/api/v1/books/:id/fanfic/refresh", async (c) => {
|
|
3530
|
+
const id = c.req.param("id");
|
|
3531
|
+
const { sourceText, sourceName } = await c.req.json<{ sourceText: string; sourceName?: string }>();
|
|
3532
|
+
if (!sourceText?.trim()) return c.json({ error: "sourceText is required" }, 400);
|
|
3533
|
+
|
|
3534
|
+
broadcast("fanfic:refresh:start", { bookId: id });
|
|
3535
|
+
try {
|
|
3536
|
+
const book = await state.loadBookConfig(id);
|
|
3537
|
+
const pipeline = new PipelineRunner(await buildPipelineConfig());
|
|
3538
|
+
await pipeline.importFanficCanon(id, sourceText, sourceName ?? "source", (book.fanficMode ?? "canon") as "canon");
|
|
3539
|
+
broadcast("fanfic:refresh:complete", { bookId: id });
|
|
3540
|
+
return c.json({ ok: true });
|
|
3541
|
+
} catch (e) {
|
|
3542
|
+
broadcast("fanfic:refresh:error", { bookId: id, error: String(e) });
|
|
3543
|
+
return c.json({ error: String(e) }, 500);
|
|
3544
|
+
}
|
|
3545
|
+
});
|
|
3546
|
+
|
|
3547
|
+
// --- Radar Scan ---
|
|
3548
|
+
|
|
3549
|
+
app.post("/api/v1/radar/scan", async (c) => {
|
|
3550
|
+
broadcast("radar:start", {});
|
|
3551
|
+
try {
|
|
3552
|
+
const pipeline = new PipelineRunner(await buildPipelineConfig());
|
|
3553
|
+
const result = await pipeline.runRadar();
|
|
3554
|
+
await saveRadarScan(root, result);
|
|
3555
|
+
broadcast("radar:complete", { result });
|
|
3556
|
+
return c.json(result);
|
|
3557
|
+
} catch (e) {
|
|
3558
|
+
broadcast("radar:error", { error: String(e) });
|
|
3559
|
+
return c.json({ error: String(e) }, 500);
|
|
3560
|
+
}
|
|
3561
|
+
});
|
|
3562
|
+
|
|
3563
|
+
app.get("/api/v1/radar/history", async (c) => {
|
|
3564
|
+
try {
|
|
3565
|
+
const items = await loadRadarHistory(root);
|
|
3566
|
+
return c.json({ items });
|
|
3567
|
+
} catch (e) {
|
|
3568
|
+
return c.json({ error: String(e) }, 500);
|
|
3569
|
+
}
|
|
3570
|
+
});
|
|
3571
|
+
|
|
3572
|
+
// --- Doctor (environment health check) ---
|
|
3573
|
+
|
|
3574
|
+
app.get("/api/v1/doctor", async (c) => {
|
|
3575
|
+
const { existsSync } = await import("node:fs");
|
|
3576
|
+
const { GLOBAL_ENV_PATH } = await import("@actalk/inkos-core");
|
|
3577
|
+
|
|
3578
|
+
const checks = {
|
|
3579
|
+
inkosJson: existsSync(join(root, "inkos.json")),
|
|
3580
|
+
projectEnv: existsSync(join(root, ".env")),
|
|
3581
|
+
globalEnv: existsSync(GLOBAL_ENV_PATH),
|
|
3582
|
+
booksDir: existsSync(join(root, "books")),
|
|
3583
|
+
llmConnected: false,
|
|
3584
|
+
bookCount: 0,
|
|
3585
|
+
};
|
|
3586
|
+
|
|
3587
|
+
try {
|
|
3588
|
+
const books = await state.listBooks();
|
|
3589
|
+
checks.bookCount = books.length;
|
|
3590
|
+
} catch { /* ignore */ }
|
|
3591
|
+
|
|
3592
|
+
try {
|
|
3593
|
+
const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false });
|
|
3594
|
+
const service = currentConfig.llm.service ?? currentConfig.llm.provider;
|
|
3595
|
+
const probe = await probeServiceCapabilities({
|
|
3596
|
+
root,
|
|
3597
|
+
service,
|
|
3598
|
+
apiKey: currentConfig.llm.apiKey,
|
|
3599
|
+
baseUrl: currentConfig.llm.baseUrl,
|
|
3600
|
+
preferredApiFormat: currentConfig.llm.apiFormat,
|
|
3601
|
+
preferredStream: currentConfig.llm.stream,
|
|
3602
|
+
preferredModel: currentConfig.llm.model,
|
|
3603
|
+
proxyUrl: currentConfig.llm.proxyUrl,
|
|
3604
|
+
});
|
|
3605
|
+
checks.llmConnected = probe.ok;
|
|
3606
|
+
} catch { /* ignore */ }
|
|
3607
|
+
|
|
3608
|
+
return c.json(checks);
|
|
3609
|
+
});
|
|
3610
|
+
|
|
3611
|
+
return app;
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
// --- Standalone runner ---
|
|
3615
|
+
|
|
3616
|
+
export async function startStudioServer(
|
|
3617
|
+
root: string,
|
|
3618
|
+
port = 4567,
|
|
3619
|
+
options?: { readonly staticDir?: string },
|
|
3620
|
+
): Promise<void> {
|
|
3621
|
+
const config = await loadProjectConfig(root, { consumer: "studio", requireApiKey: false });
|
|
3622
|
+
|
|
3623
|
+
const app = createStudioServer(config, root);
|
|
3624
|
+
|
|
3625
|
+
// Serve frontend static files — single process for API + frontend
|
|
3626
|
+
if (options?.staticDir) {
|
|
3627
|
+
const { readFile: readFileFs } = await import("node:fs/promises");
|
|
3628
|
+
const { join: joinPath } = await import("node:path");
|
|
3629
|
+
const { existsSync } = await import("node:fs");
|
|
3630
|
+
|
|
3631
|
+
// Serve static assets (js, css, etc.)
|
|
3632
|
+
app.get("/assets/*", async (c) => {
|
|
3633
|
+
const filePath = joinPath(options.staticDir!, c.req.path);
|
|
3634
|
+
try {
|
|
3635
|
+
const content = await readFileFs(filePath);
|
|
3636
|
+
const ext = filePath.split(".").pop() ?? "";
|
|
3637
|
+
const contentTypes: Record<string, string> = {
|
|
3638
|
+
js: "application/javascript",
|
|
3639
|
+
css: "text/css",
|
|
3640
|
+
svg: "image/svg+xml",
|
|
3641
|
+
png: "image/png",
|
|
3642
|
+
ico: "image/x-icon",
|
|
3643
|
+
json: "application/json",
|
|
3644
|
+
};
|
|
3645
|
+
return new Response(content, {
|
|
3646
|
+
headers: { "Content-Type": contentTypes[ext] ?? "application/octet-stream" },
|
|
3647
|
+
});
|
|
3648
|
+
} catch {
|
|
3649
|
+
return c.notFound();
|
|
3650
|
+
}
|
|
3651
|
+
});
|
|
3652
|
+
|
|
3653
|
+
// SPA fallback — serve index.html for all non-API routes
|
|
3654
|
+
const indexPath = joinPath(options.staticDir!, "index.html");
|
|
3655
|
+
if (existsSync(indexPath)) {
|
|
3656
|
+
const indexHtml = await readFileFs(indexPath, "utf-8");
|
|
3657
|
+
app.get("*", (c) => {
|
|
3658
|
+
if (c.req.path.startsWith("/api/v1/")) return c.notFound();
|
|
3659
|
+
return c.html(indexHtml);
|
|
3660
|
+
});
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
console.log(`InkOS Studio running on http://localhost:${port}`);
|
|
3665
|
+
serve({ fetch: app.fetch, port });
|
|
3666
|
+
}
|