From cb5a176e6e9b4717058c9d98bfed19e7eae388a2 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 26 Apr 2026 23:36:10 +0800 Subject: [PATCH 01/97] docs: add local development baseline --- LOCAL_DEVELOPMENT.md | 51 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 LOCAL_DEVELOPMENT.md diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md new file mode 100644 index 00000000..7afcc7a1 --- /dev/null +++ b/LOCAL_DEVELOPMENT.md @@ -0,0 +1,51 @@ +# PlotPilot NovelPro 本地二开说明 + +## 目标 + +本目录用于在 PlotPilot 基础上进行本地自用二开,重点开发作者视角的长期创作能力: + +- 剧情分支与回滚 +- 章节 A/B 对照 +- 精细改稿模式 +- 角色口吻锁定 +- 角色出场/掉线提醒 +- 时间线面板 +- 关系变化追踪 +- 按目标修文 +- 大纲与正文偏离报警 +- 导入旧稿自动建档 + +## 当前基线 + +- 当前可用本地基线:`v1.0.3` +- 当前二开主线:`local/novel-pro` +- 上游远程名:`upstream` +- 目标补齐基线:`v1.0.4` + +说明:创建项目目录时,GitHub 网络对 `v1.0.4` 的 git/zip 拉取多次超时,因此先用本机已有的完整 `v1.0.3` 副本建立二开目录。后续网络稳定后,再将 `v1.0.4` 的正式变更整合进本地二开主线。 + +## 分支约定 + +- `master`:保留上游默认分支状态,不作为开发分支。 +- `local/base-v1.0.3`:冻结基线分支,不直接开发。 +- `local/novel-pro`:本地二开主线。 +- `local/feature-*`:单功能开发分支。 + +## 开发原则 + +- 新功能优先放在独立模块中,低侵入接入现有系统。 +- 复用现有章节、Bible、知识图谱、审计、生成和工作台能力。 +- 数据迁移必须幂等。 +- AI 修订默认生成预览,不直接覆盖正文。 +- 所有会影响正文与结构化状态的能力,应保留回滚或快照路径。 + +## 上游更新策略 + +上游不直接合并进二开主线。后续只做选择性吸收: + +1. 查看上游更新内容。 +2. 判断是否对本地作者工作台有价值。 +3. 对小变更使用 `cherry-pick`。 +4. 对大变更手动移植思路。 +5. 移植后运行相关验证。 + From ddb532b962389711d81f621a9291135619f4c17d Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 26 Apr 2026 23:44:15 +0800 Subject: [PATCH 02/97] upstream: add wizard resumability --- .../components/onboarding/NovelSetupGuide.vue | 102 +++++++++++++++++- 1 file changed, 97 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/onboarding/NovelSetupGuide.vue b/frontend/src/components/onboarding/NovelSetupGuide.vue index 7ddef27c..630e8a64 100644 --- a/frontend/src/components/onboarding/NovelSetupGuide.vue +++ b/frontend/src/components/onboarding/NovelSetupGuide.vue @@ -17,6 +17,10 @@
+ + + 检测到之前的进度,已自动跳至第 {{ resumedFromStep }} 步。您可以继续完成剩余设置。 +
@@ -471,6 +475,7 @@ const modalOpen = computed({ const currentStep = ref(1) const stepStatus = ref<'process' | 'finish' | 'error' | 'wait'>('process') +const resumedFromStep = ref(0) // 0 表示新会话,>0 表示从该步续传 // 第1步:生成世界观和文风 const generatingBible = ref(false) @@ -772,6 +777,74 @@ function resetWizardStateForOpen() { plotSuggestError.value = '' charactersError.value = '' locationsError.value = '' + resumedFromStep.value = 0 +} + +/** 检查已存在数据,确定向导应从哪一步继续 */ +async function detectWizardProgress(): Promise { + try { + // 检查 Bible 数据 + const bible = await bibleApi.getBible(props.novelId, { timeout: 30_000 }) + bibleData.value = bible + + // 解析世界观 + let fromApi = emptyWorldbuildingShape() + try { + const w = await worldbuildingApi.getWorldbuilding(props.novelId) + fromApi = normalizeWorldbuildingFromApi(w as unknown as Record) + } catch { + /* 404 忽略 */ + } + const fromWs = worldbuildingFromWorldSettings(bible.world_settings) + worldbuildingData.value = mergeWorldbuildingDisplay(fromApi, fromWs) + + const hasWorldbuilding = bible.world_settings?.length > 0 || Object.values(worldbuildingData.value).some(dim => Object.keys(dim).length > 0) + const hasStyle = styleConventionFromBible(bible).length > 0 + const hasCharacters = (bible.characters?.length ?? 0) > 0 + const hasLocations = (bible.locations?.length ?? 0) > 0 + + // 检查主线是否存在 + let hasMainPlot = false + try { + const storylines = await workflowApi.getStorylines(props.novelId) + hasMainPlot = storylines.some(s => s.storyline_type === 'main_plot') + if (hasMainPlot) { + mainPlotCommitted.value = true + } + } catch { + /* 忽略 */ + } + + // 确定当前步骤 + if (!hasWorldbuilding && !hasStyle) { + resumedFromStep.value = 0 // 新会话 + return 1 // 世界观未生成 + } + bibleGenerated.value = true + + if (!hasCharacters) { + resumedFromStep.value = 2 // 从人物步骤续传 + return 2 // 人物未生成 + } + charactersGenerated.value = true + + if (!hasLocations) { + resumedFromStep.value = 3 // 从地点步骤续传 + return 3 // 地点未生成 + } + locationsGenerated.value = true + + if (!hasMainPlot) { + resumedFromStep.value = 4 // 从主线步骤续传 + return 4 // 主线未设定 + } + + resumedFromStep.value = 5 // 全部完成 + return 5 + } catch (err) { + console.warn('[NovelSetupGuide] detectWizardProgress failed:', err) + return 1 // 出错时从头开始 + } } function stopGenerationOnClose() { @@ -784,20 +857,30 @@ function stopGenerationOnClose() { watch( () => props.show, - (val) => { + async (val) => { if (val) { resetWizardStateForOpen() - void startBibleGeneration() + // 检查已有进度,确定从哪一步继续 + const step = await detectWizardProgress() + currentStep.value = step + // 只有在第 1 步且世界观未生成时才启动生成 + if (step === 1 && !bibleGenerated.value) { + void startBibleGeneration() + } } else { stopGenerationOnClose() } } ) -onMounted(() => { +onMounted(async () => { if (props.show) { resetWizardStateForOpen() - void startBibleGeneration() + const step = await detectWizardProgress() + currentStep.value = step + if (step === 1 && !bibleGenerated.value) { + void startBibleGeneration() + } } }) @@ -806,7 +889,8 @@ onUnmounted(() => { }) watch(currentStep, (step) => { - if (step === 4 && props.show && plotOptions.value.length === 0 && !plotSuggesting.value) { + // 第 4 步:主线未提交且无候选时才加载 + if (step === 4 && props.show && !mainPlotCommitted.value && plotOptions.value.length === 0 && !plotSuggesting.value) { void loadPlotSuggestions() } }) @@ -816,6 +900,10 @@ const handleNext = async () => { step2PollEpoch.value += 1 const epoch2 = step2PollEpoch.value currentStep.value = 2 + // 如果人物已存在,跳过生成 + if (charactersGenerated.value) { + return + } generatingCharacters.value = true charactersGenerated.value = false charactersError.value = '' @@ -854,6 +942,10 @@ const handleNext = async () => { step3PollEpoch.value += 1 const epoch3 = step3PollEpoch.value currentStep.value = 3 + // 如果地点已存在,跳过生成 + if (locationsGenerated.value) { + return + } generatingLocations.value = true locationsGenerated.value = false locationsError.value = '' From 54bcdf68631a39833c1529985e50d697f81caf71 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 26 Apr 2026 23:46:28 +0800 Subject: [PATCH 03/97] Revert "upstream: add wizard resumability" This reverts commit ddb532b962389711d81f621a9291135619f4c17d. --- .../components/onboarding/NovelSetupGuide.vue | 102 +----------------- 1 file changed, 5 insertions(+), 97 deletions(-) diff --git a/frontend/src/components/onboarding/NovelSetupGuide.vue b/frontend/src/components/onboarding/NovelSetupGuide.vue index 630e8a64..7ddef27c 100644 --- a/frontend/src/components/onboarding/NovelSetupGuide.vue +++ b/frontend/src/components/onboarding/NovelSetupGuide.vue @@ -17,10 +17,6 @@
- - - 检测到之前的进度,已自动跳至第 {{ resumedFromStep }} 步。您可以继续完成剩余设置。 -
@@ -475,7 +471,6 @@ const modalOpen = computed({ const currentStep = ref(1) const stepStatus = ref<'process' | 'finish' | 'error' | 'wait'>('process') -const resumedFromStep = ref(0) // 0 表示新会话,>0 表示从该步续传 // 第1步:生成世界观和文风 const generatingBible = ref(false) @@ -777,74 +772,6 @@ function resetWizardStateForOpen() { plotSuggestError.value = '' charactersError.value = '' locationsError.value = '' - resumedFromStep.value = 0 -} - -/** 检查已存在数据,确定向导应从哪一步继续 */ -async function detectWizardProgress(): Promise { - try { - // 检查 Bible 数据 - const bible = await bibleApi.getBible(props.novelId, { timeout: 30_000 }) - bibleData.value = bible - - // 解析世界观 - let fromApi = emptyWorldbuildingShape() - try { - const w = await worldbuildingApi.getWorldbuilding(props.novelId) - fromApi = normalizeWorldbuildingFromApi(w as unknown as Record) - } catch { - /* 404 忽略 */ - } - const fromWs = worldbuildingFromWorldSettings(bible.world_settings) - worldbuildingData.value = mergeWorldbuildingDisplay(fromApi, fromWs) - - const hasWorldbuilding = bible.world_settings?.length > 0 || Object.values(worldbuildingData.value).some(dim => Object.keys(dim).length > 0) - const hasStyle = styleConventionFromBible(bible).length > 0 - const hasCharacters = (bible.characters?.length ?? 0) > 0 - const hasLocations = (bible.locations?.length ?? 0) > 0 - - // 检查主线是否存在 - let hasMainPlot = false - try { - const storylines = await workflowApi.getStorylines(props.novelId) - hasMainPlot = storylines.some(s => s.storyline_type === 'main_plot') - if (hasMainPlot) { - mainPlotCommitted.value = true - } - } catch { - /* 忽略 */ - } - - // 确定当前步骤 - if (!hasWorldbuilding && !hasStyle) { - resumedFromStep.value = 0 // 新会话 - return 1 // 世界观未生成 - } - bibleGenerated.value = true - - if (!hasCharacters) { - resumedFromStep.value = 2 // 从人物步骤续传 - return 2 // 人物未生成 - } - charactersGenerated.value = true - - if (!hasLocations) { - resumedFromStep.value = 3 // 从地点步骤续传 - return 3 // 地点未生成 - } - locationsGenerated.value = true - - if (!hasMainPlot) { - resumedFromStep.value = 4 // 从主线步骤续传 - return 4 // 主线未设定 - } - - resumedFromStep.value = 5 // 全部完成 - return 5 - } catch (err) { - console.warn('[NovelSetupGuide] detectWizardProgress failed:', err) - return 1 // 出错时从头开始 - } } function stopGenerationOnClose() { @@ -857,30 +784,20 @@ function stopGenerationOnClose() { watch( () => props.show, - async (val) => { + (val) => { if (val) { resetWizardStateForOpen() - // 检查已有进度,确定从哪一步继续 - const step = await detectWizardProgress() - currentStep.value = step - // 只有在第 1 步且世界观未生成时才启动生成 - if (step === 1 && !bibleGenerated.value) { - void startBibleGeneration() - } + void startBibleGeneration() } else { stopGenerationOnClose() } } ) -onMounted(async () => { +onMounted(() => { if (props.show) { resetWizardStateForOpen() - const step = await detectWizardProgress() - currentStep.value = step - if (step === 1 && !bibleGenerated.value) { - void startBibleGeneration() - } + void startBibleGeneration() } }) @@ -889,8 +806,7 @@ onUnmounted(() => { }) watch(currentStep, (step) => { - // 第 4 步:主线未提交且无候选时才加载 - if (step === 4 && props.show && !mainPlotCommitted.value && plotOptions.value.length === 0 && !plotSuggesting.value) { + if (step === 4 && props.show && plotOptions.value.length === 0 && !plotSuggesting.value) { void loadPlotSuggestions() } }) @@ -900,10 +816,6 @@ const handleNext = async () => { step2PollEpoch.value += 1 const epoch2 = step2PollEpoch.value currentStep.value = 2 - // 如果人物已存在,跳过生成 - if (charactersGenerated.value) { - return - } generatingCharacters.value = true charactersGenerated.value = false charactersError.value = '' @@ -942,10 +854,6 @@ const handleNext = async () => { step3PollEpoch.value += 1 const epoch3 = step3PollEpoch.value currentStep.value = 3 - // 如果地点已存在,跳过生成 - if (locationsGenerated.value) { - return - } generatingLocations.value = true locationsGenerated.value = false locationsError.value = '' From 1166ceb2adfe9fdfbc7622b6e1c15641d02daf1b Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 27 Apr 2026 00:00:48 +0800 Subject: [PATCH 04/97] upstream: sync v1.0.4 changes --- .gitignore | 9 +- =++Contribution Value Roster++= | 5 + LOCAL_DEVELOPMENT.md | 11 +- application/ai/embedding_config_service.py | 5 +- .../services/story_structure_service.py | 77 + application/core/dtos/novel_dto.py | 2 + application/core/services/chapter_service.py | 11 + application/core/services/novel_service.py | 8 +- .../engine/services/autopilot_daemon.py | 190 +- .../services/context_budget_allocator.py | 5 +- .../engine/services/context_builder.py | 12 +- application/reader/__init__.py | 11 + application/reader/dtos/__init__.py | 0 .../reader/dtos/reader_feedback_dto.py | 99 + application/reader/schema.py | 138 + application/reader/services/__init__.py | 0 .../services/reader_simulation_service.py | 373 +++ .../auto_novel_generation_workflow.py | 3 +- domain/novel/entities/novel.py | 4 + frontend/src-tauri/bin/backend-sidecar.bat | 29 + frontend/src-tauri/build.rs | 3 + frontend/src-tauri/capabilities/default.json | 1 + .../src-tauri/gen/schemas/acl-manifests.json | 1 + .../src-tauri/gen/schemas/capabilities.json | 1 + .../src-tauri/gen/schemas/desktop-schema.json | 2564 +++++++++++++++++ .../src-tauri/gen/schemas/windows-schema.json | 2564 +++++++++++++++++ frontend/src-tauri/icons/128x128.png | Bin 0 -> 27125 bytes frontend/src-tauri/icons/128x128@2x.png | Bin 0 -> 86509 bytes frontend/src-tauri/icons/32x32.png | Bin 0 -> 2348 bytes frontend/src-tauri/icons/64x64.png | Bin 0 -> 8014 bytes frontend/src-tauri/icons/GENERATE_ICONS.md | 11 + .../src-tauri/icons/Square107x107Logo.png | Bin 0 -> 19835 bytes .../src-tauri/icons/Square142x142Logo.png | Bin 0 -> 32202 bytes .../src-tauri/icons/Square150x150Logo.png | Bin 0 -> 35587 bytes .../src-tauri/icons/Square284x284Logo.png | Bin 0 -> 103113 bytes frontend/src-tauri/icons/Square30x30Logo.png | Bin 0 -> 2160 bytes .../src-tauri/icons/Square310x310Logo.png | Bin 0 -> 119037 bytes frontend/src-tauri/icons/Square44x44Logo.png | Bin 0 -> 4186 bytes frontend/src-tauri/icons/Square71x71Logo.png | Bin 0 -> 9639 bytes frontend/src-tauri/icons/Square89x89Logo.png | Bin 0 -> 14381 bytes frontend/src-tauri/icons/StoreLogo.png | Bin 0 -> 5232 bytes .../android/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../icons/android/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4009 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 40312 bytes .../android/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 4261 bytes .../icons/android/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3797 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 20109 bytes .../android/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 4177 bytes .../android/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 12639 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 65730 bytes .../mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 13618 bytes .../android/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 25176 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 127982 bytes .../mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 27693 bytes .../android/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 41541 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 207569 bytes .../mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 45324 bytes .../android/values/ic_launcher_background.xml | 4 + frontend/src-tauri/icons/icon-source-1024.png | Bin 0 -> 905376 bytes frontend/src-tauri/icons/icon-source.png | Bin 0 -> 5338 bytes frontend/src-tauri/icons/icon.icns | Bin 0 -> 1941793 bytes frontend/src-tauri/icons/icon.ico | Bin 0 -> 103619 bytes frontend/src-tauri/icons/icon.png | Bin 0 -> 278294 bytes .../src-tauri/icons/ios/AppIcon-20x20@1x.png | Bin 0 -> 1082 bytes .../icons/ios/AppIcon-20x20@2x-1.png | Bin 0 -> 3460 bytes .../src-tauri/icons/ios/AppIcon-20x20@2x.png | Bin 0 -> 3460 bytes .../src-tauri/icons/ios/AppIcon-20x20@3x.png | Bin 0 -> 7112 bytes .../src-tauri/icons/ios/AppIcon-29x29@1x.png | Bin 0 -> 1987 bytes .../icons/ios/AppIcon-29x29@2x-1.png | Bin 0 -> 6676 bytes .../src-tauri/icons/ios/AppIcon-29x29@2x.png | Bin 0 -> 6676 bytes .../src-tauri/icons/ios/AppIcon-29x29@3x.png | Bin 0 -> 13746 bytes .../src-tauri/icons/ios/AppIcon-40x40@1x.png | Bin 0 -> 3460 bytes .../icons/ios/AppIcon-40x40@2x-1.png | Bin 0 -> 11876 bytes .../src-tauri/icons/ios/AppIcon-40x40@2x.png | Bin 0 -> 11876 bytes .../src-tauri/icons/ios/AppIcon-40x40@3x.png | Bin 0 -> 24068 bytes .../src-tauri/icons/ios/AppIcon-512@2x.png | Bin 0 -> 888854 bytes .../src-tauri/icons/ios/AppIcon-60x60@2x.png | Bin 0 -> 24068 bytes .../src-tauri/icons/ios/AppIcon-60x60@3x.png | Bin 0 -> 51617 bytes .../src-tauri/icons/ios/AppIcon-76x76@1x.png | Bin 0 -> 11172 bytes .../src-tauri/icons/ios/AppIcon-76x76@2x.png | Bin 0 -> 38328 bytes .../icons/ios/AppIcon-83.5x83.5@2x.png | Bin 0 -> 45131 bytes frontend/src-tauri/src/backend.rs | 640 ++++ frontend/src-tauri/src/commands.rs | 171 ++ frontend/src-tauri/src/lib.rs | 118 + frontend/src-tauri/src/main.rs | 6 + frontend/src-tauri/tauri.conf.json | 13 +- frontend/src/api/config.ts | 25 +- .../components/autopilot/AutopilotPanel.vue | 81 +- .../components/onboarding/NovelSetupGuide.vue | 102 +- infrastructure/ai/prompt_manager.py | 5 +- .../ai/prompts/prompts_defaults.json | 2 +- .../persistence/database/connection.py | 3 + .../migrations/add_reader_simulations.sql | 28 + .../database/reader_simulation_repository.py | 144 + .../database/sqlite_bible_repository.py | 13 +- .../database/sqlite_novel_repository.py | 8 +- .../database/story_node_repository.py | 40 + interfaces/api/dependencies.py | 17 +- .../blueprint/continuous_planning_routes.py | 23 +- .../api/v1/blueprint/story_structure.py | 20 +- interfaces/api/v1/core/export.py | 23 +- interfaces/api/v1/core/novels.py | 47 + interfaces/api/v1/engine/autopilot_routes.py | 25 +- interfaces/api/v1/engine/generation.py | 2 +- interfaces/api/v1/reader/__init__.py | 253 ++ interfaces/api/v1/world/bible.py | 4 +- interfaces/main.py | 95 +- scripts/install/utils.py | 27 +- tests/e2e/conftest.py | 42 + tests/e2e/test_beat_sheet_api_e2e.py | 70 +- tests/e2e/test_beat_sheet_e2e.py | 88 +- .../test_reader_simulation_service.py | 267 ++ 112 files changed, 8342 insertions(+), 206 deletions(-) create mode 100644 application/reader/__init__.py create mode 100644 application/reader/dtos/__init__.py create mode 100644 application/reader/dtos/reader_feedback_dto.py create mode 100644 application/reader/schema.py create mode 100644 application/reader/services/__init__.py create mode 100644 application/reader/services/reader_simulation_service.py create mode 100644 frontend/src-tauri/bin/backend-sidecar.bat create mode 100644 frontend/src-tauri/build.rs create mode 100644 frontend/src-tauri/capabilities/default.json create mode 100644 frontend/src-tauri/gen/schemas/acl-manifests.json create mode 100644 frontend/src-tauri/gen/schemas/capabilities.json create mode 100644 frontend/src-tauri/gen/schemas/desktop-schema.json create mode 100644 frontend/src-tauri/gen/schemas/windows-schema.json create mode 100644 frontend/src-tauri/icons/128x128.png create mode 100644 frontend/src-tauri/icons/128x128@2x.png create mode 100644 frontend/src-tauri/icons/32x32.png create mode 100644 frontend/src-tauri/icons/64x64.png create mode 100644 frontend/src-tauri/icons/GENERATE_ICONS.md create mode 100644 frontend/src-tauri/icons/Square107x107Logo.png create mode 100644 frontend/src-tauri/icons/Square142x142Logo.png create mode 100644 frontend/src-tauri/icons/Square150x150Logo.png create mode 100644 frontend/src-tauri/icons/Square284x284Logo.png create mode 100644 frontend/src-tauri/icons/Square30x30Logo.png create mode 100644 frontend/src-tauri/icons/Square310x310Logo.png create mode 100644 frontend/src-tauri/icons/Square44x44Logo.png create mode 100644 frontend/src-tauri/icons/Square71x71Logo.png create mode 100644 frontend/src-tauri/icons/Square89x89Logo.png create mode 100644 frontend/src-tauri/icons/StoreLogo.png create mode 100644 frontend/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 frontend/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png create mode 100644 frontend/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 frontend/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png create mode 100644 frontend/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png create mode 100644 frontend/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 frontend/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 frontend/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 frontend/src-tauri/icons/android/values/ic_launcher_background.xml create mode 100644 frontend/src-tauri/icons/icon-source-1024.png create mode 100644 frontend/src-tauri/icons/icon-source.png create mode 100644 frontend/src-tauri/icons/icon.icns create mode 100644 frontend/src-tauri/icons/icon.ico create mode 100644 frontend/src-tauri/icons/icon.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-20x20@1x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-20x20@2x-1.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-20x20@2x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-20x20@3x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-29x29@1x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-29x29@2x-1.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-29x29@2x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-29x29@3x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-40x40@1x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-40x40@2x-1.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-40x40@2x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-40x40@3x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-512@2x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-60x60@2x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-60x60@3x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-76x76@1x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-76x76@2x.png create mode 100644 frontend/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png create mode 100644 frontend/src-tauri/src/backend.rs create mode 100644 frontend/src-tauri/src/commands.rs create mode 100644 frontend/src-tauri/src/lib.rs create mode 100644 frontend/src-tauri/src/main.rs create mode 100644 infrastructure/persistence/database/migrations/add_reader_simulations.sql create mode 100644 infrastructure/persistence/database/reader_simulation_repository.py create mode 100644 interfaces/api/v1/reader/__init__.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/unit/application/services/test_reader_simulation_service.py diff --git a/.gitignore b/.gitignore index 99fe935e..54ae1e4b 100644 --- a/.gitignore +++ b/.gitignore @@ -114,6 +114,7 @@ base_library.zip # ── 临时 / 无关文件 ── =++Contribution Value Roster++= +# Contributors: xibian-YQ, JamesGoslings # ── PlotPilot 残留 ── PlotPilot-master/ @@ -123,8 +124,12 @@ llm_profiles.json aitext.lock scripts/aitext.lock -# ── Tauri 桌面壳(仅本机打包用,勿提交;协作者 clone 后无此目录,不影响 npm run dev) ── -frontend/src-tauri/ +# ── Tauri 桌面壳 ── +# 提交源码,忽略构建产物 +frontend/src-tauri/target/ +frontend/src-tauri/Cargo.lock +frontend/src-tauri/scripts/ +frontend/src-tauri/tools/ # ── 仅本机 Windows 打包链路(协作者只需 npm run dev + 后端) ── scripts/build_installer.py diff --git a/=++Contribution Value Roster++= b/=++Contribution Value Roster++= index 5bf8af22..534885de 100644 --- a/=++Contribution Value Roster++= +++ b/=++Contribution Value Roster++= @@ -17,3 +17,8 @@ 16 wfnysse https://github.com/wfnysse 17 https://github.com/Kobe9312 1Kobe9312 18 https://github.com/zeroranyi zeroranyi +19 bugmaker2 https://github.com/bugmaker2 +20 haoziyouxia https://github.com/haoziyouxia +21 LuoFengXiaoXiao https://github.com/LuoFengXiaoXiao +22 droid-Q https://github.com/droid-Q +23 semir0037-source https://github.com/semir0037-source diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md index 7afcc7a1..b28877cc 100644 --- a/LOCAL_DEVELOPMENT.md +++ b/LOCAL_DEVELOPMENT.md @@ -17,17 +17,19 @@ ## 当前基线 -- 当前可用本地基线:`v1.0.3` +- 当前可用本地基线:`v1.0.4` - 当前二开主线:`local/novel-pro` - 上游远程名:`upstream` -- 目标补齐基线:`v1.0.4` +- 原始建立基线:`v1.0.3` -说明:创建项目目录时,GitHub 网络对 `v1.0.4` 的 git/zip 拉取多次超时,因此先用本机已有的完整 `v1.0.3` 副本建立二开目录。后续网络稳定后,再将 `v1.0.4` 的正式变更整合进本地二开主线。 +说明:创建项目目录时,GitHub 网络对 `v1.0.4` 的 git/zip 拉取多次超时,因此先用本机已有的完整 `v1.0.3` 副本建立二开目录。随后已根据 GitHub compare API 将 `v1.0.3 -> v1.0.4` 的源码变更整合进本地二开主线。 + +注意:少量 Tauri 图标资源因 GitHub API 限流和 raw 下载超时,使用上游 `icon-source-1024.png` 在本机重新生成。尺寸和用途匹配,但这些图标文件不保证与上游二进制逐字节一致。 ## 分支约定 - `master`:保留上游默认分支状态,不作为开发分支。 -- `local/base-v1.0.3`:冻结基线分支,不直接开发。 +- `local/base-v1.0.3`:原始冻结基线分支,不直接开发。 - `local/novel-pro`:本地二开主线。 - `local/feature-*`:单功能开发分支。 @@ -48,4 +50,3 @@ 3. 对小变更使用 `cherry-pick`。 4. 对大变更手动移植思路。 5. 移植后运行相关验证。 - diff --git a/application/ai/embedding_config_service.py b/application/ai/embedding_config_service.py index 62304477..9cd44885 100644 --- a/application/ai/embedding_config_service.py +++ b/application/ai/embedding_config_service.py @@ -157,8 +157,9 @@ def update_config(self, **kwargs) -> EmbeddingConfigModel: params.append("default") # WHERE id = ? sql = f"UPDATE embedding_config SET {', '.join(set_clauses)} WHERE id = ?" - db.execute(sql, params) - db.get_connection().commit() + conn = db.get_connection() + conn.execute(sql, tuple(params)) + conn.commit() logger.info("EmbeddingConfigService: 配置已更新,字段: %s", list(kwargs.keys())) return self.get_config() diff --git a/application/blueprint/services/story_structure_service.py b/application/blueprint/services/story_structure_service.py index 69ee1bca..ab6341d2 100644 --- a/application/blueprint/services/story_structure_service.py +++ b/application/blueprint/services/story_structure_service.py @@ -89,6 +89,9 @@ def _enrich_flat_chapter_nodes(self, novel_id: str, nodes: List[Dict[str, Any]]) async def get_tree(self, novel_id: str) -> Dict[str, Any]: """获取小说的完整结构树""" + # 同步 chapters 表中缺失的章节节点到 story_nodes 表 + await self._sync_orphan_chapters_to_nodes(novel_id) + tree = await self.repository.get_tree(novel_id) data = tree.to_tree_dict() self._enrich_chapter_nodes_from_chapters_table(novel_id, data.get("nodes") or []) @@ -97,6 +100,80 @@ async def get_tree(self, novel_id: str) -> Dict[str, Any]: "tree": data, } + async def _sync_orphan_chapters_to_nodes(self, novel_id: str) -> None: + """将 chapters 表中存在但 story_nodes 表中缺失的章节同步到 story_nodes 表""" + if not self._chapter_repository: + return + + try: + # 获取所有章节 + chapters = self._chapter_repository.list_by_novel(NovelId(novel_id)) + if not chapters: + return + + # 获取现有的章节节点 + all_nodes = await self.repository.get_by_novel(novel_id) + existing_chapter_nums = { + n.number for n in all_nodes if n.node_type.value == "chapter" and n.number is not None + } + + # 为缺失的章节创建节点 + orphan_chapters = [c for c in chapters if c.number not in existing_chapter_nums] + if not orphan_chapters: + return + + # 找到合适的父节点:优先找最后一个幕,其次最后一个卷,最后为 None(顶级) + act_nodes = sorted( + [n for n in all_nodes if n.node_type.value == "act"], + key=lambda n: n.number or 0 + ) + volume_nodes = sorted( + [n for n in all_nodes if n.node_type.value == "volume"], + key=lambda n: n.number or 0 + ) + + # 根据章节号分配到合适的幕 + from domain.structure.story_node import StoryNode, NodeType, PlanningStatus, PlanningSource + import logging + logger = logging.getLogger(__name__) + + for chapter in sorted(orphan_chapters, key=lambda c: c.number): + # 尝试找到包含这个章节号的幕节点 + parent_id = None + for act in act_nodes: + if act.chapter_start and act.chapter_end: + if act.chapter_start <= chapter.number <= act.chapter_end: + parent_id = act.id + break + + # 如果没有找到匹配的幕,放到最后一个幕下面 + if parent_id is None and act_nodes: + parent_id = act_nodes[-1].id + elif parent_id is None and volume_nodes: + parent_id = volume_nodes[-1].id + + node_id = f"chapter-{novel_id}-chapter-{chapter.number}" + node = StoryNode( + id=node_id, + novel_id=novel_id, + parent_id=parent_id, + node_type=NodeType.CHAPTER, + number=chapter.number, + title=chapter.title or f"第{chapter.number}章", + description="", + order_index=chapter.number - 1, + planning_status=PlanningStatus.CONFIRMED, + planning_source=PlanningSource.MANUAL, + word_count=chapter.word_count.value if hasattr(chapter.word_count, "value") else chapter.word_count, + status=chapter.status.value if hasattr(chapter.status, "value") else chapter.status, + ) + await self.repository.save(node) + logger.info(f"[StoryStructure] 已同步孤儿章节到 story_nodes: 第{chapter.number}章") + + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"_sync_orphan_chapters_to_nodes 失败: {e}") + async def get_children(self, novel_id: str, parent_id: Optional[str] = None) -> List[Dict[str, Any]]: """获取子节点(用于渐进式加载)""" nodes = await self.repository.get_children(parent_id) diff --git a/application/core/dtos/novel_dto.py b/application/core/dtos/novel_dto.py index bc76dbac..c5108842 100644 --- a/application/core/dtos/novel_dto.py +++ b/application/core/dtos/novel_dto.py @@ -75,6 +75,7 @@ class NovelDTO: premise: str chapters: List[ChapterDTO] total_word_count: int + slug: str = "" has_bible: bool = False has_outline: bool = False autopilot_status: str = "stopped" @@ -105,6 +106,7 @@ def from_domain(cls, novel: 'Novel') -> 'NovelDTO': return cls( id=novel.novel_id.value, + slug=getattr(novel, 'slug', novel.novel_id.value) or novel.novel_id.value, title=novel.title, author=novel.author, target_chapters=novel.target_chapters, diff --git a/application/core/services/chapter_service.py b/application/core/services/chapter_service.py index df68db7d..fbb4c406 100644 --- a/application/core/services/chapter_service.py +++ b/application/core/services/chapter_service.py @@ -218,6 +218,17 @@ def save_chapter_review( if chapter is None: raise EntityNotFoundError("Chapter", f"{novel_id}/chapter-{chapter_number}") + # 同步更新章节状态:approved -> completed, reviewed -> reviewing + status_to_chapter_status = { + "approved": ChapterStatus.COMPLETED, + "reviewed": ChapterStatus.REVIEWING, + "draft": ChapterStatus.DRAFT, + } + new_chapter_status = status_to_chapter_status.get(status) + if new_chapter_status and chapter.status != new_chapter_status: + chapter.status = new_chapter_status + self.chapter_repository.save(chapter) + # 使用数据库 repository if self.chapter_review_repository: return self.chapter_review_repository.upsert( diff --git a/application/core/services/novel_service.py b/application/core/services/novel_service.py index 862d2ed3..5cce95d6 100644 --- a/application/core/services/novel_service.py +++ b/application/core/services/novel_service.py @@ -190,7 +190,13 @@ def list_novels(self) -> List[NovelDTO]: NovelDTO 列表 """ novels = self.novel_repository.list_all() - return [NovelDTO.from_domain(self._hydrate_chapters(novel)) for novel in novels] + dtos = [] + for novel in novels: + dto = NovelDTO.from_domain(self._hydrate_chapters(novel)) + dto.has_bible = self._check_has_bible(novel.novel_id.value) + dto.has_outline = self._check_has_outline(novel.novel_id.value) + dtos.append(dto) + return dtos def delete_novel(self, novel_id: str) -> None: """删除小说 diff --git a/application/engine/services/autopilot_daemon.py b/application/engine/services/autopilot_daemon.py index ec60a324..66f5d72b 100644 --- a/application/engine/services/autopilot_daemon.py +++ b/application/engine/services/autopilot_daemon.py @@ -330,6 +330,13 @@ async def _handle_act_planning(self, novel: Novel): novel_id = novel.novel_id.value target_act_number = novel.current_act + 1 # 1-indexed + # 提前计算结构推荐参数,供后续多处使用(避免动态幕生成失败时变量未定义) + from application.blueprint.services.continuous_planning_service import calculate_structure_params + target_chapters = novel.target_chapters or 100 + struct_params = calculate_structure_params(target_chapters) + rec_chapters_per_act = struct_params["chapters_per_act"] + rec_acts_per_volume = struct_params["acts_per_volume"] + all_nodes = await self.story_node_repo.get_by_novel(novel_id) act_nodes = sorted( [n for n in all_nodes if n.node_type.value == "act"], @@ -346,13 +353,6 @@ async def _handle_act_planning(self, novel: Novel): key=lambda n: n.number ) - # 使用结构计算引擎获取推荐参数(替代硬编码的 // 3) - from application.blueprint.services.continuous_planning_service import calculate_structure_params - target_chapters = novel.target_chapters or 100 - struct_params = calculate_structure_params(target_chapters) - rec_chapters_per_act = struct_params["chapters_per_act"] - rec_acts_per_volume = struct_params["acts_per_volume"] - # 智能父卷选择:优先让当前卷填满(达到 rec_acts_per_volume 幕),再跳下一卷 parent_volume = self._find_parent_volume_for_new_act( volume_nodes=volume_nodes, @@ -653,7 +653,11 @@ async def _handle_writing(self, novel: Novel): voice_anchors=voice_anchors, chapter_draft_so_far=chapter_content, ) - max_tokens = int(beat.target_words * 1.5) + # 字数控制策略: + # - prompt 中要求目标的 75%(在 context_builder 中处理) + # - max_tokens = prompt 目标 × 1.1(硬性上限,超出会被截断) + # - 最终输出应接近 prompt 目标,略低于原始目标 + max_tokens = int(beat.target_words * 1.1) cfg = GenerationConfig(max_tokens=max_tokens, temperature=0.85) beat_content = await self._stream_llm_with_stop_watch(prompt, cfg, novel=novel) else: @@ -668,6 +672,10 @@ async def _handle_writing(self, novel: Novel): ) if beat_content.strip(): + # V8: 截断检测与自动续写(软着陆) + beat_content = await self._ensure_complete_ending( + beat_content, beat, outline, chapter_content, novel + ) chapter_content += ("\n\n" if chapter_content else "") + beat_content await self._upsert_chapter_content(novel, next_chapter_node, chapter_content, status="draft") @@ -683,7 +691,11 @@ async def _handle_writing(self, novel: Novel): novel.current_beat_index = i + 1 self._flush_novel(novel) - logger.info(f"[{novel.novel_id}] ✅ 节拍 {i+1}/{len(beats)} 完成: {len(beat_content)} 字") + actual_len = len(beat_content) + target_len = beat.target_words + ratio = actual_len / target_len if target_len > 0 else 0 + warning = f" ⚠️ 超出 {int((ratio - 1) * 100)}%" if ratio > 1.1 else "" + logger.info(f"[{novel.novel_id}] ✅ 节拍 {i+1}/{len(beats)} 完成: {actual_len} 字 (目标 {target_len}){warning}") else: # 降级:无节拍,一次生成 if not self._is_still_running(novel): @@ -726,7 +738,26 @@ async def _handle_writing(self, novel: Novel): except Exception as e: logger.warning(f"post_process_generated_chapter 失败(仍落库):{e}") - # 7. 章节完成,标记 completed + # 7. 章节完成,标记 completed(带字数验证) + actual_word_count = len(chapter_content.strip()) + target_word_count = int(getattr(novel, "target_words_per_chapter", None) or 2500) + + # 字数警告:低于目标 60% 或超出 120% 时发出警告 + if actual_word_count < target_word_count * 0.6: + logger.warning( + f"[{novel.novel_id}] ⚠️ 第 {chapter_num} 章字数不足:{actual_word_count} 字 " + f"(目标 {target_word_count} 字,低于 60%)" + ) + elif actual_word_count > target_word_count * 1.2: + logger.warning( + f"[{novel.novel_id}] ⚠️ 第 {chapter_num} 章字数超出:{actual_word_count} 字 " + f"(目标 {target_word_count} 字,超出 {int((actual_word_count / target_word_count - 1) * 100)}%)" + ) + else: + logger.info( + f"[{novel.novel_id}] 第 {chapter_num} 章字数:{actual_word_count} 字 (目标 {target_word_count})" + ) + await self._upsert_chapter_content(novel, next_chapter_node, chapter_content, status="completed") # 8. 更新计数器,重置节拍索引 @@ -736,7 +767,10 @@ async def _handle_writing(self, novel: Novel): novel.current_stage = NovelStage.AUDITING self._flush_novel(novel) - logger.info(f"[{novel.novel_id}] 🎉 第 {chapter_num} 章完成:{len(chapter_content)} 字 (共 {novel.current_auto_chapters}/{novel.target_chapters} 章)") + logger.info( + f"[{novel.novel_id}] 🎉 第 {chapter_num} 章完成:{actual_word_count} 字 " + f"(目标 {target_word_count} 字,共 {novel.current_auto_chapters}/{novel.target_chapters} 章)" + ) def _latest_completed_chapter_number(self, novel_id: NovelId) -> Optional[int]: """已完结章节的最大章节号(与故事树全局章节号一致)。 @@ -758,6 +792,7 @@ async def _handle_auditing(self, novel: Novel): chapter_num = self._latest_completed_chapter_number(NovelId(novel.novel_id.value)) if chapter_num is None: novel.current_stage = NovelStage.WRITING + self._flush_novel(novel) return chapter = self.chapter_repository.get_by_novel_and_number( @@ -765,11 +800,16 @@ async def _handle_auditing(self, novel: Novel): ) if not chapter: novel.current_stage = NovelStage.WRITING + self._flush_novel(novel) return content = chapter.content or "" chapter_id = ChapterId(chapter.id) + # 审计阶段:保存进度以便前端能看到 + novel.audit_progress = "voice_check" + self._flush_novel(novel) + # 1. 先做文风预检;若严重偏离则定向改写,最多两轮,再执行章后管线,避免分析结果与最终正文错位 drift_result = await self._score_voice_only( novel.novel_id.value, @@ -784,6 +824,9 @@ async def _handle_auditing(self, novel: Novel): ) # 2. 统一章后管线:叙事/向量、文风(一次)、KG 推断;三元组与伏笔在叙事同步单次 LLM 中落库 + novel.audit_progress = "aftermath_pipeline" + self._flush_novel(novel) + if self.aftermath_pipeline: try: drift_result = await self.aftermath_pipeline.run_after_chapter_saved( @@ -806,6 +849,9 @@ async def _handle_auditing(self, novel: Novel): ) # 2. 张力打分(轻量 LLM 调用,~200 token) + novel.audit_progress = "tension_scoring" + self._flush_novel(novel) + tension = await self._score_tension(content) novel.last_chapter_tension = tension # 保存张力值到章节(用于张力曲线图) @@ -851,6 +897,7 @@ async def _handle_auditing(self, novel: Novel): ) novel.current_stage = NovelStage.WRITING + novel.audit_progress = None # 清除审计进度 # 5. 全书完成检测 chapters = self.chapter_repository.list_by_novel(NovelId(novel.novel_id.value)) @@ -1280,6 +1327,82 @@ async def _push_streaming_chunk(self, novel_id: str, chunk: str): from application.engine.services.streaming_bus import streaming_bus streaming_bus.publish(novel_id, chunk) + async def _ensure_complete_ending( + self, + content: str, + beat: "Beat", + outline: str, + chapter_draft_so_far: str, + novel=None, + ) -> str: + """V8: 截断检测与自动续写(软着陆) + + 检测内容是否被截断(没有以句号等结束符结尾), + 如果被截断,自动发起续写请求完成收尾。 + + Args: + content: 已生成的内容 + beat: 当前节拍对象 + outline: 章节大纲 + chapter_draft_so_far: 本章已生成的正文 + novel: 小说对象 + + Returns: + 完整的内容(可能包含续写部分) + """ + import re + + if not content or not content.strip(): + return content + + # 检测是否以句子结束符结尾 + # 中文句号、英文句号、叹号、问号、引号、省略号 + ending_pattern = r'[。!?…)】》"\'』」]$' + stripped = content.rstrip() + + if re.search(ending_pattern, stripped): + # 结尾完整,无需续写 + return content + + # 检测是否被截断 + logger.warning(f"[截断检测] 内容未以结束符结尾,可能被截断,发起自动续写") + + # 构建续写 Prompt + continuation_prompt = Prompt( + system="你是小说续写助手。你的任务是为被截断的段落提供一个简短、自然的结尾。" + "不要重复已有内容,只需在 150 字以内完成收尾,让段落有完整的结尾。", + user=f"""以下段落被截断了,请续写一个简短的结尾(150字以内)让它完整结束: + +---截断的内容--- +{stripped[-500:]} + +---续写要求--- +1. 承接上文,给出自然的收尾 +2. 不要重复已有内容 +3. 必须以句号结束 +4. 字数控制在 150 字以内 + +请直接续写,不要解释:""" + ) + + try: + config = GenerationConfig(max_tokens=300, temperature=0.7) + continuation = await self._stream_llm_with_stop_watch( + continuation_prompt, config, novel=novel + ) + + if continuation and continuation.strip(): + # 拼接续写内容 + result = stripped + continuation.strip() + logger.info(f"[截断续写] 成功续写 {len(continuation.strip())} 字") + return result + + except Exception as e: + logger.warning(f"[截断续写] 续写失败: {e}") + + # 续写失败,返回原内容(至少加个句号让它看起来完整) + return stripped + "。" + async def _stream_one_beat( self, outline, @@ -1320,30 +1443,61 @@ async def _stream_one_beat( user_parts.append(f"\n{beat_prompt}") user_parts.append("\n\n开始撰写:") - max_tokens = int(beat.target_words * 1.5) if beat else 3000 + # 字数控制策略(与主流程一致) + max_tokens = int(beat.target_words * 1.1) if beat else 3000 prompt = Prompt(system=system, user="\n".join(user_parts)) config = GenerationConfig(max_tokens=max_tokens, temperature=0.85) return await self._stream_llm_with_stop_watch(prompt, config, novel=novel) async def _upsert_chapter_content(self, novel, chapter_node, content: str, status: str): - """最小事务:只更新章节内容,不涉及其他表""" + """最小事务:只更新章节内容,不涉及其他表 + + 安全规则: + 1. 空内容不能将状态更新为 completed(防止空章节被标记为完成) + 2. 空内容不会覆盖已有内容(防止意外清空) + """ from domain.novel.entities.chapter import Chapter, ChapterStatus from domain.novel.value_objects.novel_id import NovelId + content_str = (content or "").strip() + existing = self.chapter_repository.get_by_novel_and_number( NovelId(novel.novel_id.value), chapter_node.number ) if existing: - # 防御:避免意外用空串覆盖已有正文(例如并发/异常分支写入空内容) - if (not (content or "").strip()) and (existing.content or "").strip(): - existing.status = ChapterStatus(status) - self.chapter_repository.save(existing) + existing_content = (existing.content or "").strip() + + # 安全检查:空内容不能标记为 completed + if not content_str and status == "completed": + logger.warning( + f"[{novel.novel_id}] 拒绝将章节 {chapter_node.number} 标记为 completed:内容为空" + ) return + + # 防御:避免意外用空串覆盖已有正文 + if not content_str: + # 空内容:只允许更新状态为 draft(不能覆盖已有内容,不能标记为 completed) + if status == "draft" and existing_content: + logger.debug( + f"[{novel.novel_id}] 章节 {chapter_node.number} 内容为空,仅更新状态为 draft(保留已有内容)" + ) + existing.status = ChapterStatus.DRAFT + self.chapter_repository.save(existing) + return + + # 正常更新:有内容时才更新 existing.update_content(content) existing.status = ChapterStatus(status) self.chapter_repository.save(existing) else: + # 新建章节:空内容只能创建为 draft + if not content_str and status == "completed": + logger.warning( + f"[{novel.novel_id}] 拒绝创建空的 completed 章节 {chapter_node.number}" + ) + return + chapter = Chapter( id=chapter_node.id, novel_id=NovelId(novel.novel_id.value), @@ -1351,7 +1505,7 @@ async def _upsert_chapter_content(self, novel, chapter_node, content: str, statu title=chapter_node.title, content=content, outline=chapter_node.outline or "", - status=ChapterStatus(status) + status=ChapterStatus(status if content_str else "draft") ) self.chapter_repository.save(chapter) diff --git a/application/engine/services/context_budget_allocator.py b/application/engine/services/context_budget_allocator.py index 9d7d83ac..74dd09a1 100644 --- a/application/engine/services/context_budget_allocator.py +++ b/application/engine/services/context_budget_allocator.py @@ -159,8 +159,9 @@ class ContextBudgetAllocator: MAX_VECTOR_RECALL_TOKENS = 5000 # 最近章节槽位:紧邻上一章侧重章末承接;更早章节仅章首短预览以省预算 - PREV_CHAPTER_BRIDGE_HEAD_CHARS = 250 - PREV_CHAPTER_BRIDGE_TAIL_CHARS = 1200 + # V8 优化:增加章末保留量,提升章节间连贯性 + PREV_CHAPTER_BRIDGE_HEAD_CHARS = 300 # 章首略览 + PREV_CHAPTER_BRIDGE_TAIL_CHARS = 2000 # 章末完整保留(原 1200 → 2000) OLDER_CHAPTER_HEAD_PREVIEW_CHARS = 500 def __init__( diff --git a/application/engine/services/context_builder.py b/application/engine/services/context_builder.py index a4e189c0..b3c2dc4b 100644 --- a/application/engine/services/context_builder.py +++ b/application/engine/services/context_builder.py @@ -269,14 +269,20 @@ def magnify_outline_to_beats(self, chapter_number: int, outline: str, target_cha ), ] - # 调整字数分配 + # 调整字数分配(保守策略) + # LLM 倾向于超出字数要求,因此 prompt 中只要求目标的 75% + # 配合 max_tokens = target × 1.1(硬性上限),强制控制字数 total_words = sum(b.target_words for b in beats) + prompt_target_ratio = 0.75 # prompt 中只要求 75% if total_words != target_chapter_words: - ratio = target_chapter_words / total_words + ratio = (target_chapter_words * prompt_target_ratio) / total_words for beat in beats: beat.target_words = int(beat.target_words * ratio) - logger.info(f"节拍放大器:将大纲拆分为 {len(beats)} 个节拍") + logger.info( + f"节拍放大器:将大纲拆分为 {len(beats)} 个节拍," + f"prompt 目标 {sum(b.target_words for b in beats)} 字(实际目标 {target_chapter_words} 字的 {int(prompt_target_ratio * 100)}%)" + ) return beats # 节拍聚焦指令已迁移至 prompts_defaults.json (id=beat-focus-instructions) diff --git a/application/reader/__init__.py b/application/reader/__init__.py new file mode 100644 index 00000000..fee2ec49 --- /dev/null +++ b/application/reader/__init__.py @@ -0,0 +1,11 @@ +"""读者模拟 Agent 模块 + +模拟不同类型读者(硬核粉、休闲读者、挑刺党)阅读每章后的反馈, +输出悬疑保持度、爽感评分、劝退风险、情感共鸣度等多维度评估。 + +核心组件: +- ReaderSimulationService: 读者模拟分析服务(LLM 驱动) +- ReaderPersona: 读者人设枚举(硬核粉/休闲读者/挑刺党) +- ReaderFeedback: 单个读者视角的反馈结果 +- ChapterReaderReport: 章节级的综合读者报告 +""" diff --git a/application/reader/dtos/__init__.py b/application/reader/dtos/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/application/reader/dtos/reader_feedback_dto.py b/application/reader/dtos/reader_feedback_dto.py new file mode 100644 index 00000000..3fd09047 --- /dev/null +++ b/application/reader/dtos/reader_feedback_dto.py @@ -0,0 +1,99 @@ +"""读者模拟反馈 DTO — 面向 API 层的序列化模型。""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional + + +@dataclass +class ReaderDimensionScoresDTO: + """四维度评分""" + suspense_retention: float = 50.0 + thrill_score: float = 50.0 + churn_risk: float = 30.0 + emotional_resonance: float = 50.0 + + def to_dict(self) -> Dict[str, float]: + return { + "suspense_retention": round(self.suspense_retention, 1), + "thrill_score": round(self.thrill_score, 1), + "churn_risk": round(self.churn_risk, 1), + "emotional_resonance": round(self.emotional_resonance, 1), + } + + +@dataclass +class ReaderFeedbackDTO: + """单个读者人设的反馈""" + persona: str # hardcore / casual / nitpicker + persona_label: str # 硬核粉 / 休闲读者 / 挑刺党 + scores: ReaderDimensionScoresDTO + one_line_verdict: str = "" + highlights: List[str] = field(default_factory=list) + pain_points: List[str] = field(default_factory=list) + suggestions: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + "persona": self.persona, + "persona_label": self.persona_label, + "scores": self.scores.to_dict(), + "one_line_verdict": self.one_line_verdict, + "highlights": self.highlights, + "pain_points": self.pain_points, + "suggestions": self.suggestions, + } + + +PERSONA_LABELS = { + "hardcore": "硬核粉", + "casual": "休闲读者", + "nitpicker": "挑刺党", +} + + +@dataclass +class ChapterReaderReportDTO: + """章节级读者模拟报告""" + novel_id: str + chapter_number: int + feedbacks: List[ReaderFeedbackDTO] = field(default_factory=list) + overall_readability: float = 50.0 + chapter_hook_strength: str = "medium" + pacing_verdict: str = "" + analyzed_at: Optional[datetime] = None + # 降级标识:True 表示 LLM 调用失败或解析失败,所有评分为默认值 + # 该字段用于 API 层判断是否持久化、返回什么 HTTP 状态码 + is_fallback: bool = False + # 降级原因(仅 is_fallback=True 时填充) + error_message: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "novel_id": self.novel_id, + "chapter_number": self.chapter_number, + "feedbacks": [f.to_dict() for f in self.feedbacks], + "overall_readability": round(self.overall_readability, 1), + "chapter_hook_strength": self.chapter_hook_strength, + "pacing_verdict": self.pacing_verdict, + "analyzed_at": self.analyzed_at.isoformat() if self.analyzed_at else None, + # 便捷聚合:三个读者的平均分 + "avg_scores": self._compute_avg_scores(), + "is_fallback": self.is_fallback, + "error_message": self.error_message, + } + + def _compute_avg_scores(self) -> Dict[str, float]: + if not self.feedbacks: + return { + "suspense_retention": 0, "thrill_score": 0, + "churn_risk": 0, "emotional_resonance": 0, + } + n = len(self.feedbacks) + return { + "suspense_retention": round(sum(f.scores.suspense_retention for f in self.feedbacks) / n, 1), + "thrill_score": round(sum(f.scores.thrill_score for f in self.feedbacks) / n, 1), + "churn_risk": round(sum(f.scores.churn_risk for f in self.feedbacks) / n, 1), + "emotional_resonance": round(sum(f.scores.emotional_resonance for f in self.feedbacks) / n, 1), + } diff --git a/application/reader/schema.py b/application/reader/schema.py new file mode 100644 index 00000000..b88b7b98 --- /dev/null +++ b/application/reader/schema.py @@ -0,0 +1,138 @@ +"""读者模拟 Agent — LLM 输出的 Pydantic 结构化模型。 + +与 prompt 约定的 JSON 字段一致;额外字段忽略。 +""" +from __future__ import annotations + +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class ReaderDimensionScores(BaseModel): + """单个读者视角的四维度评分""" + + model_config = ConfigDict(extra="ignore") + + suspense_retention: float = Field( + default=50.0, + description="悬疑保持度 (0-100): 本章是否让读者产生「接下来会怎样」的好奇", + ) + thrill_score: float = Field( + default=50.0, + description="爽感评分 (0-100): 本章是否提供了令人满足的情绪高潮或反转", + ) + churn_risk: float = Field( + default=30.0, + description="劝退风险 (0-100): 读者在本章后弃书的概率,越低越好", + ) + emotional_resonance: float = Field( + default=50.0, + description="情感共鸣度 (0-100): 本章是否触动读者情感", + ) + + @field_validator( + "suspense_retention", "thrill_score", "churn_risk", "emotional_resonance", + mode="before", + ) + @classmethod + def clamp_score(cls, value: object) -> float: + """将评分归一到 0-100 范围。""" + if value is None: + return 50.0 + try: + v = float(value) + except (TypeError, ValueError): + return 50.0 + return max(0.0, min(100.0, v)) + + +class SingleReaderFeedbackPayload(BaseModel): + """单个读者人设的 LLM 输出""" + + model_config = ConfigDict(extra="ignore") + + persona: str = Field(description="读者人设标识: hardcore / casual / nitpicker") + scores: ReaderDimensionScores + one_line_verdict: str = Field( + default="", + description="一句话总评(口语化,带该读者的语气特色)", + ) + highlights: List[str] = Field( + default_factory=list, + description="本章亮点(该读者视角)", + ) + pain_points: List[str] = Field( + default_factory=list, + description="本章痛点 / 劝退点", + ) + suggestions: List[str] = Field( + default_factory=list, + description="改进建议", + ) + + @field_validator("persona", mode="before") + @classmethod + def normalize_persona(cls, value: object) -> str: + if value is None: + return "casual" + raw = str(value).strip().lower() + mapping = { + "hardcore": "hardcore", + "硬核粉": "hardcore", + "硬核": "hardcore", + "casual": "casual", + "休闲读者": "casual", + "休闲": "casual", + "nitpicker": "nitpicker", + "挑刺党": "nitpicker", + "挑刺": "nitpicker", + } + return mapping.get(raw, raw) + + +class ReaderSimulationLlmPayload(BaseModel): + """完整的 LLM 输出——包含三个读者视角的反馈""" + + model_config = ConfigDict(extra="ignore") + + feedbacks: List[SingleReaderFeedbackPayload] = Field( + default_factory=list, + description="三个读者人设的反馈列表", + ) + overall_readability: float = Field( + default=50.0, + description="综合可读性 (0-100)", + ) + chapter_hook_strength: str = Field( + default="medium", + description="章末钩子强度: weak / medium / strong", + ) + pacing_verdict: str = Field( + default="", + description="节奏总评(一句话)", + ) + + @field_validator("overall_readability", mode="before") + @classmethod + def clamp_readability(cls, value: object) -> float: + if value is None: + return 50.0 + try: + v = float(value) + except (TypeError, ValueError): + return 50.0 + return max(0.0, min(100.0, v)) + + @field_validator("chapter_hook_strength", mode="before") + @classmethod + def normalize_hook(cls, value: object) -> str: + if value is None: + return "medium" + raw = str(value).strip().lower() + mapping = { + "weak": "weak", "弱": "weak", "w": "weak", + "medium": "medium", "中": "medium", "m": "medium", + "strong": "strong", "强": "strong", "s": "strong", + } + return mapping.get(raw, "medium") diff --git a/application/reader/services/__init__.py b/application/reader/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/application/reader/services/reader_simulation_service.py b/application/reader/services/reader_simulation_service.py new file mode 100644 index 00000000..aa850b36 --- /dev/null +++ b/application/reader/services/reader_simulation_service.py @@ -0,0 +1,373 @@ +"""读者模拟 Agent 服务 — 模拟三类读者视角评估章节质量 + +核心流程: +1. 加载章节正文 + 上下文(前一章摘要、大纲) +2. 构建包含三个读者人设的 Prompt +3. 调用 LLM 获取结构化 JSON 反馈 +4. 解析并转为 DTO 返回 + +三类读者人设: +- 硬核粉 (hardcore): 深度追更、关注伏笔/世界观一致性、不容忍逻辑漏洞 +- 休闲读者 (casual): 碎片时间阅读、追求爽感和节奏、耐心有限 +- 挑刺党 (nitpicker): 关注文笔/表达、指出陈词滥调、对重复描写敏感 +""" +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Dict, List, Optional + +from application.ai.structured_json_pipeline import ( + parse_and_repair_json, + sanitize_llm_output, + validate_json_schema, +) +from application.reader.schema import ( + ReaderSimulationLlmPayload, + SingleReaderFeedbackPayload, +) +from application.reader.dtos.reader_feedback_dto import ( + ChapterReaderReportDTO, + ReaderDimensionScoresDTO, + ReaderFeedbackDTO, + PERSONA_LABELS, +) +from domain.novel.repositories.chapter_repository import ChapterRepository +from domain.novel.value_objects.novel_id import NovelId + +logger = logging.getLogger(__name__) + +_CHAPTER_EXCERPT_MAX_CHARS = 6000 +_DEFAULT_MAX_TOKENS = 4096 +_DEFAULT_TEMPERATURE = 0.4 + + +def _excerpt(text: str, max_chars: int = _CHAPTER_EXCERPT_MAX_CHARS) -> str: + """截断过长正文,保留头尾。""" + stripped = (text or "").strip() + if len(stripped) <= max_chars: + return stripped + half = max_chars // 2 + return stripped[:half] + "\n…(正文过长,已截取首尾)…\n" + stripped[-half:] + + +class ReaderSimulationService: + """读者模拟 Agent 服务""" + + def __init__( + self, + chapter_repository: ChapterRepository, + llm_client, + knowledge_repository=None, + ) -> None: + self._chapter_repo = chapter_repository + self._llm_client = llm_client + self._knowledge_repo = knowledge_repository + + async def simulate( + self, + novel_id: str, + chapter_number: int, + ) -> ChapterReaderReportDTO: + """对指定章节运行三类读者模拟。 + + Args: + novel_id: 小说 ID + chapter_number: 章节号 + + Returns: + ChapterReaderReportDTO 包含三个读者视角的反馈 + """ + novel_id_vo = NovelId(value=novel_id) + chapters = self._chapter_repo.list_by_novel(novel_id_vo) + current = next((c for c in chapters if c.number == chapter_number), None) + if current is None: + return self._empty_report(novel_id, chapter_number, "章节不存在") + + content = (current.content or "").strip() + if not content: + return self._empty_report(novel_id, chapter_number, "章节内容为空") + + # 收集上下文 + prev_chapter = next((c for c in chapters if c.number == chapter_number - 1), None) + next_chapter = next((c for c in chapters if c.number == chapter_number + 1), None) + + # 尝试获取章节摘要 + prev_summary = "" + if self._knowledge_repo and prev_chapter: + try: + knowledge = self._knowledge_repo.get_by_novel_id(novel_id) + if knowledge: + ch_sum = knowledge.get_chapter(chapter_number - 1) + if ch_sum: + prev_summary = ch_sum.summary or "" + except Exception: + pass + + context = self._build_context( + current_content=content, + current_outline=current.outline or "", + current_title=current.title or f"第{chapter_number}章", + chapter_number=chapter_number, + prev_summary=prev_summary, + prev_content=_excerpt(prev_chapter.content, 2000) if prev_chapter else "", + next_exists=next_chapter is not None, + tension_score=current.tension_score, + ) + + prompt = self._build_prompt(context) + + # LLM 调用隔离:网络错误/超时/认证失败等均转为降级报告, + # 让上层 API 明确感知 LLM 失败而非被通用 500 掩盖。 + try: + response = await self._llm_client.generate(prompt) + except Exception as e: + logger.error( + "读者模拟 LLM 调用失败 novel=%s ch=%d: %s", + novel_id, chapter_number, e, + ) + return self._empty_report( + novel_id, chapter_number, + f"LLM 调用失败: {type(e).__name__}: {e}", + ) + + report = self._parse_response(novel_id, chapter_number, response) + return report + + def _build_context( + self, + current_content: str, + current_outline: str, + current_title: str, + chapter_number: int, + prev_summary: str, + prev_content: str, + next_exists: bool, + tension_score: float, + ) -> Dict[str, str]: + """组装供 prompt 使用的上下文数据。""" + parts = { + "chapter_title": current_title, + "chapter_number": str(chapter_number), + "chapter_content": _excerpt(current_content), + "chapter_outline": current_outline, + "tension_score": f"{tension_score:.0f}", + "has_next": "是" if next_exists else "否(本章是最新章)", + } + if prev_summary: + parts["prev_summary"] = prev_summary + elif prev_content: + parts["prev_summary"] = prev_content + return parts + + def _build_prompt(self, ctx: Dict[str, str]) -> str: + prev_block = "" + if ctx.get("prev_summary"): + prev_block = f"\n上一章摘要/片段:\n{ctx['prev_summary']}\n" + + outline_block = "" + if ctx.get("chapter_outline"): + outline_block = f"\n本章大纲:\n{ctx['chapter_outline']}\n" + + return f"""你是一位专业的小说质量分析师,需要模拟三种不同类型的读者来评估章节质量。 + +=== 三种读者人设 === + +1. **硬核粉 (hardcore)** + - 从第一章追到现在的深度读者 + - 关注伏笔回收、世界观一致性、角色成长合理性 + - 不容忍逻辑漏洞和人设崩塌 + - 对「填坑」和「前后呼应」特别敏感 + - 语气:认真、细致、偶尔兴奋 + +2. **休闲读者 (casual)** + - 碎片时间阅读,可能跳着看 + - 追求「爽感」和情节推进速度 + - 耐心有限——3段无高潮就想划走 + - 对信息密度过高、铺垫过长容易疲倦 + - 语气:随意、直接、"看得爽就行" + +3. **挑刺党 (nitpicker)** + - 文笔鉴赏家,关注遣词造句质量 + - 对陈词滥调("不由自主"、"嘴角上扬")过敏 + - 指出重复描写、水字数、逻辑硬伤 + - 会对比同类作品打分 + - 语气:犀利、挑剔、有理有据 + +=== 待评估章节 === + +标题: {ctx['chapter_title']}(第{ctx['chapter_number']}章) +系统张力评分: {ctx['tension_score']}/100 +是否有下一章: {ctx['has_next']} +{prev_block}{outline_block} +正文: +{ctx['chapter_content']} + +=== 评估要求 === + +请从三个读者视角分别评分并给出反馈。 + +**四个维度** (每项 0-100): +- **suspense_retention** (悬疑保持度): 读完本章后是否想知道"接下来会怎样" +- **thrill_score** (爽感评分): 本章是否提供了令人满足的情绪高潮、反转或爽点 +- **churn_risk** (劝退风险): 读者在本章后放弃此书的概率(0=绝不弃书, 100=必弃) +- **emotional_resonance** (情感共鸣度): 本章是否触动了读者情感 + +另外给出: +- **overall_readability** (综合可读性 0-100) +- **chapter_hook_strength** (章末钩子强度: weak/medium/strong) +- **pacing_verdict** (节奏总评,一句话) + +请以 JSON 格式返回: +{{ + "feedbacks": [ + {{ + "persona": "hardcore", + "scores": {{ + "suspense_retention": 75, + "thrill_score": 60, + "churn_risk": 15, + "emotional_resonance": 70 + }}, + "one_line_verdict": "一句话总评(带该读者的口吻)", + "highlights": ["亮点1", "亮点2"], + "pain_points": ["痛点1"], + "suggestions": ["建议1"] + }}, + {{ + "persona": "casual", + "scores": {{ ... }}, + "one_line_verdict": "...", + "highlights": [...], + "pain_points": [...], + "suggestions": [...] + }}, + {{ + "persona": "nitpicker", + "scores": {{ ... }}, + "one_line_verdict": "...", + "highlights": [...], + "pain_points": [...], + "suggestions": [...] + }} + ], + "overall_readability": 72, + "chapter_hook_strength": "strong", + "pacing_verdict": "节奏总评一句话" +}}""" + + def _parse_response( + self, + novel_id: str, + chapter_number: int, + response: str, + ) -> ChapterReaderReportDTO: + """解析 LLM 响应为 DTO。""" + cleaned = sanitize_llm_output(response) + data, parse_errors = parse_and_repair_json(cleaned) + + if data is None: + logger.warning( + "读者模拟 JSON 解析失败 novel=%s ch=%d: %s", + novel_id, chapter_number, "; ".join(parse_errors[:4]), + ) + return self._empty_report( + novel_id, chapter_number, + "LLM 返回无法解析: " + "; ".join(parse_errors[:2]), + ) + + payload, schema_errors = validate_json_schema( + data, ReaderSimulationLlmPayload, + ) + + if payload is None: + logger.warning( + "读者模拟 Schema 校验失败 novel=%s ch=%d: %s", + novel_id, chapter_number, "; ".join(schema_errors[:4]), + ) + return self._empty_report( + novel_id, chapter_number, + "JSON 结构校验失败: " + "; ".join(schema_errors[:2]), + ) + + # 空响应保护:LLM 可能返回空对象、空字符串或拒答, + # 这种情况下 payload.feedbacks 为空但 schema 能过(所有字段都有默认值)。 + # 此时应判定为降级而非假成功,避免 API 层返回空报告却宣称成功。 + if not payload.feedbacks: + logger.warning( + "读者模拟 LLM 返回空 feedbacks novel=%s ch=%d(可能是密钥缺失/模型拒答)", + novel_id, chapter_number, + ) + preview = (response or "").strip()[:200] or "(空响应)" + return self._empty_report( + novel_id, chapter_number, + f"LLM 返回无有效读者反馈(响应预览: {preview})", + ) + + feedbacks = [] + for fb in payload.feedbacks: + feedbacks.append(ReaderFeedbackDTO( + persona=fb.persona, + persona_label=PERSONA_LABELS.get(fb.persona, fb.persona), + scores=ReaderDimensionScoresDTO( + suspense_retention=fb.scores.suspense_retention, + thrill_score=fb.scores.thrill_score, + churn_risk=fb.scores.churn_risk, + emotional_resonance=fb.scores.emotional_resonance, + ), + one_line_verdict=fb.one_line_verdict, + highlights=list(fb.highlights), + pain_points=list(fb.pain_points), + suggestions=list(fb.suggestions), + )) + + # 确保三个人设都有(缺失时填默认) + existing_personas = {f.persona for f in feedbacks} + for persona_key in ("hardcore", "casual", "nitpicker"): + if persona_key not in existing_personas: + feedbacks.append(ReaderFeedbackDTO( + persona=persona_key, + persona_label=PERSONA_LABELS.get(persona_key, persona_key), + scores=ReaderDimensionScoresDTO(), + one_line_verdict="(该读者视角的反馈未能生成)", + )) + + return ChapterReaderReportDTO( + novel_id=novel_id, + chapter_number=chapter_number, + feedbacks=feedbacks, + overall_readability=payload.overall_readability, + chapter_hook_strength=payload.chapter_hook_strength, + pacing_verdict=payload.pacing_verdict, + analyzed_at=datetime.utcnow(), + ) + + @staticmethod + def _empty_report( + novel_id: str, + chapter_number: int, + reason: str, + ) -> ChapterReaderReportDTO: + """生成空报告(用于异常/降级分支)。 + + 所有降级分支(章节不存在、LLM 失败、JSON 解析失败、Schema 校验失败) + 均走此入口,标记 is_fallback=True 让 API 层能精准识别并拒绝持久化 + 假数据。 + """ + feedbacks = [] + for persona_key in ("hardcore", "casual", "nitpicker"): + feedbacks.append(ReaderFeedbackDTO( + persona=persona_key, + persona_label=PERSONA_LABELS.get(persona_key, persona_key), + scores=ReaderDimensionScoresDTO(), + one_line_verdict=reason, + )) + return ChapterReaderReportDTO( + novel_id=novel_id, + chapter_number=chapter_number, + feedbacks=feedbacks, + pacing_verdict=reason, + analyzed_at=datetime.utcnow(), + is_fallback=True, + error_message=reason, + ) diff --git a/application/workflows/auto_novel_generation_workflow.py b/application/workflows/auto_novel_generation_workflow.py index 06c78047..2cbf7e75 100644 --- a/application/workflows/auto_novel_generation_workflow.py +++ b/application/workflows/auto_novel_generation_workflow.py @@ -819,8 +819,9 @@ def _build_prompt( beat_mode = bool((beat_prompt or "").strip()) prior_in_chapter = format_prior_draft_for_prompt(chapter_draft_so_far) + # 字数控制:硬性上限,超出将被截断 length_rule = ( - f"7. 本段约 {beat_target_words} 字(本章分多节输出之一,勿写章节标题)" + f"7. 【硬性字数上限】本段最多 {beat_target_words} 字,超出将被截断,请精炼叙述。" if beat_target_words else ("7. 章节长度:3000-4000字" if not beat_mode else "7. 按下方节拍说明控制篇幅,勿写章节标题") ) diff --git a/domain/novel/entities/novel.py b/domain/novel/entities/novel.py index 6c018770..5715e5fd 100644 --- a/domain/novel/entities/novel.py +++ b/domain/novel/entities/novel.py @@ -60,6 +60,8 @@ def __init__( last_audit_issues: Optional[List[Dict[str, str]]] = None, # 目标字数控制 target_words_per_chapter: int = 2500, + # 审计进度指示 + audit_progress: Optional[str] = None, ): super().__init__(id.value) self.novel_id = id @@ -98,6 +100,8 @@ def __init__( self.last_audit_issues = last_audit_issues or [] # 目标字数控制 self.target_words_per_chapter = target_words_per_chapter + # 审计进度指示 + self.audit_progress = audit_progress def add_chapter(self, chapter: Chapter) -> None: """添加章节(必须连续)""" diff --git a/frontend/src-tauri/bin/backend-sidecar.bat b/frontend/src-tauri/bin/backend-sidecar.bat new file mode 100644 index 00000000..075f501f --- /dev/null +++ b/frontend/src-tauri/bin/backend-sidecar.bat @@ -0,0 +1,29 @@ +@echo off +:: PlotPilot Backend Sidecar +:: 由 Tauri 自动调用,不要手动运行 +:: +:: 用法: backend-sidecar.bat +:: Tauri 会传入动态分配的端口号 + +set PORT=%1 +if "%PORT%"=="" set PORT=8005 + +:: 查找 Python(优先内嵌 > venv > 系统) +set "PYTHON_EXE=" + +if exist "%~dp0..\..\tools\python_embed\python.exe" ( + set "PYTHON_EXE=%~dp0..\..\tools\python_embed\python.exe" +) else if exist "%~dp0..\.venv\Scripts\python.exe" ( + set "PYTHON_EXE=%~dp0..\.venv\Scripts\python.exe" +) else ( + where python >nul 2>&1 && set "PYTHON_EXE=python" +) + +if "%PYTHON_EXE%"=="" ( + echo [ERROR] Python not found + exit /b 1 +) + +:: 启动 uvicorn +cd /d "%~dp0..\.." +"%PYTHON_EXE%" -m uvicorn interfaces.main:app --host 127.0.0.1 --port %PORT% --log-level info diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs new file mode 100644 index 00000000..d860e1e6 --- /dev/null +++ b/frontend/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json new file mode 100644 index 00000000..899da7e6 --- /dev/null +++ b/frontend/src-tauri/capabilities/default.json @@ -0,0 +1 @@ +{"identifier": "default", "description": "Default capability for PlotPilot", "windows": ["main"], "permissions": ["core:default", "shell:allow-spawn", "shell:allow-execute", "shell:allow-kill", "shell:allow-open", "shell:allow-stdin-write", "core:window:allow-set-title", "core:window:allow-close", "core:window:allow-minimize", "core:window:allow-maximize", "core:window:allow-start-dragging", "core:window:allow-is-maximized", "core:window:allow-is-visible", "core:window:allow-inner-position", "core:window:allow-inner-size", "core:window:allow-outer-position", "core:window:allow-outer-size", "core:window:allow-is-fullscreen", "core:window:allow-set-fullscreen", "core:window:allow-set-focus", "core:webview:allow-print"]} \ No newline at end of file diff --git a/frontend/src-tauri/gen/schemas/acl-manifests.json b/frontend/src-tauri/gen/schemas/acl-manifests.json new file mode 100644 index 00000000..86cdb1f5 --- /dev/null +++ b/frontend/src-tauri/gen/schemas/acl-manifests.json @@ -0,0 +1 @@ +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file diff --git a/frontend/src-tauri/gen/schemas/capabilities.json b/frontend/src-tauri/gen/schemas/capabilities.json new file mode 100644 index 00000000..f3bd464e --- /dev/null +++ b/frontend/src-tauri/gen/schemas/capabilities.json @@ -0,0 +1 @@ +{"default":{"identifier":"default","description":"Default capability for PlotPilot","local":true,"windows":["main"],"permissions":["core:default","shell:allow-spawn","shell:allow-execute","shell:allow-kill","shell:allow-open","shell:allow-stdin-write","core:window:allow-set-title","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-start-dragging","core:window:allow-is-maximized","core:window:allow-is-visible","core:window:allow-inner-position","core:window:allow-inner-size","core:window:allow-outer-position","core:window:allow-outer-size","core:window:allow-is-fullscreen","core:window:allow-set-fullscreen","core:window:allow-set-focus","core:webview:allow-print"]}} \ No newline at end of file diff --git a/frontend/src-tauri/gen/schemas/desktop-schema.json b/frontend/src-tauri/gen/schemas/desktop-schema.json new file mode 100644 index 00000000..f827fe17 --- /dev/null +++ b/frontend/src-tauri/gen/schemas/desktop-schema.json @@ -0,0 +1,2564 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "if": { + "properties": { + "identifier": { + "anyOf": [ + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "shell:default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." + } + ] + } + } + }, + "then": { + "properties": { + "allow": { + "items": { + "title": "ShellScopeEntry", + "description": "Shell scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "cmd", + "name" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "cmd": { + "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name", + "sidecar" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + }, + "sidecar": { + "description": "If this command is a sidecar command.", + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + }, + "deny": { + "items": { + "title": "ShellScopeEntry", + "description": "Shell scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "cmd", + "name" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "cmd": { + "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name", + "sidecar" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + }, + "sidecar": { + "description": "If this command is a sidecar command.", + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + } + } + }, + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + } + } + }, + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "shell:default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "ShellScopeEntryAllowedArg": { + "description": "A command argument allowed to be executed by the webview API.", + "anyOf": [ + { + "description": "A non-configurable argument that is passed to the command in the order it was specified.", + "type": "string" + }, + { + "description": "A variable that is set while calling the command from the webview API.", + "type": "object", + "required": [ + "validator" + ], + "properties": { + "raw": { + "description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.", + "default": false, + "type": "boolean" + }, + "validator": { + "description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ", + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "ShellScopeEntryAllowedArgs": { + "description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.", + "anyOf": [ + { + "description": "Use a simple boolean to allow all or disable all arguments to this command configuration.", + "type": "boolean" + }, + { + "description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.", + "type": "array", + "items": { + "$ref": "#/definitions/ShellScopeEntryAllowedArg" + } + } + ] + } + } +} \ No newline at end of file diff --git a/frontend/src-tauri/gen/schemas/windows-schema.json b/frontend/src-tauri/gen/schemas/windows-schema.json new file mode 100644 index 00000000..f827fe17 --- /dev/null +++ b/frontend/src-tauri/gen/schemas/windows-schema.json @@ -0,0 +1,2564 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "if": { + "properties": { + "identifier": { + "anyOf": [ + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "shell:default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." + } + ] + } + } + }, + "then": { + "properties": { + "allow": { + "items": { + "title": "ShellScopeEntry", + "description": "Shell scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "cmd", + "name" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "cmd": { + "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name", + "sidecar" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + }, + "sidecar": { + "description": "If this command is a sidecar command.", + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + }, + "deny": { + "items": { + "title": "ShellScopeEntry", + "description": "Shell scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "cmd", + "name" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "cmd": { + "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name", + "sidecar" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + }, + "sidecar": { + "description": "If this command is a sidecar command.", + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + } + } + }, + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + } + } + }, + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "shell:default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "ShellScopeEntryAllowedArg": { + "description": "A command argument allowed to be executed by the webview API.", + "anyOf": [ + { + "description": "A non-configurable argument that is passed to the command in the order it was specified.", + "type": "string" + }, + { + "description": "A variable that is set while calling the command from the webview API.", + "type": "object", + "required": [ + "validator" + ], + "properties": { + "raw": { + "description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.", + "default": false, + "type": "boolean" + }, + "validator": { + "description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ", + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "ShellScopeEntryAllowedArgs": { + "description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.", + "anyOf": [ + { + "description": "Use a simple boolean to allow all or disable all arguments to this command configuration.", + "type": "boolean" + }, + { + "description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.", + "type": "array", + "items": { + "$ref": "#/definitions/ShellScopeEntryAllowedArg" + } + } + ] + } + } +} \ No newline at end of file diff --git a/frontend/src-tauri/icons/128x128.png b/frontend/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..85cb2d58a78f1303b6d626df2eeaa490c1a736d0 GIT binary patch literal 27125 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 literal 0 HcmV?d00001 diff --git a/frontend/src-tauri/icons/128x128@2x.png b/frontend/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d6e26a006636ab7b5a834a0a2bf2ac48f88dd36d GIT binary patch literal 86509 zcmd2?V{;`8vy5%qw!JY=Y}>Y-Y>bU<+jg?i#>RHT`9(y1J&idZLsR zq!8io;6Ok?5M`vrRX{*M|7$@(V4(ghdM*_fAfU=EGU6g?Uck#<=!DXBcOsALuN~jq zhr$} zej&MgB`CQ2<5zXHbMfcrM=($4pjU75M$id<=PmeVzR^?HRdMTGs`4rIe=B`T5r96% z-n?0c%WW)Xwak3(L`RV7HsM0`FO|J^e?|q`{`3fh1>*cTyybh$;=iTF=gUp(d;7I5 zV<1uYEl)h_uS?=?jtHPG9*@ryw(ECU8%|5#A zeWJGB(gMnXGp&BTy1<{swrr=*p{%x@vxTq2>TGx39KE|O?ov%lzi+NLKYRtq`iDT| zz~g_uZyoF#SE9Htq=2{C<-21mPtwi&d%w`GK(PLyaXgOFr&567!HU(1k3v4%zaMP) zRl}y}pYAgk2+S8=zkGY2zrKx{K2YN=x2UNG0Bol<){YRJk3LGaKao z;2VY`Mp^B6S3{klla|9?-t8Q9x@)3c_m0q%S^304OY>I2g2I0 z4NNeyi+FlJ1)jjvnmsdD8t>kIqPUO1{Uxx^gfo=Wr+aN~l_c}5&A`!iLdhDBO z?Kt~S<2i%-Tfr~3yi8X&?S^qHZR>QHaXGLvArm{M=BB{Iie!BA7<&488Nr=B-ZaLH7`*$#FKjOVF@8aN>wu8;7hm_)&Zk{hGa@e-9JAGmISlXrCsrH|;v#X>S`7dGt7t0s3C` zNfQ4Nm_h7EJ+roiUP?jOwjcBrsayS;A@h2ZhwFOP$XabZ5s10Tmmki=)z|Mc0w!xX zHb#I|U`KQmZ>SNIda`-bqD!&|952imrlHtb9KjkcPujsJrcgFPcpLM&AV3n^xE{5) zk21y-!hDmv;LZ)1XV@lYD$4lss%^dU(MI*|*g09r?M_N997c&a(A;*W>5%^T6&U+=}uVTng4UbVRWIJJoQLfbHoMkznN^2kLGn}5B% z4(NZLBS=03z4KW+U)HXPG%Dy`NU8fI8Gi_`*<(4Kv9b-q;x-Pi6f4yNH|-@+QsT@?PP z*MdGN4HjwkXg-b7;UqtP38u)~OT-Fv>Bb%^_4Kz?U3o1WWipV6}RnYNtnzQN66=;#?tC zpR-uZ1nab;h7>Y*`{~w~xP*NqR!e`AotsNQ zd~Vgmg!#Z}qlVXT!h^klU6bp2(p}N=3NA>o3pBKw?x*7y{%WlIeP=Tz((4#thw!?U z@#d;QtV@qwmjO^plUL$zdooIlO#+3#phawM`=h?af`-ZMfVZ=b>GIsOhO$!bQ^g+J z)S^-{|AvR@ku-)rE)`um3%ggu4}{J;Vc3+~LvL<=8ZUNjN#@e$6IbqecWXP!Hz z5_a=~rI)Iy-Zcp%__RS4wyEK(b$yw>iX}E;)bl&K3&{@j&|>fynQgbOs{+;(D7tRf zq0Uu)HRi5Ly@eu0B-I{DS_teBIZ zhmswr3KI&B^@nr!Gx%+J2RgK6sgeulT2vf(moC9|D8C9WT1d-1Zjv&ohQ!-?E3l~^ z6hXwq*MqGU(RB8YSZe74)kwvuyWdeKC40?_HMAZu?Plfa zEA_cs0P&QE6*F*kcfK0jU{_Mbi1Rv1Ad8d_4Kf&vo&ZSklVenMOBW0DhS9ATtAqUODqAR&t-r9v5oqeX753bR;a)wpw|`Sw z?FZ78Y1jqPakcKAiJtF&*=e zA$y86$ILEUGj##zmaaU39y%b)!CBo2D<12Yd#x+rmIPT@5T5Sv3(|esUaIL@y5;~B znOmR14ym8&ua8*9__5#QbEt>#8DY^P9Z$Tz-UryWli+?$xGA!weteksZmHYg5huJ( zOA#+R3plzHW8@x{x;XqxYi^gs_ zq55TX((9s!8?5vbcUWC=DZ8l=_iCZ*$j-M=+p0UFHe|Wzv;2<+T82=RDr?aCNkyjK zI8y;st=^NJU92kU)attwB^odQbRpH+C#)c*lii93?(@l|L%m*&LI5H`um+V;3##d$ zS8$rGkxu9D+s`bEX2=^vxq`+>I=*e)O420ZPEH>YoIaSj$mI{`8@~=fid}b_7ZU$pUsPVd_JPddy zi3xRa8AgA{*iB$;Dy{wTJ=Fz6y+@2qbPCHYx4`}tw@`n$Y)~dnBpITS;^>X_k(zkm z1cvXtVJINmm$tm%td(gTuj=Zx3m3GAEwI>j3WEm?Vb{3%7;Fl1A8o|fyjDNCeJM4| zPop%;8-*(UYj9_N-}d)Oa~@rQ&@L<3kcH!f=c84`rc0#To^p>6H-W| z+C|~WnM~)fiUmI*x!WZJ?!5(q~b=V9+} z-L+2{PV$I|G{v?|-5W5FcbbSwKY3{3jg=63N0)D=Qh~8!GqwDmnf!ITL6+U8&yV*Z zvh`A^?=6hqhJPPc8!X~xK#75o&2L%7{cS*FkajfVv^J^n10ag(-3uM1!Y0AgL^4l- z9*7cbbe#j-=ax;kn0Ip3HPuXUPj#yF>iL%#oYE+IPkD+hzODBFY4O(P}h)j}}%ozbs~S|bph2iflnxqneS4)N{-c^;KxXex!zb(z(4{%)5O35}UY^!mX$EPQqtGjuT4phAx~%_AgGO-`HqS9KeTH!9 zLBZH-9_(w?Us2YzR4F&i!bz=Ux==XTiqLD}ow(%I8t3fR|> z0u@Nw{O4a6@~`kDb?7=|@{n@TT55DiAIovR%hP4;nC=l5+4}fYx!&X5mZC4mP~L1( zDV1wv7OF5+nDX&3dl0gBY1=|!Rt?ZJWo&Haw<&Gx4Wa3hLV&Bv5~TIcpI0HFl)s^u zDIe7)xG%L}^}PH9?;pVy|(%D?}#s-6V=~4^wC> zAy^!Bq)~C}pLR{OBY4M>gv6IYJiDkRtj{nXoRZ##L|yu8NeBXg;z!DfXb;6q5|40TGjZQ%C{_OKc_&Cjxfs`O`3e)Jg-+RE#gklY~(gLhz)h z#La$HcEJ99v8@HfHl%V#Mjv&w$JBYUOUH{?$Y!?~p;j?TdqAh9it|=`-=#Xg-$am} z2FXKio|A4^?e9#(4AADs;Ojf5pr_&pZEOve5_}1lvjP(UA##QDSvAtEDK|5Bd)`si zX2}hJ^iJ{|unLt2EtM$Pjj8nT+G#QabbxOU(T3PlJ6Q4940m>x*OP`(24}g`pp(X? z#q*vN52~%dr?$$?i^1v|gc)o}Co!d2b&sXm5Er_tm`ikWQ#-CX+^HV3 zGTWZPhpCREzH}_T^BCX(Dd>ngj7U)gcfv23`CBe2fdmbio;xBsNfZ|5G0?_P)dHev zlVSpD#QBRniaO0%uXgd)w*xx}$TtY)pNc5uaPR_gp+|K=fk<{o0Q7EbQ{!r)cXRO< z;sH6rUn-So%w5nV6G)p+ap_OD`4D&@W}$RCDn)2L;|rqMGDC}YukGB_D%Fk~_FO@Zqhe(P5pyIRq&(($}$(OGabTe7C35u#2IFQczm*>_|u!@p2W$TX0PkiC_Q8GPzLD{-VI&!{8qJp%0+o+W4 zt)xom702I|m#ceLOocy-L5cRLh;EyUSu374$W%)D&NMwuqoJ=rP5--s#3y} zF#TDQvAMva4<@45k}OqQfH40W;nsIlvqFQz5d(c!((Ey|Cn6CCeJ;Z|Qt|9jvG)oE z2mo|g+bQuMG~lD!AQr=6geArXwB#XQNKXRXi%lL!>KjoS8dx^5YKYnp?_ch6?h<&gI0!}6FwxTGu4 z-}v?WuYSr7cdEc?Ziv>qPH}fM zdqh&R*lJvQq|qJybY}x$r&lDXt)gK(hvT@8!?sW+y-esSBCBfIl(7b+&zjpzAL?|n zVrm9jUwTb5$?{pZ8Y4`=-Mc0tK=Uc36RP(;vk0jOfB*+8F^aqmVS4%85$ad z_eqU-K6k>L94i#^d_VtEb2$dMmp;URvScdYcvmg`OcUpof{~QT?U+|r%hb{awyo6B z3S8gLSU1b+M(9@}RWIq9OoX?oKtG>m2ZdLbuju2+-gDxCZWybDybc4=oR%fcSn28E zB@$%4gOPWBmjZR+?G7Jwn{4;F)=)y|+pXjW?Cpxu3q<=P?N!s8AoC5CRo-CXRAs0` z;COo!It9Nsn<`YylF_W^MHrIQFj}iq&P@>XAL>k*&uQ*O1t(Cyz)IM>QLs?=a2V$u zgJoJZ-Me;t<4M?1+%vYukS#77K}igIt41q2To2OJ3My(hEA6EICfi@+pS3a3X;Z>~ zg`wV^^s{Uisz)bb1w}tGM!1-of=}c1=xvN@RkJGTW0m^AGY)Zst8YSS&`-ESI_}sn z()PoObC_T%DOaY#0!Rt92*KM35vi+N`P1u?@2;21dcZddj|$O@Yg=CDG&&-!`l9jq zILfhm2}S0HfK5)}aLZZGeIY8I1>Ao`+7;cqe*g%fPxi-G$Lb9sk!Tl1_=~TMH`>mZ zaqOrx|9Vszd%7M*H~myoF7)sn)X6|CUq3m`p&P*JC|WG-Jl1c&KhyW#;)Jji%pZK% z>*}9x11YJi)r=ulLI_K2i;`rbBq^`(C%AwAMs^z&u>`yiccPND!zPY;tPDAFA{$tX zjm1?uAbCM36-$$EBlVQ>i=HcdHQdKL?V7EK5S^jSY1wWW)M_lZrD)i%VtISliV`=q z`)xEga!ojgUF7gK+5Uq$oM`jzED_Jdvt2gOu_fQ2(4^3`AEh?_I#+kKQPg|z0t5F` zFs?{mGqo#pGbcYTARofxL+>;hoaP9lON)L_8=PZ>NWn+e>2|~t)`2{0A-SPC8eEar zu8u$7pyL1$Uur&k{LkIYrRTPgfHVtpDAsa#-psje0-5sEDBpCY0p}uc=MG{n4fs%_ zm5K^7BXJ&Io_?r{Wv6~h1)*68E8%}{Q)PBnom_?zZPOjT_2zTxo@D&BB#&i#bd1w& zQ=$}^wwB~!y!LvDngRy|vk8OQr4q-SP5kdzg2{o;76)znYo)cZvS8cm6z&qoz_l>M zj~cw`YivH*p)({xpJm^pL(dNt%_E$s(<&ueg@~geb6!S92~1l+xDa!}k1{!g);U=1 z23JstP(q1mIyDMG5~<*@Vy2wRx2EV~5pCKpo-XOJ(VuLs$AGw7xHn)cqAKZy^YBuP z^wVKs!~7UYuvvQM3}dwQ`cowC z=Rvt^Coefrr`2YSJVGv|>!5Q>(D^#AfY{v{6SzjQx>y*Nu3={<11Ve!KvdeQS8rd) zljp?evqd3{?p=Aw0J4GFK5bONX(_bgKReZbKABm|Nf?lhX+qc?x7?|mH+tDeC7XG6@~xwqB6Bzx zvmn<_rT!#64NTW6-Hd#9smgyurkit&V>`j&-)Nl%C4GAunTTRhlpJl<>M(xm4Gwe&yN&;9VO+~Rr$jOrK&8IG>y%zXh#liO5gCTqd?qsw+u(lZB zs22#7N>DhX0xO4om{rzgXO6A%@0<@C7FA>0E{V+XAfrmc19^j?q@1qEAjRkOoQKBu z)&MGFS{^Dh&TZ>J0D7XqNV{ZHbloz_N*aoIr_@z9-#L_BQprJ|2WxxgMSfIaQ!`m{ zuuz@1w_(E}B20A!TVGc5one|=gfT5C|?B57|)cYfEWRKH{qbSwJY1A%YP}Dk8`xA4G_s590J6@N zSwO^rHOh>^U@P@oyVk03F|_-bf>J(?oDk_L)0ff*h_lR8z%Dk>T1(J(Lel|yH_YU@ z$h9)kY-?MOrg!R0Pcb`jOB)OSjr-^eaTE;2!vme#Be_YxJ7~#JKPQBSoj5rT6t0)*mvI$X$=4LfXVa1EcWe_6NqA2LHytDd zJ1Z0wS{s~*CVk3w^xwuBsjt*{Wl=p$h79W%i&B%nSv*<=Dq))Tj4_3?V6FT4PB#EeMzTlzqU5qwJ zj9w@Q0Q%ccMXe;=>@fUhLI?u9BgtWh?LA+MY|P*EEws_hB1!WMa?P*CFj#Gb8B}T5 zLtiIMDOIP_*2=3=Z?w6|A7cL|%VTsubJrwt-%su176!O0Q~~j_-QY0H1>u?@QUF%x zRRzIO!&+3^I;tc1pjWs-BGDU_N>a}9h9c7L)I8IXZ{8R?W`?IwnHroij;6KYpM*jPGw>@UGaHRs7hZ;j zfy8uhjI5ICG?3=I_#9X#M-Nckb`W)laj}-lP;uuj>~Y&4enkX8fNt3I5iT*e?ULFVu^f-=rwB0J zbgl2$JSdhkZJmE!4n?;Yp~(UWuL-DBDvn!LPfN*@>~!<&yBjdOmv@)o9F^@$^;*e- zs~?Df3%ujb=6p@pE!1)ATuxLF*Q4ceD;*tkt&<-Y2i<(&`}O|(Jd_Kj0Z7^PFPi#r z=XYS~WM+Mi1^X+oMWBSm)K|&S^1)7*=XaUN^YqUg`^F{`$d&gd$}|sI^zDtmvP5-*J zML&aDPQ`udy)I|A8qCFK=c-;4pgk}T_|&dSwSj9=kO|YQ&j`n;gmy;_)9Spml2uNU z*Al9>KJr|}Y5}Bj_qT4*qfB^vbSikSYr!dDPehT%J{xy0OQ_K?NpQ7T9={d8RrlMR(HxhjX-{CQ>#x_I-2}GUu=eCGC7aO zR>B+3R0va{2diM^dyF2C_d3S9X(aJ?P{+oEdD%{5zT!$d`4tFaQed#u+Sx%-ADr(A z5l+!&$28*;q}~jw<-$FU^QXFNL1IoAIjCSIzyt{&0S8*eRq)XTe+!jVPL!XrAJ%@U zQuFNg&#?WjMyjLSOd}lz#swpMK|~rrOojU(BjDWew5wA$(Bvl?7e< z=6cLrLwLoVSE@=;Q0^GrNMVr&A|Y16`47(STkAvh^en18tII2RiyPHT((e z6;t1^(rKRZe0pA2LKn3hu=hxLUG*Vx3V{{tjtfwg;%*)=sbh#yAOkZj4LaGG-(My9Jqu;V76gT8>nUnbLBYoOPd z0nlYu5cu#4?soO`_i@SbOHdt(9eb0GFgr&ylg-LsN@=#Ph8V|}2VG!{qA-7WcLmWT z&bk(ME776$Hg%3vzKsiaO;=5(M$XfNNA|R%76TkX4})a9)O{~;axz+vVQv-&w)OHZ zOk(98K~-}gE=O|GJH|uc))U2vVd3dtdXW0c zr_SXg2j7CQ1DWL$mm7#f>s@&+#zMemVbv~H{3=3&1C6AvlYs_>4LX{73zxiz`ngDZ zl*+Vx3luPl(ED$>FRev_+|Xf_as@?L9(b|oEG%WI5U-}=Ystjbm?kg-&xu0mDbePb zyLIE#X}%6IQ-tHH2pkmDzk*p(i!#l(A#WyFjBYHg)+@e|D8YB z%5sUJlK10|ci<=g13s}W$0QCpm@rj=$*Zyh4BQImn@m?*rFG|^XfC3+kKGB)Rlt9A z^S)64x}7MTuL}TTaO~qAv(DLNIouzij#eG#Hvg$Q3494d;|b=D`~72jNfz^4*DQO0 zh<0H`T*o3yB-F{ITdq(w)hjircpzw5y&G7uQV#;YM)F0M9;(*0NhkE9Z3srmWBBwGm(3 z;OL838(^Cpuv7)fEd>Om7~w%=ajmAeWt!7bH|nsvEg^ZkHf$y~l#KrBkLKb@WN-4R zO9x?Y#Z3z&_SpryI_lB19huRI^5dIqxJG-G?Q{>7MmUZ7$?oH%b`t!9s3&*c;41k8^DwTno=1?FX~^9BsbfXM3O(S7Z@+ zj`LGTlsb9Pc!B1@b$vcrZ9=z5T~2o-oLI*dqyAr)w{CxDm8Z$pY5?+zGTinmT=HTK zv1-=Lzul<$T5mtRrh7feIvMe84XRvOI7?feem@h&SEIQwRHC~evZAqW#vPqd>?Gw> z?ce|6a?w`BP}jTOJ065_GEuh{Rccr0yG9sz%&^zJ>jSwtmrX~ppCREE^IFv`l=u9B zctUU}Ta4&GRGQaGV?niFu&4qC;bvQ5RwuO$=nTELCZua{G0?5p@r%r=ZE-Tc!)hcj zGcSbQ*K{}NF3U&qiuhFym#J;xE7;lnVg$NF+$nYu*IHhbHed$2VY$AAbSKc$P5!&1 ztLm6do+boU#6FkB!0YYW$|8^{I6P8(C(qe+F=O9y zk=V--u!@AY9WKA4OG&ie-jT7mSqhnz|9j>eQST$XY@1Td@pI7A`s9~yWxe9!5x0RQ zq}l}QfLJ?@QVpP`)O5QpDWyigp|ZJ!?tG1~88H+l{w{>NZ2;yobv86ots{zWF5ez@ zVZLp19d{|y{NDpap`J9fx2W<|DPF<(16zr(9HUCGUN?#?z_WjTZcC-VJ@IF>{HLR->>Q03g6CUz&Oo7-RE6GW$Sgxa*da;c z_~|I_8irhZa`2`(lSxPK``L5V&?Nv{R_XMP0hZgWdRKsy=Y-Peq=sESszh&A1X$;j=WeY z(4bcUpI1Pwu%M3bX}=GIQTOkSF1>r*BmPj5=-#snV1MxkLz(V{Z9}f{ zD1G{Sm$Z2`8@g&3mw_QLCR=~OLj9I+ob)5-Le@PvN>DC5F@YFIJ=M8Nu%}OQvH)=f zf0L_4PV6u4Rj(!gmH&x-%gLU8%UPTJk`NEY-aSGUW;wQwn}0xJ9TSH&k!h>WWR{J) z{BjRn#L_Bq()1%6;a9O{JCqcwr59yPQ9R{biBe;1bMJ?#uqzQW@=Wqm1VAw9 z8V;i3S5Ap9~{Dqz7cG=H1l5?F2XY@3m>f)e>(=9&SFsAsG^Lf28!KYwa?r4 z=%u?2ftV;$6`Ftf_9X{=gn@*H7ag7%Aa61K2xifQ|(C)U+EV_;T7CVW1T&6hkU} z?OyBh5g|zCbvYI_ikhFF(CJ`4a>^M--5Oi=usih<{bV!FHl2|gMA`0hFP+%3(bx|as4%~HaYf+w~qc* zf5V`39!EvaTn7-9qD))1K$aIoA4X4iO&cg>Wtb%oxe576o!oaD$Le3|3(;-Hng3u} zNh?xk=Qdl*tsh2b=f_`>vbq9d-#;X+|IU0^6M1h8-J>msdoFIjeCh+8zWLprTkFaC z`4P#LFo70yCdpvG+b}vy*teIfBnvnWn4eRzq&=ML3|#mM3(FQS7sFAHew9|oeAMyU zk_D+Uv?XMh!~GYMG>N;SGbsY}yOtNq79~si&hDg5(wD;XqnFI}7{f2@XjGbEr1evg z>Mt2^kn7~NR7%NqDsl(ADbriu756cACGA*Z&b0;n3w34KC=&rT0o#rDQ61wh*eA6UeE`E}2) z*oK-SR2@B75%SX4uvUUjJ!68nWbL(xg35IRR?(oTw9Ox`cPoHo4f_hRs71GzcV0xs z7%>&fx!xJ94_pPG8Q6n&dqzfD98k%Ry`B+}u|6>6uk0%P6CdzFtJCG7PE*Uy**a#(*&)E=r?1&^j_u~ zIR~EW+AY47*JQDkqpPS_&Ih0$?By!n%_T26J!th}O%kkBhuvTS-p=r|O!NZ9_g}VjBaE_8 z+#DNP{aSFX#LKNsMXi8iBp-94ta*IkUl>0E4|kuswxK7eh0!jTz-pEM&4-Vyru(NA?i33R0NPk zf2>ddE!n0Ie|9}PIUY}=HSg|Nb7&VoHfN+;F;$E6#fy~jP3LYCYZT}bZ9XhrRN_?f+;!rAUM|?iW0ZH=OH7ua_tga7nBQ@YJad8ne|Jf zYX5{Hv&hA)&g{>npN=kBWFU;kKip_xDapP zKqr}~Kq16<>uQ4KR4cW=;S#w}4|0nx>s+elr{CKc1P99(w=4ALzQ2)KfWTTt5UN7! z>0rWz*na(_23LZ@&x?cd4P+(ybmRhAmO}t*?`M;dk0QNm(&(F)3N2Y-XZh{d84=&( zlNm1F0EW}gxjh5!U|U;68M!iEu7Jr;a&*K`vn&hE%ZF=bB|i+oUA|H4_rlRx1inoRnnpJ?D>V z{gAyt*JKD)R+jGf-Ot1F22bP1DtTRV&ri_~A%Fey#xj~fQIFsT`DNef8v5iogu)_B zVLb?b1J&<~>;reZ87HIEi7S7dNGHas%Y^$=QnA#nR05}c$&WmgII&urx>K`1-FqGj z|CjKzLi;yath~B$q9%=;Y%38<3>lUIspJRG%KI{f%ArBwxWB2=g*!=Y9KA@Y>>V)m z*^ZBW8{NLfWtyw$jlCWv_02SOlxl~*C|6T+`=j~TUqdDqCj2cQ*qWhwBDLN{)j&Q{sKXEBo*ckNOyh&-yDt9?-z{J>y?6) zesbD&b_(XnqM(XDx%I zfrB9u`>=B5lg3&KbCzycSD&j z*&U`;*A-U#@Fu+J$fglU14epDa_k5J-mT#D=GniK8a(+{tfg2J09U-I=7-Rye@_CoJSqN&~Z$A za}vVg$uILFKLX)ve`}q+IQoUqLDsXfw)yG#(t`z32g$r@1d7*(z1co9+nARtZO50= zRjH@6wYz{*XHK|k1}pSLWhl#dFqWbS>r$_(9Sk!{kfrvg$0cgp>y2T0)zbQfNy*>$ z*J{E&cL>9 zbn?No_xkSWu%973$l9aIF6h*B^1s104q*1n?7%Hd!*I^|F8LCjwsp^{%^{C$6-RsX z+7dmtqqeRUR@pEIs@xUbuaU`ua|Z!t6;dOZ-Hjv{C~*80uPRCLYJX7XnRQ#K5BXIc zpBCvVoTJV8yW?ac&3m}oQJ6E%)sXVDp+x4n_=unX0u~>q!1WVQtkpwTCL;edE)oWU z-YY?nkePFfF3lA$*G7?Ac-1Am>+(MSyn>Az-&hZQUXgTzPuVVyQ(l|xj`-|2=JQGz z7WF9!PJ@{&G+aANltDfu z^^lBOV0~kB9DUvS0VmMUJ9%~S)gEnky z{?}5p_F!uT!~27V06Pm(qv6K7`A9WwPS_?A2FkG1YtHY_q@Jiy<(--e=jkvIb61-I zRJ3*XR=BpE9mH?e=41_QInvPV&MGr-Zmcor{NUi2?YJEk7f#4OY500CJ+a!I%3oZ| z`(t9NIKTNwOTDBm_VuzvYQs1)fBNfBqJ%gJhlPtVWuOIN>poP_&`6;m-kKZq)#tx% z>oICU*#{|ul1b)IN_nEdDZ5XjVam4f&Njd9OdVtxGi z`C|VRJ$TdVm6T5tjO6yuz2Y~H(Jn(>A-h3l(BJX$3)RPizz<`e9b}>K3E^C%ZL2wo z?Oz6X%P*=`$={}~rP2Ot6YAGBI4h-0uq zhZ5YONbT}7<5=uO43K#sF5ivLQC2#xvhiD%msy!vT#3xo>M6ncCzer7>%0Wqxu3cB zs6N!s+8bq4bQ8YDh4hACNi7TX-z|1UBzYgn9x-C;7{P5(9O?1$B&01|0ifYoo+V(Z zD3=WJ*vPyM=-7-kMN8a+Tiai+JH<6WTv-C|V3JJwX*u$e!luwG4Y=HW2gW6E=qi6G zmE(?XmjOhV6LT7PV(+6+mB>r828i_jcM$pkZ@66nyO26OzF_(AS7Mh-z=48EvAANq z7@T6_n_&`Ft`^j*l6K{rV}G?R7wAev{>Hx%cde%lQ(9MIv9$^BHtD8Jag7@L zit7868!df}5vueR{i|y!niCms_6Gp}{uMjuxz~jgW(|@(R9Rkpfn~tQ*2n2H^i7%m zX(91OXH*prG`Bf6RPDSAp?7SoD$!zcmU^RL9_6ED?Sg_irCDy`U_ch1cW%H7cb_Eo z42KcN$7j=d=}0FZ;BW7vY5${O*Tp>l>F_;SubXKiQ?>yxlBW2nmE7%GaaOti8k?a2 zj?NCzwlh4<@YG)=PBjS1a0o$RH=xS0`Oz}}=Y%Xj7y8f6{=Z%a9+-4+*GYiZBpb^x z&;sJeEW}+rAo7?_c z+E>bvK=<#=65>bJNRD6%;#}ZIz&rI4db+0)rEP0A&X83Y}|< z9w$RjH7hrqwT5F(vRIt<2hUIOF+zE7+m4^RSHKDTrIroSPq1GlKV$p`I7~u;pO^SM zu3`3WcdTaQB)}-GoM|j>NY0{Xx?814Vphjh5%|u>NW?3?+W$qsNFX3Lxmpi!Q{HnL zl8siy!Oi;}ts}ak7h7vPKTUQv?j4BS2k`ZhU_uM^M%cdVturNUj^CMUg9UyJ%jnTopR~Csl5-P{j7qA8j$HTj@SJDAlJOQPv*6evu%Y2A3Na zGk>mdTR9lagQ(C$4-_?8)=7L^=NHU=PK;UoU2*0MzJ2GN3v}Gga}+&n`z)6_{nVTv z2cPQz>ITmbdI#O?#9Zi$5QF-JLwsqDw{cw&l}{6Q!Y_yQ7g+Y+dCzAXK?s@pL1pVB z;NXMwjYXol^-**rmzoQ)FFt^#nC3Mt60El-Ge`@vvuvkED|m6_#u*uV6d+>qFyGme zO%Q8!3gWFuxMo(9oyIGqZ0-bN#v*zTbU7XnmiE^9OPjQuv#l?sMKZt6{W_Cx0 z>zc46gNH>Nf@Vd$;@t3+r?UM6)nOQun|lk5W1>V75HZw9JUg42js>EXF_ebYWN_)k z>TTG$>u$)E!~Ie|OVBgzT+2>H{5R{a-p)UE{B^llut{Vrr7)>q%TRaTx|Nn-@^re- zD)wUz|GjamN3;zT6i`j=aje=WX47%Ofj~5GzFlI00KzK5cnEQa?i@y_)`9e=oxJ`@ z=R2RSS#Q_P9KO#vK`yu;ES0|0c_Z%}adAOn_01WxlQ+#yfArTC=Dc>mHXzdbq8>J! zXC1nJe*IZodHE$AsyliZz_kDVu#c(CGz(`zIMzV0-VuZpx;v;2N9Uyq8TkCyM9maD z;YRi35y6}7Ve5kpIwRm9MsYs>Yu?lOEBsD15rxCi%p_LuHS}cx`D+u}*$$UbjN+E; z-M90=5x&pwxO)7p-viS~m)N|R6R+Er*_s1lTF^HZYK}=y!f*hj&M|Hk*i@AJr&!(V z2j}ZZGcZ@7?+va9$L)vIuARlEDC8>B7Y!C?71LI$84N0@zBTq$1&RKkm9xbjzRx%4 zn8`4;iM@P*Q~A+e#ua5JosIx)(vy9To_q;c>R*o9&i!VHT}EFaX?enVb{S4NIxG5Y zBZ@0_7Z;;M~$VwsVN}p z5iC=*XOcW`qPk(YQN~Pmzz9ZmVf!}43%#th>%9{I2&zgMtH3x-V3okW%g@;b~ckYvB zg=fv5Q7+_ps1mqdY3lQn*}13C`nl9}q=m!A?KFD3j=%0b1b?E`AS}#E$1~E7`*f0J7~lBIGL$M`oG}y54?1e2I2Ls zR|;x@`Bz0F&}V+}qx9%U9{~TqNw3W;(jmb{dT9&J_u3yHpo@Ps4a3t%8tKTU2W}_D z-F_esEj)LO)Wt>WGlSF*TN7mv>60YIE(4dS6YoME#(<+WoIa=X0&6O#}RQP_# zp63Rl-pzN(lr@*{``U6_G!PAdPA9lnN}PHzeo+N&Hjf#EqNOg%QZ|`t(mvDMa9uUe z3vGDq!KVPQ*zz#MR9j8t$T(dC7m2Mq;KaI zgs<3iqa(D;5J$uHBQRyP5_Bf9fb$9)M&mHB9xRq&Mzv9E-aJ6#qbNDzbDP-wlP6Bn z`4?WIvoE|%Q>RWrRc1lTe-&&*zQK+bt2~l#TpLv@7sQ;sf^2Rq;0mIG-mN>R|F%16 zVDCGqx@|Y*h!A)}RE`5R&D@h)GS`fSYv1a}#@>o>_O^Z+YR=N)UwoU+{hKe-;?*mZ zjr3E7R7ebua!p1m$VB=3D^;1yG5mN#^ZHssTPlNj`+xqkwDSXx;MjOk&dWmOe7Rb2 z(O|{rv>Dn{Yy1PRQ$sX64;ihi=PuFp(-&y+;x(GNHbsj!=4lzd_=|{cc+(f1bZ(@t z0v7Y%|HglZ*6&&R;{Wl-bm`&}>gI#;oqYXfrx$3~_EGxmul^8P!*|h{g#~&M$9cgh zkp$a<>oWW019a)>1+e>2je(KecrVJmC;~MvP0`5KZPYl8<9OyYK*a&-YjR}Qpph-3 zwEaE%>GdPuq-Xy6_YwYATuR3k4c@}iP=*bsm}%N@Wn1e?f+QF~J*qSfZa|$MCw1UtpAk=H?xFIsFN&sV9 zv}X5jxYJS?6P?W0N)=t5rBbf^p$hTJvi?}1a-+k@<)Tlrq)3vAd{EcYa03COdNsb?~nGR8y3^V=51h8X+$S?e)>n~_8z|t{gc<*Uns^1#Ea$3gYWm5yDW#V+%E3 zW-KIN8L#O9x#zL>KSIxZ^JU>jaO4G4M#MiB0Lne_-n;0hf8k?DgZk(o4D2hoSJO=5 zf-&5QvZAU(}=)^1hCw>f47t$ue|s&oqhQLJ@M%u#dnU-)Sn-t+SLXPvfLOp z>sGWBkL=$_fBt8`PNz?OMQe_7}=H8&Ez@Rj5d%es&1bNoCM)ZvD zwKk3__F0(nLk+y(A>S-E0_>r}lu?;g)>g}pz(uIphv$rqj=uOFRq&kKl~CKtnNHI$ zE2E-@LApU27~f0RPaTvE7{nIbd^am0Pj&b<+I;U11J%Eq=C53$rORiKhBZY#u2soh zC4!@doQFO%qy*HrX_Ut9ybXQew^0R68cp22Wf%$0=eTXa_Hu5Fbp;MV_3BBgj`q>g zhON@Y#m29PG3j1VP4W94qzi*kUKxb(sG%HbE?=YRH;>cBgNM+re3~ZDUPKSWoSOiP zjmOhoxU43EtYH|6kY`#lf5EUpsG;d;WP*nG-ARKFJVHIU-wU3>CPW%oi1n72!&WW_ z1-jMTn$hqWHq}e7(Xrq9U0S+$Ucz_ABxI73O1c->gX11AgP=09dpq6xE5Ah5hwh`L zrq!^aW%yhay}pQRhuFgv2&5?%mn@>RTJV56h_ z5KQOIG9UTnj?L(4-&`WA<380YBaV}T&tX}3ZruOzpZN%V_=lg6*6(MKO1uW+SC=(W zLD&yM*B`x3m!5&~0(jSebltZ7`)PFRAT>^%rXzpxXOzQW-1W}K>Gt=0kmg^#hA7}7 z&JFhm-?{UNJ7}>sN&ohbpM>#+q2~5sP8+eYm3MqiNQG;e{YS18YwDgL$Kn4nN_`uI zxV-HJ+C1WO@;oy0v2(atc+9Eq4t(Rmh&TSnbCXKKV6G^!ev7 z{Kn7h4AZLn!9Qs<8UixA^vag!RaeJuvr+R=**Mco2NpbwD`rkqG0ZD3(D`(JM8S4S zNl7*dbJF(~y!FJRrwXVgR3IzHVUZt`gyZ7A?E60b-_qn82kG1aNHsIf)ToknJx2nW z>VwMbN3dbu4?IB8;sto5+3OMk2z$-r9wGhVjn4U(9s*#r3-#;$zzI;tHUl*&{WNf~ z8!U>7P1JYp7}Z~Yi3Wf67ojP)D0O`X8x6}eVFQ{f=aoRZ5z7 zkF#|3I$b$&hE6?y5Pb8abmi1JXc%0@UT=wq?BNkmeLd2_=mtC**LTK73~~i6(AC*a_p>;Jm!z>Bo4_5RPd_m4>IM=<2`v9XkKrr%+ZLaMU8t?z29i zTm~lwRw|zGV>oTXwdlk3HfJu=)n{I&6VDxhF}ud}J&|f> zG8Vr!_e?`xJ6=FaRDXjIh@^L3NJa*N1e{6Ik*YK!W#|i-U7Vqb&BOEyzxosO(1Ul; zX_WZBg^>R=DiNIDW-yGq;r*|E`2-z%2JDMIY!Xy%#`f)?!JWWXz!E+7^{+5eDOBcf)U+oHpjm68Q+? zj#Pl7RCYEw#5aa;fB7AYAP>o&2|n6%&QxkQ0KAb0f%z<*4IGY8!EA@OMo8b!|9ZCpaUn~-AEl9WE`bb$Idt9;bK*QP&xsmg1T@( zr2S`zz$Cm1&pN9NZKusY_^YHApb>EPu!OD>2FIT7ii~HPzIcQhlh6&oT(th3=p)DC zZ(tE|`=#`=dFnH>$`~Q#e4Ps%i|}MqN8!j$2oM}G99 z^zbKtfF5}FBeZ?*4tzGGr?AN9=8;MuN?>%C<)}HgmQWhyMUCTZA>^Ka?I1kxE0lpv zIJ_0?!;PaZN&{HM*SiL^+)N{-0RT4s{>Q0?W|yf$uYqIGD}DA>CO-kj0=B>3Uwx1l*FXbiNiEJH$OW~_dL9h{`NomTeJ;Y{fBYw{tEa0 zoMv@9C&T>^(y^<5a)OS(gbj(bpoxLCqdPZK&-SfQk-J1E|Ky9*LO9-|+z5{aFfBQa z*?i|c)CWMYd3lPq@862Ny`P?Y0YWl2&cLvEZ|ntH9ctfnG$lU)SU4#hL&E?K0;O-^ z*caxe{pc`9%CWi-%3bZ@%IDUX+Y$8vlURnvfs+DjNhva*{>^$r_Qx{TFW;Z0ln#}6 zdDa4C$uB2Q?l2sdF>5S0+CvYd0He2ArA=9}HJy4J1&T?CFBQ-^Wg`lMHJ5x<>XyNC zE8jbyRWTUC6Ax_C+A9-H0x@WXQM8E>|t*_HA$C_oTP)#yhN`)^CF!+ za)Rb&5oy5t4h#;)CLrE8Ia_%&!t{-ApzS~KahiDlC#YUQ#~fI5nOE2mHQqs>)0?Wm z1^$Rmed$X82ceh8)d2u<4}9)(H2#tI(iBEt@dc0$IQHUrYyT)J6)=Jqzw|Xa32>3c zDyt&{PG3R1wHNas9mQmlRuu2KGIB%#H-|DZNHgD9#H2IcFi3+~&6hXe$A9KS^uf=5 zl$NkgUjZoi8aAw~MOLN8Wqc>zd+Lu*(vcTI>xDsHf*=e7aD!+O?nm4B#V>z>Zd^PE zk&iw>%;g3k{pkfh@PWVicLA0T(83F+Xdh+*H0&Jx&j0-n(8qz?-Bx2so6CQ0YOpTNI|tp!AS%Vt${8YZ;Ft-XxNcZOFIS=hoUCv0@y^dICD49Skr+IIF_GE zDSVOCP(%^75F80j3`t{A7_cBMn5=1v1wV4z-89#`je5bj8{NES5%}k&E>OvW$M$5G=1z6 zU4HEpJ_p|Cm2XjX@(K+gbe_Z4Z3J&%!^UBR%1EEAQ)p$6%vdsq&`$4COGL%j%)nqR z;`?U+9A3u;zk*qGSK-B4NF6rs+eHt)=L!1or$0&`_<_f12f#`W(=T1RhQ;2X9vBXe zT#U@U=n=)5>xT}~!qJ1c9;1{YI$7eBf^$m#51VEgyKCf~_ruKK*bf|#yz!x5`Xw6v z(0geH5lW5AxYGTG>kM{bKV0X+H@`)P{@4GLF1>o#DasD?dATVo7>a1oNw=MEjWzCf zWNqRBwV%lAJ4}5mvkdw0DL@t1WN~Jm#x@Ml&;G-oqQ^e@Zn})?_biUK#^TElxMsJ~LD$*=zjO`SL_f*d?F(@EDgWN^(QxGwZDBb}1x(x$)wxI~?F z>lAZLV^MP5;oXlyC3TMUMoe|N=TWlrnBa2xq|@5V!dPXeILiQ=L4G)W=_qvIUqct? zEIh^@7?L|^aN{P?mS@0IL={^mPLBdL+&p7MY;h83|0@ucXi)9`N6?jq z{QN9JPLv_H0#w|*1r0FZ4fJC}^SLu;e%Z9Vu#`(dcrG31mYjmH*ImH*&LaxC4AgrP zqLGd*1af`oW+0A$sVMdwIMwT?EQLg`7_aOWJ`=oC?idyGECvev0}Rkrv$Z z4ytp99@nxN)CK!(3FXewNA4$uC}YdL_tE&LKY`7^;Gzn?R(#I}dZD~LMQ8rSzot`v z_@`11s~|$*`_B=e_4nD`V<%cELt=9`C0YeT?$wMG6;pn!4Xw*MiheKwZ2@W0J@34e ze&&Dp1=;|uzhmIde+OXSNmj(Iu(D20%m#P@=X>-I4$+YV-~-^8S}=T7qyl5m3hW!w zH1(H%j^?hXMc9*#FK=R@LGyVv9hyHqb%A#8c?8B|02E=Or=R&ET}LlT1#;t#8{Mk`D{`3i&I*%?tAmu|?m9%s&ZU2hhHak z{-l7J4IHa!x)I*OnLxBN_V9f)aMxWDO&Cu-<&tj|b@zeer|Fgd@t@Pwkz>GH2EAav>yNyfl|*FY>dL3-^V3=+z&A>G@!cN6t(sL|ppFVT6Z`1HWwN-*aq zS1YcS=Prcjr7#b*pFV8w_DE#qG-xowRXdEwM+ z9>?Z}ii%4%3D8YN_jWf;jG`mcb#8J;BpZMlB9aF4HDv6d8#G$O=9Uf5G!!xLOO7)H@2gAl>a!XWZ=wI6x~G?(eN63{3TkvauhuT&(Y$!7tuj@ z)z$T(&px;bKLGxi#KRVzftZG*2_#x|hDkp7{^sl{A&A^~2qx`fRx%?ey6xo3VKY`G?h7^G{1-O*dV$`3- zDsXM8{yuQgX{LoH7vL6?sOxXqxs5*l$q&;*?|O*NV56SGPY*mIhmYKB#O=s)sLL-O zdX>iR*-vJ40sv=I21@XqV$qaVNa3`L{Cc3Oqz=7Kul&!yMh$?FHSilG#J4^WR<OZlmGf>=p%W_>{w{#JLk-8#;IiNn z$WMD=xPJ)X=(j)r3LQF#O##%MbLSkX!35NRdbbWxe(p4#`^q0OFV?a2jB^yyZ^3DR zYB*G1q5%vBxp9&1y7vd@?D1FVb%g(XpE>#yh@uOTyK8PeFQksjQKR0FsDKG`jP6SX zf?1Sk6k}sxaLpJ%JPgWhAcrx>XjmpnPm(rNOIZoZ$ zDZMz7i2E^KktQg^1d&7S6=DLgqf$g)>++9Rdl94Ize9WDQ&bU>MhP!!DV2qQtytG- zmIM7By6=6sC@=`q^KMbif`Nyc?8v(Th}{VW8&rRmFiofd$>pFiPoBH&fJUczP@kPd z_q96RsCuqy|4fN`FU!r-0 z&D>|tY_$rAM2m=&77rbuiTm#+JH81`Y|DNlTgS>!j{e+-BX6s!Vmq2=z=|qH|yRH`oAE z&U>;foLDVROPWqU(dYAg4pn&gx#=skVas+pcji@^yn58B_|#w-5%O&icH=V2FpzNG{}9MFGt#nLLCD1Y z{_I`M<{dQh`1@${T@TA}hb9<$&AAyj*FgBb2Z&>*isv6TmyjY{MhJiMWoQGu zM2k~rQF>aY0eI|TH1UjX-A+TBH=x}Zo_ll$*knsIfAxgqTQhIIPUiIks21#^z6b9S z#^U1SG^{mp*A1EHjA5Rm1HQpo=9^7X5y8b_L$HX&{mw@przZdwo{D})fm@-@$LC2y&G}J)( zZ_@)0P!pfa9QU9J$oH>?w5j##tMtZi{HCDYBDT>?DBX-Nc#I}&cx4M}cetNgm8UOJ zkT&zm(rkPM7{&L0@)v%XKKl&gh{isJ>-Cl2 zeVtxDjK&y5B>btY6x2I5gtToO<-8fX{3pLn^U#On@~Aa7&RBH1eT77JAirsE0H>~= zMkH{~b@T=C48Z_6^ykrA$f2QSz~rI~(}>t0wtzWt6d}V$b=6b33xdu{!XD{!t~zF- zf$U?afO zOa@7?SgQ@I|fCC{>4&%rb=G?ADz$@tzMu z3hXg}D)5ds5Q&DDv=Y1mbk(F zC>K^3B*bExMiKt_g#~){$aVVb1Lx`Zn-{3c1f9`Q+J^6`B66CCv1xFwubm(Z_u(_2#&}&DK6Arl=Jicp*Bb-x5Y11R`#FUv@A!x##e`inA8@~?E z&$A!7G{+#QaS?*^_9m&$n4~q;cIdfC1zmd@w{=QFP8z(5CZGjrsQT%D{5g8$$3IMG z0Oo${#xz2H=DdSlXq+)&o&1j@$Nv~M!5{qIK|1gznz3Z_TS4j7K!y5tqa+JsJ^!^Y z(DmboK^q=)o}WWz+*i%rldQTV%P6S#7}u;Or8iVXmS%-CrUd+M3D_wHK>6kc2PgxvEE5av!vHV8<|TvL#ZDNq{fw;O~46Nt*KlHc?5%rD{kJsW_2q#ayk zX-OXhB2BC--t0X?{j$+}VC=XOROKHy9_2w-Az|@oF|70c(IGJeeMn`O(JHOI69))5 z{3L~qXN+&#^`Zs=)KC;9kfauBO}PWOWBgYk0anyF%NN2VszK@Nx~ihsWb~3ZAU3&h z;&HH$8*|qMB_4S2gV@xI)VMw)CAwaC4gm~d09rSeWTsjZrLo?3y_<&K^M0_WHj|oz z9w4ZjEs#tEG~$JjdF;dUsAcwiwI+GLG=$7vqlJsdX!9qDH1O!%RJ~&x7F4f*QU>4~gN(*gH8~35poF#JG%S7t zo96;#uwMj*@FIp+T)8|;qfph^g%V#43v(9CM2^%lQR%^C931;3&Vy0jANr9`q9U?L z&p&+t2C3i8iUTKM8Fb*vaeFkhdla4wDaun{p^MM|r4YP0s^i?e2YjUu{n*Fo zB=j5HVl`g0GYrCY#=w{`8OnGZBadr1%GKfbRjje*%rbtaFr|7%jEeMB@a9L!)~N(5 zvfajjuW{}q8vx$)OJH9Hy1h%0wdDQMljAFJd|Nn|XJ^eRVuSl)*ibYBYq1rj;*?y^ zru@$M-y7*jgaJ^N5)JVJk&jVaWCw;)j_sT_--d8@>X&sQpxr_wRhnU*%vmdquu?@c zk8bx!klrjjiO0kbtBaM_exAh>$^jpzZS z#?7P^pw_dH^_s?j34QuF4S_@7zkf6Jfqm8wgTcl?pei8rjM8&DX1r=I7cICvR0BfH zoBQO|WqKZ%!Ku@eP`c@%J=@SS3zE!i-G#4J>pbr?JO~3sAN$yc1XO(f+b;lR?iFLt zO-QrQGU(^X(SQ6av^3UB&ri+K3rNuxxTT%RL^!r3R@+7U?B^yX=)GVr{>$Hg zjb1ofl_8i;+kqtV8QzPqAHpd5;4^gk-~9>Mfohz{wg4mZ!~e}M(cPbTFFp6gulgu5 zmk?b`lREW6f=_0z&}T2=>Q;IF}L&~@P!m{#2z7FC+%n9fRE zedN52gGQND$Ba+wbALygGjY>P7b_Fec*6t3VMZX+g_$KNOhG(nY(dWBHOLhbHXc$p zxS~Vw%rO0+_V6y)Ml{jTY zIc%xoF3iQ#G=fw>K^#(DnG!+GCV(c%^|8@fJZ+UdCPxH_6c#VCMaPBRb365a;^V6W6;Sz87XIRc*ZG*OtG=1DE;`HkblQI z1Kgv3hzr}bM!i*A6u;nL5aT)g3UBA+o1mvM)5Jc{nviud3yOc zq5{5lEF^|#XbGKoBRe4|x^sm3PadUHU;aGqM^n;_3hwP3RFU3~?|AfAe~xBw?@xX6 z+tQNE>s~4u#xwMdiD^tBn@y2M&O{q#7HULU%ZLySbMv|J2iKIvL5$5qzDty>80Slbdd1D&p8s?+4y#1~Wue0^gGJhfk3>lY`6YPV z8iru-f%l0@Q0ppE0o0EhH|9hJOfvzZ>MIaDA+?1IJ_Ad78g=}05EW&Oht1ol`d&l^ zXdB)2BOjy9kKcz1z&2q7Vm1{%>pHr_NGyp*wo;y*KqPS+ZnFuGq#l zwgEdfBqWeB`It>P(p?ngE21HmTlRxC0Vl7yI$}8 z)^qm!*V_BE`|gwN@Ar|PBKhg*zI)F(d+)XW>tFupw`mwBb3MW~ho3tDhv;)c2$D9} zn%k;~Y!dbEmgz@Uz01=iu3S6-tehceoSn+{TEx?&-B=T(6N%LE5ZUEV|G4eEQ!`~t zTT*^K_#^zRG1-SnKui!4;2P~wQ+uma0!+4JkT3)MgvrYc7-u%@pcH=j%r!vkz*cL9 z3T)?=q03fm{%%AYc77Yz{)eMEC!ywxEW!NHE}N{*ETr_t9csxBTZ*v9RGQ-d=h%6@ zt0L#Y(A!SMgAt5F5ijb6{^RJUa?}93Z?I#~`_DoMf-)3fpf@oqV>D{a3bBLiPAZXA z*eLulfPLr3Y4+SC5PeJ$kl9XKw^8$5cTm?)+)F(G*LrThh1xf71P}vCERWM*+r)EN zeo{rG<2W{D$#NXX3G)Mk&=4L8N?FSy9Ju^0oB_ai;1Eqc{S@VK3KLv_bN&L=^)^t$ z#tvAA0ksERjfEj)aW67uD&qj$qMRF8q*E6sX+vK#eCM-p$SKOgfe#Fg;H33H=e_a9 zy>$4AXEBDH6y_LXC-XzYP_Z>r)86Z-0N3HkgORm*sM?W~YaNn)TuO$BYBv({$kXCx zh1lc6!t6)i|6Y36AN&T*LQC-C$TU3*`?Ptc{Bmnmab%@UVNvx!srSjXE2$SEtp`4J zfR3MswL0S#_GhphIMoi{fep<_^2yWKx4tU2Z`|7P&t!&R``T{$iNE>?%|W8AVPD3W z!Qx4Xgbp0Wb;=qRU_8mkK7*RP7@@5q4AM-R^#}!V2Uga1IRc>#t=*w`%j}0F2&I?C zz(<~!I&$_^OTy&C@Ec7&C1`Q%b%FccjC&=T6d5U-sTC7}lqLH(f&-N0Yb*yYqGGAw zKDXR-H>GypLN4#3>`mygLr}Km0Y5gTOP1qN>SO?hESDs#5~sOd0_KI0SBYJ)hC)g$ zou7t)imFP%u2Fet8)42;zT%k!)Y#lZnH_rogdwnFVo_38HG^&=7y&}TmV#a&1q*AB z9b5Rui$EeREIOX(RAyjRb$OcR&y2xhd;&au^sC5k*W0&I-Mx2H=TE*%HiW)w_lO!y zPftN>FolQTm7ZOi1V3_ORY6>{%})q&hOldu=| zJ@7GlJq{g%Y|6e}^uZxz$An?hvS?yVqHjqsi%K}=H1b~+9h8oW0$P=|(I*`b#dU0&e zucA0IQYyhX4D$uTht|*{7l6*Ubo5eo`*i?tfc{U;7=|2!PP0H-Ap@;|>BeKcbStdB zu_(mwk*&kIZ%-LQl*<4Q9^Ud8FavA|GiOI(2zr^O2c~HOaC2(UcItii+i1g2yo0*0 z+5m~}G|hse&+k*_wK}G!VD=qHLILR&3k=sRCTSIzhoDS#z(gaiCMsw%7T_5$`TPNz zhj6D3RN(G)-PE*YJ+cm9ca5qV*{|7qA{#anY5o1 z-+4?b1QB3a9Lou@uHYFrQgfzP@ZuLk4lcD(MJOA=7Rc3wB^!^li&dmo#J|)PjsFv2 zUQ3sTQT`a2wYKg=ChL$ymk}fuX`2Xa0?OE*vkC0a(&Q-eE)D<-8p3!mkIe(z5T~K@ z0WKv1nV@6)V$&ZR#SvLcFuvJBDqw_^=atsEh98uabu;z-D=hvv=Ao8}B&k&aB&|qe zXOdd}`r1>rg2s3r6wjuXZfe-IR}^vn)U0{Uj0v027!5AMtF8#+1lCPwFkVy0gmbd2 z6<8EXC0}4(m>HDXJa=V|zQd?1##akNGc-r=nF>`Fc%~5Ec(ScO3uotjV>_vI~N!WGy0AfG(m#@&d$!4@wX(2iV5!nSyG8)!l zTC)bF|B;vI+}HjA%u7pXQ5D;6;{OArZC%+ztM0fS>)-f1@S17IMsfBBPt(lQl)-Q; zU*53&D)x8Z27VTW<}F$ZWdc&TP2~E-+OeEvBj9WRj0!yQ6%`_UhK353h&4?BtQxB(UTpM;Ma#?4>I0lox8d%ER)cJtq zw#lhfkU~s1Kdo@ENcs~uzX;`O$kHFv%US|$Jd&TONl7@Ta zairen9O+H@JryU$W%1OS<*HaSpmkIM3h9IiD~QtinNR(9+W78!K}MRz7;*vB3q|Wd zEnb~s-+f$4%YO%*hq-UQCG-UBVjgv7@lbEL_jZit9H}(8&1gZDb8-JmGziCLzIJhJDooAQ zh9c|>doEVK$EnVuhP4E_5JN~x*Ud}oB}SjGs{(lKd6_KyrsgI?&9~QGoWIXLSV`q4!1EpU#(Q%2$y0^wD`5^K{W?EwB-Rq@G*=$DRV+c7th*& z=`iy?;22lLx!3BB$?BCYIl*}HsiYNn?Cdsbx(yp*Hxw@L^VhiVaR2`y3jQ% zCuL7iHu0PSo!3Vsc*`cZz^vJXMTF0T*e{l`N+}+MEn;Kv!6eKd>j!Q2JpMdJS&oZZ z(?TuVyQpn*7d66l$89t^aOt&JXu+dbJa`50)+dJMQ4!=p7w)3X?|2)vVv+Vgwhx=7 zVRKc0P@nt350P%1qPF+_IO*%JrgkX%*1YR(+WfYgX)Vh14CtUmCl?pa5(J{vHOv}Xvy5-(G@xIfN^2-ltGYn5APn@Q6`wxoB zP6RvQ2H?r&XQmxGc95u$x#|R?PtTUw6iC?p1l|>okdJ)!G*#(am*cr5He4svz(k?0 z@<|!1W)~>WSbYZ$Tm^{2*g~wFFUe=-YOfy5Mc&L8@N6hc>|>sQa1F?O9@oj#JrFYU zjMBsCygfgfV`}41uC4Jy$P2gs%>|NYrG`-ql@2q@!h|OkX!32pqo{^U+VXQ#m5j8E zMRx0Ihwvr9iae~S;O53oi$idIOFK1fy#~MtG6Ap&@R&#XNw!|*iii<<`~wYSy3`L5 z%}Zw~|Lid;y?lmToLFt>ue;DD^la{+zHL3o0_>nh1WKk`vVzp}taTPGK|d(GE~Ln- z@3{lU9xLeL_nwez$}JP_Bn`p8za1M!!*#nM^94AG$%W5WR@d#MzI)$7tKRk|>OznO zZxlt0>lT1581*J^HYI7#*l=^=m6MdZc$)fdzMjfhXdYy=%6j}eKED@Od&3wrFR^NA zngY?L4H||gd#?`{AsEY6SSm zb6@xudf`i7r$hhxJv#FgHecAQZGt+o2Re!#0Gcl!9i-1cyGRoSjIm%>#>qooF!JK( z44Xy$j&=yKe478?e@_#yzKkRrB@4X(EMqPB3md^G?1%bNdD-|CVMus(^?RItXs-hgmT4QTzzwiFCGn^ z73NVind9mpgUZkZsk-w0Z3rVN;^fbR{Fbuqy<DV_KDw zn=xJVbdWN?lbCO2%+Ik# zp($(V$^L)FJvJ6tsw@`a`{B-NPJG{H2P{WHj?WHRB8(r@N5 zikw6|cBQL}y6$;1t-p0I6py1c4&Qx#FQTwy5C~YrG*XIOu->}vyamZfxSop@1BT%q z%;I%&Ex6af1d5ocvwc?k$}XbI!*uDzS7l}%w`L1$sKb+}iuA=`Zv0|N?pYeo2@_bRLBD?fp{HnMyo>zpd&sGSZhZux+ayl2l$nHZ zEO^T9T_==^rmQf=2@zN&vR7IB_Cs(f=(;BSfgc!_po%RQsWA6U z&}}O@ChIpKh* zLBMD)H2>>i4F+#P>~8Y8ODMIayz(p29N9iNJT_th2g^m-SR|vch>$hsjRAN(F+vmj z5D4=4L7IKx6vl^R;2UIU#XIhy283>0e(FX1yR6(K4j`F1_zG>h^L8pTID_)6pbg>2 zM{JcsyIY^r4-v7qN|x4aPynK^uPFxrd*L`rCjXuj0v1*5^(cJ_CzTe)6NgMjP+DlUfk5 z){f`k@T2==qvz+NfX~+eUc}nluEWT3HFOu#K%tBBu(AZWjCP>w)>~)+nuy8}gf!!L zfbqb3uJ-RaL(lG~p^IqYGAeZYRdKnpN=Del-HVic?yILK^PQ$DqwSo z!Ve;w@;ngyLzwM931=Xr#Q?=`Lljr{hHdyAJvd^z!~g_GBEN59M;2zfu?WLAr<{0w z@_QAR8}@4BI5139ieM&=!G;?Gp5Z4?i`DqfUwRin#*@gOKZ8YRyx$hk<5$4SYq{+< zEL4>1euf<}sH{9OE9G=q2gII5ES6bRCY^WPNZqi#9)0qld@h+5aQY5dc#|g}_uaOh zJe-D=iF~L{D9!q!5_AxfS@g_x@9OQK@n;Uv%)qeaEa&gHt%Nt<-Y#l}r{myf{};ks1_hsxJOuD2Hr@lR#;^ap zC>Q~)h^T7nhx=)Ict}QkGQPt?U3v3u#&bbN3G5lsKr<(f)A=I@u>rV|=VY9t6YJlF zff|#%hb9}oCaQ()sfeZ?ssUqPi!?4e!DkD%9~+U*VlD3RrJVPSNo^p!@`2ZIf1k$* z?_+~d)-5WHO(2KEfUDL#I2YXVX|bj^q_~uyoUADwv2PKh*7Sgg4pgK_qTquE29FjL z6k|`jctdQ&GiajZrSmVl$i;+FYb$Y$Kot2EUPuGR1e1pN-%I)FdDhj%4>yCjY&h-| z9PDG3TAlzvi0|ux-TJlg=5O$5=JS6`6Ng_S)s&IL zhJ#L8V9mDue|(sx_++e@6tZ~7OJ&1H{3!w*rV4wR-5mD5QR7`p*WzRW>93b>b~Cww3s{bF!`A64t90bM{=u z^h3q+01Y7qeE9UkcxalzPV1)T&JBp<+e&q9YpDV1wyw3vfMaA9H>H$kO6CGaFAxhM zN{g!)EuL&P@YFLuBLlP1vHb(|{9>9quepsbJoa^z@byNjjBhyi_n)O*+pZ=LxdYQb zc!l&40DjOdWS|txZSF)5kBtBrMt&$y7w19Yz5OOcc>QNO^!aaK%*ChWqRhKzPo1UC z{g40vRY0o0@4b@>(?#<@Lj+ZxMb$EevKWi2fD>gBocXrBS4)c~uD{BO9tT_ga=%~3 zMz#K`&Gfdv{Cye${&5so!|uLTDj{rh4hcuhU(dtTW7}PK(sWxh0z?<6eHJ-=4w_Gzmq*M(~AfB!RGe)UqVEl!WfcmDYOez6uzYQeMEp>GAGEm@abNOHrDNmO+Pga? zwyiunD<^*z_!)1@B~(3ppk*SR|-=WlN$#k_18w_NK;lj6BeUXSl zS!Pr~1kxd;c=@JB#kGYs4sE@}OCag1Mv4CawHJRxQkH0Cytk%ID~&HALjd8E88>V7 z*Cg(|4tJ0l>zD&I-NMv)nuc*G`45V1bhaLP^d`8_w)IiA1@Tj@-MHXg!4}x1p#LO-(N4d%k*U?ynK#2e*O2TvU@G%5sRH# zfKB*|12lgGYC-T2(p!4rApqBy@kQ!?v7gqw<6c_*>P0M^XMlJaA&&!0V&cgcXbn`6 zC6vnW5+er1S>v#P3L*z04o?Fnr?6qH!~zAHzW{|}DpP06Ub`u#Ob&e`Sb{(HnLnht zz8*R`2+aWc^%lC)weEy2SHC)%Y1gS=| z>G4#$;rcGhtcBp|kw@v|xBeNMTsE}yGLjoqHPA;AP^GwW0F>Za|&B~*JAVTq4D9%!ptMUVj7kdVSoQoRc z>?J%``@Ak!K^R?#ANq2LGf^0&N0KzCz~SieX;8TNd1}PymwhRC^DgHL zCc6OEWp$0M;-|=MY6HM$#)_uL#|2>I*GW~Mw6#i`z{WeiQ2hWYcCJiqd|lv2Oh7>~ zJL&ifXth*Qto0iJS#)GO9+Y>CRqWY%KXo>4qGwKgpDr()#OIn98ZFyA2PL1BWIRfe zNUp54H^XTJE3o3?7)`-e+_&*%X)I#54%&Jo#bg`tKx2bQ!K*OS2-C`jc5&-fZC&_0 z#=D@v4n2F{MWc^@9t)u$BAGfMxdV?sOszZjQr%nLiE@9MD#u5G{^q2`T0A;J`ZW9k zH+54z9C6B{MHO#v%*9}gQJbyP4|Wy`M27I+}Q)~ogS zQ9A#{PxBlAS0QWKLrsw>1yqEEff1r>Rk)zaD2?kdT1{h|T9}^~9E8i_g)va9VX#_3 z<&w!7^Un z&+|kl5|HRzw!5Sp{QQcz9=tKvi+hvUJEKA>%PCh60GmxfBvQ_Lckq~7xe>0%hY~Zq`E$=x zaR9`kkxL-?!0#OiGm8*um=I6{zRiP^-@Rie(JuH5f*(+V%8)+{1BMyg?EC=q3rHJw z;8r`s}uyi1BQhnyTf z27{2}7`bJPSq9IZnSeBtr+0kxH)+Fre;mf80Oyc*fbEdj!{{o|rQ6$D>BPPlXyC*J zLEsl4Z616c@4c}THe-a*-#L2v$uH8`3;Xe4=7gQrx@|W#{P=q$`rB{IQWvrtM*idz zG%?XHCL9&t*lQ_U{)^q&!aOysUqjh9-H7YEDEF4DhdNY3vyc1`((O^1i8I-S`R~2g z-v*~u`W`#`3SB&kR45B9MnHfU+DvPE`Wxbn@#2&5S)h&3f#TH<)tp*I z4oQ)}fFC*W#`80^H6zi{`Z083&vH=g^oxmh0l4i4)_NqtEF7LPk7OhMx>T1;@&Q() zOQ!GlY+&~1N3H+H?5ws+kGLlDqT z=g!lqvFGXX4W2tHn3g4J0)$Mj;4ef3pJ+zPaHcQxcEN4V-Zdt%~F209Vw zffOQk?9sD+Sl&*aNDCjIIT8Ur%i^DTETofv@kv_u3%^dyx4#*scN-NB!GshpMgr`@ z?9v0!90w|MC7OYm82ge5v1ebpGQU;|s2WV05Wkj*K> zwQx<3-MwlfTAZ|T$+f+{6Ow)+hGQJrXSSmg`h(IorU(z;0TV-S;F?+0*8(f0acl_CXdYz#eq{Dalpx+oS{Ospwjqq+L-H`ALw^WW(LXv%~A&?Ts>&G%CT zh>o_mWBG%mv}?;Iav+u754}SwtHpv!CJsR4Gbv+9#bZ-3Z{bnmdH5p2W54J6YiagN zU#9*?zbED@j*PY{0@TS^Rohcg)Q6tD@1A?!N@Y|Ds$j}p;wc7TrwY7|z4W zoo|B2V>b*`4Z_ZdqmRnx*nuZxSf5?utKCe)u#IC;jcnXb#bg;) zkHZ4I$Wb=)f~-?=AOZ@&B~=r~4*Ug!GGFUhdmP%U;aPM{N|m@1BdLf3K1%R>;P@^x zA`D{ajKeQ8XO(_~L?f*ei#b^HsW<=^cO!Ewp&`TweQkEikcmgjM;^TaFNB1{%5okd z36J9cfBt8t8z#w?wbWUZr$>h$p)b6w={NTL8Ers7_sPj&8R>BvjFe7Hm4@R^1K5oX zbz7;gWhZUffCm?172X);5iLEBtik*|j2C7GQNoW2s-B<4#xOS_B#koq*bXFEi2lko2^BVls=|Z%t~@zw%0MTIkoHg+)TbdqhU3t=fB6cg8d*7onB~_5 zZ~Z$z{Qze9@H999?4g(v<4&()*k}t-p7qu%+Kx7=3pm4xe}d7%aUcdvvfxoPM z!00cRg= zV@1TGaX!cI-24Ih?V3*T7jZJ)JE;hrqsx4BBBd*3k=dEG(LIXUB=;aVqJf5vgtU z5?_o2>RmNx2*0c%ZbB*tHXHX9s$!Hz?D?i_k&|ejotSb@MTCivYrNzX*GXfASn97I z?1PSBCdhA2korqYdYWXGK(g&f3OM`(N>M#gh(s4EnzzP3B>s6F(Bp%HkI^S8zd;{) z(@85{k{Y{YDQ)J@3Y_yw23P?W zAiJ~hc&!A(id4%QO7{YphqSe$k)vgJ0|cv$!snaLmQhWgw#T-uO z1~CxjhcgX;umYfH7L|Zr1br0l!R?#ZK^$}f1RvZ>Uab!B1m?VZV(umuR~|M2%|Rr4_Mf7ryehl9kBxVD7v9xdPq&z=x^+$L^z{#^E>qNeuft+2qk?9)lrt3kha?fH{NzUTWJhYT7h?LU$Kfh@%yH(RkWdF z6AfJ0Cpw5QHCiPnMyl$B3o$2d11=3A3CKQE_$=JYRFE>11ufb;dmfkGmoexfn2@De zv};flR=gnk4jc2T4bqN@v{@cR8vw&;M1s~81Ex$8Nb@N|zN{6V5vTEbaZSY#@KSV4 zIaqZ-E#bSU>J!|FHi!7L?yF@yIjc8-xHP{8-ln5#3qOG72UrEhrIVn8jvum;bZo|F z`ciF^VO97&q0%Tu_w{0CrplHreh?Av2RmhyH5Y^wM7@-bF|(3Lei(8XGgXcMnUqRc zhAE20)h2>0RgLkb;6zyDu#%oXkB#6N+O%RN-LU2wL{&jkKRtuFDzg8uVa|bh#k;i_ zQnG~?gFzB-nlhG6{8(Y}rSO2d02LKx(k{klG=)J#pQAOu^Q!>-(9R5? zg3xs~4T=RSz}x&O`q1?&sQK~)ec=7yq;tpWs2hlW4uEAn1UKTL09bMgvfN9Plhg%# z;msSi(2A}`I#N7DpLpz#=wLq_dh0VGJ541lqf|KV@?(>yATXBSOBTYT7Fe|x@@(EB z^A$?BBNh1QF&aPovWyWWehns}t9RW3N%0C0mb8@6B`6ksDCJJqCLhOq)k$`9ovN|5-@6nLf`oKuuej9To$)v1z7Rfl2|jR4r3s=z<7B5 zMI`8??Y!FviahyIX}3;#EDpfr-~|q~%%dD88+2jBJEoNI?1r_(Cr~W6buvoKT{@LW zgp7uCPbCkD@T+sp)Sn-q-+SZ(^xmC6Pd|Ote-WO*5tN^2;B#L9n#}KQ3f}Y{BEeaT z%hePwL{eNlfiHGU@KYs<1-^=)U8Ofy=i)&0GrV$C=j)b4q)f;FA2s@uf zJ{F^^&2Z9HWue>_QH6A;Dm3}n*J$kY3pn{XN(-kdNJ7`&cR%g?=x@>B_yQHrp=7V* zBp{V10t>(k`ZCk>;hR>HclaXx%5Q%kQ-V(F0L0F=e7xO=OA(N7wxjC6HNRu)Zn_a- zpR&6^Uq1R7`iDdRN(&w<0_%iN5oG7-DoL!a_K*xtAV$VHU=`d41hvyUTt+{CpwY0AmHKEIulJxdw{VO|93E=reZEbZv+#IwG9-JsH6puU6mJUo1D{vJp0Y0P_pCQ zeE#s?(1DB3(R;7^WxDyQ+iA;KHys{4Pv=2xmC6Q-yRx_lsV;N?!~E_+%7CG$202jTqNl#$?$0!rwOJ9pA`fAG6t_#p-7)I4S9(Pm^w=IsTz60gfF(C^;TN5hXD zr$75|Ph)z}L#<61ixsUsnPc317#q2|tCjBBwu9DmbknPIhw0N#eu|#G_^h-s^|?Aj ztcgjtllLM)0$WW-{U%6q1AT-pGhd()KwFalWjs zfz+!ShPTycwXUIK)0AwdX~{NO>jfeQqacz!=>4@7L#4!QfGL$wXBDB14?Xy`N+gy9 zBMcq40M|jA5Eb1eIeV=8jxvD}jB+#5z6j=#4}W+x%gD8NA_0XhA|9FuDgQ_c@gXGvzS|s9-Ku^cFi#`%P)Usw$S}oxx81F-tVe&05h(k^eu(+FR zr6V(k=p&E*D&4j2Zu*%Wzd-lEj%plQ`&Y&;(@Bi~raXh{r<5uGS$tAvQ%p#I^ZeL| z6_+8iFawbvWLEpK6qvmPQaBZ}kaW8Eoh`XDP$YQ_aACx?hy|K|xt}IqL7*p0GJ1ac zR}qK3gDyYxxakXpmB?c~v=T#u5=aU?kO8(V+RTw!IZ%Ur*rZ>2=|OBpz!R`&%itOG zv~|*LfAUe9scVPUU=FN8HY)KXu%m*7wLUjPAG~7)Tz+1nPk(GbHK;z!*74>`#@m4n zV|deQ!2P>*>t?!j{Wd&V1^U-h|43hW;fpj@m_lX(P=A(&dtQ=jpI96^IYJ9@fB$Y;p`*kYyJ_s)NZ_6CC%z#gCXY8|o}Z&{f9oIrA?4TYq^ZL*2u&3RvA754LGamH zH%%Y8Yc)Oj)&2CDPadQ8x)mnT$5XP|@m1tNinVB&-o6cE(KRbU8ahdzd-_xK*x4U~ zn3F}xpOfhAB)_``MNGR1&?;`r6<4QnOISRO)0=JEQ%3LaGZ5I)MmY$T=Aai~mt5YM z^5gxWN28@FHV9{c zeee@j(m);)2(g##*aO;v*DgBF1>m>f32m_sy`A-b5G9J`9@ds7NchnQ1`z zSjH*#@R_gE!{@(An_ITiZQXa#u8x~%C)jp7^(LA@?>V|Kh0u!`ng^CKS3&Lode6L9 zBpnn?GU{QXkJWWcAc$$Rqz&LiB5W!iP9GL0IdHbkbhT3sA)Z;c5k7?dVE1jI_6_r3 z)m@Z85VINL|4ZPA3a<}?lXZ(jH1fhzz)r9^WlgKc?fpFue1!Cy?xNY3AsQNHKr<^R zVjkLqZEcJ6+qd@7w?F+Pef94yz~vWarH{RjHe zi(jOn#bKCKG|PRhcoj;dIWF7t>Ka)OD!KUOT0vrr4WJ1tx`IhZil;DODi(W@8?bQ` zjqZPeQ7y?goMVgisbMIWwhFlK+e2yvL_zGP<6FJFzZ{t-K_q>nPCw>mS3nAbHZp7% z-q^Dl`&rf}V=2{LFEeY~{a1W|VLCiy-& zc~Pk_CNVpFfqGVL7Z6m4PT*K@Qcg2AsHyoGsg|x_g<6$YapmvRD#%kb6k~1ErNY)n zDH~0;+!Vml-eNZQwQTaQ^h`T;1ZfMGJpsD$3O5Fi)(_DF+7Vzgq1Ys5#>gQGp*Qx+#oB4@h%Jy_o!v?q%b#-F_4_OzPlC)0T^DE5> zMgUtmwblGsu6D2eI2 z*;Jiew+fqA`bhhoAEQD4l$kO47R7OlX+zH81mUcl@Hw}EnzA^NzyWB&ua3I)APB9c zRoSha-inQ=NCR`1X>@)7o5GwNlKemy2Kq4yMAfpUm%QAxuh z@s!p{6kq9O+_{9Bgrb)-fg*pi->gpDNs}tjnYApg|V2e{jt!q*wf)d}9H^I1zsuBe4 z$9I=PEMf|(qR-5rP72c1Y{v#D#tPy=C&drEMi4V6psfxt;x*u$@FrKl>^~12pz+OM{4qa(NB7tOXiYceR<5MQS0D}MaWt;W)c63T z&$HM#8ibD_#FS>6a>Y!-^fE z!DrOE3eeLv`8uIN$&x4EPn_b`B4nv1Ku5BDbBI@^mc$f&;t1qXmF>|RR@)^Nx5mBA zG;V@Y@<@em9@l9xk2GV~@7VH)^ZBw?Mr$zueibfhZFpm~L^$O2$Cyn^N~&c(jPJw( zO8C97-BPZL+>Uiro-P;f95^@=kxvXgfKG`IcscTx7npQY^6FGHMi6ak^g z=^Hr#GYp(I%8TJCHs~TWS4r82*tUrdg^99uc#8J~Zy5X zX>-r5w59iI8i$@>7@h@-KJpCF$ClFundV6bD+3oNkJ0p{XF#}eMV4B^0&a(q>8-#2 z37XIJV5|ry-Z@WjmpO!!-hNd*ZOhHmAN|&MXlks5I$N`{VOQ{QH-iYXcl`#m1{ zc_T2Ol8JO;_uJRYhSG_7V+?l)ya9YG2I}ZT+1v)R&3*|4;j)$!H1pG#7eoD)28l&d zRapP-?fpA-HE7HQ#{O=zoq*eeDBS7_Jdd>Zr^!Wv~;tMcUDmZ)fx}FSXa- zR78$8W+M*9bUw=SqMo4B=(R75KTR9DZlSHK@1wPCTd05L6ivWngr8QfFw!`QN{eS; zd-X$X1l($6#gniO;NROn@^SKdchLMR@Di9JQO)I%=yT82jnq~gr@#2bqxi5$HG;hs zGc9@ZUJGH*Ei1Ot#;!g(K7WWl_u}8usiFM<`H;lp)C*u~+%A$O|1tsB3FoE&q-6QA z%=j%RrIissh5ioKK}Tr0vSS+c<|4>IX|VIMFiiDl7p2E%vz59vs|{A=8K>$3Ph%!7 z!>t$_7%*rIUSwlygEY9It_k4lyph`3Dp)o&CvdH1QksN5n*0T2-Fz%rPT9jGu)I&= zQhrPk=qDCn+(`5dsaTaw+|%l{&G+=u0b@*;Y+8V7Z#_TEfPeiie@nhsyOD;u>YH>d zZ{BxInV42)(Q{9n0RZRX-wPhV2ID! z1=dOpc#SeXe?6uFJK!I1?W(Ql?KOSv;#Y9$e+lizEWW4Nz%2pVt(^pcMa9tQcqgfx z;K_1~jl6FHXxZkPeb^Mc12YR0lN0kRZLV1Y88_xC4pf0NN6t`Xd<>4st@1U$%z~^k z<<_nj5}L>U`7RT1ipUK(Pun)V1#VB~@#LiBE^rq*kraJ?WJZ{b3{W#qK$wa$X69$G zEbKqip4m2&!BD#~`F0NPd0wohiJsG-J#|8>F!h=Phbvaxo=zTSzOJ3Bt*M62BCfDA z&sj=#t*R-2uAaxLNOw@JwNuNu{v^(Ny%Ysjq3?jP)RHn>#GQ;>vnIe3&VXzCFCxzZ zekxP|g>%`kq0}*vQnZ$|QcLzP?#WfC?9^t)-?<5nvK83?CRgxT#V=@F(=N0isj4gB_7$@+Zp3j_Klf zPKk`DXxWB#Dj+U89H8LF02S*4K&G6Oi|fT?oK>Zk6N7u%GT8xwpFst{;h-2{mY1{A_h8@%(}`)YyvR!oE}1ddKnMy5TvVv0NZAyVdBkz0W8Cg^Q&mtK)cyBH(RC?1OFjvxSp=7 z>D0-m(CZHa`^w1mDq`_p_qHFS4fnmDMlYgD8N>vkglh~|-rM)K(fR#H==Yn~w?mdfHdHWf-& ze8G4cU>=%Xxq<38?GnvGb{$-S!DmRLqN+eDTuvE0sDK4tWM2XysCkxRZNx^*P!uaJ zEZ7R(b8!|RB_=Wja4K{G*Btb0r@Ko=qy^Z7<$wv4QPqu1pU36{sWeY)7+u19VxyEU zY}!2OsYpdrNucJQbq3@6Yv}a4ip-=FOj(s!?1A}*m;7&Ctv^i~=!6wAEuHyC;XM=s zL{$>G3w?WZqOI2n!$1ldys71Ez=O@e51fCm=*p{>RuR=UW4cxpc;G@DAi*ZdmFLZ( z&XNV5s?n0@kl!o|L|5et+tG;cs-?VzFeWi30mj2qUymf(<{o(ULjaF{-^YWJfwHfZ zzX0P&Ll)82OE0$paaR1k^GvqQ+H889-msU^D7Vu#b6!cZi-}S5b;Tqm@n?dm2bx$A zL{^g1z8irb(9`2*I-n7Ill_z*I}VBO36OP$j9Es?;se?+)}k5>?=hRFOXE+{=+q%l za=}B}cRL;Ej11mqk>l<*StymHJXBGzMSW7H8#o{MsrB2*>+GZoXwC%?i58i?2gz~y%%Ei2m5aU|Kwnyu7O1q7kBGHDQN zHA@mp86Ak@8%==T*h$Un*MTi~N?56!5IBL&)`R~+#)WyR#~BT{M%c{zI#%F5q=llb zT|eP#!CC_2gJC!>OrTOuEhua`=b9QmoA$5@(zt0Exv^28RnUilB*C`*Kf%v$x{3S? z~-|;PBg+= znlz?j0v=u4({DMeT^$9R>X0)1+J|>4>8Kkw22BEMKItpwq9p@H6uowMhq4cj^-q_P zpC=cY#W$M83~Z~ZiUJ5~YwDrnd4Pf3HS9H$rV`5H0RmOo5l)~L54$!Fslfj^aH;Va z00iZhM?^kqd$x}*4&oF+i@-!5CM>Z(V2T4R7%uk@BCxKJKe|L-3@J_G2uqpx<#!U*-q#QT7r$w&k$nnt)MNy%}klL>L8L zOBakQ@Q`XGvoc>n)$sxbZ-3}CLuMwP38J2^t-GjeH2^EfXjMHFZUFR3c<8(Z^pRkj zal$bhXBq=eSCWJrMzrC9uiG%{#EI?t^$-+c5%9(^2ifpE`vA^~Ww%qZv{Am6{8}<* z>!td&z0|bw7AlOKq>0Nf(Corlye?NBX<;aG<-|5&^|=41R==?xr!$ z<1iE#^Z67udt5}^H)Pu*Wr!1SJpg$=SfreGq!~c8)dCx>xBvj1CX8h98tmTeVD9fg z3F+Z=a+|lo?*P{sBR=tvfZ!m%%?j_*=h=occ-fuyFbND**O9o*a0dO)$2C zMebAi;pUhz%;6Oqf}kAEv?A9J6kE;_Ot-GYg6IKw*hv}@TN+h@zmOMgzl25rJac%1 zEOI%FR9t|J>pOP9@bYmwfA;%8`zv9!=%DJj<)=RcUcxOf#{|eYx&YmN6ZLc`dgj50 zXcm@Yb?^k}tZShiecO=2tLUjS|3Xi|#v2ZUVDvRw4u3hE-$|PlL$>99am!M{IIv|c z^<8rxBFe6#A~20<1c!KVMD`41ZYCj+#fVhR(s|XMVx75?JsaZw9P9^pzm)ZZNsA<*k6duCZfd@)LU{;ur^(165#SleM_T z#=_4I?)&h>Dd4-PZi=Aog3MK7`ZsW!OCx}UBJY9;S;!8gs(@1+*_FkDP*aK6 z?5f=%p8Ok-BEvE!plOB>+Z*5z#@tu=q+ySE-bp-O@I1@W*2sc8WWQt=zi$y$zqXTN zY!s-Iuqwr}TqsIH^2{Y+=3ExNZW|^5LvylOFp8Z6AgCA4;dCFx;>{WAGKe+P6PL(` zx{QH2@%blX;hoC5)=MZdv+#q`jvp-UdjUd~Kuev!c$8r}Q@rDJN`s72kFvcEB%L%W zkt`NgI@gY$j|UY%nf40iv-7N+uah@RGdN+H1j9UIcDZ56?G5+5pIW!vCuqMrT88^i z8#SgD>G|(I0WZUOYQ*Wju6-?S>R3-xmGku7LtmxSP&sB`4wA*@B9)eaj_J6aayZxH z4V2TA75p6fw%ycLEWLU2c$Kzv%U~&q8 zbKo?XikN!9vk!AuQ^nbw1SN?}SpoSnb#^4maw#WfR-x=jH1Me4f6nDUI2j%XkRoe< z>CsHw68bjoT^h9P$=Lz;2#!*{Mxrymmq9-UA6K-lme0>Egl|uKYZYx4w>_EVCpz&& zY$se56vTijluaZuFK#OTYT5Gw$b#tL2(I6qeu1dkfwPC%pP*})&@eQ z%#XA=Z=|A=K*u&5-!?q^%s8jOaGbXfyTVu2k3$riN!udn^**M7>+2VQ=HTCUA zsSF)EWYv{|am%m68$5UHYcw@`nCj9kBAG5ixw!YbU!+w#evTF|v2Xr7#6&o4p%>Wy z!>0iN7N95Sq0Q}^s2yKFKK>Xzb@t0N1D}J&Y=htx6)wMVD;uC%ELNs%C8*5Zm5+fI z4Y)-^%PLxZ?Yk+xY7fm}3|byLFJnG&W5OxSzKHb@~ZKxC32{C&0lhAGyi=~jAo1C z9>uK(-~RAPz&@76PUs{ZV<`s+k?jjlv*HJ4kjmH z_L&yb3sQ@;@haeFfhQ}B39%_j#S2tHPN)(Jy$@q<$P^pN2OgEaZ-E|rs&Lie`^gcc z$^LX~W`M4;?oT=mRRe;lYKm+$%hyqmCck(n4UW-0t%X)HaA28@An`tQc%o>VgvuwF z%La@}TifoKZIlZ9*vU>LVg?7VwdW+J6Q+7#o__}6nQ4^AlanJ<-?K(OTLq~z3gtXZ z0mO+XKXE~nXA!50N}V6s?1=LMtkRrrujIj_aU-!{4HriDN+YTc!FcLV3Am+x@ih zO}|7lF!S_=7N{kS(;VcbgD=8*8^i2P0Oz)Kf#gIp^w_z-rX&5|7c25QaXDfJ9r|rl zV(T`&6DA&Abn=z2V}CT9b;%^)jTj(X`f*=q6?nN~>aMx@J_Q0+?M6XO2x1r)IvcY$l5LJ2ih9ab`E;-}BFgkO<)H8aN4c zAjotCBCA}g-h%LcA_G7Y-^iBy+!iQv7;vtlcDz_S5RJu|9q0h7$l=~{im`~0f+?4^ za??`L@n(TMWMCQvG3GT6QY^wMRw|(Zj=1=8ek6NnU;{0~c*YlHEmuofjI2vZ&p;I) zHRw<|8*l@Zsu}|>^(VHA$1!h~Q)ZMU6iBHgeTru{aQ}*_KruAXs>1Av?nE#+6pI{4B3q{nn&7w&@cn*q?~pe^A4&Z1OzvQ2`@Yq8>Xq<0Tuo_qj@Wj0;qBq02%lmYlb z(#w6W#@~BTP5RLL^Sqy3XZgJ{<1B?m#@j<-euxH7B6auN0c-$0Gz0K2%bOC8yb?gi zK8y^ndGm+C_`_#IK43c@;JN8@bnM6>YOQOdtyugkV9t4Y_9*Q;{UwB+zHIEl(227_ zYZ-lZJ&^E?8-JXdd#d?3`4K2ff6+d%3%)bg+Q3@J*;g-eNsQuyy#= zkcR6KDw_s;jvSM-(kzz-c9KpB6VS2B!7$dd!lo+lF~oH);P-iCD=gp|&j7!f^>B}2 zLbHgNts+x;am~+OK1{c-`6;7t#G;%>Rsv*})YG_zj#dr`KSTQ#%ZVR?%Bm85({AND zd#HEI^>pdLK5SrJ zQXWrMywE_GtnrHwqj=r9CRdPIDWd<6XUwU1+q+bwk)kjkRoUbd72|E2F^!OOaH=@a zu5*69<8cIbLCQw%38h!kHXT=XZNGNkpRj*bAwN1g$OBueN(AVIguWQwU`-i_D`O#^cMA=nR1uPl+#o&;IGW1yL+^3rdzQ6Tu`P{f9x#lc9FTv!(j3NU)7ZTc z9kz(L@6s4{b^v6UQS)_lpaDq3zrKQvVg_l!d;;*tELG;Hxo$JvblV5e#{fk@y1ybs z6i&IVIAy1X&ePemuhMD&jT<{RNL=>53ty*0{ola9o5Nyjv11`un0KLsKXDVA)-K_CXMiUY3I) zm2G5OC=nEKR5#y*v}&e~bcdLOw!rJ4G24l3f*#-!Jy_(;avw7MSs7EYNj<(j7*aM( zN>Y9U9soawY~zlNAqBI~G^zwXMP*qrecELM70`PWoR`odP2>6wVq?t7NU(s{fkF~i z=S=9DnNif+w88NH<(~N_l++SYf>diAoS<05W!SDwT{t7xO882iAG%OS{*@#GA_g$I z9WbYN#T%@XZ)RaoM>B{Bs94mLEs}&#;-oNe0u4f@3VS&1$Ifc+jsgE3F~uA)Oo) zY8^4E`81NY#M31e1fAg0P?R`gKwt{qAwsn^j8$XNK#x~OLz*BV9f(A zR#YxPvJ_6f<~8WA`5*CAVHyv^IOP{Eu;cCSt;Iu zImmv8E}p=cZ-BP-g46?>uGzu`di?ai(#6TAs1BzoW@VwSy#iWnXU~nadh<^~4Od~4 z18g)|>tqGw1r+G4+d*4)zMFLGMg*bE7}+vUP4K?6=<_oG7{_1vHyS$j2sR6x?ChJ! znFn}n1}2-cZPeVb1?s{L)S6pMP3d0A4d4!4l6vtF7eJ<3WReZ{>ojL3HQFpDZBKf; z6vIuxT+vj>$`cKVGAAp?oAV)6kBY4hr+EV;-1Yd`t&N*$Ysal}jBrA53KdQvZxH_} z_%HUgY>{is$-Hi0iLWU(3XP53aPP{%Kc;a_xW^BKTsmRO8<(l`N$<%9#m@j+l*cXZ zBLB@mV^9e(xFzFj&x^78~V zPUHisAXeD`u57|$=V21sDcAxOg{W97cs4n}bYkAKIF&18E&dmcX&Xt#dH`%ZGiV1p zJ62

INflQYJH8g(~eYJ;yBeB>^~THyo#_CGDo!tfFKsUkF~@%CJETw;xqb2-ZEw zIQI&U6qx5YPJ+oANv<8^Gohl!noWAkZVOmcpNN0Pg1Dwrd<+l0pf! zO!Seo1AY|)IvB6w0yK1Ol+i4kYVan*64hylNJLw2mW6B6iSM1IAD;apB*Tcl!g#gLsk5dkDBltD)!TCu z{6$9re!ePdMSvV;#3`G-7VZRek+fm!e*ze~74670PE}Vl4QaRxHDZ=OcYYsTdiiUR zLd=QtZi?D~q;I5dI2N^{Epb64;!1&&rt$9YnTSv!CqdCzhi~cw3 zno>E#<53YrIdY=Ajyd_`5#DUH$vgpH;^=%Kk!g#t^M5n3OtyR3XmB^nDe-e$1lNL} zt&}kR;4FmqxdK>3ZzE{Jh*=ZgFjk*hLRp;YssaE{88A>+8{))}{PQJKz67CQ~TNrJ23_gfWp{9Pbi+M#ugDX z!lP-BMdG7Rt#vYCQP*%*(dI%%63M{ikxy+f(pKz{vc8Cul*J^_mI`{zUVX?iLw^b2 z4_4vXMkekw0sw3>BSh<6CS)eRDCI1Ria0`wDZ|=27Ly+(wVFNKHRK-NR4OL^TTT`9 zk{*Y%ptKY@D!A#FE68Dn_iS@Tv_EMjWV zxAuPUAhrv5=2~ku7i6A#u=s|Lev>Yr_%2Ss5_|zRQAgVj5 z_AD!nE2y+o9LNZOOt}u%uo+~e77&jz@<1z@nR9hiW^~>+s!u+hE1FU9V%XjV?n#or zs8Z4j@R&9OnX-!wIgJgOy(RdNC}D%kL$I~5$exafMZ*}g$kQC&gn0_V8!#4m0h?+z z)kG^>H`B4v7n09qybKS? z5pOPTiYc%_w*f3>AiBw5td~n6S{(lWDInXYUwwq~6;}6U1avE48nJof-L&bdpF(01 zHj9N>nz}rUQ@0N@ccP<%|A$Uq`X+v29>{v5-MEV~-dx#p8?~*x9bd{oD=-N^{3cqz z@m=Wa2k^DP@B$kc0G)=WUQ(^=uvix5;b7++7gLcsoc`wyex1e#o<M$`;lK@%2zjy?JS(3N0gmiG{FQbhVO#8)%e!=i1$1T1wLL|7Gnv;3X@{e7~yG;l|0CFytUP2T2kXP=dRFh^VW( zx~R*l7}kIYtO?&5&^6$qqAmz33a$hR5|tb!!@$hIB4E|LbZ$b3`v2#na4sf(dCCXOy$ z?6|nd+VhYe9zi=k7*QHck#RJJH8%Sw;dG#0>*Ym5pFMXL`VH*x&sg(g5Jr81@eLbL ztA92+bRPD}b0G8~ba7VFL{So=Dv$XFf5QfLm216Z*FBd{3_+ZV zHz8BBNTJ7wszEoN{B`X5KKI~HF!(Kn?YBG`X7%rdY5oj+J#Hcl%JfiZLsszw?(3e7 zfng5Hprg_dN*7bm9v)i*i&x(dYX=v@s?nEW%Co2<>YdvZbg&S+N?eE5>`Gpt(*0&J4YO?}Bmf~KI~ zERfDmLO0&b*HO@&^S%Qe*p!1nphHrm;S-QWK1bNGmj`t85a{I3n=&ueI@csxK$9uH zn^Yz$W(p~cShUt4xW|Ig9;qJCPp*rXGii&S^=ZbExig)u$9>H z?vfEKqtH;uvwLfpGi!fdWGmL7FTkR!aol$jKGSYyA<}cCo`ncp76@`51XRd}02P3+ zB?>P*a4n(=Td>y-@5PgLOCJ3;TjLxIsn0N_3tfVdQ8yJ&U`ppVjV^~5R^AQE*Zmn* zZo+$uGx-MkP7)w0V^=9rKC0uSg~?c^7Ki79lC8_RN9#$j8_kN2aq;k*F8bsf{Bd5ter5qY+M9 zgL+b`Z8YNTyp^rK7d0z+l!+6Roa=f&>Az`b2pVJldhd&%&}0gb9q;q5zIlbvIEbq* zmmwgKLrSxF9AmR`15G>PQtG;rN`mRYnRbEZsFKi*wH-A8n8FZ*y5cT2(j=c?SBj)p z>O*Rlm33u}wAC58)`Rd)dh6lA*BHQ57Em&=liVPgD(ZscqN!2+h!37!V%5=t-DsjB zu`E<)z0Q1#lOR*yxCs-DRGiVOCY);Md7CgP!NtaFamL_C%1(pB4*3)UWOl=*AHr9$ zlipPwgqJYoU$pXeEF}o4T-O?s$~82;oUuPl@7V+EX?A|V&ER0ox(E1f&PCg^+*QR3 z&xR(aDXS!@PXg|GSoP951SsHXl3Hx zpmT6W;Bg-y=cbwG$w!r((79>Kop+`Hx7tK+3h>+Jf7zrtUiQsMbv87{u^}v>`naw) zows%7JGd)#fWCJQNZL40;l`x^P^?JaoHCmXw^f3GHNMReNC`XX=4UpZ>(B($h3$%X zBlBIK_?CIDN;v&5i9zP7F={3I2oh$dtVSeXPXs54;6vZBIgU_N>Hr53hSE}#?l*Py z+0Im(H{Q3q(zLlUr~a@8CJg`uCOZ`~EhM45HEkM-A+eDtOS9tx_xT8F{=E?f*I{Fg z*s^vBJoECEr~vec%Y}=zW z*KXV&$a%GgPP0!YnT8#71~$A1z1TPC@7*4I5TksJJ=rvvHGKihM75+AmPD&Iz5vfI zy%|<)c!~u}X_8`j)Kt;B*mE;5COyH1?r(8!GJdnCtV!svViIjw`Il9KOm`F_wbBU_ z7;iY3)PUAAC$+&rp<+}wg0e^7DhDF(6?*&^6oI@g1+Gp3pg4DB9o5lNjpW-&+TGgD zGyAkBA<}*3ue^l?pC+gUaeu880Fv11EF_XVcG3q?HW1Mh(zw#dKc@S>lasnBO$0ci z#ab&dF3q4Y){LpVFL|aLb{CPZX${eHTxTI*hts)s#BdxF>LxZm8Y%?Lv@eKorOj_T z6kP@;1^^!!W2vI?L_CB^xD>p;aP<;fZMU7?20L%_UUa#U$-?S^hv9`+ufmjiHS7Hg zIVG(JGo&%#JbT(6CNrTKmgcDO!jT(ydJ{k2H2$fbEp7{8jDMcqlBT1px z3I2^IWEUEePVYy1v;L_F99jjhu6hKPtbPPGjK0jrZH=)0JZh3zc6`T>wwgA7S<~4F zto(i2tlWL^_mTKw(m>#&d$fSBsyrDmUi1V3vXZ|PD+npWl&~DFI4P1--Vz|k=Ou`{ z2_A+~^|zSwmom&WmkSx6frMXBzG!YRQkjW8fpG+wWy!sWa{Wrj=bnOrQb`i|JE@wH zY8w>NcdjHh!hbh000br7ApBs8Q_D<@H`YxIRk?KqDJI4pt>M8W&`LOC_-1aTJQ$geW#Bg7XUYEYdI`kc>^q2dz+-URHpEhW}=w{HRfnB)@w8H zq<$SNn0*K$;kLw47;58>tmQ>Om=2;dM;Y>xT{YFmRe`pWR5~I*XVS(;vd<9K_Ajrv z1D0=i6obGz#Xi)FfEk{iYqP&f{%_11O#$I{3e>PV)9xhTnkcwQ3g4kAW&@j0o2nIr zBU>YeC7I4`waONal6Sa14z_MgXu%348WO51Hfd>QPb`d7Z6inrs~TfA+!S16T_(TZ)&i>qZelw{7~bx z9vDDjRoo=yWBZgXolrBq>D+MXs3_baO-(P|2NP3c%Gr}Q6Yq(#2OG%spV_}J?7#bG zv4*#>cGH8fXvwc&LbaBSOcedeB1HISGq9sSd&(Td(yxSJ10gwwL7U>R7*q|>2dL{g3#fy6+ip1|1X zm>>gbQ!_(SxByHrQaS(Jxxxyb0)To_M4=F78|K>6ko^!!W_q8jQtP|0vja&gUu-5o zZ`;*HCs-txL^{&frkZDIka6+#HY|=<9hk(hv}ocb=Vtt$g#uYsvJ5vSf*%CH6Wa1d zO*$rS{8rX(6tnyEYFF-unXlN`0_+`ETKwSAK#_ zw-uQ3_ZOY}wGyrU&Mu zKuIs6)m~hFBRq*chc!db3az)Qj8NZZHkpctwE5I|lhpiXBKp2jsW@%;w;AkuAgVJF z&fnZ^&>pKo8G@W&hk$|aLhK`9~i@t zCn4kL`_~bE+=~dkF65T4+xRp*zxX;B7&8|KMFwx8>yJxDxYDi(?Owf~R!&(T zXsJolwrmuWnrWtpUk`z5;S)PKSE>lTOpXY_NgIevTD;W%>?gnmGs0{o&H$OtymsF`RLI_x`5Ih5Y(;#gfa zJT-b-I2j-@7N7Z28BlSd`Ei!(y z_Ml}wv!v1CW2$G%Gtkn(VG@8up<)uc?98Y$38mL3y-A;#or3uBm7I-Cq0&;+{iZmP zLmf3)H`NqbPSA?8+RZLKNaK@6P&h$?t5NZ1q2r&DMhLt@?|0~4=VFT84G%qgIX3Oq zFmx7l6G`FbBx7&WSTx>DVhiVEunblcB(iF720m}r0jQ9B7>@YnOPvgYogW-siVePd zamw&Gmj^V$6CjH#()dKr&Y6iWQWFknYbH8L1@vY+j{{7}yRPI)-DaR+66G6y<~4$F zWBoif#`=gf)~$eOG=%IkXGE%y)^ab;ltzH8JZ%JWE{B!+P%@_=kKGF?lU!od5$bJ| zSfbnHUND-3Jq%dR&6J(A!9(QSgmMYAm%-L+#D%~h&>*qe&?^dfnCP$XQ@ZQg(vm;3=2ag!TZ0e_GZq{^dn4M^TjBw%@y?$abxOWjY}ixyC`uWKIS z===D+#;~zB+E~H*22?)_N}$j-h?5f^(sSQoqM>`jC*v>j=QHJk(tGGAuk8#>7@Zl{ zsp)EB^E9F>YZ*H{IkE)944{j+0Y==F3Ws7HTq|gb?nI7E%RB^dzD&mwUxK}t08uJ6 zjf?d2a-gz>EqEwjvaHA;NcqYL6Ev_2jY}s|Df7{ZJF!=BSt9n}h>-WG9zp_rPpsDC zh$PcY$%kAy_G_#u2vdBp)_XAk*#AEPpolDasZ;N*`HKMn0`rQEA+k0ua0hr~*od{7 z+K(3&P3;XnmjEb&fr2pbT**S2$5j(kQY5%1i9XT0}t=;QhTPKAqc_NA%PDCDgk&giKD<}Y1 z#7A_KfQ=Crr}xFQDQ8H#LidRSVG3~$v7l24Hx-W!jna?-#1hB&Hvh|FY8HDkT9bov z2y&O;$DD`4AgFwR5ZKIWhQJt6oZ1jhAV<*fJnAIvLYy^xk}cwtrY zo}g(E`7^@*@>lx;Ykzp&n!lVeSV0>4w0mrdgvux45LWfkcm#z~+YMGmX~k2+wyCZ9 z;15s|2X%~uqq`FwkBkanmYwazlA0HA&I`jNCtI%YCgV*(Nc2eo8|TN;oa_`+sd+Ox zHh>RxJucXO2$O#)n*;-rr!t8PRXTO?Ns0`ySymnvdX*thIUZ2pZ~<*t9qW3rjeg2v!8c zCL@h9Q|bWodz!=zi&-C`FW~(|QJ8XGCc1dqpYp~`7(KE8if(LMLLj-ap^Z2iCP6_| zkc()ZYXI}HM-e6m)+a1CQ}kU*WvvNQ4SjC8TE`p3^oj@5sB4NMJ~46ig*<&y>bj)&rF3A+QPL^Q(3@a| z92RtluYydJ}r?u*kJlPC#I&*M-^CuJ@x zLBJUoKO;nO>yt$rGj>Q7vddS1xNz_%30+`vg61 z5?AWblaVoC)ZsTK>ViPD`VIoIDs>J8tr_$>E7n2;)mq)UL1``k<(%kEQYJt`T=kwQ zoC(-G_03Q=N3aMpVZCTCN0o;_QH_=Ok9XU=%@!yRfyx6he{> zRR)Uq9aprzTv_hSgh_|@iEp8aMy)2z5KLji1_>>;JV5tRjVKU&Tp*}q#rTz^wz+_| zHjZ9x2d=qO%D1p=e9`3(lI}Xz3*)A^a21zN)MLx$sbb}&&;#)`1esT}8ZVK-q8m?m zW?9@sY`|eSsSUbr%-A!&K<5RYO34lyX)+r#As?J~Oz`0IvDZ){jhS>T4H0JSZEB!X zWVkWNn~=^IW;Q|H+9&0okMiEy>qw^oR)PQzllV1yk7+$MMLD~U7Iuw+VR?=9ZDynG zpu>q%mkJ|0 zS$;2T%c#w?q2`UqzWR8+Ye3b;Q;?|&6!DPfSfk~Dyf*~^@-pyfg4&G=J)~wefFiF= zY;g4=WY+nBZP(M?NYmHpff2@X^!zevY_*yi>T1$YphL+g;sK^0b(M%$so81tp6?7V zj+hOt)A;~Mo2y3pNg-1U!IsM^^}PsdPy$gFk+RWG6AwEwSK)Fums;JBG#WJH^ur|D zmtkMEa81&!?M4-;u}zy;dLB9e^CnjqVUQV0K%!N&WBqlK4kBaBF4h&J2RJ%1$~tYd z;Ru*O7Rq!1J!md5Iy}O{gU!)#IX!Xi+KUP@q&P!jj>Bm5*g}Wc?*3k8IaO(Mv;IX_| zIoN3jpWw18Q(kCpWKonQa?v5U)8x`?Nu7}hU+&b6)t6nML&;|mO*o1Y>T~ALg+tzV z7EIr7cQzGq%T~ZsSN{$ky5o<0nvpq%(XJ|~6^)OKmZ9QYis2eStbC0u#^?ZGNxG_T zW?ve`aZ?psf=Ul)QKMb|05Y(h!f>HLt{C%Ol^FfPo+w~lTkSu@c>W=cQDfq@k&o zVz}J1X@%>A{@3bBF zaVYbm*o)FFaMu^U3Ag<0H_+YP$L~Q(IWrfaW6_}ppruz!wHkz>SgVpa>C^Dq(=X!T zVc9m^aUf_4`Z8A#PcofI86)inq7RvXDb#CITYD}+0?A5Ge3`dr#jM}hLew*Ct|W#8 z7i}8t8Smm4mjW<>m+DBos-PhaH7>_S$#NbATgOo{4f!E_0C@?%)|d2mmDxx77o;Hh%g-*eimrRlm=Yor-ckqgh1I?8%-ctVbkL&2kW zLW3Q^6olD#_$4U==<4p`zay=)(a}*(t%rw)1;VIzm*I`L2y&|JG5QVQ-#ZK*gnb`( zZ+<7cR;Mg1`M8fl%aHY8y4=&>3!I}2XAD>Md&V# z6dM>M=FXf7-@f3Z9W$t({^ln5-9wKfXa_TotSNOfg+DGyLh5N^dM6E4nbSfE{G!v| zFpAPzwa_-OdXID>!s%fqL=noF>6uUfPHDCjOt|NfOOT~gk5q!3D+E437OJmR#pWDe zN6$)ne3IGsUD`p-@}0|W4ebKcFH ze;7?xq=cicIKVP2H4pgUyWyF;AA*&yu4Xzc*`v?jep`6Mzkdu?5WQP*15^f1$LoLe ztKWqsPd?A!7%pL5tJr8P-IgOt)gp=J}2Y3Pr{veqoyu#^UJ+7 z2uAZJA-jbVtl5;)I>?8IXW;_tRo;g-Znf6OewBGft_%puMvg}8rzDfhcJi%P+wvy4 z9d~)mmS-bQU@hx+#)f;!yj3|h4nnrg9oV`Ae8G{zalK1fKum27UmGCuDo{zQqV-|rY#}r zWRX>*b53L&5&*&PNs!esT39wKwdklGrc$a($jFw0bMioGE#8j^Gqm}S|KNLI`X0Mr z0y7eyzcFQnMw4{JQhVtfAYN!lVX#s5Lj9ax z^bef2+m0}dLD?IyYx}KX*^94%MHU=g^EiJ04I?eq@oxqmJekN& zN2S6BH6*K?%{X;i^0`gC@T3GQfJ}u|7LRm&n^u8I_zE5pLQX{m@2p}`fHOw&f%J&D z%V`Wp+YN`r&0KWunJo-!KB;!R^aU&3AG*cO4f)`pM zhY)-+m(;!1ha#CxNzIz1#DfcPoO=Kjez^3-=-<)_MmNa?*>egAWPC{JCY~VFAmUDO zH%dJB*5D2M?TW0mt^c<`MHoh{{aYV<625=kEpW?2Pw{51VRq8WhXq4VKX^?|S~z&R zcabp#;^5=@P&OD(DR6O9kZ@U&f?FBIaY>;kF2s?kRUlEJc_0u|(XE#c$#vwF6`t@h z#kBF%H=KD?QJ4c=fwtcYH*sDdXuOP(maW+UOINSOUP~9N+5&fq)$2~dPoB#y3$qT` z8|EImH;kzu@8+pqWi*G;R#Sx)(}!Wr)6c>)zxq8qar50UM#dLt5loxWDF1@)Rg+=I zxIQRAG{@CJ*>QH;pv6gL3vOylbv+qrQU*hTnFT*-S|J&d^CI{u&9{u}Z$`E8wOKXv z$P{}KSbH|non%06oU0duDVO3$3)rv*P)6o)sU#B<5;djIoVRcbG*z96*WHj=g<&Ig z6jI4x&EjP+ym3gN?V1@$b|jh=UUd;$sP#tb%5EI@ifv%uY*f7f^Z@aGQaGJ=$>f4E zErdbS%X`twlFD(GG-H@^X!;i8PP&dt|MVHK5#3P8C1v`oS?DZ_c39ZFt5T|n3vYaU zj5l#tS6A#XlEslxHxjDFNgxPj^V{x( z&wl?Zcx~f2erB(v%RKfI@erCo1QQ2i+6+r5Ew4xeEdZASXep9x2w!gv6s5n8=}aMR zAdj*jnt*Z&3N)OFcnE6H@`Qo{B?jX`46J)?vklz#!*4OZJA5_;pBg@gi~j4UaN+lU zh8wVp({ZncDv@kLr%_cT(n)M$2zOs&V2H2TB!rYgHP&?0cMgrr7OhRPwLWM+IP9SP zVXqU9g&Qxv6qdfW0v&^=DAacrXW9| zF)A4+HBB-fWd+$X45`{4WzsY9XrS^EU8Fs}-iI{a`F3fI$pbb%%T{BA`vn=TYoC|g zvm$uO2_>vDMs+!Na&xqcTE{gqjUyUlRcN->I{Ujm88or3o~NGnWWL;CQlU03^)aHGvd>~$~0H{opMJerqXDX zYrm{iA980Or!bT)NTbe)`%rFmhM#c;ljWMxWP*L*`MipP_ym1qBJwa%LDtcq^eLsm z+8P*xjri7mM;`?5*#7Hq&4)h^OO~wRAWO|T`qkS%1Md3y4KNkQf7_n<78u0IPB5mh z&%OlzbK#{ZS=Qu(h6P%y>9Y@zTPU=D8Mh%dv{Nw`9&<)tIR(S$sqMA7>Lc1TCGmhV z=46UuB3vX<5R9CCN&QL28^={xiZo#@{<9%eW3Fnyhi=a>O-ty#DBxz&aAmKC&?C}d z!-BV1R*%zJga$O^Z0uTNtZ5KOnjMUGI`O|F)tt%|Y)&`bsFw$Ls_lPJdFg*)C*nbC%g3Mr5W+|COZ!c`Z2 z5>EKs*I+%O4QnkSurQ1=tHhbT_X!+V$8%P*nw-oW>cK>#8L6@=*vG*u0v@wM`L$ro z$?3$SqP!|Io!m#jkvzh61Z^hrdnqo%mSEeSb?S}kCb<#<@nwuVz=xv zg;*8p8IdO?XkmlaPir1g>x?d4cHmePzFop*aBAa9Z4~~09XVw6Zs`tZB>Z$-q0DAhb}-R zN@2ZvBIG8Vsg`y6ZD6ahQ7G`n0s=45=^hYuRZ7{h%Z?OR^5(1HLh0(NM}It1La0;9 zjEfR2%_^9FGGBpv6)kScdA6Uzdy5OVrL%9fz>G~VIwi=utA(bc{CZl1O&{qRIR#4m z{3+9>LK7GM$S_)pdx&PJR7Uzi^ekLBA5Qq!cf!@@e;WfpZ_%it?4v#lUU~Xec_&J`CGpX8}H(=s`N&E7S*YrUUnm@ex_UUQDFrI7`baYP3?(=V2o*qhf=b zmB53kVMouwwxo7uP?GV5Ej?u=xQ@^m0W6Mzte%`sJL{vE+MN2;|ANJXXs}526GqJn zHN;v1PML5}wzIV%g@bLN}^smD{Mxz-u z(>sAC7;{e>i|Q(i^>wDTRVKt2MyydSO}>A)QAY54y3r&PH z)a>OzK_wZuXu<6jRn4~$|7?bm!R}NZ^nogP4Wl zGaw;MlQK)aEBV+(Id3m^@fj-;XVPyXD=}}PB2;LK89q>ua!u<4yX2JpRLSwEQsL~Z zi5ssmh5@o6LAy%sxvaR+5L-M5h8p5vL5<)sD&w10KuZ{%Y6SSSzL)_i7?TV~TKHpI zhEsA&nKbsxs-bk$h9sGwGUc2~yue)nU($`3Z1ejPo)jj?%}1L);}bC&6A4I_zX3P+ zw^4+3^QLC1Qm<+^*8q==T56kJLm&f1a#BPkbVz1RNn|w8V5uv+5d-I1df z7e{lc)*IW#@FuUQG_;UNZ6>+_72N2XaFJE82eQxh3vrUMC+xrLjNjtl0Z-(C-nRvWPEf_c(e z_Y+UTt*7_H?%U6YsTedIHBWJ|VZMOxzY$);A9B#807lxlpt^d(v(WJ#MKZjV;|K^F z#wM&oQP2waQJlz7(>+Fyf{m+Rfaf242KK)9VL0~F?}q_Z6mmLVqK5Av_H(v-!$I)$ z15e=x^l@t6jhlSGQ{N0Z29b&hoOH)c&NWXy2a6s-oAavR7x=w2{q-s;61RnsfXveo zdKP&_6q%ITE!l>cv5pQ5@*%U`KD)uT2kZeecH16$=FGx?BPREREX~&sz}lBk3hm)P z!_tQz$42-t&eMDOd6IiFErOQX%TX4&?Y3Kj$M+n@adRE9Bdu0Y8?u~T(ff|#8tdO` z4$K|F_o7iEXLs>H0Ihr z&}?4lBYDqAA7!Z z1-jHJu;T)2p-;3j3og;L1M zSk9wk4uOfNZD{dB8;D_Opq=(92kj33dfG{F*xq~L$Sa%l5xi-?z2Mx_5CpSkHC%b) z?eN{-+yKkb^|!aLpEDw^`|w0;jts#!KJ-pFdhb2(#2M!-BWD_Cs$?WeukXQ*^t

+ + AI 生成样本建议 + ([]) const selectedCharacterId = ref(null) const editMental = ref('NORMAL') const editVerbal = ref('') const editIdle = ref('') +const oocGuardrails = ref('') const aiOriginal = ref('') const authorRefined = ref('') @@ -277,6 +304,7 @@ function syncSelectedCharacter(character: CharacterDTO | null) { editMental.value = character?.mental_state || 'NORMAL' editVerbal.value = character?.verbal_tic || '' editIdle.value = character?.idle_behavior || '' + oocGuardrails.value = '' } function anchorStrength(character: CharacterDTO) { @@ -332,9 +360,9 @@ function buildSuggestedScenePrompt() { if (!character) return '' const scene = sceneType.value.trim() if (scene && scene !== 'general') { - return `请写一段${character.name}在“${scene}”场景中的对白,保留当前口吻锚点。` + return `请写一段${character.name}在“${scene}”场景中的对白,保留当前口吻锚点。${oocGuardrails.value ? `OOC 禁忌:${oocGuardrails.value}` : ''}` } - return `请写一段${character.name}在当前章节语境下的对白,保留当前口吻锚点。` + return `请写一段${character.name}在当前章节语境下的对白,保留当前口吻锚点。${oocGuardrails.value ? `OOC 禁忌:${oocGuardrails.value}` : ''}` } function jumpToSandbox() { @@ -427,6 +455,83 @@ async function saveAnchors() { } } +function suggestionText(fields: Record, key: string) { + const value = fields[key] + if (value == null) return '' + return String(value) +} + +async function suggestVoiceAnchors() { + const character = selectedCharacter.value + if (!character) { + message.warning('先选择角色再生成口吻建议') + return + } + suggestingAnchors.value = true + try { + const result = await novelproSuggestionsApi.suggestFields(props.slug, { + suggestion_type: 'voice_anchor', + chapter_number: props.currentChapter, + fields: ['mental_state', 'verbal_tic', 'idle_behavior', 'ooc_guardrails'], + target: { + character_id: character.id, + character_name: character.name, + }, + current_values: { + mental_state: editMental.value, + verbal_tic: editVerbal.value, + idle_behavior: editIdle.value, + ooc_guardrails: oocGuardrails.value, + }, + instruction: '根据初始设定、当前章节、掉线/OOC 风险,生成可直接编辑的角色口吻锚点。', + }) + editMental.value = suggestionText(result.fields, 'mental_state') || editMental.value + editVerbal.value = suggestionText(result.fields, 'verbal_tic') || editVerbal.value + editIdle.value = suggestionText(result.fields, 'idle_behavior') || editIdle.value + oocGuardrails.value = suggestionText(result.fields, 'ooc_guardrails') || oocGuardrails.value + message.success(result.rationale || '已生成口吻锚点建议') + } catch { + message.error('生成口吻建议失败,请稍后重试') + } finally { + suggestingAnchors.value = false + } +} + +async function suggestVoiceSample() { + const character = selectedCharacter.value + if (!character) { + message.warning('先选择角色再生成样本建议') + return + } + suggestingSample.value = true + try { + const result = await novelproSuggestionsApi.suggestFields(props.slug, { + suggestion_type: 'voice_sample', + chapter_number: currentSampleChapter.value, + fields: ['scene_type', 'ai_original', 'author_refined'], + target: { + character_id: character.id, + character_name: character.name, + ooc_guardrails: oocGuardrails.value, + }, + current_values: { + scene_type: sceneType.value, + ai_original: aiOriginal.value, + author_refined: authorRefined.value, + }, + instruction: '生成一组短样本对:AI 原文要有轻微可修问题,作者定稿要体现角色稳定口吻并避免 OOC。', + }) + sceneType.value = suggestionText(result.fields, 'scene_type') || sceneType.value + aiOriginal.value = suggestionText(result.fields, 'ai_original') || aiOriginal.value + authorRefined.value = suggestionText(result.fields, 'author_refined') || authorRefined.value + message.success(result.rationale || '已生成作者样本建议') + } catch { + message.error('生成样本建议失败,请稍后重试') + } finally { + suggestingSample.value = false + } +} + async function saveVoiceSample() { if (!canSubmitSample.value) return diff --git a/frontend/src/components/workbench/WorkArea.vue b/frontend/src/components/workbench/WorkArea.vue index 76301014..29b42e34 100644 --- a/frontend/src/components/workbench/WorkArea.vue +++ b/frontend/src/components/workbench/WorkArea.vue @@ -524,6 +524,14 @@ diff --git a/interfaces/api/dependencies.py b/interfaces/api/dependencies.py index fe876e01..f13e01bb 100644 --- a/interfaces/api/dependencies.py +++ b/interfaces/api/dependencies.py @@ -309,10 +309,10 @@ def get_style_profile_service(): def _build_style_bible_llm_extractor(): - """构造写作手法档案的 LLM 提炼器,使用当前激活的 PP AI 配置。""" - llm_service = get_llm_service() + """构造写作手法档案的 LLM 提炼器,支持按请求指定 PP AI 配置。""" + provider_factory = get_llm_provider_factory() - def extract(samples, metrics): + def extract(samples, metrics, llm_profile_id: str = ""): prompt = Prompt( system=( "你是小说写作手法分析师,只学习文本的节奏、句法、镜头、对白与禁用表达," @@ -323,6 +323,12 @@ def extract(samples, metrics): ) async def run(): + selected_profile_id = (llm_profile_id or "").strip() + llm_service = ( + provider_factory.create_by_profile_id(selected_profile_id) + if selected_profile_id + else provider_factory.create_active_provider() + ) result = await llm_service.generate( prompt, GenerationConfig( diff --git a/interfaces/api/v1/style_bible.py b/interfaces/api/v1/style_bible.py index b9c5ea42..f8667e9d 100644 --- a/interfaces/api/v1/style_bible.py +++ b/interfaces/api/v1/style_bible.py @@ -52,6 +52,7 @@ class GenerateStyleProfileRequest(BaseModel): description: str = "" sample_ids: List[str] = Field(default_factory=list) use_llm: bool = False + llm_profile_id: str = "" class UpdateTechniqueCardRequest(BaseModel): diff --git a/tests/unit/application/services/test_style_profile_service.py b/tests/unit/application/services/test_style_profile_service.py index 84211a24..8bb4cac7 100644 --- a/tests/unit/application/services/test_style_profile_service.py +++ b/tests/unit/application/services/test_style_profile_service.py @@ -95,8 +95,8 @@ def test_style_profile_service_normalizes_llm_payload_shapes(tmp_path): def test_style_profile_service_uses_llm_payload_when_available(tmp_path): calls = [] - def extractor(samples, metrics): - calls.append((samples, metrics)) + def extractor(samples, metrics, llm_profile_id): + calls.append((samples, metrics, llm_profile_id)) return { "profile_summary": "DS 提炼:短句、留白、动作推进。", "rhythm_rules": ["短段落承接动作", "对白必须释放信息"], @@ -133,10 +133,11 @@ def extractor(samples, metrics): name="DS 手法档案", sample_ids=[imported.sample.id], use_llm=True, + llm_profile_id="deepseek-default", ) ) - assert calls + assert calls[0][2] == "deepseek-default" assert result.profile.description == "DS 提炼:短句、留白、动作推进。" assert result.profile.rules == ["短段落承接动作", "对白必须释放信息"] assert result.cards[0].title == "动作留白" From d4b537196e4cb5c5c11535d1c695fd13082421c1 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 30 Apr 2026 11:40:56 +0800 Subject: [PATCH 83/97] feat: upload style bible text samples --- .../components/workbench/StyleBiblePanel.vue | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/frontend/src/components/workbench/StyleBiblePanel.vue b/frontend/src/components/workbench/StyleBiblePanel.vue index 8fa87acf..6a818744 100644 --- a/frontend/src/components/workbench/StyleBiblePanel.vue +++ b/frontend/src/components/workbench/StyleBiblePanel.vue @@ -15,6 +15,21 @@ 允许用于生成 + + + 可粘贴文本,也可上传 .txt / .md + + + + 上传文件 + + + (null) @@ -347,6 +363,46 @@ async function importAndProfile() { await importSample(true) } +async function handleSampleFileSelect(data: { + file: { file?: File | null; name?: string } + fileList: Array<{ file?: File | null }> +}) { + const file = data.file?.file + if (!file) return + readingSampleFile.value = true + try { + const text = await readTextFile(file) + const content = text.trim() + if (!content) { + message.warning('文件内容为空') + return + } + sampleContent.value = content + if (!sampleTitle.value.trim()) { + sampleTitle.value = file.name.replace(/\.(txt|md|markdown)$/i, '') + } + message.success(`已读取 ${file.name}`) + } catch { + message.error('文件读取失败') + } finally { + readingSampleFile.value = false + } +} + +async function readTextFile(file: File) { + const buffer = await file.arrayBuffer() + const utf8 = new TextDecoder('utf-8').decode(buffer) + const replacementCount = (utf8.match(/\uFFFD/g) || []).length + if (replacementCount <= Math.max(3, utf8.length * 0.01)) { + return utf8 + } + try { + return new TextDecoder('gb18030').decode(buffer) + } catch { + return utf8 + } +} + async function importSample(createProfile: boolean) { importing.value = true try { @@ -496,6 +552,10 @@ onMounted(() => { diff --git a/frontend/src/components/workbench/SettingsPanel.vue b/frontend/src/components/workbench/SettingsPanel.vue index b93e84a7..9e149cd6 100644 --- a/frontend/src/components/workbench/SettingsPanel.vue +++ b/frontend/src/components/workbench/SettingsPanel.vue @@ -79,6 +79,9 @@ + + + @@ -129,6 +132,7 @@ const SandboxDialoguePanel = defineAsyncComponent(() => import('./SandboxDialogu const VoiceLockPanel = defineAsyncComponent(() => import('./VoiceLockPanel.vue')) const VoiceDriftPanel = defineAsyncComponent(() => import('./VoiceDriftPanel.vue')) const PowerSystemPanel = defineAsyncComponent(() => import('./PowerSystemPanel.vue')) +const PropLedgerPanel = defineAsyncComponent(() => import('./PropLedgerPanel.vue')) const ModelRolePanel = defineAsyncComponent(() => import('./ModelRolePanel.vue')) const StyleBiblePanel = defineAsyncComponent(() => import('./StyleBiblePanel.vue')) @@ -137,12 +141,12 @@ const ALL_TABS = new Set([ 'bible', 'worldbuilding', 'knowledge', 'storyline-arc', 'chronicles', 'novelpro-monitor', 'candidate-refine', - 'continuity', 'voice-lock', 'voice-drift', 'power-system', 'model-role', 'sandbox', 'style-bible', 'foreshadow', + 'continuity', 'voice-lock', 'voice-drift', 'power-system', 'prop-ledger', 'model-role', 'sandbox', 'style-bible', 'foreshadow', ]) -const NOVELPRO_TABS = new Set(['novelpro-monitor', 'candidate-refine', 'continuity', 'voice-lock', 'voice-drift', 'power-system', 'model-role', 'sandbox', 'style-bible']) +const NOVELPRO_TABS = new Set(['novelpro-monitor', 'candidate-refine', 'continuity', 'voice-lock', 'voice-drift', 'power-system', 'prop-ledger', 'model-role', 'sandbox', 'style-bible']) const BASE_TABS = new Set(['bible', 'worldbuilding', 'knowledge', 'storyline-arc', 'chronicles', 'foreshadow']) const GROUP_TABS = { - novelpro: ['novelpro-monitor', 'candidate-refine', 'continuity', 'voice-lock', 'voice-drift', 'power-system', 'model-role', 'sandbox', 'style-bible'], + novelpro: ['novelpro-monitor', 'candidate-refine', 'continuity', 'voice-lock', 'voice-drift', 'power-system', 'prop-ledger', 'model-role', 'sandbox', 'style-bible'], base: ['bible', 'worldbuilding', 'knowledge', 'storyline-arc', 'chronicles', 'foreshadow'], } as const const TAB_LABELS: Record = { @@ -152,6 +156,7 @@ const TAB_LABELS: Record = { 'voice-lock': '口吻锁定', 'voice-drift': '文风监控', 'power-system': '战力系统', + 'prop-ledger': '道具账本', 'model-role': 'PP AI', sandbox: '对话沙盒', 'style-bible': '手法库', diff --git a/infrastructure/persistence/database/schema.sql b/infrastructure/persistence/database/schema.sql index 5cbdefe3..e19626ba 100644 --- a/infrastructure/persistence/database/schema.sql +++ b/infrastructure/persistence/database/schema.sql @@ -815,6 +815,49 @@ CREATE INDEX IF NOT EXISTS idx_power_profiles_novel CREATE INDEX IF NOT EXISTS idx_power_events_novel_chapter ON power_progression_events(novel_id, chapter_number DESC); +-- ========== 道具账本(NovelPro)========== +-- 记录关键道具的当前持有人、位置、状态与最近出场,防止后期遗忘或状态错乱 +CREATE TABLE IF NOT EXISTS prop_ledger_items ( + id TEXT PRIMARY KEY, + novel_id TEXT NOT NULL, + name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + current_holder TEXT NOT NULL DEFAULT '', + current_location TEXT NOT NULL DEFAULT '', + first_seen_chapter INTEGER, + last_seen_chapter INTEGER, + importance TEXT NOT NULL DEFAULT 'normal', + description TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (novel_id) REFERENCES novels(id) ON DELETE CASCADE, + UNIQUE(novel_id, name) +); + +CREATE TABLE IF NOT EXISTS prop_ledger_events ( + id TEXT PRIMARY KEY, + novel_id TEXT NOT NULL, + prop_id TEXT NOT NULL, + prop_name TEXT NOT NULL, + chapter_number INTEGER NOT NULL, + event_type TEXT NOT NULL DEFAULT 'mention', + holder TEXT NOT NULL DEFAULT '', + location TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + evidence TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (novel_id) REFERENCES novels(id) ON DELETE CASCADE, + FOREIGN KEY (prop_id) REFERENCES prop_ledger_items(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_prop_ledger_items_novel + ON prop_ledger_items(novel_id, importance, last_seen_chapter DESC); +CREATE INDEX IF NOT EXISTS idx_prop_ledger_events_novel_chapter + ON prop_ledger_events(novel_id, chapter_number DESC, created_at DESC); + -- ========== 提示词广场系统(Prompt Plaza)========== -- 模板包:一组相关提示词的集合(如"内置"、"自定义工作流") diff --git a/infrastructure/persistence/database/sqlite_prop_ledger_repository.py b/infrastructure/persistence/database/sqlite_prop_ledger_repository.py new file mode 100644 index 00000000..70ba1aab --- /dev/null +++ b/infrastructure/persistence/database/sqlite_prop_ledger_repository.py @@ -0,0 +1,178 @@ +"""SQLite repository for NovelPro prop ledger.""" +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any, Optional + +from infrastructure.persistence.database.connection import DatabaseConnection + + +class SqlitePropLedgerRepository: + """关键道具账本。""" + + def __init__(self, db: DatabaseConnection): + self.db = db + + def get_item_by_name(self, novel_id: str, name: str) -> Optional[dict[str, Any]]: + return self.db.fetch_one( + """ + SELECT * FROM prop_ledger_items + WHERE novel_id = ? AND name = ? + """, + (novel_id, name), + ) + + def list_items(self, novel_id: str) -> list[dict[str, Any]]: + return self.db.fetch_all( + """ + SELECT * FROM prop_ledger_items + WHERE novel_id = ? + ORDER BY + CASE importance + WHEN 'major' THEN 0 + WHEN 'normal' THEN 1 + ELSE 2 + END, + COALESCE(last_seen_chapter, first_seen_chapter, 0) DESC, + updated_at DESC + """, + (novel_id,), + ) + + def list_events(self, novel_id: str, *, limit: int = 50) -> list[dict[str, Any]]: + return self.db.fetch_all( + """ + SELECT * FROM prop_ledger_events + WHERE novel_id = ? + ORDER BY chapter_number DESC, created_at DESC + LIMIT ? + """, + (novel_id, int(limit)), + ) + + def upsert_item( + self, + *, + novel_id: str, + name: str, + category: str, + status: str, + current_holder: str, + current_location: str, + first_seen_chapter: Optional[int], + last_seen_chapter: Optional[int], + importance: str, + description: str, + notes: str, + ) -> dict[str, Any]: + existing = self.get_item_by_name(novel_id, name) + now = datetime.utcnow().isoformat() + if existing: + self.db.execute( + """ + UPDATE prop_ledger_items + SET category = ?, status = ?, current_holder = ?, current_location = ?, + first_seen_chapter = ?, last_seen_chapter = ?, importance = ?, + description = ?, notes = ?, updated_at = ? + WHERE novel_id = ? AND name = ? + """, + ( + category, + status, + current_holder, + current_location, + first_seen_chapter, + last_seen_chapter, + importance, + description, + notes, + now, + novel_id, + name, + ), + ) + else: + self.db.execute( + """ + INSERT INTO prop_ledger_items ( + id, novel_id, name, category, status, current_holder, + current_location, first_seen_chapter, last_seen_chapter, + importance, description, notes, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + str(uuid.uuid4()), + novel_id, + name, + category, + status, + current_holder, + current_location, + first_seen_chapter, + last_seen_chapter, + importance, + description, + notes, + now, + now, + ), + ) + self.db.commit() + return self.get_item_by_name(novel_id, name) or {} + + def create_event( + self, + *, + novel_id: str, + prop_id: str, + prop_name: str, + chapter_number: int, + event_type: str, + holder: str, + location: str, + status: str, + evidence: str, + notes: str, + ) -> dict[str, Any]: + event_id = str(uuid.uuid4()) + now = datetime.utcnow().isoformat() + self.db.execute( + """ + INSERT INTO prop_ledger_events ( + id, novel_id, prop_id, prop_name, chapter_number, event_type, + holder, location, status, evidence, notes, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event_id, + novel_id, + prop_id, + prop_name, + int(chapter_number), + event_type, + holder, + location, + status, + evidence, + notes, + now, + ), + ) + self.db.execute( + """ + UPDATE prop_ledger_items + SET current_holder = COALESCE(NULLIF(?, ''), current_holder), + current_location = COALESCE(NULLIF(?, ''), current_location), + status = COALESCE(NULLIF(?, ''), status), + last_seen_chapter = ?, + updated_at = ? + WHERE id = ? + """, + (holder, location, status, int(chapter_number), now, prop_id), + ) + self.db.commit() + return self.db.fetch_one( + "SELECT * FROM prop_ledger_events WHERE id = ?", + (event_id,), + ) or {} diff --git a/interfaces/api/dependencies.py b/interfaces/api/dependencies.py index f13e01bb..8194818e 100644 --- a/interfaces/api/dependencies.py +++ b/interfaces/api/dependencies.py @@ -491,6 +491,20 @@ def get_power_system_service() -> PowerSystemService: return PowerSystemService(get_power_system_repository()) +def get_prop_ledger_repository(): + from infrastructure.persistence.database.sqlite_prop_ledger_repository import ( + SqlitePropLedgerRepository, + ) + + return SqlitePropLedgerRepository(get_database()) + + +def get_prop_ledger_service(): + from application.analyst.services.prop_ledger_service import PropLedgerService + + return PropLedgerService(get_prop_ledger_repository()) + + def get_obsidian_memory_service(): """Obsidian 长期记忆镜像;导出时读取 PP 缓存,避免被尚未同步的 Obsidian 内容遮挡。""" from application.world.services.obsidian_memory_service import ( @@ -850,6 +864,7 @@ def build_auto_workflow(llm_service: LLMService) -> AutoNovelGenerationWorkflow: conflict_detection_service=ConflictDetectionService(), cliche_scanner=ClicheScanner(), style_prompt_overlay_service=get_style_prompt_overlay_service(), + prop_ledger_service=get_prop_ledger_service(), ) diff --git a/interfaces/api/v1/analyst/prop_ledger.py b/interfaces/api/v1/analyst/prop_ledger.py new file mode 100644 index 00000000..8a45cbed --- /dev/null +++ b/interfaces/api/v1/analyst/prop_ledger.py @@ -0,0 +1,136 @@ +"""道具账本 API。""" +from __future__ import annotations + +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Path +from pydantic import BaseModel, Field + +from application.analyst.services.prop_ledger_service import PropLedgerService +from interfaces.api.dependencies import get_novel_service, get_prop_ledger_service + + +router = APIRouter(tags=["prop-ledger"]) + + +class PropLedgerItem(BaseModel): + id: str + novel_id: str + name: str + category: str + status: str + current_holder: str + current_location: str + first_seen_chapter: Optional[int] = None + last_seen_chapter: Optional[int] = None + importance: str + description: str + notes: str + created_at: str + updated_at: str + + +class PropLedgerEvent(BaseModel): + id: str + novel_id: str + prop_id: str + prop_name: str + chapter_number: int + event_type: str + holder: str + location: str + status: str + evidence: str + notes: str + created_at: str + + +class PropLedgerWarning(BaseModel): + severity: str + title: str + message: str + + +class PropLedgerOverview(BaseModel): + novel_id: str + items: List[PropLedgerItem] + recent_events: List[PropLedgerEvent] + warnings: List[PropLedgerWarning] + + +class UpsertPropItemRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + category: str = Field(default="", max_length=100) + status: str = Field(default="", max_length=100) + current_holder: str = Field(default="", max_length=100) + current_location: str = Field(default="", max_length=200) + first_seen_chapter: Optional[int] = Field(default=None, ge=1) + last_seen_chapter: Optional[int] = Field(default=None, ge=1) + importance: str = Field(default="normal", max_length=20) + description: str = Field(default="", max_length=4000) + notes: str = Field(default="", max_length=4000) + + +class CreatePropEventRequest(BaseModel): + prop_name: str = Field(..., min_length=1, max_length=100) + chapter_number: int = Field(..., ge=1) + event_type: str = Field(default="mention", max_length=50) + holder: str = Field(default="", max_length=100) + location: str = Field(default="", max_length=200) + status: str = Field(default="", max_length=100) + evidence: str = Field(default="", max_length=4000) + notes: str = Field(default="", max_length=4000) + + +def _ensure_novel_exists(novel_id: str, novel_service) -> None: + if novel_service.get_novel(novel_id) is None: + raise HTTPException(status_code=404, detail="Novel not found") + + +@router.get( + "/novels/{novel_id}/prop-ledger/overview", + response_model=PropLedgerOverview, +) +def get_prop_ledger_overview( + novel_id: str = Path(...), + novel_service=Depends(get_novel_service), + service: PropLedgerService = Depends(get_prop_ledger_service), +) -> PropLedgerOverview: + _ensure_novel_exists(novel_id, novel_service) + return PropLedgerOverview(**service.get_overview(novel_id)) + + +@router.post( + "/novels/{novel_id}/prop-ledger/items", + response_model=PropLedgerItem, + status_code=201, +) +def upsert_prop_item( + request: UpsertPropItemRequest, + novel_id: str = Path(...), + novel_service=Depends(get_novel_service), + service: PropLedgerService = Depends(get_prop_ledger_service), +) -> PropLedgerItem: + _ensure_novel_exists(novel_id, novel_service) + try: + return PropLedgerItem(**service.upsert_item(novel_id=novel_id, **request.model_dump())) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post( + "/novels/{novel_id}/prop-ledger/events", + response_model=PropLedgerEvent, + status_code=201, +) +def create_prop_event( + request: CreatePropEventRequest, + novel_id: str = Path(...), + novel_service=Depends(get_novel_service), + service: PropLedgerService = Depends(get_prop_ledger_service), +) -> PropLedgerEvent: + _ensure_novel_exists(novel_id, novel_service) + try: + return PropLedgerEvent(**service.create_event(novel_id=novel_id, **request.model_dump())) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/interfaces/main.py b/interfaces/main.py index 0be2e0fb..5edfbabb 100644 --- a/interfaces/main.py +++ b/interfaces/main.py @@ -79,7 +79,7 @@ from interfaces.api.v1.audit import chapter_review_routes, macro_refactor, chapter_element_routes # Analyst module -from interfaces.api.v1.analyst import voice, narrative_state, foreshadow_ledger, continuity, power_system, novelpro_monitor, novelpro_suggestions +from interfaces.api.v1.analyst import voice, narrative_state, foreshadow_ledger, continuity, power_system, prop_ledger, novelpro_monitor, novelpro_suggestions # System module (internal tooling) from interfaces.api.v1 import system as system_routes @@ -571,6 +571,7 @@ def restart_autopilot_daemon(): app.include_router(voice.router, prefix="/api/v1") app.include_router(continuity.router, prefix="/api/v1") app.include_router(power_system.router, prefix="/api/v1") +app.include_router(prop_ledger.router, prefix="/api/v1") app.include_router(novelpro_monitor.router, prefix="/api/v1") app.include_router(novelpro_suggestions.router, prefix="/api/v1") app.include_router(narrative_state.router, prefix="/api/v1") diff --git a/tests/unit/application/services/test_prop_ledger_service.py b/tests/unit/application/services/test_prop_ledger_service.py new file mode 100644 index 00000000..91df5d0f --- /dev/null +++ b/tests/unit/application/services/test_prop_ledger_service.py @@ -0,0 +1,53 @@ +"""道具账本服务测试。""" + +from application.analyst.services.prop_ledger_service import PropLedgerService +from infrastructure.persistence.database.connection import DatabaseConnection +from infrastructure.persistence.database.sqlite_prop_ledger_repository import ( + SqlitePropLedgerRepository, +) + + +def test_prop_ledger_tracks_current_state_after_events(tmp_path): + db = DatabaseConnection(str(tmp_path / "prop-ledger.db")) + db.execute( + "INSERT INTO novels (id, title, slug) VALUES (?, ?, ?)", + ("novel-1", "测试小说", "novel-1"), + ) + db.commit() + service = PropLedgerService(SqlitePropLedgerRepository(db)) + + item = service.upsert_item( + novel_id="novel-1", + name="青铜钥匙", + category="钥匙", + status="未使用", + current_holder="林晚", + current_location="旧公寓", + first_seen_chapter=2, + last_seen_chapter=2, + importance="major", + description="能打开地下档案室的旧钥匙。", + notes="不要写丢。", + ) + assert item["name"] == "青铜钥匙" + + event = service.create_event( + novel_id="novel-1", + prop_name="青铜钥匙", + chapter_number=5, + event_type="transfer", + holder="周砚", + location="警局证物柜", + status="被封存", + evidence="第5章周砚把钥匙放入证物袋。", + notes="下一次需要先取出。", + ) + + assert event["prop_name"] == "青铜钥匙" + overview = service.get_overview("novel-1") + [current] = overview["items"] + assert current["current_holder"] == "周砚" + assert current["current_location"] == "警局证物柜" + assert current["status"] == "被封存" + assert current["last_seen_chapter"] == 5 + assert overview["recent_events"][0]["evidence"] == "第5章周砚把钥匙放入证物袋。" From 4f30e2b008c25d04771e23dea7686b5a0dc8e0ba Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 30 Apr 2026 12:16:10 +0800 Subject: [PATCH 85/97] feat: suggest prop ledger events from chapters --- .../analyst/services/prop_ledger_service.py | 102 ++++++++++++++++++ frontend/src/api/propLedger.ts | 20 ++++ .../components/workbench/PropLedgerPanel.vue | 94 +++++++++++++++- interfaces/api/v1/analyst/prop_ledger.py | 39 +++++++ .../services/test_prop_ledger_service.py | 45 ++++++++ 5 files changed, 299 insertions(+), 1 deletion(-) diff --git a/application/analyst/services/prop_ledger_service.py b/application/analyst/services/prop_ledger_service.py index 1a4bda16..d09edbaa 100644 --- a/application/analyst/services/prop_ledger_service.py +++ b/application/analyst/services/prop_ledger_service.py @@ -1,6 +1,7 @@ """道具账本服务。""" from __future__ import annotations +import re from typing import Any, Optional @@ -97,6 +98,41 @@ def create_event( notes=notes.strip(), ) + def suggest_events_from_chapter( + self, + *, + novel_id: str, + chapter_number: int, + content: str, + ) -> list[dict[str, Any]]: + """从章节正文中提示可能需要人工确认的道具事件。""" + chapter = int(chapter_number) + if chapter < 1: + raise ValueError("chapter_number must be greater than 0") + clean_content = (content or "").strip() + if not clean_content: + return [] + + suggestions: list[dict[str, Any]] = [] + for item in self.repository.list_items(novel_id): + name = str(item.get("name") or "").strip() + if not name or name not in clean_content: + continue + evidence = self._build_evidence_snippet(clean_content, name) + event_type, status, reason, confidence = self._classify_event(evidence) + suggestions.append({ + "prop_name": name, + "chapter_number": chapter, + "event_type": event_type, + "status": status or str(item.get("status") or ""), + "holder": "", + "location": self._extract_location(evidence), + "evidence": evidence, + "reason": reason, + "confidence": confidence, + }) + return suggestions[:12] + @staticmethod def _positive_or_none(value: Optional[int]) -> Optional[int]: if value is None: @@ -108,6 +144,72 @@ def _positive_or_none(value: Optional[int]) -> Optional[int]: def _normalize_importance(value: str) -> str: return value if value in {"major", "normal", "minor"} else "normal" + @staticmethod + def _build_evidence_snippet(content: str, prop_name: str, radius: int = 36) -> str: + index = content.find(prop_name) + if index < 0: + return "" + start = max(0, index - radius) + end = min(len(content), index + len(prop_name) + radius) + return content[start:end].strip() + + @staticmethod + def _classify_event(evidence: str) -> tuple[str, str, str, float]: + rules = [ + ( + "sealed", + "被封存", + 0.82, + "正文出现已登记道具,并命中封存/证物相关表达。", + ("封存", "证物袋", "证物柜", "保险柜", "锁进", "收押"), + ), + ( + "lost_or_broken", + "疑似丢失/损坏", + 0.78, + "正文出现已登记道具,并命中丢失/损坏相关表达。", + ("丢失", "不见", "遗失", "摔碎", "碎裂", "折断", "损坏", "烧毁"), + ), + ( + "transfer", + "疑似转交", + 0.74, + "正文出现已登记道具,并命中转交相关表达。", + ("递给", "交给", "交到", "递到", "塞给", "转交", "给了"), + ), + ( + "use", + "已使用", + 0.70, + "正文出现已登记道具,并命中使用相关表达。", + ("使用", "打开", "启动", "点燃", "按下", "照亮", "刺入", "割开", "解开"), + ), + ( + "acquire", + "被取得", + 0.68, + "正文出现已登记道具,并命中取得/带走相关表达。", + ("拿到", "拿起", "取出", "接过", "收下", "获得", "捡起", "握住", "攥住", "带走"), + ), + ] + for event_type, status, confidence, reason, keywords in rules: + if any(keyword in evidence for keyword in keywords): + return event_type, status, reason, confidence + return "mention", "", "正文提到已登记道具,建议确认当前状态是否变化。", 0.48 + + @staticmethod + def _extract_location(evidence: str) -> str: + patterns = [ + r"(警局证物柜|证物柜|保险柜|证物袋)", + r"(?:锁进|放进|装进|塞进|收入|放入)([^,。;;、\s]{2,20}(?:柜|箱|袋|盒|室|库|房|抽屉))", + ] + matches: list[str] = [] + for pattern in patterns: + matches.extend(match.group(1) for match in re.finditer(pattern, evidence)) + if not matches: + return "" + return max(matches, key=len) + @staticmethod def _build_warnings(items: list[dict[str, Any]]) -> list[dict[str, str]]: warnings: list[dict[str, str]] = [] diff --git a/frontend/src/api/propLedger.ts b/frontend/src/api/propLedger.ts index 6c322a0d..ffc9f2f0 100644 --- a/frontend/src/api/propLedger.ts +++ b/frontend/src/api/propLedger.ts @@ -45,6 +45,18 @@ export interface PropLedgerOverview { warnings: PropLedgerWarning[] } +export interface PropLedgerEventSuggestion { + prop_name: string + chapter_number: number + event_type: string + holder: string + location: string + status: string + evidence: string + reason: string + confidence: number +} + export interface UpsertPropItemRequest { name: string category?: string @@ -69,6 +81,11 @@ export interface CreatePropEventRequest { notes?: string } +export interface SuggestPropEventsRequest { + chapter_number: number + content: string +} + export const propLedgerApi = { getOverview: (novelId: string) => apiClient.get(`/novels/${novelId}/prop-ledger/overview`) as Promise, @@ -78,4 +95,7 @@ export const propLedgerApi = { createEvent: (novelId: string, data: CreatePropEventRequest) => apiClient.post(`/novels/${novelId}/prop-ledger/events`, data) as Promise, + + suggestEvents: (novelId: string, data: SuggestPropEventsRequest) => + apiClient.post(`/novels/${novelId}/prop-ledger/events/suggestions`, data) as Promise, } diff --git a/frontend/src/components/workbench/PropLedgerPanel.vue b/frontend/src/components/workbench/PropLedgerPanel.vue index cd650185..849597f0 100644 --- a/frontend/src/components/workbench/PropLedgerPanel.vue +++ b/frontend/src/components/workbench/PropLedgerPanel.vue @@ -13,6 +13,9 @@ 刷新 + + 识别本章 + 复制道具约束 @@ -105,6 +108,31 @@ + + 发现 {{ suggestions.length }} 条候选道具事件。点击候选会预填到下方表单,确认后再保存。 + + + + @@ -159,7 +187,8 @@ + + diff --git a/frontend/src/components/workbench/CocCluePanel.vue b/frontend/src/components/workbench/CocCluePanel.vue new file mode 100644 index 00000000..ca10db1b --- /dev/null +++ b/frontend/src/components/workbench/CocCluePanel.vue @@ -0,0 +1,615 @@ + + + + + diff --git a/frontend/src/components/workbench/SettingsPanel.vue b/frontend/src/components/workbench/SettingsPanel.vue index 9e149cd6..732afda8 100644 --- a/frontend/src/components/workbench/SettingsPanel.vue +++ b/frontend/src/components/workbench/SettingsPanel.vue @@ -82,6 +82,12 @@ + + + + + + @@ -133,6 +139,8 @@ const VoiceLockPanel = defineAsyncComponent(() => import('./VoiceLockPanel.vue') const VoiceDriftPanel = defineAsyncComponent(() => import('./VoiceDriftPanel.vue')) const PowerSystemPanel = defineAsyncComponent(() => import('./PowerSystemPanel.vue')) const PropLedgerPanel = defineAsyncComponent(() => import('./PropLedgerPanel.vue')) +const CocCanonPanel = defineAsyncComponent(() => import('./CocCanonPanel.vue')) +const CocCluePanel = defineAsyncComponent(() => import('./CocCluePanel.vue')) const ModelRolePanel = defineAsyncComponent(() => import('./ModelRolePanel.vue')) const StyleBiblePanel = defineAsyncComponent(() => import('./StyleBiblePanel.vue')) @@ -141,12 +149,12 @@ const ALL_TABS = new Set([ 'bible', 'worldbuilding', 'knowledge', 'storyline-arc', 'chronicles', 'novelpro-monitor', 'candidate-refine', - 'continuity', 'voice-lock', 'voice-drift', 'power-system', 'prop-ledger', 'model-role', 'sandbox', 'style-bible', 'foreshadow', + 'continuity', 'voice-lock', 'voice-drift', 'power-system', 'prop-ledger', 'coc-canon', 'coc-clues', 'model-role', 'sandbox', 'style-bible', 'foreshadow', ]) -const NOVELPRO_TABS = new Set(['novelpro-monitor', 'candidate-refine', 'continuity', 'voice-lock', 'voice-drift', 'power-system', 'prop-ledger', 'model-role', 'sandbox', 'style-bible']) +const NOVELPRO_TABS = new Set(['novelpro-monitor', 'candidate-refine', 'continuity', 'voice-lock', 'voice-drift', 'power-system', 'prop-ledger', 'coc-canon', 'coc-clues', 'model-role', 'sandbox', 'style-bible']) const BASE_TABS = new Set(['bible', 'worldbuilding', 'knowledge', 'storyline-arc', 'chronicles', 'foreshadow']) const GROUP_TABS = { - novelpro: ['novelpro-monitor', 'candidate-refine', 'continuity', 'voice-lock', 'voice-drift', 'power-system', 'prop-ledger', 'model-role', 'sandbox', 'style-bible'], + novelpro: ['novelpro-monitor', 'candidate-refine', 'continuity', 'voice-lock', 'voice-drift', 'power-system', 'prop-ledger', 'coc-canon', 'coc-clues', 'model-role', 'sandbox', 'style-bible'], base: ['bible', 'worldbuilding', 'knowledge', 'storyline-arc', 'chronicles', 'foreshadow'], } as const const TAB_LABELS: Record = { @@ -157,6 +165,8 @@ const TAB_LABELS: Record = { 'voice-drift': '文风监控', 'power-system': '战力系统', 'prop-ledger': '道具账本', + 'coc-canon': 'CoC正典', + 'coc-clues': 'CoC线索', 'model-role': 'PP AI', sandbox: '对话沙盒', 'style-bible': '手法库', diff --git a/frontend/src/components/workbench/VoiceLockPanel.vue b/frontend/src/components/workbench/VoiceLockPanel.vue index 3f6970c6..b6ec3024 100644 --- a/frontend/src/components/workbench/VoiceLockPanel.vue +++ b/frontend/src/components/workbench/VoiceLockPanel.vue @@ -38,6 +38,14 @@ 当前作者样本 {{ fingerprint.sample_count }} 组。样本达到 10 组以后,现有文风漂移检测会更稳定。 + + {{ fingerprintLoadWarning }} + @@ -265,6 +273,7 @@ const { voiceLockDraft, voiceLockDraftVersion } = storeToRefs(contextStore) const loading = ref(false) const loadError = ref('') +const fingerprintLoadWarning = ref('') const saveLoading = ref(false) const sampleSaving = ref(false) const suggestingAnchors = ref(false) @@ -409,13 +418,19 @@ async function reloadAll() { if (!props.slug) return loading.value = true loadError.value = '' + fingerprintLoadWarning.value = '' try { - await Promise.all([loadCharacters(), loadFingerprint()]) + await loadCharacters() + try { + await loadFingerprint() + } catch { + fingerprintLoadWarning.value = '文风样本统计暂时不可用,不影响角色口吻锚点编辑。' + } if (!sampleChapter.value) { sampleChapter.value = props.currentChapter || 1 } } catch { - loadError.value = '加载口吻锁定面板失败,请稍后重试' + loadError.value = '加载角色口吻锚点失败,请确认本书 Bible 已生成后重试。' } finally { loading.value = false } diff --git a/frontend/src/components/workbench/WorkArea.vue b/frontend/src/components/workbench/WorkArea.vue index dd44fab0..296ab9ef 100644 --- a/frontend/src/components/workbench/WorkArea.vue +++ b/frontend/src/components/workbench/WorkArea.vue @@ -237,16 +237,176 @@ - + + + + 套用CoC结构模板 + + + 手动规划会随生成一起读取 Bible、正典、线索和道具账本。 + + + + + + + + + CoC 认知预检 + + {{ + cocPrecheckResult?.risk_level === 'block' + ? '阻断' + : cocPrecheckResult?.risk_level === 'warning' + ? '提醒' + : cocPrecheckResult?.checked + ? '通过' + : '未检查' + }} + + + + + + + 立即预检 + + + 一键安全改写 + + + + + + 生成前检查是否越过“读者已知 / 角色已知 / 作者真相”边界,命中阻断项会默认禁止生成。 + + + + + + - {{ item }} + + + + + + + - {{ item }} + + + + + + + + + 仅本次生成忽略阻断(建议仅用于实验) + + + + + + + + 本次改写模式 + + {{ cocRewriteResult.rewrite_mode === 'aggressive' ? '激进' : '保守' }} + + + {{ + cocRewriteResult.rewrite_style === 'coc' + ? 'CoC向' + : cocRewriteResult.rewrite_style === 'suspense' + ? '悬疑向' + : '通用' + }} + + + + - {{ item }} + + + + + + @@ -269,6 +429,93 @@ + + + + + + + + {{ targetWordRangeHint }} + + + + + + + + + + {{ longDraftMode ? `先写连续母稿,预计拆成 ${longDraftSplitCount || 2} 章` : '灰度功能:先写长稿再拆章(默认关闭)' }} + + + + + + + + + 对照测试:跳过节拍拆分、AI味后处理和章后质检,只让模型按上下文直接写一版 + + + + + + 直接写作模式不会自动生成一致性报告,也不会套用手法档案后处理;适合拿去检测,判断 PP 流程是否影响正文质感。 + + + + + + + 直接写完后只做 10%-20% 局部编辑,压低 AI 特征但不进入完整 PP 后处理 + + + + + + + + 本章写作策略 + 已预览 + + + + {{ chapterStrategy ? '重新生成策略' : '生成策略预览' }} + + + 清空策略 + + + +
+ + + 本章问题:{{ chapterStrategy.chapter_contract.chapter_question }} + 主角想要:{{ chapterStrategy.chapter_contract.protagonist_want }} + 阻力来源:{{ chapterStrategy.chapter_contract.opposition }} + 信息变化:{{ chapterStrategy.chapter_contract.required_information_change }} + 章末追问:{{ chapterStrategy.chapter_contract.ending_question }} + + 展示优先 + + - {{ rule }} + + + + +
+
+ 角色想要 + {{ chapterStrategy.dramatic_task.goal }} +
+
+ 主要阻碍 + {{ chapterStrategy.dramatic_task.obstacle }} +
+
+ 读者期待 + {{ chapterStrategy.dramatic_task.reader_expectation }} +
+
+ 章末钩子 + {{ chapterStrategy.dramatic_task.ending_hook }} +
+
+ +
+ + {{ index + 1 }}. {{ scene.label }} + {{ scene.target_words }} 字 + + 任务:{{ scene.task }} + 阻力:{{ scene.resistance }} + 变化:{{ scene.info_shift }} + 关系:{{ scene.relationship_shift }} + 锚点:{{ scene.anchor }} + 动作:{{ scene.visible_action }} + 潜台词:{{ scene.subtext_dialogue }} + 不直说:{{ scene.unspoken_emotion }} + 线索/道具:{{ scene.object_or_clue_change }} + 钩子:{{ scene.hook }} +
+
+
+ + 先生成一份可见策略,再让正文按“戏剧任务 + 场景推进”写,会比只丢大纲更稳。 + +
+ +
+ + + + 主编审稿 + + {{ editorialReview.verdict }} + + + + 重新审稿 + + + 按审稿精修候选稿 + + + + 正在按开篇、冲突、人物、对白、追读、节奏做主编审稿… + + + +
@@ -659,6 +1076,14 @@ > PP AI 生成候选稿 + + Web 写作 + + + + + PP 只生成提示词和管理候选稿,不调用写作 API。你把提示词复制到 ChatGPT / Kimi / DeepSeek 网页,生成后把正文粘回这里保存为候选稿。 + + + + + + + + + + + + + + + + + + + + + + 当前章节:{{ currentChapter ? `第${currentChapter.number}章 ${currentChapter.title || ''}` : '未选择章节' }} + + + + 生成提示词 + + + 复制提示词 + + + + + + + + + + + + + + + +
@@ -951,8 +1468,19 @@ import { consumeGenerateChapterStream, analyzeScene, retrieveContext, + previewChapterStrategy, + reviewGeneratedChapterEditorially, + precheckCocCognitionBoundary, + rewriteOutlineByCocBoundary, +} from '../../api/workflow' +import type { + CocCognitionPrecheckDTO, + CocCognitionRewriteResultDTO, + ChapterEditorialReviewDTO, + ChapterStrategyPreviewDTO, + ContextPreviewResult, + GenerateChapterWorkflowResponse, } from '../../api/workflow' -import type { ContextPreviewResult, GenerateChapterWorkflowResponse } from '../../api/workflow' import { chapterApi } from '../../api/chapter' import { novelproSuggestionsApi } from '../../api/novelproSuggestions' import { styleBibleApi, type StyleProfileDetail, type StyleProfileMatchReportDTO } from '../../api/styleBible' @@ -1040,6 +1568,7 @@ const showGenerateModal = ref(false) const showGenerationStyleModal = ref(false) const showPrecisionRewriteModal = ref(false) const showCandidateDraftsModal = ref(false) +const showWebWritingModal = ref(false) const generateOutline = ref('') const generatedContent = ref('') /** 弹窗内选中的目标章节(与 useWorkbench 映射一致:id === number) */ @@ -1053,11 +1582,28 @@ const styleMatchLoading = ref(false) const generateInProgress = ref(false) const lastWorkflowResult = ref(null) const lastQcChapterNumber = ref(null) +const chapterStrategy = ref(null) +const loadingChapterStrategy = ref(false) +const cocPrecheckLoading = ref(false) +const cocRewriteLoading = ref(false) +const cocPrecheckResult = ref(null) +const cocRewriteResult = ref(null) +const cocRewriteMode = ref<'conservative' | 'aggressive'>('conservative') +const cocRewriteStyle = ref<'generic' | 'suspense' | 'coc'>('generic') +const ignoreCocPrecheckBlockOnce = ref(false) +const editorialReview = ref(null) +const loadingEditorialReview = ref(false) const blurSceneCache = ref | undefined>(undefined) const outlineBlurAnalyzing = ref(false) const streamPhaseLabel = ref('') const streamProgressPct = ref(0) const streamStats = ref({ chars: 0, estimated_tokens: 0, chunks: 0 }) +const targetWordCount = ref(2500) +const wordTolerancePercent = ref(5) +const longDraftMode = ref(false) +const longDraftSplitCount = ref(2) +const directWritingMode = ref(false) +const directLightPolish = ref(false) const candidateDrafts = ref([]) const loadingCandidateDrafts = ref(false) const candidateBranches = ref([]) @@ -1065,12 +1611,20 @@ const selectedCandidateCompare = ref(null) const selectedCandidateSupervisorReview = ref(null) const branchMemoryDiff = ref(null) const externalModelTasks = ref([]) +const webWritingModelLabel = ref('ChatGPT Web') +const webWritingTaskPrompt = ref('') +const webWritingPrompt = ref('') +const webWritingResponse = ref('') +const webWritingTask = ref(null) const savingCandidateDraft = ref(false) const savingPrecisionRewriteTask = ref(false) const suggestingPrecisionRewriteTask = ref(false) const savingPartialCandidateDraft = ref(false) const mergingBranch = ref(false) const generatingDirectCandidate = ref(false) +const generatingEditorialPolishCandidate = ref(false) +const creatingWebWritingPrompt = ref(false) +const importingWebWritingDraft = ref(false) const reviewingCandidateDraft = ref(false) const acceptingCandidateDraftId = ref(null) const selectedCandidateDraftId = ref(null) @@ -1093,6 +1647,21 @@ const precisionRewriteObjectiveOptions = [ { label: '更暧昧', value: '更暧昧' }, { label: '保留事件只改表达', value: '保留事件只改表达' }, ] +const cocRewriteModeOptions = [ + { label: '保守改写', value: 'conservative' }, + { label: '激进改写', value: 'aggressive' }, +] +const cocRewriteStyleOptions = [ + { label: '通用', value: 'generic' }, + { label: '悬疑向', value: 'suspense' }, + { label: 'CoC向', value: 'coc' }, +] +const targetWordRangeHint = computed(() => { + const target = Math.max(800, Math.min(12000, Number(targetWordCount.value || 2500))) + const tolerance = Math.max(2, Math.min(20, Number(wordTolerancePercent.value || 5))) + const delta = Math.max(80, Math.floor(target * (tolerance / 100))) + return `容差 ${tolerance}%:约 ${Math.max(500, target - delta)}-${target + delta} 字` +}) // Autopilot 状态 const autopilotStatus = ref(null) const isAutopilotRunning = computed(() => autopilotStatus.value?.autopilot_status === 'running') @@ -1444,6 +2013,205 @@ const previewContext = async () => { } } +function applyCocManualStructureTemplate() { + const chapter = modalTargetChapter.value || currentChapter.value + const chapterNumber = chapter?.number || 1 + const chapterTitle = chapter?.title || '未命名章节' + generateOutline.value = [ + `第${chapterNumber}章:${chapterTitle}`, + '', + '【Bible一致性核对(写作前必须遵守)】', + '- 只使用 Bible 中已存在的主角团、地点、世界规则与人物关系;如果需要新增人物/地点,先写成临时角色/临时地点,不直接改主角团固定席位。', + '- 白雨翔、许照、周闻笙、陈泊舟为固定主角团;可失联、受伤、互疑或遗忘关系,但不能无铺垫替换成员。', + '- 每名主角的固定核心道具必须保持当前持有人、状态与代价规则;非核心道具只能在本任务内使用,带出前必须转为证物。', + '', + '【本章戏剧任务】', + '- 角色目标:', + '- 主要阻碍:', + '- 读者期待:', + '- 本章结尾钩子:', + '', + '【场景推进表】', + '1. 场景一:', + ' - 目标:', + ' - 阻力:', + ' - 信息变化:', + ' - 人物关系变化:', + ' - 道具变化:', + ' - 钩子:', + '2. 场景二:', + ' - 目标:', + ' - 阻力:', + ' - 信息变化:', + ' - 人物关系变化:', + ' - 道具变化:', + ' - 钩子:', + '', + '【CoC线索与认知边界】', + '- 本章新增线索候选:', + '- 读者可知道:', + '- 角色可知道:', + '- 作者真相/禁止直出:', + '- 误导点:', + '- 需要章后确认是否登记到 CoC线索/正典:', + '', + '【理智/认知代价】', + '- 谁受到影响:', + '- 具体表现:记忆缺口 / 感官错位 / 熟人陌生化 / 时间感错误 / 判断偏移', + '- 是否影响下章:', + '', + '【写作限制】', + '- 限制视角,不全知解释。', + '- 不直接说出作者真相,只写角色能看见、听见、推断或误解的证据。', + '- 正文必须服务于冲突、线索、人物关系和结尾追读。', + ].join('\n') + cocPrecheckResult.value = null + cocRewriteResult.value = null + blurSceneCache.value = undefined + message.success('已套用 CoC 手动结构规划模板') +} + +function editorialScoreLabel(key: string) { + const map: Record = { + opening: '开头', + conflict: '冲突', + character: '人物', + dialogue: '对白', + hook: '追读', + pacing: '节奏', + showing: '展示', + } + return map[key] || key +} + +async function resolveSceneDirectorResultForModal(chapterNumber: number) { + let sceneDirectorResult: Record | undefined = blurSceneCache.value + if (useSceneDirector.value && !sceneDirectorResult) { + analyzingScene.value = true + try { + const outline = generateOutline.value || `第${chapterNumber}章:承接前情,推进主线` + const analysis = await analyzeScene(props.slug, chapterNumber, outline) + sceneDirectorResult = analysis as Record + blurSceneCache.value = sceneDirectorResult + } catch (e: unknown) { + sceneDirectorError.value = e instanceof Error ? e.message : '分析失败' + } finally { + analyzingScene.value = false + } + } + return sceneDirectorResult +} + +async function runCocPrecheckForModal(options?: { silent?: boolean }) { + const target = modalTargetChapter.value + if (!target) return null + const outline = generateOutline.value.trim() || `第${target.number}章:承接前情,推进主线` + cocPrecheckLoading.value = true + try { + const result = await precheckCocCognitionBoundary(props.slug, target.number, outline) + cocPrecheckResult.value = result + if (!options?.silent) { + if (result.checked && result.allow_generate === false) { + const detail = result.blocking_issues?.[0] || '命中认知边界阻断规则' + message.error(`预检阻断:${detail}`) + } else if (result.checked && (result.warnings?.length || 0) > 0) { + message.warning(`预检提醒:${result.warnings[0]}`) + } else { + message.success('预检通过') + } + } + return result + } catch { + cocPrecheckResult.value = null + if (!options?.silent) { + message.warning('预检失败,已跳过') + } + return null + } finally { + cocPrecheckLoading.value = false + } +} + +async function rewriteOutlineForCocBoundaryForModal() { + const target = modalTargetChapter.value + if (!target) return + const outline = generateOutline.value.trim() || `第${target.number}章:承接前情,推进主线` + cocRewriteLoading.value = true + try { + const result = await rewriteOutlineByCocBoundary( + props.slug, + target.number, + outline, + cocRewriteMode.value, + cocRewriteStyle.value, + ) + cocRewriteResult.value = result + if (result.changed) { + generateOutline.value = result.rewritten_outline + cocPrecheckResult.value = result.precheck_after + ignoreCocPrecheckBlockOnce.value = false + message.success(`已完成${result.rewrite_mode === 'aggressive' ? '激进' : '保守'}安全改写,并复检通过`) + } else { + cocPrecheckResult.value = result.precheck_after + message.success('当前大纲无需改写') + } + } catch { + message.error('安全改写失败,请稍后重试') + } finally { + cocRewriteLoading.value = false + } +} + +async function previewChapterStrategyForModal() { + const target = modalTargetChapter.value + if (!target) { + message.warning('请选择目标章节') + return + } + loadingChapterStrategy.value = true + try { + const sceneDirectorResult = await resolveSceneDirectorResultForModal(target.number) + const defaultOutline = `第${target.number}章:承接前情,推进主线` + chapterStrategy.value = await previewChapterStrategy(props.slug, target.number, { + outline: generateOutline.value || defaultOutline, + scene_director_result: sceneDirectorResult, + style_profile_id: generateStyleProfileId.value || '', + scene_type: generateSceneType.value.trim(), + target_word_count: targetWordCount.value || undefined, + word_tolerance_percent: wordTolerancePercent.value || 5, + }) + message.success('本章写作策略已生成') + } catch { + message.error('生成写作策略失败,请检查模型配置') + } finally { + loadingChapterStrategy.value = false + } +} + +async function runEditorialReviewForModal(chapterNumber: number, outline: string, content: string) { + if (!content.trim()) return + loadingEditorialReview.value = true + try { + editorialReview.value = await reviewGeneratedChapterEditorially(props.slug, chapterNumber, { + outline, + content, + chapter_strategy: chapterStrategy.value, + }) + } catch { + editorialReview.value = null + message.warning('主编审稿失败,请稍后重试') + } finally { + loadingEditorialReview.value = false + } +} + +async function rerunEditorialReview() { + const target = modalTargetChapter.value + if (!target || !generatedContent.value.trim()) return + const defaultOutline = `第${target.number}章:承接前情,推进主线` + await runEditorialReviewForModal(target.number, generateOutline.value || defaultOutline, generatedContent.value) +} + async function onOutlineBlurAnalyze() { const ch = modalTargetChapter.value const outline = generateOutline.value.trim() @@ -1459,6 +2227,7 @@ async function onOutlineBlurAnalyze() { } finally { outlineBlurAnalyzing.value = false } + void runCocPrecheckForModal({ silent: true }) } function clearWorkflowQc() { @@ -1466,20 +2235,57 @@ function clearWorkflowQc() { lastQcChapterNumber.value = null } +function clearChapterStrategy() { + chapterStrategy.value = null +} + function clearGeneratedDraft() { generatedContent.value = '' styleMatchReport.value = null + editorialReview.value = null clearWorkflowQc() } watch(generateTargetChapterId, () => { blurSceneCache.value = undefined contextPreview.value = null + chapterStrategy.value = null + editorialReview.value = null + cocPrecheckResult.value = null + cocRewriteResult.value = null + cocRewriteMode.value = 'conservative' + cocRewriteStyle.value = 'generic' + ignoreCocPrecheckBlockOnce.value = false }) +watch( + () => + [ + generateOutline.value, + generateSceneType.value, + generateStyleProfileId.value, + targetWordCount.value, + wordTolerancePercent.value, + longDraftMode.value, + longDraftSplitCount.value, + ] as const, + () => { + chapterStrategy.value = null + editorialReview.value = null + cocPrecheckResult.value = null + cocRewriteResult.value = null + ignoreCocPrecheckBlockOnce.value = false + } +) + watch(showGenerateModal, (shown) => { if (shown) { void loadStyleProfilesForGeneration() + cocPrecheckResult.value = null + cocRewriteResult.value = null + cocRewriteMode.value = 'conservative' + cocRewriteStyle.value = 'generic' + ignoreCocPrecheckBlockOnce.value = false } }) @@ -1651,6 +2457,13 @@ const handleGenerateChapter = async () => { 承接前情,推进主线与人物节拍;保持人设与叙事节奏一致。` generatedContent.value = '' + chapterStrategy.value = null + editorialReview.value = null + cocPrecheckResult.value = null + cocRewriteResult.value = null + cocRewriteMode.value = 'conservative' + cocRewriteStyle.value = 'generic' + ignoreCocPrecheckBlockOnce.value = false activeCandidateRewriteTask.value = null contextPreview.value = null blurSceneCache.value = undefined @@ -1785,6 +2598,135 @@ const generateDirectCandidateDraft = async () => { } } +const generateEditorialPolishCandidate = async () => { + const target = modalTargetChapter.value + if (!target || !generatedContent.value.trim() || !editorialReview.value) { + message.warning('需要先完成正文生成和主编审稿') + return + } + + const outline = generateOutline.value.trim() || `第${target.number}章:${target.title || '承接前情,推进主线'}` + generatingEditorialPolishCandidate.value = true + try { + const result = await chapterApi.generateEditorialPolishCandidate(props.slug, { + chapter_number: target.number, + outline, + current_content: generatedContent.value, + editorial_review: editorialReview.value, + target_word_count: targetWordCount.value || 2500, + branch_name: candidateBranchFilter.value.trim() || 'main', + title: `${target.title || `第${target.number}章`} 主编精修候选稿`, + model_label: 'PP 写作 AI', + max_tokens: Math.min(4096, Math.max(1800, Math.ceil((targetWordCount.value || 2500) * 1.6))), + }) + message.success('已按主编审稿生成精修候选稿') + if (currentChapter.value?.number === target.number) { + await loadCandidateDrafts() + selectedCandidateDraftId.value = result.draft.id + showCandidateDraftsModal.value = true + } + } catch { + message.error('生成精修候选稿失败,请检查写作模型配置') + } finally { + generatingEditorialPolishCandidate.value = false + } +} + +const openWebWritingModal = () => { + const chapter = currentChapter.value + if (!chapter) { + message.warning('请先选择章节') + return + } + if (!webWritingTaskPrompt.value.trim()) { + webWritingTaskPrompt.value = `生成一版约 ${targetWordCount.value || 2500} 字的完整章节正文;保留既有设定、角色口吻、伏笔和道具状态。` + } + webWritingPrompt.value = '' + webWritingResponse.value = '' + webWritingTask.value = null + showWebWritingModal.value = true +} + +const createWebWritingPrompt = async () => { + const chapter = currentChapter.value + if (!chapter) return + const outline = generateOutline.value.trim() || `第${chapter.number}章:${chapter.title || '承接前情,推进主线'}` + creatingWebWritingPrompt.value = true + try { + const result = await chapterApi.createWebWritingPrompt(props.slug, { + chapter_number: chapter.number, + outline, + current_content: chapterContent.value, + model_label: webWritingModelLabel.value.trim() || 'Web 写作', + task_prompt: webWritingTaskPrompt.value.trim(), + }) + webWritingPrompt.value = result.prompt + webWritingTask.value = result.task + externalModelTasks.value = await chapterApi.listExternalModelTasks( + props.slug, + chapter.number, + ).catch(() => externalModelTasks.value) + message.success('Web 写作提示词已生成') + } catch { + message.error('生成 Web 写作提示词失败') + } finally { + creatingWebWritingPrompt.value = false + } +} + +const copyWebWritingPrompt = async () => { + if (!webWritingPrompt.value.trim()) return + try { + await navigator.clipboard.writeText(webWritingPrompt.value) + message.success('提示词已复制') + } catch { + message.error('复制失败,请手动选中复制') + } +} + +const importWebWritingResponseAsCandidate = async () => { + const chapter = currentChapter.value + if (!chapter || !webWritingResponse.value.trim()) return + + importingWebWritingDraft.value = true + try { + const draft = await chapterApi.createCandidateDraft(props.slug, chapter.number, { + source: 'external-model', + title: `${chapter.title || `第${chapter.number}章`} ${webWritingModelLabel.value || 'Web'} 回稿`, + content: webWritingResponse.value.trim(), + rationale: `Web 写作回稿:${webWritingModelLabel.value || 'Web 写作'}`, + branch_name: candidateBranchFilter.value.trim() || 'main', + metadata: { + external_model: webWritingModelLabel.value || 'Web 写作', + web_writing_task_id: webWritingTask.value?.id || '', + prompt: webWritingPrompt.value, + }, + }) + if (webWritingTask.value?.id) { + await chapterApi.upsertExternalModelTask(props.slug, { + id: webWritingTask.value.id, + chapter_number: chapter.number, + model: webWritingModelLabel.value || webWritingTask.value.model || 'Web 写作', + prompt: webWritingPrompt.value || webWritingTask.value.prompt, + instruction: webWritingTaskPrompt.value || webWritingTask.value.instruction, + candidate_draft_id: draft.id, + response_preview: draft.content.slice(0, 160), + status: 'imported', + execution_mode: 'web_copy_paste', + }) + } + await loadCandidateDrafts() + selectedCandidateDraftId.value = draft.id + showWebWritingModal.value = false + showCandidateDraftsModal.value = true + message.success('Web 回稿已保存为候选稿') + } catch { + message.error('保存 Web 回稿失败') + } finally { + importingWebWritingDraft.value = false + } +} + const reviewSelectedCandidateDraft = async () => { const chapter = currentChapter.value const draft = selectedCandidateDraft.value @@ -1965,6 +2907,8 @@ const handleGenerateFromCandidateTask = (draft: ChapterCandidateDraftDTO) => { generateTargetChapterId.value = chapter.id generateOutline.value = candidateDraftRewritePrompt(draft) generatedContent.value = '' + chapterStrategy.value = null + editorialReview.value = null contextPreview.value = null blurSceneCache.value = undefined showCandidateDraftsModal.value = false @@ -1989,6 +2933,7 @@ function streamPhaseToProgress(phase: string): number { planning: 18, context: 40, llm: 72, + polish: 86, post: 92, } return map[phase] ?? 12 @@ -1999,6 +2944,7 @@ function streamPhaseToLabel(phase: string): string { planning: '规划节拍…', context: '组装上下文…', llm: '撰写正文…', + polish: '轻修正文…', post: '质检与收尾…', } return map[phase] ?? phase @@ -2015,11 +2961,25 @@ const handleStartGenerate = async () => { return } + const defaultOutline = `第${target.number}章:承接前情,推进主线` + const outlineForGenerate = generateOutline.value || defaultOutline + const precheck = await runCocPrecheckForModal({ silent: true }) + if (precheck?.checked && precheck.allow_generate === false && !ignoreCocPrecheckBlockOnce.value) { + const reason = precheck.blocking_issues?.[0] || '命中 CoC 认知边界阻断规则' + message.error(`预检阻断:${reason}`) + return + } + if (precheck?.checked && (precheck.warnings?.length || 0) > 0) { + message.warning(`预检提醒:${precheck.warnings[0]}`) + } + const targetChapterId = target.id const targetChapterNumber = target.number + ignoreCocPrecheckBlockOnce.value = false generatingChapterId.value = targetChapterId generateInProgress.value = true generatedContent.value = '' + editorialReview.value = null styleMatchReport.value = null sceneDirectorError.value = '' lastWorkflowResult.value = null @@ -2031,32 +2991,25 @@ const handleStartGenerate = async () => { const ctrl = new AbortController() generateAbortCtrl.value = ctrl - let sceneDirectorResult: Record | undefined = blurSceneCache.value - if (useSceneDirector.value && !sceneDirectorResult) { - analyzingScene.value = true - try { - const outline = generateOutline.value || `第${targetChapterNumber}章:承接前情,推进主线` - const analysis = await analyzeScene(props.slug, targetChapterNumber, outline) - sceneDirectorResult = analysis as Record - } catch (e: unknown) { - sceneDirectorError.value = e instanceof Error ? e.message : '分析失败' - } finally { - analyzingScene.value = false - } - } - - const defaultOutline = `第${targetChapterNumber}章:承接前情,推进主线` + const sceneDirectorResult = await resolveSceneDirectorResultForModal(targetChapterNumber) try { await consumeGenerateChapterStream( props.slug, { chapter_number: targetChapterNumber, - outline: generateOutline.value || defaultOutline, + outline: outlineForGenerate, scene_director_result: sceneDirectorResult, style_profile_id: generateStyleProfileId.value || '', scene_type: generateSceneType.value.trim(), avoid_compressed_expression: avoidCompressedExpression.value, + target_word_count: targetWordCount.value || undefined, + word_tolerance_percent: wordTolerancePercent.value || 5, + direct_writing_mode: directWritingMode.value, + direct_light_polish: directWritingMode.value && directLightPolish.value, + chapter_strategy: chapterStrategy.value || undefined, + long_draft_mode: longDraftMode.value, + long_draft_split_count: longDraftMode.value ? (longDraftSplitCount.value || 2) : undefined, }, { signal: ctrl.signal, @@ -2074,15 +3027,29 @@ const handleStartGenerate = async () => { lastWorkflowResult.value = result lastQcChapterNumber.value = targetChapterNumber generatedContent.value = result.content + streamStats.value = { + chars: result.content.length, + estimated_tokens: Math.floor(result.content.length / 1.5), + chunks: streamStats.value.chunks || 0, + } streamProgressPct.value = 100 streamPhaseLabel.value = '已完成' void updateStyleMatchReport(result.content) - if (props.currentChapterId === targetChapterId) { + void runEditorialReviewForModal( + targetChapterNumber, + outlineForGenerate, + result.content, + ) + if (result.direct_writing_mode) { + message.success('直接写作完成,可先复制去检测或保存为候选稿') + } else if (props.currentChapterId === targetChapterId) { message.success('生成完成,质检已同步到「章节状态」') } else { message.success(`第 ${targetChapterNumber} 章生成完成,质检在对应章的「章节状态」查看`) } - activeTab.value = 'chapter-status' + if (!result.direct_writing_mode) { + activeTab.value = 'chapter-status' + } }, onError: (err) => { if (!ctrl.signal.aborted) { @@ -2485,4 +3452,71 @@ defineExpose({ ensureAssistedMode }) .paragraph-diff-text--candidate { background: rgba(24, 160, 88, 0.08); } + +.generate-strategy-preview, +.editorial-review-card { + margin-top: 8px; + padding: 12px; + border: 1px solid var(--aitext-split-border); + border-radius: 12px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.75), rgba(248, 250, 252, 0.94)), + var(--app-surface); +} + +.chapter-contract-card { + margin-bottom: 12px; +} + +.coc-precheck-card { + border: 1px solid var(--aitext-split-border); + border-radius: 10px; + background: rgba(15, 23, 42, 0.02); +} + +.generate-strategy-grid, +.editorial-score-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.strategy-chip, +.editorial-score-item, +.strategy-scene-row { + padding: 10px; + border-radius: 10px; + background: rgba(15, 23, 42, 0.03); +} + +.strategy-chip { + display: flex; + flex-direction: column; + gap: 4px; +} + +.strategy-chip__label { + font-size: 12px; + color: #64748b; +} + +.strategy-scene-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.editorial-score-item { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; +} + +@media (max-width: 900px) { + .generate-strategy-grid, + .editorial-score-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/utils/candidateDraftDisplay.ts b/frontend/src/utils/candidateDraftDisplay.ts index 47fef983..6f447cec 100644 --- a/frontend/src/utils/candidateDraftDisplay.ts +++ b/frontend/src/utils/candidateDraftDisplay.ts @@ -16,6 +16,7 @@ const SOURCE_LABELS: Record = { 'continuity-dropout': '角色掉线', 'continuity-relationship': '关系推进', 'precision-rewrite': '精细改稿', + 'editorial-polish': '主编精修', 'partial-accept': '部分采纳', 'external-model': '外部模型稿', } @@ -28,6 +29,7 @@ const SOURCE_TYPES: Record = { 'continuity-dropout': 'warning', 'continuity-relationship': 'warning', 'precision-rewrite': 'warning', + 'editorial-polish': 'success', 'partial-accept': 'success', 'external-model': 'info', } diff --git a/infrastructure/ai/prompts/prompts_defaults.json b/infrastructure/ai/prompts/prompts_defaults.json index b5b04658..cd7fd59d 100644 --- a/infrastructure/ai/prompts/prompts_defaults.json +++ b/infrastructure/ai/prompts/prompts_defaults.json @@ -1441,7 +1441,7 @@ } ], "output_format": "text", - "system": "你是中文小说改稿编辑。你的目标是降低文本的AI味,而不是重写成另一段剧情。\n\n改稿原则:\n1. 保留事实、出场人物、因果顺序、伏笔和关键台词。\n2. 删除抽象解释、模板总结和万能比喻。\n3. 把情绪改成动作、物件、声音、停顿、身体反应和旁人反应。\n4. 把被压缩的过程展开成动作链和对白来回。\n5. 对话更像人说话:允许停顿、回避、半句、反问和误解。\n6. 首选上一轮效果较好的路线:调查动作清楚、物证逐步出现、对白试探、临场判断;不要刻意粗糙化,不制造错别字或奇怪口癖。\n7. 全文尽量少用“不是X,是Y”结构,非必要不用;不要连续排比式否定。\n8. 不输出修改说明,只输出改写后的正文。", + "system": "你是中文小说改稿编辑。你的目标是降低文本的AI味,而不是重写成另一段剧情。\n\n改稿原则:\n1. 保留事实、出场人物、因果顺序、伏笔和关键台词。\n2. 删除抽象解释、模板总结和万能比喻。\n3. 把情绪改成动作、物件、声音、停顿、身体反应和旁人反应。\n4. 把被压缩的过程展开成动作链和对白来回。\n5. 对话更像人说话:允许停顿、回避、半句、反问和误解。\n6. 首选上一轮效果较好的路线:调查动作清楚、物证逐步出现、对白试探、临场判断;不要刻意粗糙化,不制造错别字或奇怪口癖。\n7. 如果原文是连续解释、说明机制、讲完背景后很快达成共识,要删改成“角色接触证据 → 误判或试错 → 对白核对 → 临场选择”的过程。\n8. 硬性清理检测器敏感句法:全文“不是”不超过4次,“像某种”尽量为0,“某种”不超过4次;普通“像……”比喻不超过每千字3处,不要连续排比式否定。\n9. 清理过度统一的场景母题:雨、水、冷、潮湿、铁锈味等词反复出现时,保留必要物证信息,其余改成动作、设备、纸张、脚步、光线或对白反应。\n10. 不输出修改说明,只输出改写后的正文。", "user_template": "【改写目标】\n{rewrite_goal}\n\n【必须保留】\n{must_keep}\n\n【禁用表达】\n{taboo_phrases}\n\n【原文】\n{draft}\n\n请输出改写后的正文:" }, { @@ -1551,7 +1551,7 @@ } ], "output_format": "text", - "system": "你是中文小说行文节奏编辑。你的目标不是把文字改得华丽,而是减少过度整齐、过度解释和段段收束的AI感。\n\n硬规则:\n1. 紧张处允许短句、断句和单独成段。\n2. 非关键说明压缩,关键动作和对白展开。\n3. 删除段尾总结和哲理化解释,让画面自己收束。\n4. 避免每段结构都像“动作+心理解释+意义总结”。\n5. 保留事实和叙事顺序,只调整表达节奏。\n6. 只输出改写后的正文。", + "system": "你是中文小说行文节奏编辑。你的目标不是把文字改得华丽,而是减少过度整齐、过度解释和段段收束的AI感。\n\n硬规则:\n1. 紧张处允许短句、断句和单独成段。\n2. 非关键说明压缩,关键动作和对白展开。\n3. 删除段尾总结和哲理化解释,让画面自己收束。\n4. 避免每段结构都像“动作+心理解释+意义总结”。\n5. 清理外部检测器敏感句法:减少“不是X,是Y”“像某种”“某种”连用,普通“像……”比喻不超过每千字3处;改成直接动作、物证变化、对白停顿或角色误判。\n6. 如果同一组场景词反复出现(雨、水、冷、潮湿、铁锈味等),删掉可有可无的氛围句,换成光线、设备、纸张、脚步、手部动作或人物反应。\n7. 保留事实和叙事顺序,只调整表达节奏。\n8. 只输出改写后的正文。", "user_template": "【节奏目标】\n{rhythm_goal}\n\n【原文】\n{draft}\n\n请调整句式和段落节奏,只输出正文:" }, { @@ -1589,15 +1589,15 @@ } ], "output_format": "text", - "system": "你是中文小说文风贴合编辑。你要学习 style_overlay 中的节奏、细节选择、句式倾向和叙述距离,但不能复刻样本文字。\n\n硬规则:\n1. 保留原文剧情事实、人物关系、时间线、伏笔和道具状态。\n2. 只学习技法,不复制样本句子。\n3. 如果风格要求与事实锁冲突,优先事实锁。\n4. 优先调整节奏、细节密度、对白留白、镜头距离,不随意改剧情。\n5. 只输出改写后的小说正文。", + "system": "你是中文小说文风贴合编辑。你要学习 style_overlay 中的节奏、细节选择、句式倾向和叙述距离,但不能复刻样本文字。\n\n硬规则:\n1. 保留原文剧情事实、人物关系、时间线、伏笔和道具状态。\n2. 只学习技法,不复制样本句子。\n3. 如果风格要求与事实锁冲突,优先事实锁。\n4. 优先调整节奏、细节密度、对白留白、镜头距离,不随意改剧情。\n5. 贴合风格时不要把全文收束到同一组意象;普通“像……”比喻、雨水冷潮等场景词过密时,优先换成动作、物件和对白反应。\n6. 只输出改写后的小说正文。", "user_template": "【风格约束】\n{style_overlay}\n\n【必须保留】\n{must_keep}\n\n【原文】\n{draft}\n\n请按风格约束改写,只输出正文:" }, { "id": "workflow-chapter-generation", "name": "工作流章节生成(低AI味自然化版)", "category": "generation", - "source": "PlotPilot NovelPro prompts_defaults.json v2.2", - "description": "AutoNovelGenerationWorkflow 实际使用的章节生成模板。v2.2 回到首版较优路线:调查动作清楚、物证逐步出现、对白试探,不刻意粗糙化。", + "source": "PlotPilot NovelPro prompts_defaults.json v2.6", + "description": "AutoNovelGenerationWorkflow 实际使用的章节生成模板。v2.6 改为好看优先:先确定章节戏剧任务和场景推进表,再用事实锚点、低AI味规则和类型写法约束表达。", "builtin": true, "tags": [ "章节生成", @@ -1607,8 +1607,8 @@ "工作流" ], "output_format": "text", - "system": "你是长期连载型中文商业小说作者,同时也是一名严苛的去AI味行文编辑。你的任务不是“概括剧情”,而是把本章写成可直接发布的小说正文。\n\n{planning_section}{voice_block}{context}\n\n{fact_lock}\n\n【必守边界】\n1. 绝对服从上下文、Bible、人设、事实锁和已完成节拍;不能新造核心角色、组织、能力或设定来解决问题。\n2. 本章必须推进一个可指认的变化:信息差被打破、关系变动、目标受阻、代价出现、选择落地,至少满足其一。\n3. {length_rule}\n4. 用中文写作,默认第三人称限制视角;只写 POV 能看见、听见、触到、闻到、误解到或推断到的东西。{beat_extra}\n\n【去AI味硬规则】\n1. 用“动作/物件/声音/反应”承载情绪,不用抽象情绪说明。少写“他很愤怒/她心里复杂/空气凝固”,改成手指、呼吸、视线、停顿、物件变化。\n2. 对话必须有潜台词。角色可以回避、试探、误解、岔开话题、说半句、反问;不要让每个人都把动机解释清楚。\n3. 场景细节只写会改变阅读判断的东西:能暴露阶层、关系、危险、时间压力、人物习惯的细节。不要堆漂亮形容词。\n4. 核心冲突慢写。摊牌、发现、打脸、危机、心动、背叛等节点要拆成动作链、微反应、对话来回和信息递进,不能一句话跳过。\n5. 句子长短要随压力变化。紧张处短句、断句、动作前置;缓和处允许长句,但每句都要有信息增量。\n6. 不要在段尾替读者总结意义。禁止“他知道一切才刚刚开始”“命运的齿轮开始转动”“这一刻改变了所有人”等宿命总结。\n7. 少用万能比喻和套路氛围。禁止把冲击写成“像刀/像雷/像巨石压下”,优先写身体感、物理后果和旁人反应。\n8. 不要解释你在写什么,不要输出分析、提纲、标题说明、规则说明;只输出正文。\n\n【首版优化路线】\n1. 优先写“调查动作 → 物证出现 → 对白试探 → 临场判断”的链条,让读者跟着角色一步步看见信息。\n2. 物证必须逐步出现:门禁、屏幕、旧标签、U盘、指示灯、纸条、声音、气味等只能在角色接触时呈现,不要一次性说明。\n3. 对白少解释,多核对、反问、停顿和误解;让角色通过手上动作和选择暴露判断。\n4. 不刻意粗糙化,不制造错别字,不用奇怪口癖,不为了“像人写”而破坏流畅度。\n5. 全文尽量少用“不是X,是Y”结构,非必要不用;不要连续排比式否定。\n\n【质量自检后再输出】\n输出前在内部检查:是否有至少一场互动、至少三段有来回的对白、一个明确阻力或张力点、一个结尾钩子;若缺失,直接补进正文,不要说明。", - "user_template": "请根据以下大纲撰写本章正文:\n\n{outline}\n\n写作执行清单(必须遵守):\n- 开头从具体动作、声音、物件异常或人物正在做的事切入,不要先介绍背景。\n- 采用首版较优路线:调查动作清楚、物证逐步出现、对白试探、临场判断,不刻意粗糙化。\n- 至少 2-3 个角色出场并发生互动;如果上下文只允许少角色,则用环境压力或信息差制造对抗。\n- 必须有不少于 3 段来回对白,对白要带试探、遮掩、反问或未说出口的信息。\n- 每个场景至少同时完成两件事:推进情节 + 改变关系/暴露性格/埋下风险。\n- 冲突节点要慢写,不要用“一番交谈后”“很快达成共识”“简单说明情况”压缩过程。\n- 结尾用动作、画面、未完成对白、突发信息或选择后果收束,不要哲理化总结。\n{prior_draft}{beat_section}", + "system": "你是长期连载型中文商业小说作者。你的首要目标不是规避 AI 检测,而是写出符合当前类型读者期待、能推动连载阅读的可发布正文;去AI味规则是表达约束,不是剧情目标。\n\n{planning_section}{voice_block}{context}\n\n{fact_lock}\n\n{genre_overlay}\n\n【必守边界】\n1. 绝对服从上下文、Bible、人设、事实锁和已完成节拍;不能新造核心角色、组织、能力或设定来解决问题。\n2. 本章必须推进一个可指认的变化:信息差被打破、关系变动、目标受阻、代价出现、身份暴露、选择落地,至少满足其一。\n3. {length_rule}\n4. 用中文写作,默认第三人称限制视角;只写 POV 能看见、听见、触到、闻到、误解到或推断到的东西。{beat_extra}\n\n【商业章节硬规则】\n1. 开头先给动作、对话、物件异常、危险信号或人物正在处理的麻烦,不先解释世界。\n2. 每个场景至少同时完成两件事:推进情节 + 改变关系/暴露性格/埋下风险/制造误判。\n3. 核心冲突慢写。摊牌、发现、打脸、危机、心动、背叛等节点要拆成动作链、微反应、对话来回和信息递进,不能一句话跳过。\n4. 爽点或钩子按“期待建立 → 阻碍加压 → 角色选择 → 局部兑现 → 更大问题”推进。\n5. 结尾必须留下可追读钩子:未解决的问题、突发信息、选择后果、关系裂痕或更大危险。\n\n【去AI味表达约束】\n1. 用“动作/物件/声音/反应”承载情绪,不用抽象情绪说明。少写“他很愤怒/她心里复杂/空气凝固”,改成手指、呼吸、视线、停顿、物件变化。\n2. 对话必须有潜台词。角色可以回避、试探、误解、岔开话题、说半句、反问;不要让每个人都把动机解释清楚。\n3. 场景细节只写会改变阅读判断的东西:能暴露阶层、关系、危险、时间压力、人物习惯的细节。不要堆漂亮形容词。\n4. 句子长短要随压力变化。紧张处短句、断句、动作前置;缓和处允许长句,但每句都要有信息增量。\n5. 不要在段尾替读者总结意义。禁止“他知道一切才刚刚开始”“命运的齿轮开始转动”“这一刻改变了所有人”等宿命总结。\n6. 少用万能比喻和套路氛围。禁止把冲击写成“像刀/像雷/像巨石压下”,优先写身体感、物理后果和旁人反应。同一场景不要反复压在雨、水、冷、潮湿、铁锈味等少数意象上,必要时换成光线、脚步、设备、纸张、手部动作或对白反应。\n7. 硬性清理检测器敏感句法:全文“不是”不超过4次,“像某种”尽量为0,“某种”不超过4次;普通“像……”比喻不超过每千字3处,不要连续排比式否定。\n8. 不要解释你在写什么,不要输出分析、提纲、标题说明、规则说明;只输出正文。\n\n【首版优化路线】\n1. 优先写“调查动作 → 物证出现 → 对白试探 → 临场判断”的链条,让读者跟着角色一步步看见信息。\n2. 物证必须逐步出现:门禁、屏幕、旧标签、U盘、指示灯、纸条、声音、气味等只能在角色接触时呈现,不要一次性说明。\n3. 对白少解释,多核对、反问、停顿和误解;让角色通过手上动作和选择暴露判断。\n4. 不刻意粗糙化,不制造错别字,不用奇怪口癖,不为了“像人写”而破坏流畅度。\n\n【质量自检后再输出】\n输出前在内部检查:是否有至少一场互动、至少三段有来回的对白、一个明确阻力或张力点、一个结尾钩子;若缺失,直接补进正文,不要说明。", + "user_template": "请根据以下大纲撰写本章正文:\n\n{outline}\n\n写作执行清单(必须遵守):\n- 开头从具体动作、声音、物件异常或人物正在做的事切入,不要先介绍背景。\n- 采用首版较优路线:调查动作清楚、物证逐步出现、对白试探、临场判断,不刻意粗糙化;少用“像……”比喻,场景意象不要只围着雨/水/冷反复打转。\n- 每 700-1000 字嵌入一个事实锚点:编号、时间、票据、日志、流程、材质、阈值、误差或操作记录,让角色通过核对/误读/操作来推进判断。\n- 至少 2-3 个角色出场并发生互动;如果上下文只允许少角色,则用环境压力或信息差制造对抗。\n- 必须有不少于 3 段来回对白,对白要带试探、遮掩、反问或未说出口的信息。\n- 每个场景至少同时完成两件事:推进情节 + 改变关系/暴露性格/埋下风险。\n- 冲突节点要慢写,不要用“一番交谈后”“很快达成共识”“简单说明情况”压缩过程。\n- 结尾用动作、画面、未完成对白、突发信息或选择后果收束,不要哲理化总结。\n{prior_draft}{beat_section}", "variables": [ { "name": "planning_section", @@ -1670,6 +1670,27 @@ "default": "", "required": false }, + { + "name": "genre_overlay", + "desc": "可选:由当前上下文/大纲推断出的类型写法规则,如悬疑、都市爽文、玄幻/仙侠、古言、情感或漫画转小说", + "type": "string", + "default": "", + "required": false + }, + { + "name": "chapter_contract", + "desc": "可选:运行时生成的本章写作契约与追读自检,通常已合入 planning_section;可视化模板可直接引用", + "type": "string", + "default": "", + "required": false + }, + { + "name": "detector_calibration", + "desc": "可选:根据外部检测器真人样本提炼的事实锚点写法规则,通常已合入 planning_section;可视化模板可直接引用", + "type": "string", + "default": "", + "required": false + }, { "name": "style_overlay", "desc": "可选:写作手法库 overlay,通常已合入 planning_section;可视化模板可直接引用", diff --git a/infrastructure/ai/providers/mock_provider.py b/infrastructure/ai/providers/mock_provider.py index b8f47b20..f365f7db 100644 --- a/infrastructure/ai/providers/mock_provider.py +++ b/infrastructure/ai/providers/mock_provider.py @@ -384,7 +384,10 @@ async def generate( } ] }, ensure_ascii=False) - elif "世界观" in user_prompt or "worldbuilding" in user_prompt: + elif ( + ("世界观" in user_prompt or "worldbuilding" in user_prompt) + and not ('"characters": []' in user_prompt and '"locations": []' not in user_prompt) + ): # Worldbuilding generation content = json.dumps({ "style": "第三人称有限视角,以主角视角为主。基调轻松幽默,节奏明快。避免过度描写。营造轻松愉快的阅读氛围。", diff --git a/infrastructure/ai/providers/openai_provider.py b/infrastructure/ai/providers/openai_provider.py index f3b59f7c..23272c46 100644 --- a/infrastructure/ai/providers/openai_provider.py +++ b/infrastructure/ai/providers/openai_provider.py @@ -10,6 +10,7 @@ from domain.ai.value_objects.prompt import Prompt from domain.ai.value_objects.token_usage import TokenUsage from infrastructure.ai.config.settings import Settings +from infrastructure.ai.url_utils import should_trust_env_proxy_for_openai_base from .base import BaseProvider from .model_resolution import require_resolved_model_id @@ -46,7 +47,7 @@ def __init__(self, settings: Settings): self._http_client = httpx.AsyncClient( timeout=httpx.Timeout(settings.timeout_seconds), - trust_env=False, + trust_env=should_trust_env_proxy_for_openai_base(settings.base_url), ) client_kwargs["http_client"] = self._http_client self.async_client = AsyncOpenAI(**client_kwargs) @@ -63,7 +64,7 @@ async def generate( if use_responses: try: return await self._generate_via_responses(prompt, config) - except (openai.NotFoundError, openai.BadRequestError, RuntimeError) as e: + except (openai.NotFoundError, openai.BadRequestError) as e: logger.info(f"Responses API unsupported for {base_url}, falling back to chat completions: {str(e)}") self.__class__._fallback_to_chat_cache.add(base_url) except Exception as e: @@ -76,7 +77,7 @@ async def generate( # 使用降级的 Chat Completions API return await self._generate_via_chat(prompt, config) - except RuntimeError: + except (RuntimeError, ValueError): raise except Exception as e: raise RuntimeError(f"Failed to generate text: {str(e)}") from e @@ -214,34 +215,54 @@ async def _generate_via_responses(self, prompt: Prompt, config: GenerationConfig output = getattr(response, "output", None) content_parts: list[str] = [] + output_text = getattr(response, "output_text", None) + if isinstance(output_text, str) and output_text.strip(): + content_parts.append(output_text.strip()) if output: for item in output: if getattr(item, "type", "") == "message": for part in getattr(item, "content", []): - if getattr(part, "type", "") == "text": - piece = str(getattr(part, "text", "")).strip() + if getattr(part, "type", "") in ("text", "output_text"): + text_value = getattr(part, "text", "") + if not isinstance(text_value, str): + text_value = getattr(text_value, "value", "") + piece = str(text_value).strip() if piece: content_parts.append(piece) content = "\n".join(content_parts).strip() if not content: raise RuntimeError("Responses API returned empty content") - input_tokens = response.usage.prompt_tokens if response.usage else 0 - output_tokens = response.usage.completion_tokens if response.usage else 0 + input_tokens = self._usage_value(response.usage, "prompt_tokens", "input_tokens") + output_tokens = self._usage_value(response.usage, "completion_tokens", "output_tokens") return GenerationResult( content=content, token_usage=TokenUsage(input_tokens=input_tokens, output_tokens=output_tokens) ) + @staticmethod + def _usage_value(usage: Any, *names: str) -> int: + if usage is None: + return 0 + for name in names: + value = getattr(usage, name, None) + if isinstance(value, int): + return value + return 0 + @staticmethod def _extract_text_from_responses_chunk(chunk: Any) -> str: """原生 Responses stream 解析封装""" try: event_type = getattr(chunk, "type", "") + if event_type == "response.output_text.delta": + delta = getattr(chunk, "delta", "") + if isinstance(delta, str): + return delta if event_type == "response.content_part.added": part = getattr(chunk, "part", None) - if part and getattr(part, "type", "") == "text": + if part and getattr(part, "type", "") in ("text", "output_text"): return getattr(part, "text", "") elif event_type == "message.delta": delta = getattr(chunk, "delta", None) diff --git a/infrastructure/ai/qdrant_vector_store.py b/infrastructure/ai/qdrant_vector_store.py new file mode 100644 index 00000000..b9e0efb0 --- /dev/null +++ b/infrastructure/ai/qdrant_vector_store.py @@ -0,0 +1,89 @@ +"""Qdrant vector store adapter. + +The heavy qdrant-client dependency is imported lazily so local users who rely +on the default FAISS-backed store can still import the application without it. +""" +from typing import List + +from domain.ai.services.vector_store import VectorStore + + +class QdrantVectorStore(VectorStore): + """VectorStore implementation backed by a remote Qdrant service.""" + + def __init__(self, host: str = "localhost", port: int = 6333, api_key: str | None = None): + try: + from qdrant_client import QdrantClient + from qdrant_client.http.models import Distance, PointStruct, VectorParams + except ImportError as e: + raise ImportError( + "使用 Qdrant 向量库需要安装 qdrant-client:pip install qdrant-client" + ) from e + + self.client = QdrantClient(host=host, port=port, api_key=api_key) + self._Distance = Distance + self._PointStruct = PointStruct + self._VectorParams = VectorParams + + async def insert( + self, + collection: str, + id: str, + vector: List[float], + payload: dict, + ) -> None: + self.client.upsert( + collection_name=collection, + points=[ + self._PointStruct( + id=id, + vector=vector, + payload=payload or {}, + ) + ], + ) + + async def search( + self, + collection: str, + query_vector: List[float], + limit: int, + ) -> List[dict]: + results = self.client.search( + collection_name=collection, + query_vector=query_vector, + limit=limit, + ) + return [ + { + "id": str(item.id), + "score": float(item.score), + "payload": item.payload or {}, + } + for item in results + ] + + async def delete(self, collection: str, id: str) -> None: + self.client.delete( + collection_name=collection, + points_selector=[id], + ) + + async def create_collection(self, collection: str, dimension: int) -> None: + existing = set(await self.list_collections()) + if collection in existing: + return + self.client.create_collection( + collection_name=collection, + vectors_config=self._VectorParams( + size=dimension, + distance=self._Distance.COSINE, + ), + ) + + async def delete_collection(self, collection: str) -> None: + self.client.delete_collection(collection_name=collection) + + async def list_collections(self) -> List[str]: + result = self.client.get_collections() + return [item.name for item in result.collections] diff --git a/infrastructure/ai/url_utils.py b/infrastructure/ai/url_utils.py index bc233431..f469d2ee 100644 --- a/infrastructure/ai/url_utils.py +++ b/infrastructure/ai/url_utils.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Optional +from urllib.parse import urlparse def _strip_known_suffix(url: str, suffixes: tuple[str, ...]) -> str: @@ -48,3 +49,12 @@ def normalize_gemini_base_url(url: Optional[str]) -> Optional[str]: '/v1/models', ), ) + + +def should_trust_env_proxy_for_openai_base(url: Optional[str]) -> bool: + """官方 OpenAI 在部分本地网络下需要系统代理;国产兼容网关保持直连。""" + raw = (url or "https://api.openai.com/v1").strip() + if "://" not in raw: + raw = f"https://{raw}" + host = (urlparse(raw).hostname or "").lower() + return host == "api.openai.com" or host.endswith(".openai.com") diff --git a/infrastructure/persistence/database/schema.sql b/infrastructure/persistence/database/schema.sql index e19626ba..9d653c7a 100644 --- a/infrastructure/persistence/database/schema.sql +++ b/infrastructure/persistence/database/schema.sql @@ -858,6 +858,79 @@ CREATE INDEX IF NOT EXISTS idx_prop_ledger_items_novel CREATE INDEX IF NOT EXISTS idx_prop_ledger_events_novel_chapter ON prop_ledger_events(novel_id, chapter_number DESC, created_at DESC); +-- ========== CoC 正典注册表(NovelPro)========== +-- 记录固定设定及其章节证据,供写作约束 overlay 使用 +CREATE TABLE IF NOT EXISTS coc_canon_entries ( + id TEXT PRIMARY KEY, + novel_id TEXT NOT NULL, + canon_type TEXT NOT NULL, + title TEXT NOT NULL, + public_facts TEXT NOT NULL DEFAULT '', + hidden_truth TEXT NOT NULL DEFAULT '', + lock_level TEXT NOT NULL DEFAULT 'soft', + mutable_notes TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (novel_id) REFERENCES novels(id) ON DELETE CASCADE, + UNIQUE(novel_id, canon_type, title) +); + +CREATE TABLE IF NOT EXISTS coc_canon_events ( + id TEXT PRIMARY KEY, + entry_id TEXT NOT NULL, + chapter_number INTEGER NOT NULL, + event_type TEXT NOT NULL DEFAULT 'mention', + evidence TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (entry_id) REFERENCES coc_canon_entries(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_coc_canon_entries_novel + ON coc_canon_entries(novel_id, status, canon_type, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_coc_canon_events_entry_chapter + ON coc_canon_events(entry_id, chapter_number DESC, created_at DESC); + +-- ========== CoC 线索账本(NovelPro)========== +-- 记录线索条目与章节证据,维持读者/主角/作者的信息边界 +CREATE TABLE IF NOT EXISTS coc_clue_items ( + id TEXT PRIMARY KEY, + novel_id TEXT NOT NULL, + clue_key TEXT NOT NULL, + clue_text TEXT NOT NULL DEFAULT '', + visibility TEXT NOT NULL DEFAULT 'reader_known' + CHECK(visibility IN ('reader_known', 'protagonist_known', 'author_only')), + reveal_chapter INTEGER, + known_by TEXT NOT NULL DEFAULT '', + confidence REAL NOT NULL DEFAULT 0.5, + lock_level TEXT NOT NULL DEFAULT 'soft' + CHECK(lock_level IN ('soft', 'strict', 'absolute')), + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active', 'resolved', 'refuted')), + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (novel_id) REFERENCES novels(id) ON DELETE CASCADE, + UNIQUE(novel_id, clue_key) +); + +CREATE TABLE IF NOT EXISTS coc_clue_events ( + id TEXT PRIMARY KEY, + clue_id TEXT NOT NULL, + chapter_number INTEGER NOT NULL, + event_type TEXT NOT NULL DEFAULT 'mention', + evidence TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (clue_id) REFERENCES coc_clue_items(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_coc_clue_items_novel + ON coc_clue_items(novel_id, status, visibility, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_coc_clue_events_clue_chapter + ON coc_clue_events(clue_id, chapter_number DESC, created_at DESC); + -- ========== 提示词广场系统(Prompt Plaza)========== -- 模板包:一组相关提示词的集合(如"内置"、"自定义工作流") diff --git a/infrastructure/persistence/database/sqlite_coc_canon_repository.py b/infrastructure/persistence/database/sqlite_coc_canon_repository.py new file mode 100644 index 00000000..e259e7b8 --- /dev/null +++ b/infrastructure/persistence/database/sqlite_coc_canon_repository.py @@ -0,0 +1,201 @@ +"""SQLite repository for CoC canon registry.""" +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Any, Optional + +from infrastructure.persistence.database.connection import DatabaseConnection + + +class SqliteCocCanonRepository: + """CoC 正典注册表仓储。""" + + def __init__(self, db: DatabaseConnection): + self.db = db + + def get_entry_by_id(self, entry_id: str) -> Optional[dict[str, Any]]: + return self.db.fetch_one( + "SELECT * FROM coc_canon_entries WHERE id = ?", + (entry_id,), + ) + + def get_entry_by_key(self, novel_id: str, canon_type: str, title: str) -> Optional[dict[str, Any]]: + return self.db.fetch_one( + """ + SELECT * FROM coc_canon_entries + WHERE novel_id = ? AND canon_type = ? AND title = ? + """, + (novel_id, canon_type, title), + ) + + def get_entry_by_title(self, novel_id: str, title: str) -> Optional[dict[str, Any]]: + return self.db.fetch_one( + """ + SELECT * FROM coc_canon_entries + WHERE novel_id = ? AND title = ? + ORDER BY + CASE lock_level + WHEN 'absolute' THEN 0 + WHEN 'strict' THEN 1 + ELSE 2 + END, + updated_at DESC + LIMIT 1 + """, + (novel_id, title), + ) + + def list_entries(self, novel_id: str) -> list[dict[str, Any]]: + return self.db.fetch_all( + """ + SELECT * FROM coc_canon_entries + WHERE novel_id = ? + ORDER BY + CASE lock_level + WHEN 'absolute' THEN 0 + WHEN 'strict' THEN 1 + ELSE 2 + END, + canon_type ASC, + title ASC + """, + (novel_id,), + ) + + def list_events(self, novel_id: str, *, limit: int = 100) -> list[dict[str, Any]]: + return self.db.fetch_all( + """ + SELECT + e.id, + e.entry_id, + c.novel_id, + c.canon_type, + c.title, + e.chapter_number, + e.event_type, + e.evidence, + e.notes, + e.created_at + FROM coc_canon_events e + JOIN coc_canon_entries c ON c.id = e.entry_id + WHERE c.novel_id = ? + ORDER BY e.chapter_number DESC, e.created_at DESC + LIMIT ? + """, + (novel_id, int(limit)), + ) + + def upsert_entry( + self, + *, + entry_id: Optional[str], + novel_id: str, + canon_type: str, + title: str, + public_facts: str, + hidden_truth: str, + lock_level: str, + mutable_notes: str, + status: str, + ) -> dict[str, Any]: + now = datetime.now(timezone.utc).isoformat() + existing = self.get_entry_by_id(entry_id) if entry_id else self.get_entry_by_key( + novel_id, + canon_type, + title, + ) + if existing: + self.db.execute( + """ + UPDATE coc_canon_entries + SET canon_type = ?, title = ?, public_facts = ?, hidden_truth = ?, + lock_level = ?, mutable_notes = ?, status = ?, updated_at = ? + WHERE id = ? + """, + ( + canon_type, + title, + public_facts, + hidden_truth, + lock_level, + mutable_notes, + status, + now, + existing["id"], + ), + ) + saved_id = existing["id"] + else: + saved_id = str(uuid.uuid4()) + self.db.execute( + """ + INSERT INTO coc_canon_entries ( + id, novel_id, canon_type, title, public_facts, hidden_truth, + lock_level, mutable_notes, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + saved_id, + novel_id, + canon_type, + title, + public_facts, + hidden_truth, + lock_level, + mutable_notes, + status, + now, + now, + ), + ) + self.db.commit() + return self.get_entry_by_id(saved_id) or {} + + def create_event( + self, + *, + entry_id: str, + chapter_number: int, + event_type: str, + evidence: str, + notes: str, + ) -> dict[str, Any]: + event_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc).isoformat() + self.db.execute( + """ + INSERT INTO coc_canon_events ( + id, entry_id, chapter_number, event_type, evidence, notes, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + event_id, + entry_id, + int(chapter_number), + event_type, + evidence, + notes, + now, + ), + ) + self.db.commit() + return self.db.fetch_one( + """ + SELECT + e.id, + e.entry_id, + c.novel_id, + c.canon_type, + c.title, + e.chapter_number, + e.event_type, + e.evidence, + e.notes, + e.created_at + FROM coc_canon_events e + JOIN coc_canon_entries c ON c.id = e.entry_id + WHERE e.id = ? + """, + (event_id,), + ) or {} diff --git a/infrastructure/persistence/database/sqlite_coc_clue_repository.py b/infrastructure/persistence/database/sqlite_coc_clue_repository.py new file mode 100644 index 00000000..a1adc260 --- /dev/null +++ b/infrastructure/persistence/database/sqlite_coc_clue_repository.py @@ -0,0 +1,192 @@ +"""SQLite repository for CoC clue ledger.""" +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Any, Optional + +from infrastructure.persistence.database.connection import DatabaseConnection + + +class SqliteCocClueRepository: + """CoC 线索账本仓储。""" + + def __init__(self, db: DatabaseConnection): + self.db = db + + def get_item_by_id(self, item_id: str) -> Optional[dict[str, Any]]: + return self.db.fetch_one( + "SELECT * FROM coc_clue_items WHERE id = ?", + (item_id,), + ) + + def get_item_by_key(self, novel_id: str, clue_key: str) -> Optional[dict[str, Any]]: + return self.db.fetch_one( + """ + SELECT * FROM coc_clue_items + WHERE novel_id = ? AND clue_key = ? + """, + (novel_id, clue_key), + ) + + def list_items(self, novel_id: str) -> list[dict[str, Any]]: + return self.db.fetch_all( + """ + SELECT * FROM coc_clue_items + WHERE novel_id = ? + ORDER BY + CASE status + WHEN 'active' THEN 0 + WHEN 'resolved' THEN 1 + ELSE 2 + END, + CASE visibility + WHEN 'reader_known' THEN 0 + WHEN 'protagonist_known' THEN 1 + ELSE 2 + END, + updated_at DESC + """, + (novel_id,), + ) + + def list_events(self, novel_id: str, *, limit: int = 100) -> list[dict[str, Any]]: + return self.db.fetch_all( + """ + SELECT + e.id, + e.clue_id, + i.novel_id, + i.clue_key, + e.chapter_number, + e.event_type, + e.evidence, + e.notes, + e.created_at + FROM coc_clue_events e + JOIN coc_clue_items i ON i.id = e.clue_id + WHERE i.novel_id = ? + ORDER BY e.chapter_number DESC, e.created_at DESC + LIMIT ? + """, + (novel_id, int(limit)), + ) + + def upsert_item( + self, + *, + item_id: Optional[str], + novel_id: str, + clue_key: str, + clue_text: str, + visibility: str, + reveal_chapter: Optional[int], + known_by: str, + confidence: float, + lock_level: str, + status: str, + notes: str, + ) -> dict[str, Any]: + now = datetime.now(timezone.utc).isoformat() + existing = self.get_item_by_id(item_id) if item_id else self.get_item_by_key(novel_id, clue_key) + if existing: + self.db.execute( + """ + UPDATE coc_clue_items + SET clue_key = ?, clue_text = ?, visibility = ?, reveal_chapter = ?, known_by = ?, + confidence = ?, lock_level = ?, status = ?, notes = ?, updated_at = ? + WHERE id = ? + """, + ( + clue_key, + clue_text, + visibility, + reveal_chapter, + known_by, + float(confidence), + lock_level, + status, + notes, + now, + existing["id"], + ), + ) + saved_id = existing["id"] + else: + saved_id = str(uuid.uuid4()) + self.db.execute( + """ + INSERT INTO coc_clue_items ( + id, novel_id, clue_key, clue_text, visibility, reveal_chapter, known_by, + confidence, lock_level, status, notes, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + saved_id, + novel_id, + clue_key, + clue_text, + visibility, + reveal_chapter, + known_by, + float(confidence), + lock_level, + status, + notes, + now, + now, + ), + ) + self.db.commit() + return self.get_item_by_id(saved_id) or {} + + def create_event( + self, + *, + clue_id: str, + chapter_number: int, + event_type: str, + evidence: str, + notes: str, + ) -> dict[str, Any]: + event_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc).isoformat() + self.db.execute( + """ + INSERT INTO coc_clue_events ( + id, clue_id, chapter_number, event_type, evidence, notes, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + event_id, + clue_id, + int(chapter_number), + event_type, + evidence, + notes, + now, + ), + ) + self.db.execute( + "UPDATE coc_clue_items SET updated_at = ? WHERE id = ?", + (now, clue_id), + ) + self.db.commit() + return self.db.fetch_one( + """ + SELECT + e.id, + e.clue_id, + i.novel_id, + i.clue_key, + e.chapter_number, + e.event_type, + e.evidence, + e.notes, + e.created_at + FROM coc_clue_events e + JOIN coc_clue_items i ON i.id = e.clue_id + WHERE e.id = ? + """, + (event_id,), + ) or {} diff --git a/infrastructure/persistence/database/sqlite_style_bible_repository.py b/infrastructure/persistence/database/sqlite_style_bible_repository.py index 7bca3c36..f520e069 100644 --- a/infrastructure/persistence/database/sqlite_style_bible_repository.py +++ b/infrastructure/persistence/database/sqlite_style_bible_repository.py @@ -127,7 +127,7 @@ def list_profiles( clauses: list[str] = [] params: list[Any] = [] if novel_id: - clauses.append("novel_id = ?") + clauses.append("(novel_id = ? OR novel_id = '')") params.append(novel_id) if status: clauses.append("status = ?") @@ -250,6 +250,104 @@ def update_technique_card(self, card: StyleTechniqueCard) -> StyleTechniqueCard: row = self.db.fetch_one("SELECT * FROM style_technique_cards WHERE id = ?", (card.id,)) return self._row_to_card(row) if row else card + def ensure_default_profiles(self) -> None: + """为空库提供可直接选择的全局手法档案。""" + profile_id = "style-profile-default-low-ai-webnovel" + if self.get_profile(profile_id) and len(self.list_technique_cards(profile_id)) >= 5: + return + profile = StyleProfile( + id=profile_id, + name="默认低AI味网文手法", + description="内置全局档案:用于没有导入样本前的章节生成,强调行动链、证据链、潜台词和追读钩子。", + novel_id="", + status="active", + profile={ + "summary": "商业网文通用低AI味写法:少解释,多用现场动作、证据推进、对白试探和具体选择制造阅读黏性。", + "source": "builtin_default", + }, + metrics={ + "avg_sentence_length": 18, + "dialogue_ratio": 0.28, + "action_density": 0.7, + "max_like_metaphor_per_1k": 3, + }, + rules=[ + "先写角色正在做的事,再释放解释信息。", + "每个场景同时推进情节和关系/风险。", + "章尾留下未解决问题或选择后果。", + "比喻和场景母题要限量;不要让雨、水、冷、潮湿、铁锈味等词反复接管整章。", + ], + forbidden_patterns=[ + "空气凝固", + "心中五味杂陈", + "命运齿轮", + "一切才刚刚开始", + "很快达成共识", + "一番交谈后", + ], + ) + self.save_profile(profile) + self.save_technique_cards( + profile_id, + [ + StyleTechniqueCard( + id="style-card-default-evidence-chain", + profile_id=profile_id, + title="证据链推进", + category="structure", + scene_type="悬疑/调查", + rule_text="不要让人物站着解释设定;让线索在角色接触、误判、核对和选择中逐步出现。", + example_summary="适合悬疑、都市、系统等需要信息递进的章节。", + prompt_instruction="本章至少写出一个可触摸或可验证的证据变化,并按“发现证据、误判或试错、对白核对、临场选择”的顺序推进。", + weight=1.0, + ), + StyleTechniqueCard( + id="style-card-default-subtext-dialogue", + profile_id=profile_id, + title="对白潜台词", + category="dialogue", + scene_type="通用", + rule_text="对白不负责把动机讲完,要承担试探、遮掩、反问、误会或露出破绽。", + example_summary="减少说明文腔和人物互相念设定。", + prompt_instruction="每段关键对白至少有一处未说透的信息:角色可以回避、反问、打断、改口或用动作替代回答。", + weight=1.0, + ), + StyleTechniqueCard( + id="style-card-default-action-emotion", + profile_id=profile_id, + title="动作承载情绪", + category="anti_ai", + scene_type="通用", + rule_text="不用抽象情绪总结,把情绪落到手、眼神、停顿、物件、声音和身体反应上。", + example_summary="针对外部检测器常抓的抽象心理说明。", + prompt_instruction="删除“心情复杂、说不清、空气凝固”等抽象句;改用动作、物件变化、停顿或旁人反应表现情绪。", + weight=1.0, + ), + StyleTechniqueCard( + id="style-card-default-scene-friction", + profile_id=profile_id, + title="现场摩擦", + category="texture", + scene_type="通用", + rule_text="给场景加入少量真实阻滞,同时避免同一组氛围词反复复现。", + example_summary="降低文本过于顺滑、意象过度统一和精准服务悬念的机器感。", + prompt_instruction="每个重要场景加入一个不改变主线的小摩擦;若雨、水、冷、潮湿、铁锈味或“像……”比喻过密,改成光线、设备、纸张、脚步、手部动作或对白反应。", + weight=0.9, + ), + StyleTechniqueCard( + id="style-card-default-chase-hook", + profile_id=profile_id, + title="追读钩子", + category="pacing", + scene_type="通用", + rule_text="章节末尾不做哲理总结,用动作、未完成对白、突发信息或选择后果收束。", + example_summary="替代“命运齿轮/一切才刚刚开始”等模板句。", + prompt_instruction="结尾必须留下一个具体未完成问题、关系裂口、危险升级或证据反转;不要用哲理化总结收尾。", + weight=1.0, + ), + ], + ) + def _find_sample_by_hash( self, novel_id: str, diff --git a/interfaces/api/dependencies.py b/interfaces/api/dependencies.py index e1a621c4..8a4c869f 100644 --- a/interfaces/api/dependencies.py +++ b/interfaces/api/dependencies.py @@ -274,7 +274,9 @@ def get_style_bible_repository(): SqliteStyleBibleRepository, ) - return SqliteStyleBibleRepository(get_database()) + repo = SqliteStyleBibleRepository(get_database()) + repo.ensure_default_profiles() + return repo # Service 依赖 @@ -297,7 +299,7 @@ def get_topic_idea_service(): return TopicIdeaService( get_topic_idea_repository(), - get_writing_llm_service(), + get_analysis_llm_service(), get_novel_service(), ) @@ -511,6 +513,44 @@ def get_prop_ledger_service(): return PropLedgerService(get_prop_ledger_repository()) +def get_coc_canon_repository(): + from infrastructure.persistence.database.sqlite_coc_canon_repository import ( + SqliteCocCanonRepository, + ) + + return SqliteCocCanonRepository(get_database()) + + +def get_coc_canon_service(): + from application.analyst.services.coc_canon_service import CocCanonService + + return CocCanonService(get_coc_canon_repository()) + + +def get_coc_clue_repository(): + from infrastructure.persistence.database.sqlite_coc_clue_repository import ( + SqliteCocClueRepository, + ) + + return SqliteCocClueRepository(get_database()) + + +def get_coc_clue_service(): + from application.analyst.services.coc_clue_service import CocClueService + + return CocClueService(get_coc_clue_repository()) + + +def get_coc_preset_service(): + from application.analyst.services.coc_preset_service import CocPresetService + + return CocPresetService( + canon_service=get_coc_canon_service(), + clue_service=get_coc_clue_service(), + prop_ledger_service=get_prop_ledger_service(), + ) + + def get_obsidian_memory_service(): """Obsidian 长期记忆镜像;导出时读取 PP 缓存,避免被尚未同步的 Obsidian 内容遮挡。""" from application.world.services.obsidian_memory_service import ( @@ -604,12 +644,8 @@ def _task_profile_id(env_name: str, default: str) -> str: @lru_cache def get_writing_llm_service(): - """正文/创意生成:默认固定走 Kimi。""" - return ProfilePinnedLLMService( - get_llm_provider_factory(), - profile_id=_task_profile_id("PLOTPILOT_WRITING_LLM_PROFILE_ID", "kimi-moonshot-default"), - role_name="writing", - ) + """正文/创意生成:跟随后台当前激活模型配置。""" + return DynamicLLMService(get_llm_provider_factory()) @lru_cache @@ -629,7 +665,7 @@ def get_setup_main_plot_suggestion_service(): ) return SetupMainPlotSuggestionService( - llm_service=get_writing_llm_service(), + llm_service=get_analysis_llm_service(), bible_service=get_bible_service(), novel_service=get_novel_service(), ) @@ -804,11 +840,14 @@ def get_triple_indexing_service(): def get_vector_store() -> Optional[VectorStore]: """获取向量存储(单例,整个进程共享同一实例) - 使用本地 FAISS 向量存储(ChromaDBVectorStore),无需外部服务。 + 默认使用本地 FAISS 向量存储(ChromaDBVectorStore),也可通过 + VECTOR_STORE_TYPE=qdrant 或旧版 QDRANT_ENABLED=true 切到远程 Qdrant。 环境变量配置: - VECTOR_STORE_ENABLED: 是否启用("true" 启用,默认 "true") + - VECTOR_STORE_TYPE: chromadb/qdrant(默认 chromadb) - VECTOR_STORE_PATH: 本地存储路径(默认 "./data/chromadb") + - QDRANT_HOST/QDRANT_PORT/QDRANT_API_KEY: Qdrant 连接配置 Returns: VectorStore 实例或 None @@ -826,8 +865,24 @@ def get_vector_store() -> Optional[VectorStore]: _vector_store_init_failed = True return None + store_type = os.getenv("VECTOR_STORE_TYPE", "chromadb").strip().lower() + legacy_qdrant_enabled = os.getenv("QDRANT_ENABLED", "").strip().lower() == "true" + if legacy_qdrant_enabled: + store_type = "qdrant" + try: + if store_type == "qdrant": + from infrastructure.ai.qdrant_vector_store import QdrantVectorStore + + host = os.getenv("QDRANT_HOST", "localhost") + port = int(os.getenv("QDRANT_PORT", "6333")) + api_key = os.getenv("QDRANT_API_KEY") or None + _vector_store_singleton = QdrantVectorStore(host=host, port=port, api_key=api_key) + logger.info("Qdrant 向量存储初始化成功: %s:%s", host, port) + return _vector_store_singleton + from infrastructure.ai.chromadb_vector_store import ChromaDBVectorStore + persist_dir = os.getenv("VECTOR_STORE_PATH", "./data/chromadb") _vector_store_singleton = ChromaDBVectorStore(persist_directory=persist_dir) logger.info("向量存储初始化成功: %s", persist_dir) @@ -895,6 +950,8 @@ def build_auto_workflow(llm_service: LLMService) -> AutoNovelGenerationWorkflow: cliche_scanner=ClicheScanner(), style_prompt_overlay_service=get_style_prompt_overlay_service(), prop_ledger_service=get_prop_ledger_service(), + coc_canon_service=get_coc_canon_service(), + coc_clue_service=get_coc_clue_service(), ) @@ -919,7 +976,7 @@ def get_auto_bible_generator() -> AutoBibleGenerator: Returns: AutoBibleGenerator 实例 """ - llm_service = get_writing_llm_service() + llm_service = get_analysis_llm_service() if llm_runtime_is_mock(llm_service): logger.warning("No API key found, using MockProvider for Bible generation") else: diff --git a/interfaces/api/v1/analyst/coc_canon.py b/interfaces/api/v1/analyst/coc_canon.py new file mode 100644 index 00000000..6e5b930b --- /dev/null +++ b/interfaces/api/v1/analyst/coc_canon.py @@ -0,0 +1,194 @@ +"""CoC 正典注册表 API。""" +from __future__ import annotations + +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Path +from pydantic import BaseModel, Field + +from application.analyst.services.coc_canon_service import CocCanonService +from interfaces.api.dependencies import ( + get_coc_canon_service, + get_coc_preset_service, + get_novel_service, +) + + +router = APIRouter(tags=["coc-canon"]) + + +class CocCanonEntry(BaseModel): + id: str + novel_id: str + canon_type: str + title: str + public_facts: str + hidden_truth: str + lock_level: str + mutable_notes: str + status: str + created_at: str + updated_at: str + + +class CocCanonEvent(BaseModel): + id: str + entry_id: str + novel_id: str + canon_type: str + title: str + chapter_number: int + event_type: str + evidence: str + notes: str + created_at: str + + +class CocCanonCognitionLayers(BaseModel): + author_truth: List[str] = [] + reader_known: List[str] = [] + author_truth_snippets: List[str] = [] + + +class CocCanonOverview(BaseModel): + novel_id: str + entries: List[CocCanonEntry] + recent_events: List[CocCanonEvent] + cognition_layers: CocCanonCognitionLayers + + +class UpsertCocCanonEntryRequest(BaseModel): + entry_id: Optional[str] = Field(default=None, min_length=1, max_length=64) + canon_type: str = Field(..., min_length=1, max_length=60) + title: str = Field(..., min_length=1, max_length=200) + public_facts: str = Field(default="", max_length=10000) + hidden_truth: str = Field(default="", max_length=10000) + lock_level: str = Field(default="soft", max_length=20) + mutable_notes: str = Field(default="", max_length=4000) + status: str = Field(default="active", max_length=20) + + +class CreateCocCanonEventRequest(BaseModel): + entry_id: Optional[str] = Field(default=None, min_length=1, max_length=64) + title: Optional[str] = Field(default=None, min_length=1, max_length=200) + chapter_number: int = Field(..., ge=1) + event_type: str = Field(default="mention", max_length=40) + evidence: str = Field(default="", max_length=10000) + notes: str = Field(default="", max_length=4000) + + +class CocPresetTemplate(BaseModel): + key: str + name: str + description: str + source_novel_id: str + canon_count: int + clue_count: int + prop_count: int = 0 + + +class ApplyCocPresetRequest(BaseModel): + preset_key: str = Field(default="analysis-loop-721", min_length=1, max_length=80) + overwrite_existing: bool = Field(default=False) + + +class ApplyCocPresetResponse(BaseModel): + preset_key: str + novel_id: str + created_canon: int + created_clues: int + created_props: int = 0 + skipped: int + overwrite_existing: bool + + +def _ensure_novel_exists(novel_id: str, novel_service) -> None: + if novel_service.get_novel(novel_id) is None: + raise HTTPException(status_code=404, detail="Novel not found") + + +@router.get( + "/novels/{novel_id}/coc-canon/overview", + response_model=CocCanonOverview, +) +def get_coc_canon_overview( + novel_id: str = Path(...), + novel_service=Depends(get_novel_service), + service: CocCanonService = Depends(get_coc_canon_service), +) -> CocCanonOverview: + _ensure_novel_exists(novel_id, novel_service) + return CocCanonOverview(**service.get_overview(novel_id)) + + +@router.post( + "/novels/{novel_id}/coc-canon/entries", + response_model=CocCanonEntry, + status_code=201, +) +def upsert_coc_canon_entry( + request: UpsertCocCanonEntryRequest, + novel_id: str = Path(...), + novel_service=Depends(get_novel_service), + service: CocCanonService = Depends(get_coc_canon_service), +) -> CocCanonEntry: + _ensure_novel_exists(novel_id, novel_service) + try: + payload = service.upsert_entry(novel_id=novel_id, **request.model_dump()) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return CocCanonEntry(**payload) + + +@router.post( + "/novels/{novel_id}/coc-canon/events", + response_model=CocCanonEvent, + status_code=201, +) +def create_coc_canon_event( + request: CreateCocCanonEventRequest, + novel_id: str = Path(...), + novel_service=Depends(get_novel_service), + service: CocCanonService = Depends(get_coc_canon_service), +) -> CocCanonEvent: + _ensure_novel_exists(novel_id, novel_service) + try: + payload = service.create_event(novel_id=novel_id, **request.model_dump()) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return CocCanonEvent(**payload) + + +@router.get( + "/novels/{novel_id}/coc-preset/templates", + response_model=List[CocPresetTemplate], +) +def list_coc_preset_templates( + novel_id: str = Path(...), + novel_service=Depends(get_novel_service), + preset_service=Depends(get_coc_preset_service), +) -> List[CocPresetTemplate]: + _ensure_novel_exists(novel_id, novel_service) + return [CocPresetTemplate(**item) for item in preset_service.list_presets()] + + +@router.post( + "/novels/{novel_id}/coc-preset/apply", + response_model=ApplyCocPresetResponse, + status_code=201, +) +def apply_coc_preset( + request: ApplyCocPresetRequest, + novel_id: str = Path(...), + novel_service=Depends(get_novel_service), + preset_service=Depends(get_coc_preset_service), +) -> ApplyCocPresetResponse: + _ensure_novel_exists(novel_id, novel_service) + try: + payload = preset_service.apply_preset( + novel_id=novel_id, + preset_key=request.preset_key, + overwrite_existing=request.overwrite_existing, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return ApplyCocPresetResponse(**payload) diff --git a/interfaces/api/v1/analyst/coc_clue.py b/interfaces/api/v1/analyst/coc_clue.py new file mode 100644 index 00000000..e4f1a394 --- /dev/null +++ b/interfaces/api/v1/analyst/coc_clue.py @@ -0,0 +1,132 @@ +"""CoC 线索账本 API。""" +from __future__ import annotations + +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Path +from pydantic import BaseModel, Field + +from application.analyst.services.coc_clue_service import CocClueService +from interfaces.api.dependencies import get_coc_clue_service, get_novel_service + + +router = APIRouter(tags=["coc-clues"]) + + +class CocClueItem(BaseModel): + id: str + novel_id: str + clue_key: str + clue_text: str + visibility: str + reveal_chapter: Optional[int] = None + known_by: str + confidence: float + lock_level: str + status: str + notes: str + created_at: str + updated_at: str + + +class CocClueEvent(BaseModel): + id: str + clue_id: str + novel_id: str + clue_key: str + chapter_number: int + event_type: str + evidence: str + notes: str + created_at: str + + +class CocClueCognitionLayers(BaseModel): + author_truth: List[str] = [] + character_known: List[str] = [] + reader_known: List[str] = [] + + +class CocClueOverview(BaseModel): + novel_id: str + items: List[CocClueItem] + recent_events: List[CocClueEvent] + cognition_layers: CocClueCognitionLayers + + +class UpsertCocClueItemRequest(BaseModel): + entry_id: Optional[str] = Field(default=None, min_length=1, max_length=64) + clue_key: str = Field(..., min_length=1, max_length=120) + clue_text: str = Field(default="", max_length=10000) + visibility: str = Field(default="reader_known", max_length=40) + reveal_chapter: Optional[int] = Field(default=None, ge=1) + known_by: str = Field(default="", max_length=200) + confidence: float = Field(default=0.5, ge=0, le=1) + lock_level: str = Field(default="soft", max_length=20) + status: str = Field(default="active", max_length=20) + notes: str = Field(default="", max_length=4000) + + +class CreateCocClueEventRequest(BaseModel): + entry_id: Optional[str] = Field(default=None, min_length=1, max_length=64) + clue_key: Optional[str] = Field(default=None, min_length=1, max_length=120) + chapter_number: int = Field(..., ge=1) + event_type: str = Field(default="mention", max_length=40) + evidence: str = Field(default="", max_length=10000) + notes: str = Field(default="", max_length=4000) + + +def _ensure_novel_exists(novel_id: str, novel_service) -> None: + if novel_service.get_novel(novel_id) is None: + raise HTTPException(status_code=404, detail="Novel not found") + + +@router.get( + "/novels/{novel_id}/coc-clues/overview", + response_model=CocClueOverview, +) +def get_coc_clue_overview( + novel_id: str = Path(...), + novel_service=Depends(get_novel_service), + service: CocClueService = Depends(get_coc_clue_service), +) -> CocClueOverview: + _ensure_novel_exists(novel_id, novel_service) + return CocClueOverview(**service.get_overview(novel_id)) + + +@router.post( + "/novels/{novel_id}/coc-clues/items", + response_model=CocClueItem, + status_code=201, +) +def upsert_coc_clue_item( + request: UpsertCocClueItemRequest, + novel_id: str = Path(...), + novel_service=Depends(get_novel_service), + service: CocClueService = Depends(get_coc_clue_service), +) -> CocClueItem: + _ensure_novel_exists(novel_id, novel_service) + try: + payload = service.upsert_item(novel_id=novel_id, **request.model_dump()) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return CocClueItem(**payload) + + +@router.post( + "/novels/{novel_id}/coc-clues/events", + response_model=CocClueEvent, + status_code=201, +) +def create_coc_clue_event( + request: CreateCocClueEventRequest, + novel_id: str = Path(...), + novel_service=Depends(get_novel_service), + service: CocClueService = Depends(get_coc_clue_service), +) -> CocClueEvent: + _ensure_novel_exists(novel_id, novel_service) + try: + payload = service.create_event(novel_id=novel_id, **request.model_dump()) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return CocClueEvent(**payload) diff --git a/interfaces/api/v1/core/chapter_candidate_drafts.py b/interfaces/api/v1/core/chapter_candidate_drafts.py index 30387225..9fb36bde 100644 --- a/interfaces/api/v1/core/chapter_candidate_drafts.py +++ b/interfaces/api/v1/core/chapter_candidate_drafts.py @@ -17,6 +17,8 @@ get_chapter_aftermath_pipeline, get_chapter_candidate_draft_service, get_database, + get_analysis_llm_service, + get_writing_llm_service, get_llm_service, get_llm_provider_factory, get_novel_service, @@ -190,6 +192,50 @@ class GenerateCandidateDraftResponse(BaseModel): task: ExternalModelTaskResponse +class EditorialReviewScores(BaseModel): + opening: int = 0 + conflict: int = 0 + character: int = 0 + dialogue: int = 0 + hook: int = 0 + pacing: int = 0 + + +class EditorialReviewPayload(BaseModel): + summary: str = "" + scores: EditorialReviewScores = Field(default_factory=EditorialReviewScores) + strengths: List[str] = Field(default_factory=list) + problems: List[str] = Field(default_factory=list) + actions: List[str] = Field(default_factory=list) + verdict: str = "" + + +class GenerateEditorialPolishCandidateRequest(BaseModel): + chapter_number: int = Field(..., ge=1) + outline: str = Field(..., min_length=1) + current_content: str = Field(..., min_length=1) + editorial_review: EditorialReviewPayload + target_word_count: int = Field(default=2500, ge=800, le=12000) + branch_name: str = "main" + title: str = "" + model_label: str = "" + max_tokens: int = Field(default=4096, ge=256, le=16000) + temperature: float = Field(default=0.55, ge=0, le=2) + + +class CreateWebWritingPromptRequest(BaseModel): + chapter_number: int = Field(..., ge=1) + outline: str = Field(..., min_length=1) + current_content: str = "" + model_label: str = "ChatGPT Web" + task_prompt: str = "" + + +class WebWritingPromptResponse(BaseModel): + prompt: str + task: ExternalModelTaskResponse + + class SupervisorReviewCandidateDraftRequest(BaseModel): model_label: str = "" llm_profile_id: str = "" @@ -294,6 +340,75 @@ def _mark_external_task_status(db, novel_id: str, candidate_draft_id: str, statu db.commit() +def _format_editorial_review_for_prompt(review: EditorialReviewPayload) -> str: + scores = review.scores + score_lines = [ + f"开头 {scores.opening}", + f"冲突 {scores.conflict}", + f"人物 {scores.character}", + f"对白 {scores.dialogue}", + f"追读 {scores.hook}", + f"节奏 {scores.pacing}", + ] + + def lines(title: str, items: List[str]) -> List[str]: + if not items: + return [title, "- 无"] + return [title, *[f"- {item}" for item in items]] + + return "\n".join( + [ + f"审稿结论:{review.verdict or '可优化后使用'}", + f"审稿摘要:{review.summary or '无'}", + "评分:", + *score_lines, + "", + *lines("亮点(必须保留)", review.strengths), + "", + *lines("问题(只针对这些问题动刀)", review.problems), + "", + *lines("修改动作(逐条落实)", review.actions), + ] + ) + + +def _build_web_writing_prompt(novel_id: str, request: CreateWebWritingPromptRequest) -> str: + task = request.task_prompt.strip() or "生成一版可直接进入候选稿区的完整章节正文。" + current_content = request.current_content.strip() or "(当前正文为空,请根据大纲生成完整章节正文。)" + return "\n".join( + [ + "你现在是中文商业小说作者,请根据 PlotPilot 提供的上下文写作。", + "", + "【输出要求】", + "只输出完整章节正文,不要输出标题、解释、提纲、分析、Markdown 代码块或改稿说明。", + "", + "【小说 ID】", + novel_id, + "", + "【章节】", + f"第 {request.chapter_number} 章", + "", + "【本次任务】", + task, + "", + "【章节大纲】", + request.outline.strip(), + "", + "【当前正文/上一版草稿】", + current_content, + "", + "【写作规则】", + "1. 以动作、物件异常、对话或正在发生的麻烦开头,不要先解释世界观。", + "2. 每个场景都要同时推进情节和人物关系/信息差/风险。", + "3. 对话要有试探、回避、反问或未说出口的信息,不要把动机讲透。", + "4. 冲突节点慢写,展开动作链、停顿、反应和信息递进。", + "5. 保留现有事实、角色关系、伏笔、道具状态和章节钩子。", + "6. 不新增核心角色,不提前揭露作者真相,不用总结句替读者解释意义。", + "7. 如果是改稿,只局部修正文风、节奏和衔接,不改变核心剧情。", + ] + ) + + @router.post( "/{novel_id}/chapters/{chapter_number}/candidate-drafts", response_model=ChapterCandidateDraftResponse, @@ -332,7 +447,7 @@ async def generate_candidate_draft( request: GenerateCandidateDraftRequest, novel_service=Depends(get_novel_service), service=Depends(get_chapter_candidate_draft_service), - llm_service: LLMService = Depends(get_llm_service), + llm_service: LLMService = Depends(get_writing_llm_service), llm_provider_factory=Depends(get_llm_provider_factory), db=Depends(get_database), ): @@ -415,6 +530,136 @@ async def generate_candidate_draft( ) +@router.post( + "/{novel_id}/candidate-drafts/editorial-polish", + response_model=GenerateCandidateDraftResponse, + status_code=201, +) +async def generate_editorial_polish_candidate( + novel_id: str, + request: GenerateEditorialPolishCandidateRequest, + novel_service=Depends(get_novel_service), + service=Depends(get_chapter_candidate_draft_service), + llm_service: LLMService = Depends(get_writing_llm_service), + db=Depends(get_database), +): + if novel_service.get_novel(novel_id) is None: + raise HTTPException(status_code=404, detail="Novel not found") + + review_text = _format_editorial_review_for_prompt(request.editorial_review) + lower_words = max(600, int(request.target_word_count * 0.9)) + upper_words = int(request.target_word_count * 1.1) + max_tokens = min(request.max_tokens, max(1800, int(request.target_word_count * 1.6))) + system = ( + "你是中文商业小说主编兼改稿作者。你只输出完整精修后的章节正文," + "不要输出解释、标题、审稿意见、Markdown 代码块或改稿说明。" + ) + user = "\n".join( + [ + f"小说 ID:{novel_id}", + f"章节:第 {request.chapter_number} 章", + "", + "【任务】", + "根据主编审稿做编辑型精修,生成一份候选稿。不要重写成另一章,不要覆盖主稿。", + "", + "【章节大纲】", + request.outline.strip(), + "", + "【主编审稿】", + review_text, + "", + "【篇幅约束】", + f"目标字数:约 {request.target_word_count} 字;允许范围 {lower_words}-{upper_words} 字。", + "这是硬约束。只做编辑型精修,不要因为补细节把篇幅扩写成超长章节;若原文过长,优先压缩绕口句、重复动作和解释性段落。", + "", + "【当前正文】", + request.current_content.strip(), + "", + "【改稿边界】", + "1. 保留审稿中列出的亮点、核心剧情、伏笔、角色关系和章节结尾钩子。", + "2. 只围绕“问题”和“修改动作”局部精修:压紧开头、顺滑出场、修剪绕口句、强化动作链。", + "3. 不新增核心角色,不提前解释真相,不把线索总结给读者。", + "4. 保持原章节叙事顺序;必要时可小幅调整段落顺序增强可读性,但必须服从篇幅约束。", + "5. 输出完整正文,不要附带任何说明。", + ] + ) + try: + result = await llm_service.generate( + Prompt(system=system, user=user), + GenerationConfig(max_tokens=max_tokens, temperature=request.temperature), + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"模型精修失败:{exc}") from exc + + content = result.content.strip() + draft = service.create_draft( + novel_id=novel_id, + chapter_number=request.chapter_number, + source="editorial-polish", + title=request.title or f"第{request.chapter_number}章 主编审稿精修候选稿", + content=content, + rationale="按主编审稿生成精修候选稿", + metadata={ + "editorial_polish": True, + "editorial_review": request.editorial_review.model_dump(), + "outline": request.outline, + "target_word_count": request.target_word_count, + "model_label": request.model_label, + "execution_mode": "editorial_polish_api", + }, + branch_name=request.branch_name or "main", + ) + task_row = _upsert_external_model_task( + db, + novel_id, + ExternalModelTaskRequest( + chapter_number=request.chapter_number, + model=request.model_label or "writing-llm", + prompt=user, + instruction="按主编审稿生成精修候选稿", + candidate_draft_id=draft.id, + response_preview=content[:160], + status="imported", + execution_mode="editorial_polish_api", + ), + ) + return GenerateCandidateDraftResponse( + draft=ChapterCandidateDraftResponse.from_dto(draft), + task=_task_row_to_response(task_row), + ) + + +@router.post( + "/{novel_id}/candidate-drafts/web-writing-prompt", + response_model=WebWritingPromptResponse, + status_code=201, +) +async def create_web_writing_prompt( + novel_id: str, + request: CreateWebWritingPromptRequest, + novel_service=Depends(get_novel_service), + db=Depends(get_database), +): + if novel_service.get_novel(novel_id) is None: + raise HTTPException(status_code=404, detail="Novel not found") + + prompt = _build_web_writing_prompt(novel_id, request) + task_row = _upsert_external_model_task( + db, + novel_id, + ExternalModelTaskRequest( + chapter_number=request.chapter_number, + model=request.model_label or "Web 写作", + prompt=prompt, + instruction=request.task_prompt or request.outline, + response_preview="", + status="prompted", + execution_mode="web_copy_paste", + ), + ) + return WebWritingPromptResponse(prompt=prompt, task=_task_row_to_response(task_row)) + + @router.get( "/{novel_id}/chapters/{chapter_number}/candidate-drafts", response_model=List[ChapterCandidateDraftResponse], @@ -545,7 +790,7 @@ async def review_candidate_draft_with_supervisor( chapter_number: int = Path(..., ge=1), novel_service=Depends(get_novel_service), service=Depends(get_chapter_candidate_draft_service), - llm_service: LLMService = Depends(get_llm_service), + llm_service: LLMService = Depends(get_analysis_llm_service), llm_provider_factory=Depends(get_llm_provider_factory), db=Depends(get_database), ): @@ -568,6 +813,8 @@ async def review_candidate_draft_with_supervisor( system = ( "你是 PlotPilot 的审稿/记忆监督模型。你不改写正文,只做采纳前检查。" "请用中文输出结构化意见,重点指出需要作者确认或写入记忆系统的事项。" + "你还要专门识别 AI味:抽象情绪说明、对白直白、过程压缩、段尾总结、" + "万能比喻和过度整齐的句式。" ) user = "\n".join( [ @@ -578,6 +825,13 @@ async def review_candidate_draft_with_supervisor( "【检查重点】", request.focus.strip() or "检查记忆、连续性、战力崩坏和采纳建议。", "", + "【AI味专项检查】", + "- 抽象情绪说明:复杂情绪、说不清的东西、心里一震等是否过多。", + "- 对白直白:角色是否一次性解释动机、信息和情绪,缺少试探/回避/停顿。", + "- 过程压缩:是否用“一番交谈后、很快、简单说明、转眼之间”等跳过付费看点。", + "- 段尾总结:是否频繁替读者总结意义或使用宿命式收束。", + "- 句式过整:是否连续出现“不是X,是Y”“像某种”等检测器高风险结构。", + "", "【当前主稿】", primary_text.strip() or "(当前主稿为空)", "", diff --git a/interfaces/api/v1/core/chapters.py b/interfaces/api/v1/core/chapters.py index e5827951..506d0309 100644 --- a/interfaces/api/v1/core/chapters.py +++ b/interfaces/api/v1/core/chapters.py @@ -12,10 +12,12 @@ from application.audit.dtos.chapter_review_dto import ChapterReviewDTO from application.core.dtos.chapter_structure_dto import ChapterStructureDTO from application.engine.services.chapter_aftermath_pipeline import ChapterAftermathPipeline +from application.workflows.auto_novel_generation_workflow import AutoNovelGenerationWorkflow from interfaces.api.dependencies import ( get_chapter_service, get_novel_service, get_chapter_aftermath_pipeline, + get_auto_workflow, ) from domain.shared.exceptions import EntityNotFoundError logger = logging.getLogger(__name__) @@ -172,8 +174,24 @@ async def update_chapter( chapter_number: int = Path(..., gt=0, description="章节编号"), service: ChapterService = Depends(get_chapter_service), pipeline: ChapterAftermathPipeline = Depends(get_chapter_aftermath_pipeline), + workflow: AutoNovelGenerationWorkflow = Depends(get_auto_workflow), ): """更新章节内容,保存成功后后台执行统一章后管线(见 ChapterAftermathPipeline)。""" + coc_guard = workflow.validate_coc_content_boundary( + novel_id=novel_id, + chapter_number=chapter_number, + content=request.content, + ) + if not coc_guard.get("allow_save", True): + reasons = coc_guard.get("blocking_issues") or [] + detail = { + "message": "命中 CoC 硬约束,章节保存已阻断", + "blocking_issues": reasons, + "chapter_number": chapter_number, + "risk_level": coc_guard.get("risk_level", "block"), + } + raise HTTPException(status_code=422, detail=detail) + try: chapter = service.update_chapter_by_novel_and_number( novel_id, diff --git a/interfaces/api/v1/engine/generation.py b/interfaces/api/v1/engine/generation.py index bb76aa9b..c8e71c68 100644 --- a/interfaces/api/v1/engine/generation.py +++ b/interfaces/api/v1/engine/generation.py @@ -20,6 +20,7 @@ from domain.novel.value_objects.plot_point import PlotPoint, PlotPointType from domain.novel.entities.plot_arc import PlotArc from interfaces.api.dependencies import ( + build_auto_workflow, get_auto_workflow, get_hosted_write_service, get_storyline_manager, @@ -30,6 +31,7 @@ get_auto_bible_generator, get_auto_knowledge_generator, get_setup_main_plot_suggestion_service, + get_analysis_llm_service, ) # from application.services.story_structure_ai_service import StoryStructureAIService # 已废弃,使用 ContinuousPlanningService from application.blueprint.services.continuous_planning_service import ContinuousPlanningService @@ -78,6 +80,11 @@ def get_continuous_planning_service() -> ContinuousPlanningService: ) +def get_analysis_workflow() -> AutoNovelGenerationWorkflow: + """获取用于策略预览 / 主编审稿的分析工作流。""" + return build_auto_workflow(get_analysis_llm_service()) + + # Request/Response Models class GenerateChapterRequest(BaseModel): """生成章节请求""" @@ -90,6 +97,151 @@ class GenerateChapterRequest(BaseModel): False, description="是否注入避免 AI 压缩表达的慢写约束", ) + target_word_count: Optional[int] = Field( + None, + ge=800, + le=12000, + description="可选:本章目标字数", + ) + word_tolerance_percent: Optional[float] = Field( + 5.0, + ge=2.0, + le=20.0, + description="可选:目标字数容差百分比(2-20);例如 5 表示 2500 字允许约 2375-2625", + ) + direct_writing_mode: bool = Field( + False, + description="直接写作对照模式:跳过节拍拆分、自然化后处理与章后质检", + ) + direct_light_polish: bool = Field( + False, + description="直接写作后轻修:只局部降低 AI 特征,不进入完整后处理", + ) + chapter_strategy: Optional[dict] = Field( + None, + description="可选:生成前确认的章节写作策略,会作为硬约束注入正文生成", + ) + long_draft_mode: bool = Field( + False, + description="长稿母本灰度模式:先按更长目标字数连续写,再用于后续拆章", + ) + long_draft_split_count: Optional[int] = Field( + 2, + ge=2, + le=4, + description="长稿母本预计拆章数(2-4)", + ) + + +class ChapterStrategyPreviewRequest(BaseModel): + outline: str = Field(..., min_length=1, description="章节大纲") + scene_director_result: Optional[dict] = Field(None, description="可选场记分析结果") + style_profile_id: str = Field("", description="可选写作手法档案 ID") + scene_type: str = Field("", description="可选场景类型") + target_word_count: Optional[int] = Field(None, ge=800, le=12000) + word_tolerance_percent: Optional[float] = Field(5.0, ge=2.0, le=20.0) + + +class CocCognitionPrecheckRequest(BaseModel): + outline: str = Field(..., min_length=1, description="章节大纲") + + +class CocCognitionRewriteRequest(BaseModel): + outline: str = Field(..., min_length=1, description="章节大纲") + rewrite_mode: str = Field( + default="conservative", + description="改写强度:conservative(保守) / aggressive(激进)", + ) + rewrite_style: str = Field( + default="generic", + description="改写风格:generic(通用) / suspense(悬疑) / coc(CoC)", + ) + + +class CocCognitionPrecheckResponse(BaseModel): + checked: bool + allow_generate: bool + risk_level: str + blocking_issues: List[str] + warnings: List[str] + matched_tokens: List[str] + chapter_number: int + + +class CocCognitionRewriteResponse(BaseModel): + original_outline: str + rewritten_outline: str + changed: bool + rewrite_mode: str + rewrite_style: str + applied_rules: List[str] + precheck_before: CocCognitionPrecheckResponse + precheck_after: CocCognitionPrecheckResponse + + +class ChapterStrategyDramaticTaskResponse(BaseModel): + goal: str + obstacle: str + reader_expectation: str + ending_hook: str + + +class ChapterContractResponse(BaseModel): + chapter_question: str + protagonist_want: str + opposition: str + reader_expectation: str + required_information_change: str + required_relationship_change: str + ending_question: str + show_dont_tell_rules: List[str] + + +class ChapterStrategySceneResponse(BaseModel): + label: str + task: str + resistance: str + info_shift: str + relationship_shift: str + anchor: str + visible_action: str = "" + subtext_dialogue: str = "" + unspoken_emotion: str = "" + object_or_clue_change: str = "" + hook: str + target_words: int + + +class ChapterStrategyPreviewResponse(BaseModel): + chapter_contract: ChapterContractResponse + dramatic_task: ChapterStrategyDramaticTaskResponse + scene_plan: List[ChapterStrategySceneResponse] + writing_focus: List[str] + + +class ChapterEditorialReviewRequest(BaseModel): + outline: str = Field(..., min_length=1, description="章节大纲") + content: str = Field(..., min_length=1, description="章节正文") + chapter_strategy: Optional[dict] = Field(None, description="可选:本章写作策略") + + +class ChapterEditorialReviewScoresResponse(BaseModel): + opening: int + conflict: int + character: int + dialogue: int + hook: int + pacing: int + showing: int = Field(0, description="展示优先:少解释、多动作细节、潜台词对白") + + +class ChapterEditorialReviewResponse(BaseModel): + summary: str + scores: ChapterEditorialReviewScoresResponse + strengths: List[str] + problems: List[str] + actions: List[str] + verdict: str ANTI_COMPRESSION_DIRECTIVE = """【避免 AI 压缩表达】 @@ -241,6 +393,103 @@ class HostedWriteStreamRequest(BaseModel): # Endpoints +@router.post( + "/{novel_id}/chapters/{chapter_number}/strategy-preview", + response_model=ChapterStrategyPreviewResponse, + status_code=status.HTTP_200_OK, +) +async def preview_chapter_strategy( + novel_id: str, + chapter_number: int, + request: ChapterStrategyPreviewRequest, + workflow: AutoNovelGenerationWorkflow = Depends(get_analysis_workflow), +): + """生成本章可见写作策略。""" + scene_director = None + if request.scene_director_result: + scene_director = SceneDirectorAnalysis(**request.scene_director_result) + payload = await workflow.generate_chapter_strategy( + novel_id=novel_id, + chapter_number=chapter_number, + outline=request.outline, + scene_director=scene_director, + style_profile_id=request.style_profile_id, + scene_type=request.scene_type, + target_word_count=request.target_word_count, + word_tolerance_ratio=(request.word_tolerance_percent or 5.0) / 100.0, + ) + return ChapterStrategyPreviewResponse(**payload) + + +@router.post( + "/{novel_id}/chapters/{chapter_number}/coc-cognition-precheck", + response_model=CocCognitionPrecheckResponse, + status_code=status.HTTP_200_OK, +) +async def precheck_coc_cognition_boundary( + novel_id: str, + chapter_number: int, + request: CocCognitionPrecheckRequest, + workflow: AutoNovelGenerationWorkflow = Depends(get_auto_workflow), +): + payload = workflow.precheck_coc_cognition_boundary( + novel_id=novel_id, + chapter_number=chapter_number, + outline=request.outline, + ) + payload.setdefault("chapter_number", chapter_number) + payload.setdefault("blocking_issues", []) + payload.setdefault("warnings", []) + payload.setdefault("matched_tokens", []) + payload.setdefault("risk_level", "none") + payload.setdefault("checked", False) + payload.setdefault("allow_generate", True) + return CocCognitionPrecheckResponse(**payload) + + +@router.post( + "/{novel_id}/chapters/{chapter_number}/coc-cognition-rewrite-outline", + response_model=CocCognitionRewriteResponse, + status_code=status.HTTP_200_OK, +) +async def rewrite_outline_by_coc_boundary( + novel_id: str, + chapter_number: int, + request: CocCognitionRewriteRequest, + workflow: AutoNovelGenerationWorkflow = Depends(get_auto_workflow), +): + payload = workflow.rewrite_outline_for_coc_boundary( + novel_id=novel_id, + chapter_number=chapter_number, + outline=request.outline, + rewrite_mode=request.rewrite_mode, + rewrite_style=request.rewrite_style, + ) + return CocCognitionRewriteResponse(**payload) + + +@router.post( + "/{novel_id}/chapters/{chapter_number}/editorial-review", + response_model=ChapterEditorialReviewResponse, + status_code=status.HTTP_200_OK, +) +async def review_generated_chapter_editorially( + novel_id: str, + chapter_number: int, + request: ChapterEditorialReviewRequest, + workflow: AutoNovelGenerationWorkflow = Depends(get_analysis_workflow), +): + """按开篇/冲突/人物/对白/追读/节奏做主编审稿。""" + payload = await workflow.review_generated_chapter_editorially( + novel_id=novel_id, + chapter_number=chapter_number, + outline=request.outline, + content=request.content, + chapter_strategy=request.chapter_strategy, + ) + return ChapterEditorialReviewResponse(**payload) + + @router.post( "/{novel_id}/generate-chapter-stream", status_code=status.HTTP_200_OK, @@ -273,6 +522,16 @@ async def event_gen(): request.avoid_compressed_expression, ) + coc_precheck = workflow.precheck_coc_cognition_boundary( + novel_id=novel_id, + chapter_number=request.chapter_number, + outline=outline, + ) + if coc_precheck.get("risk_level") == "block": + reasons = coc_precheck.get("blocking_issues") or [] + yield f"data: {json.dumps({'type': 'error', 'message': 'CoC 认知边界阻断:请先改写大纲后再生成', 'blocking_issues': reasons, 'chapter_number': request.chapter_number}, ensure_ascii=False)}\n\n" + return + async for event in workflow.generate_chapter_stream( novel_id=novel_id, chapter_number=request.chapter_number, @@ -280,6 +539,14 @@ async def event_gen(): scene_director=scene_director, style_profile_id=request.style_profile_id, scene_type=request.scene_type, + enable_beats=not request.direct_writing_mode, + direct_writing_mode=request.direct_writing_mode, + direct_light_polish=request.direct_light_polish, + chapter_strategy=request.chapter_strategy, + target_word_count=request.target_word_count, + word_tolerance_ratio=(request.word_tolerance_percent or 5.0) / 100.0, + long_draft_mode=request.long_draft_mode, + long_draft_split_count=request.long_draft_split_count, ): yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" diff --git a/interfaces/api/v1/style_bible.py b/interfaces/api/v1/style_bible.py index f8667e9d..0c2f4a66 100644 --- a/interfaces/api/v1/style_bible.py +++ b/interfaces/api/v1/style_bible.py @@ -139,6 +139,8 @@ def list_style_profiles( repository: StyleBibleRepository = Depends(get_style_bible_repository), ): """列出写作手法档案。""" + if hasattr(repository, "ensure_default_profiles"): + repository.ensure_default_profiles() return [_profile_detail(repository, profile) for profile in repository.list_profiles(novel_id, status)] diff --git a/interfaces/api/v1/workbench/llm_control.py b/interfaces/api/v1/workbench/llm_control.py index e9c9e88f..4f37a15f 100644 --- a/interfaces/api/v1/workbench/llm_control.py +++ b/interfaces/api/v1/workbench/llm_control.py @@ -19,6 +19,7 @@ ) from infrastructure.ai.provider_factory import LLMProviderFactory from infrastructure.ai.prompt_manager import get_prompt_manager +from infrastructure.ai.url_utils import should_trust_env_proxy_for_openai_base logger = logging.getLogger(__name__) router = APIRouter(prefix='/llm-control', tags=['llm-control']) @@ -147,9 +148,11 @@ async def list_models(payload: ModelListRequest) -> ModelListResponse: } try: - # 不向子进程继承 HTTP(S)_PROXY:本机 Clash/V2 等监听 127.0.0.1 时,httpx 走代理易导致 - # start_tls / BrokenResourceError,而国内直连 API 域名通常无需系统代理。 - async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: + # 国产 OpenAI-compatible 网关通常直连更稳;官方 OpenAI 在部分本地网络下需要系统代理。 + async with httpx.AsyncClient( + timeout=timeout, + trust_env=should_trust_env_proxy_for_openai_base(base_url), + ) as client: response = await client.get(url, headers=headers) response.raise_for_status() try: diff --git a/interfaces/main.py b/interfaces/main.py index 33e0a593..ea5f863e 100644 --- a/interfaces/main.py +++ b/interfaces/main.py @@ -79,7 +79,7 @@ from interfaces.api.v1.audit import chapter_review_routes, macro_refactor, chapter_element_routes # Analyst module -from interfaces.api.v1.analyst import voice, narrative_state, foreshadow_ledger, continuity, power_system, prop_ledger, novelpro_monitor, novelpro_suggestions +from interfaces.api.v1.analyst import voice, narrative_state, foreshadow_ledger, continuity, power_system, prop_ledger, coc_canon, coc_clue, novelpro_monitor, novelpro_suggestions # System module (internal tooling) from interfaces.api.v1 import system as system_routes @@ -195,7 +195,7 @@ async def startup_event(): # 启动自动驾驶守护进程(后台线程) _start_autopilot_daemon_thread() - get_topic_signal_automation_service().start() + logger.info("市场信号采集为手动触发模式,启动时不启动定时采集线程") def _checkpoint_sqlite_wal_safe() -> None: """桌面端优雅退出时尽量将 WAL 落盘,降低异常断电时的损坏概率。""" @@ -598,6 +598,8 @@ def restart_autopilot_daemon(): app.include_router(continuity.router, prefix="/api/v1") app.include_router(power_system.router, prefix="/api/v1") app.include_router(prop_ledger.router, prefix="/api/v1") +app.include_router(coc_canon.router, prefix="/api/v1") +app.include_router(coc_clue.router, prefix="/api/v1") app.include_router(novelpro_monitor.router, prefix="/api/v1") app.include_router(novelpro_suggestions.router, prefix="/api/v1") app.include_router(narrative_state.router, prefix="/api/v1") diff --git a/tests/integration/interfaces/api/v1/test_chapter_candidate_drafts_api.py b/tests/integration/interfaces/api/v1/test_chapter_candidate_drafts_api.py index 8f9209de..21ba0ea7 100644 --- a/tests/integration/interfaces/api/v1/test_chapter_candidate_drafts_api.py +++ b/tests/integration/interfaces/api/v1/test_chapter_candidate_drafts_api.py @@ -5,8 +5,10 @@ from domain.ai.value_objects.token_usage import TokenUsage from interfaces.api.dependencies import ( get_chapter_aftermath_pipeline, + get_analysis_llm_service, get_llm_provider_factory, get_llm_service, + get_writing_llm_service, ) from interfaces.main import app @@ -280,6 +282,97 @@ def test_generate_candidate_draft_uses_requested_llm_profile(self): assert payload["task"]["model"] == "Kimi" assert factory.requested_profiles == ["writer-profile"] + def test_generate_editorial_polish_candidate_uses_review_actions(self): + novel_id = f"test-novel-editorial-polish-{uuid.uuid4().hex[:8]}" + create_novel = self.client.post( + "/api/v1/novels", + json={ + "novel_id": novel_id, + "title": "主编审稿精修测试", + "author": "测试作者", + "target_chapters": 12, + "premise": "测试 premise", + }, + ) + assert create_novel.status_code == 201 + + writing_service = _FakeLLMService("按主编审稿精修后的候选正文") + app.dependency_overrides[get_writing_llm_service] = lambda: writing_service + + response = self.client.post( + f"/api/v1/novels/{novel_id}/candidate-drafts/editorial-polish", + json={ + "chapter_number": 2, + "outline": "第二章主角继续调查灰卡。", + "current_content": "旧正文里许照突然出现,黑线发现过程偏散。", + "branch_name": "editorial", + "target_word_count": 2500, + "editorial_review": { + "summary": "可优化后使用", + "verdict": "可优化后使用", + "scores": { + "opening": 90, + "conflict": 92, + "character": 88, + "dialogue": 90, + "hook": 95, + "pacing": 87, + }, + "strengths": ["物证层层递进", "对白潜台词丰富"], + "problems": ["许照出场缺少铺垫", "开头一两段偏散文化"], + "actions": ["许照出场前增加极简暗示", "压紧前300字"], + }, + }, + ) + + assert response.status_code == 201 + payload = response.json() + assert payload["draft"]["source"] == "editorial-polish" + assert payload["draft"]["content"] == "按主编审稿精修后的候选正文" + assert payload["draft"]["branch_name"] == "editorial" + assert payload["draft"]["metadata"]["editorial_review"]["verdict"] == "可优化后使用" + assert payload["task"]["execution_mode"] == "editorial_polish_api" + assert "许照出场缺少铺垫" in writing_service.calls[0]["prompt"].user + assert "许照出场前增加极简暗示" in writing_service.calls[0]["prompt"].user + assert "旧正文里许照突然出现" in writing_service.calls[0]["prompt"].user + assert "目标字数:约 2500 字" in writing_service.calls[0]["prompt"].user + assert writing_service.calls[0]["config"].max_tokens <= 4096 + + def test_create_web_writing_prompt_records_copy_paste_task(self): + novel_id = f"test-novel-web-writing-{uuid.uuid4().hex[:8]}" + create_novel = self.client.post( + "/api/v1/novels", + json={ + "novel_id": novel_id, + "title": "Web 写作测试", + "author": "测试作者", + "target_chapters": 12, + "premise": "测试 premise", + }, + ) + assert create_novel.status_code == 201 + + response = self.client.post( + f"/api/v1/novels/{novel_id}/candidate-drafts/web-writing-prompt", + json={ + "chapter_number": 6, + "outline": "第六章,主角在灯塔里发现旧记录。", + "current_content": "上一版正文开头偏慢。", + "model_label": "ChatGPT Web", + "task_prompt": "生成一版 2500 字左右的商业悬疑正文。", + }, + ) + + assert response.status_code == 201 + payload = response.json() + assert payload["task"]["model"] == "ChatGPT Web" + assert payload["task"]["status"] == "prompted" + assert payload["task"]["execution_mode"] == "web_copy_paste" + assert payload["prompt"] == payload["task"]["prompt"] + assert "第六章,主角在灯塔里发现旧记录" in payload["prompt"] + assert "上一版正文开头偏慢" in payload["prompt"] + assert "只输出完整章节正文" in payload["prompt"] + def test_supervisor_review_uses_requested_llm_profile_and_records_task(self): novel_id = f"test-novel-supervisor-review-{uuid.uuid4().hex[:8]}" create_novel = self.client.post( @@ -326,3 +419,50 @@ def test_supervisor_review_uses_requested_llm_profile_and_records_task(self): assert payload["task"]["execution_mode"] == "supervisor_api" assert factory.requested_profiles == ["supervisor-profile"] assert "甲突然击败高阶敌人" in factory.supervisor_service.calls[0]["prompt"].user + + def test_supervisor_review_defaults_to_analysis_llm_when_no_profile_requested(self): + novel_id = f"test-novel-supervisor-default-{uuid.uuid4().hex[:8]}" + create_novel = self.client.post( + "/api/v1/novels", + json={ + "novel_id": novel_id, + "title": "默认审稿模型测试", + "author": "测试作者", + "target_chapters": 12, + "premise": "测试 premise", + }, + ) + assert create_novel.status_code == 201 + + create_draft = self.client.post( + f"/api/v1/novels/{novel_id}/chapters/4/candidate-drafts", + json={ + "source": "direct-model", + "title": "第4章候选", + "content": "甲突然击败高阶敌人,但没有付出代价。", + "branch_name": "main", + }, + ) + assert create_draft.status_code == 201 + draft = create_draft.json() + + active_service = _FakeLLMService("当前激活 Kimi 检查结果") + analysis_service = _FakeLLMService("DS 分析模型检查结果") + app.dependency_overrides[get_llm_service] = lambda: active_service + app.dependency_overrides[get_analysis_llm_service] = lambda: analysis_service + + response = self.client.post( + f"/api/v1/novels/{novel_id}/chapters/4/candidate-drafts/{draft['id']}/supervisor-review", + json={ + "model_label": "PP 当前 AI", + "focus": "检查记忆、连续性、战力崩坏和采纳建议。", + }, + ) + + assert response.status_code == 201 + payload = response.json() + assert payload["review"] == "DS 分析模型检查结果" + assert len(analysis_service.calls) == 1 + assert active_service.calls == [] + assert "AI味" in analysis_service.calls[0]["prompt"].user + assert "对白直白" in analysis_service.calls[0]["prompt"].user diff --git a/tests/integration/interfaces/api/v1/test_coc_canon_api.py b/tests/integration/interfaces/api/v1/test_coc_canon_api.py new file mode 100644 index 00000000..80b5b1ce --- /dev/null +++ b/tests/integration/interfaces/api/v1/test_coc_canon_api.py @@ -0,0 +1,183 @@ +from pathlib import Path + +import pytest +from fastapi import HTTPException + +from application.analyst.services.coc_canon_service import CocCanonService +from infrastructure.persistence.database.connection import DatabaseConnection +from infrastructure.persistence.database.sqlite_coc_canon_repository import ( + SqliteCocCanonRepository, +) +from interfaces.api.v1.analyst.coc_canon import ( + ApplyCocPresetRequest, + apply_coc_preset, + list_coc_preset_templates, + CreateCocCanonEventRequest, + UpsertCocCanonEntryRequest, + create_coc_canon_event, + get_coc_canon_overview, + upsert_coc_canon_entry, +) + + +SCHEMA_PATH = ( + Path(__file__).resolve().parents[5] + / "infrastructure" + / "persistence" + / "database" + / "schema.sql" +) + + +class _FakeNovelService: + def get_novel(self, novel_id: str): + return {"id": novel_id} + + +def test_coc_canon_route_functions_round_trip(): + db = DatabaseConnection(":memory:") + db.get_connection().executescript(SCHEMA_PATH.read_text(encoding="utf-8")) + db.get_connection().commit() + db.execute( + "INSERT INTO novels (id, title, slug, target_chapters) VALUES (?, ?, ?, ?)", + ("novel-coc-api", "正典接口测试", "novel-coc-api", 12), + ) + db.commit() + + service = CocCanonService(SqliteCocCanonRepository(db)) + novel_service = _FakeNovelService() + + entry = upsert_coc_canon_entry( + UpsertCocCanonEntryRequest( + canon_type="character", + title="林晚", + public_facts="法医,左手旧伤。", + hidden_truth="真实身份是卧底。", + lock_level="absolute", + mutable_notes="只允许补充线索来源。", + status="active", + ), + novel_id="novel-coc-api", + novel_service=novel_service, + service=service, + ) + assert entry.title == "林晚" + + event = create_coc_canon_event( + CreateCocCanonEventRequest( + entry_id=entry.id, + chapter_number=3, + event_type="confirm", + evidence="第3章明确写到左手旧伤复发。", + ), + novel_id="novel-coc-api", + novel_service=novel_service, + service=service, + ) + assert event.chapter_number == 3 + assert event.title == "林晚" + + overview = get_coc_canon_overview( + novel_id="novel-coc-api", + novel_service=novel_service, + service=service, + ) + assert overview.entries[0].lock_level == "absolute" + assert overview.recent_events[0].event_type == "confirm" + + +def test_coc_canon_route_rejects_absolute_core_patch(): + db = DatabaseConnection(":memory:") + db.get_connection().executescript(SCHEMA_PATH.read_text(encoding="utf-8")) + db.get_connection().commit() + db.execute( + "INSERT INTO novels (id, title, slug, target_chapters) VALUES (?, ?, ?, ?)", + ("novel-coc-api-2", "正典接口测试2", "novel-coc-api-2", 12), + ) + db.commit() + + service = CocCanonService(SqliteCocCanonRepository(db)) + novel_service = _FakeNovelService() + entry = upsert_coc_canon_entry( + UpsertCocCanonEntryRequest( + canon_type="character", + title="周砚", + public_facts="刑警。", + hidden_truth="卧底。", + lock_level="absolute", + ), + novel_id="novel-coc-api-2", + novel_service=novel_service, + service=service, + ) + + with pytest.raises(HTTPException) as exc_info: + upsert_coc_canon_entry( + UpsertCocCanonEntryRequest( + entry_id=entry.id, + canon_type="character", + title="周砚", + public_facts="法医。", + hidden_truth="卧底。", + lock_level="absolute", + ), + novel_id="novel-coc-api-2", + novel_service=novel_service, + service=service, + ) + + assert exc_info.value.status_code == 400 + assert "absolute lock" in str(exc_info.value.detail) + + +def test_coc_preset_apply_and_list(): + db = DatabaseConnection(":memory:") + db.get_connection().executescript(SCHEMA_PATH.read_text(encoding="utf-8")) + db.get_connection().commit() + db.execute( + "INSERT INTO novels (id, title, slug, target_chapters) VALUES (?, ?, ?, ?)", + ("novel-coc-preset", "预设测试", "novel-coc-preset", 20), + ) + db.commit() + + canon_service = CocCanonService(SqliteCocCanonRepository(db)) + from application.analyst.services.coc_clue_service import CocClueService + from application.analyst.services.coc_preset_service import CocPresetService + from application.analyst.services.prop_ledger_service import PropLedgerService + from infrastructure.persistence.database.sqlite_coc_clue_repository import SqliteCocClueRepository + from infrastructure.persistence.database.sqlite_prop_ledger_repository import SqlitePropLedgerRepository + + clue_service = CocClueService(SqliteCocClueRepository(db)) + prop_service = PropLedgerService(SqlitePropLedgerRepository(db)) + preset_service = CocPresetService(canon_service, clue_service, prop_service) + novel_service = _FakeNovelService() + + templates = list_coc_preset_templates( + novel_id="novel-coc-preset", + novel_service=novel_service, + preset_service=preset_service, + ) + template_keys = {item.key for item in templates} + assert "analysis-loop-721" in template_keys + assert "fog-harbor-gray-card" in template_keys + fog_template = next(item for item in templates if item.key == "fog-harbor-gray-card") + assert fog_template.canon_count >= 10 + assert fog_template.clue_count >= 12 + assert fog_template.prop_count >= 6 + + result = apply_coc_preset( + ApplyCocPresetRequest(preset_key="fog-harbor-gray-card", overwrite_existing=False), + novel_id="novel-coc-preset", + novel_service=novel_service, + preset_service=preset_service, + ) + assert result.created_canon >= 10 + assert result.created_clues >= 12 + assert result.created_props >= 6 + assert canon_service.repository.get_entry_by_key( + "novel-coc-preset", + "character_truth", + "主角:白雨翔", + ) + assert clue_service.repository.get_item_by_key("novel-coc-preset", "witness-ritual-mainline") + assert prop_service.repository.get_item_by_name("novel-coc-preset", "灰卡") diff --git a/tests/integration/interfaces/api/v1/test_coc_clue_api.py b/tests/integration/interfaces/api/v1/test_coc_clue_api.py new file mode 100644 index 00000000..28967358 --- /dev/null +++ b/tests/integration/interfaces/api/v1/test_coc_clue_api.py @@ -0,0 +1,82 @@ +from pathlib import Path + +from application.analyst.services.coc_clue_service import CocClueService +from infrastructure.persistence.database.connection import DatabaseConnection +from infrastructure.persistence.database.sqlite_coc_clue_repository import ( + SqliteCocClueRepository, +) +from interfaces.api.v1.analyst.coc_clue import ( + CreateCocClueEventRequest, + UpsertCocClueItemRequest, + create_coc_clue_event, + get_coc_clue_overview, + upsert_coc_clue_item, +) + + +SCHEMA_PATH = ( + Path(__file__).resolve().parents[5] + / "infrastructure" + / "persistence" + / "database" + / "schema.sql" +) + + +class _FakeNovelService: + def get_novel(self, novel_id: str): + return {"id": novel_id} + + +def test_coc_clue_route_functions_round_trip(): + db = DatabaseConnection(":memory:") + db.get_connection().executescript(SCHEMA_PATH.read_text(encoding="utf-8")) + db.get_connection().commit() + db.execute( + "INSERT INTO novels (id, title, slug, target_chapters) VALUES (?, ?, ?, ?)", + ("novel-coc-clue-api", "线索接口测试", "novel-coc-clue-api", 15), + ) + db.commit() + + service = CocClueService(SqliteCocClueRepository(db)) + novel_service = _FakeNovelService() + + item = upsert_coc_clue_item( + UpsertCocClueItemRequest( + clue_key="raincoat_fiber", + clue_text="雨衣纤维与案发现场匹配。", + visibility="reader_known", + reveal_chapter=4, + known_by="林晚", + confidence=0.81, + lock_level="strict", + status="active", + notes="等待实验室复检。", + ), + novel_id="novel-coc-clue-api", + novel_service=novel_service, + service=service, + ) + assert item.clue_key == "raincoat_fiber" + + event = create_coc_clue_event( + CreateCocClueEventRequest( + entry_id=item.id, + chapter_number=5, + event_type="confirm", + evidence="第5章法医复检确认纤维来源。", + ), + novel_id="novel-coc-clue-api", + novel_service=novel_service, + service=service, + ) + assert event.clue_key == "raincoat_fiber" + assert event.chapter_number == 5 + + overview = get_coc_clue_overview( + novel_id="novel-coc-clue-api", + novel_service=novel_service, + service=service, + ) + assert overview.items[0].visibility == "reader_known" + assert overview.recent_events[0].event_type == "confirm" diff --git a/tests/integration/interfaces/api/v1/test_generation_api.py b/tests/integration/interfaces/api/v1/test_generation_api.py index dd19e05a..2a46d86a 100644 --- a/tests/integration/interfaces/api/v1/test_generation_api.py +++ b/tests/integration/interfaces/api/v1/test_generation_api.py @@ -44,6 +44,41 @@ def mock_workflow(): """Mock AutoNovelGenerationWorkflow""" workflow = Mock(spec=AutoNovelGenerationWorkflow) workflow.generate_chapter_stream = _mock_generate_chapter_stream + workflow.precheck_coc_cognition_boundary = Mock(return_value={ + "checked": True, + "allow_generate": True, + "risk_level": "none", + "blocking_issues": [], + "warnings": [], + "matched_tokens": [], + "chapter_number": 1, + }) + workflow.rewrite_outline_for_coc_boundary = Mock(return_value={ + "original_outline": "原始大纲", + "rewritten_outline": "改写后大纲", + "changed": True, + "rewrite_mode": "conservative", + "rewrite_style": "generic", + "applied_rules": ["替换敏感片段:ledger_owner"], + "precheck_before": { + "checked": True, + "allow_generate": False, + "risk_level": "block", + "blocking_issues": ["命中 author_only 线索键:ledger_owner"], + "warnings": [], + "matched_tokens": ["ledger_owner"], + "chapter_number": 1, + }, + "precheck_after": { + "checked": True, + "allow_generate": True, + "risk_level": "none", + "blocking_issues": [], + "warnings": [], + "matched_tokens": [], + "chapter_number": 1, + }, + }) return workflow @@ -111,6 +146,7 @@ def app(mock_workflow, mock_storyline_manager, mock_plot_arc_repository, mock_ho # Override dependencies from interfaces.api.v1.engine import generation test_app.dependency_overrides[generation.get_auto_workflow] = lambda: mock_workflow + test_app.dependency_overrides[generation.get_analysis_workflow] = lambda: mock_workflow test_app.dependency_overrides[generation.get_hosted_write_service] = lambda: mock_hosted_service test_app.dependency_overrides[generation.get_storyline_manager] = lambda: mock_storyline_manager test_app.dependency_overrides[generation.get_plot_arc_repository] = lambda: mock_plot_arc_repository @@ -164,6 +200,74 @@ def test_generate_chapter_stream_sse(self, client): assert "data:" in body assert '"type": "done"' in body or '"done"' in body + def test_generate_chapter_stream_blocked_by_coc_precheck(self, client, mock_workflow): + mock_workflow.precheck_coc_cognition_boundary.return_value = { + "checked": True, + "allow_generate": False, + "risk_level": "block", + "blocking_issues": ["命中 author_only 线索键:clue-zhou-origin"], + "warnings": [], + "matched_tokens": ["clue-zhou-origin"], + "chapter_number": 1, + } + + response = client.post( + "/api/v1/novels/novel-1/generate-chapter-stream", + json={ + "chapter_number": 1, + "outline": "主角直接确认 clue-zhou-origin 的真相。", + }, + ) + assert response.status_code == 200 + assert '"type": "error"' in response.text + assert "CoC 认知边界阻断" in response.text + + def test_coc_cognition_precheck_endpoint(self, client, mock_workflow): + mock_workflow.precheck_coc_cognition_boundary.return_value = { + "checked": True, + "allow_generate": False, + "risk_level": "block", + "blocking_issues": ["命中 author_only 线索键:ledger_owner"], + "warnings": [], + "matched_tokens": ["ledger_owner"], + "chapter_number": 6, + } + + response = client.post( + "/api/v1/novels/novel-1/chapters/6/coc-cognition-precheck", + json={"outline": "主角确认 ledger_owner 的真实身份。"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["checked"] is True + assert data["allow_generate"] is False + assert data["risk_level"] == "block" + assert len(data["blocking_issues"]) == 1 + + def test_coc_cognition_rewrite_outline_endpoint(self, client): + response = client.post( + "/api/v1/novels/novel-1/chapters/6/coc-cognition-rewrite-outline", + json={"outline": "主角确认 ledger_owner 的真实身份。", "rewrite_mode": "aggressive"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["changed"] is True + assert data["rewritten_outline"] == "改写后大纲" + assert data["rewrite_mode"] == "conservative" + assert data["rewrite_style"] == "generic" + assert data["precheck_before"]["risk_level"] == "block" + + def test_coc_cognition_rewrite_outline_passes_mode_to_workflow(self, client, mock_workflow): + response = client.post( + "/api/v1/novels/novel-1/chapters/8/coc-cognition-rewrite-outline", + json={"outline": "测试大纲", "rewrite_mode": "aggressive", "rewrite_style": "coc"}, + ) + assert response.status_code == 200 + mock_workflow.rewrite_outline_for_coc_boundary.assert_called_once() + kwargs = mock_workflow.rewrite_outline_for_coc_boundary.call_args.kwargs + assert kwargs["rewrite_mode"] == "aggressive" + assert kwargs["rewrite_style"] == "coc" + def test_generate_chapter_stream_can_enable_anti_compression_directive( self, client, mock_workflow ): @@ -190,6 +294,83 @@ async def stream_with_capture(*args, **kwargs): assert "避免 AI 压缩表达" in captured["outline"] assert "不要用一句概括跳过" in captured["outline"] + def test_strategy_preview_returns_chapter_contract_and_showing_scene_fields(self, client, mock_workflow): + async def strategy_with_showing_fields(*args, **kwargs): + return { + "chapter_contract": { + "chapter_question": "灰卡为什么能刷开门禁?", + "protagonist_want": "白雨翔要确认写卡器来源。", + "opposition": "许照只给半份证据。", + "reader_expectation": "看到两人互相试探。", + "required_information_change": "签收记录暴露伪造痕迹。", + "required_relationship_change": "两人形成有限合作。", + "ending_question": "谁借用了审计流程?", + "show_dont_tell_rules": ["不能直写怀疑,只写扣住证物。"], + }, + "dramatic_task": { + "goal": "确认写卡器来源", + "obstacle": "许照保留证据", + "reader_expectation": "看到试探", + "ending_hook": "审计流程异常", + }, + "scene_plan": [ + { + "label": "核对签收单", + "task": "确认签名真伪", + "resistance": "许照不交原件", + "info_shift": "签名疑点出现", + "relationship_shift": "有限合作", + "anchor": "证物袋", + "visible_action": "白雨翔按住证物袋封口。", + "subtext_dialogue": "表面问流程,实际逼许照露底。", + "unspoken_emotion": "怀疑不能直说。", + "object_or_clue_change": "灰卡变成伪造链条证据。", + "hook": "审计流程异常", + "target_words": 800, + } + ], + "writing_focus": ["少解释,多展示。"], + } + + mock_workflow.generate_chapter_strategy = strategy_with_showing_fields + response = client.post( + "/api/v1/novels/novel-1/chapters/2/strategy-preview", + json={"outline": "白雨翔追查灰卡。"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["chapter_contract"]["chapter_question"].startswith("灰卡") + assert data["scene_plan"][0]["visible_action"].startswith("白雨翔") + + def test_editorial_review_returns_showing_score(self, client, mock_workflow): + async def editorial_with_showing(*args, **kwargs): + return { + "summary": "对白有张力,但解释略多。", + "scores": { + "opening": 88, + "conflict": 90, + "character": 86, + "dialogue": 84, + "hook": 92, + "pacing": 87, + "showing": 79, + }, + "strengths": ["证物动作具体。"], + "problems": ["部分情绪仍被直接命名。"], + "actions": ["把解释改成动作。"], + "verdict": "可优化后使用", + } + + mock_workflow.review_generated_chapter_editorially = editorial_with_showing + response = client.post( + "/api/v1/novels/novel-1/chapters/2/editorial-review", + json={"outline": "白雨翔追查灰卡。", "content": "白雨翔按住证物袋。"}, + ) + + assert response.status_code == 200 + assert response.json()["scores"]["showing"] == 79 + def test_hosted_write_stream_sse(self, client): """托管连写 SSE""" response = client.post( diff --git a/tests/unit/application/ai/test_llm_json_extract.py b/tests/unit/application/ai/test_llm_json_extract.py index dce3aefa..124930dc 100644 --- a/tests/unit/application/ai/test_llm_json_extract.py +++ b/tests/unit/application/ai/test_llm_json_extract.py @@ -11,3 +11,10 @@ def test_parse_llm_json_to_dict_with_junk(): data, errs = parse_llm_json_to_dict(raw) assert errs == [] assert data == {"k": "v"} + + +def test_parse_llm_json_to_dict_repairs_unclosed_string(): + raw = '{"chapter_contract": {"chapter_question": "灰卡为什么能刷开门禁?' + data, errs = parse_llm_json_to_dict(raw) + assert errs == [] + assert data["chapter_contract"]["chapter_question"].startswith("灰卡") diff --git a/tests/unit/application/services/test_auto_bible_generator.py b/tests/unit/application/services/test_auto_bible_generator.py index e2370a1c..1a806893 100644 --- a/tests/unit/application/services/test_auto_bible_generator.py +++ b/tests/unit/application/services/test_auto_bible_generator.py @@ -1,9 +1,26 @@ import pytest +import json from unittest.mock import AsyncMock, Mock from application.world.services.auto_bible_generator import AutoBibleGenerator from domain.ai.services.llm_service import GenerationResult from domain.ai.value_objects.token_usage import TokenUsage +from infrastructure.ai.providers.mock_provider import MockProvider + + +@pytest.mark.asyncio +async def test_mock_provider_character_stage_returns_characters_when_prompt_mentions_worldbuilding(): + """人物阶段 prompt 会带已有世界观,MockProvider 仍应返回 characters。""" + svc = AutoBibleGenerator(llm_service=MockProvider(), bible_service=Mock()) + + result = await svc._generate_characters( + "测试故事", + 100, + {"society": {"class_system": "都市阶层"}}, + ) + + assert result["characters"] + assert result["characters"][0]["name"] == "张三" @pytest.mark.asyncio @@ -44,7 +61,7 @@ async def test_call_llm_and_parse_repairs_truncated_locations_json(): @pytest.mark.asyncio -async def test_call_llm_and_parse_returns_empty_dict_when_content_is_unrecoverable(): +async def test_call_llm_and_parse_raises_when_content_is_unrecoverable(): llm = Mock() llm.generate = AsyncMock( return_value=GenerationResult( @@ -54,9 +71,8 @@ async def test_call_llm_and_parse_returns_empty_dict_when_content_is_unrecoverab ) svc = AutoBibleGenerator(llm_service=llm, bible_service=Mock()) - result = await svc._call_llm_and_parse("system", "user") - - assert result == {} + with pytest.raises(json.JSONDecodeError): + await svc._call_llm_and_parse("system", "user") @pytest.mark.asyncio diff --git a/tests/unit/application/services/test_coc_canon_service.py b/tests/unit/application/services/test_coc_canon_service.py new file mode 100644 index 00000000..755842f3 --- /dev/null +++ b/tests/unit/application/services/test_coc_canon_service.py @@ -0,0 +1,106 @@ +"""CoC 正典注册表服务测试。""" + +import pytest + +from application.analyst.services.coc_canon_service import CocCanonService +from infrastructure.persistence.database.connection import DatabaseConnection +from infrastructure.persistence.database.sqlite_coc_canon_repository import ( + SqliteCocCanonRepository, +) + + +def _create_service(tmp_path): + db = DatabaseConnection(str(tmp_path / "coc-canon.db")) + db.execute( + "INSERT INTO novels (id, title, slug) VALUES (?, ?, ?)", + ("novel-1", "测试小说", "novel-1"), + ) + db.commit() + return CocCanonService(SqliteCocCanonRepository(db)) + + +def test_absolute_lock_blocks_core_field_changes(tmp_path): + service = _create_service(tmp_path) + service.upsert_entry( + novel_id="novel-1", + canon_type="character", + title="林晚", + public_facts="林晚是法医。", + hidden_truth="她是卧底。", + lock_level="absolute", + mutable_notes="可更新备注", + status="active", + ) + + with pytest.raises(ValueError, match="absolute lock"): + service.upsert_entry( + novel_id="novel-1", + canon_type="character", + title="林晚", + public_facts="林晚是刑警。", + hidden_truth="她是卧底。", + lock_level="absolute", + mutable_notes="备注", + status="active", + ) + + +def test_absolute_lock_allows_mutable_notes_update(tmp_path): + service = _create_service(tmp_path) + service.upsert_entry( + novel_id="novel-1", + canon_type="organization", + title="镜湖会", + public_facts="地下情报组织。", + hidden_truth="受王室资助。", + lock_level="absolute", + mutable_notes="初始备注", + status="active", + ) + + updated = service.upsert_entry( + novel_id="novel-1", + canon_type="organization", + title="镜湖会", + public_facts="地下情报组织。", + hidden_truth="受王室资助。", + lock_level="absolute", + mutable_notes="已补充线人名单来源。", + status="active", + ) + assert updated["mutable_notes"] == "已补充线人名单来源。" + + +def test_create_event_by_title_auto_creates_draft_entry(tmp_path): + service = _create_service(tmp_path) + event = service.create_event( + novel_id="novel-1", + title="黄铜钥匙", + chapter_number=2, + event_type="mention", + evidence="第2章出现黄铜钥匙。", + ) + assert event["title"] == "黄铜钥匙" + + overview = service.get_overview("novel-1") + assert any(item["title"] == "黄铜钥匙" and item["status"] == "draft" for item in overview["entries"]) + assert "cognition_layers" in overview + + +def test_cognition_layers_include_author_truth_and_reader_known(tmp_path): + service = _create_service(tmp_path) + service.upsert_entry( + novel_id="novel-1", + canon_type="world_rule", + title="灯塔信号", + public_facts="午夜会闪三次绿灯。", + hidden_truth="信号其实是旧教团的召集暗号。", + lock_level="strict", + mutable_notes="", + status="active", + ) + + layers = service.get_cognition_layers("novel-1") + assert any("灯塔信号" in line for line in layers["reader_known"]) + assert any("召集暗号" in line for line in layers["author_truth"]) + assert any("召集暗号" in text for text in layers["author_truth_snippets"]) diff --git a/tests/unit/application/services/test_coc_clue_service.py b/tests/unit/application/services/test_coc_clue_service.py new file mode 100644 index 00000000..b439bfde --- /dev/null +++ b/tests/unit/application/services/test_coc_clue_service.py @@ -0,0 +1,100 @@ +"""CoC 线索账本服务测试。""" + +import pytest + +from application.analyst.services.coc_clue_service import CocClueService +from infrastructure.persistence.database.connection import DatabaseConnection +from infrastructure.persistence.database.sqlite_coc_clue_repository import ( + SqliteCocClueRepository, +) + + +def _create_service(tmp_path): + db = DatabaseConnection(str(tmp_path / "coc-clue.db")) + db.execute( + "INSERT INTO novels (id, title, slug) VALUES (?, ?, ?)", + ("novel-1", "测试小说", "novel-1"), + ) + db.commit() + return CocClueService(SqliteCocClueRepository(db)) + + +def test_absolute_lock_blocks_key_text_and_reveal_chapter_changes(tmp_path): + service = _create_service(tmp_path) + saved = service.upsert_item( + novel_id="novel-1", + clue_key="bloody_ticket", + clue_text="染血票据来自北站。", + visibility="reader_known", + reveal_chapter=2, + confidence=0.88, + lock_level="absolute", + status="active", + ) + + with pytest.raises(ValueError, match="absolute lock"): + service.upsert_item( + novel_id="novel-1", + entry_id=saved["id"], + clue_key="bloody_ticket", + clue_text="染血票据来自南站。", + visibility="reader_known", + reveal_chapter=2, + confidence=0.88, + lock_level="absolute", + status="active", + ) + + +def test_create_event_by_clue_key_auto_creates_draft_item(tmp_path): + service = _create_service(tmp_path) + + event = service.create_event( + novel_id="novel-1", + clue_key="old_well_map", + chapter_number=3, + event_type="mention", + evidence="第3章首次提到井底地图。", + ) + assert event["clue_key"] == "old_well_map" + assert event["chapter_number"] == 3 + + overview = service.get_overview("novel-1") + created = [item for item in overview["items"] if item["clue_key"] == "old_well_map"] + assert len(created) == 1 + assert created[0]["lock_level"] == "soft" + assert "draft" in created[0]["notes"] + assert "cognition_layers" in overview + + +def test_cognition_layers_split_by_visibility(tmp_path): + service = _create_service(tmp_path) + service.upsert_item( + novel_id="novel-1", + clue_key="ticket_stub", + clue_text="车票缺了一角。", + visibility="reader_known", + known_by="林岚", + confidence=0.6, + ) + service.upsert_item( + novel_id="novel-1", + clue_key="archive_code", + clue_text="档案室门禁码 11473。", + visibility="protagonist_known", + known_by="林岚", + confidence=0.9, + ) + service.upsert_item( + novel_id="novel-1", + clue_key="sponsor_name", + clue_text="背后资助者姓裴。", + visibility="author_only", + known_by="", + confidence=0.8, + ) + + layers = service.get_cognition_layers("novel-1") + assert any("ticket_stub" in line for line in layers["reader_known"]) + assert any("archive_code" in line for line in layers["character_known"]) + assert any("sponsor_name" in line for line in layers["author_truth"]) diff --git a/tests/unit/application/services/test_style_prompt_overlay_service.py b/tests/unit/application/services/test_style_prompt_overlay_service.py index 39643219..ff255ee5 100644 --- a/tests/unit/application/services/test_style_prompt_overlay_service.py +++ b/tests/unit/application/services/test_style_prompt_overlay_service.py @@ -3,7 +3,7 @@ from application.style_bible.services.style_prompt_overlay_service import ( StylePromptOverlayService, ) -from domain.style_bible.entities import StyleProfile, StyleTechniqueCard +from domain.style_bible.entities import StyleProfile, StyleSample, StyleTechniqueCard from infrastructure.persistence.database.connection import DatabaseConnection from infrastructure.persistence.database.sqlite_style_bible_repository import ( SqliteStyleBibleRepository, @@ -121,3 +121,44 @@ def test_style_prompt_overlay_ranks_scene_type_cards_first(tmp_path): assert overlay.card_ids[0] == cards[1].id assert overlay.prompt.index("先给异常细节") < overlay.prompt.index("先写情绪余波") + + +def test_style_prompt_overlay_includes_style_anchors_from_allowed_samples(tmp_path): + repo = _repo(tmp_path) + sample = repo.save_sample( + StyleSample( + title="样本A", + content=( + "白雨翔把灰卡在指间转了一圈,没立刻答话。\n" + "“你再说一遍时间。”许照没有抬头,笔尖却停住了。\n" + "走廊尽头的灯管嗡了一下,门缝里先伸出一截白手套。" + ), + novel_id="novel-1", + allowed_for_generation=True, + ), + [], + ) + profile = repo.save_profile( + StyleProfile( + name="锚点档案", + novel_id="novel-1", + profile={"source_sample_ids": [sample.id], "anchor_max": 3}, + ) + ) + repo.save_technique_cards( + profile.id, + [ + StyleTechniqueCard( + profile_id=profile.id, + title="动作优先", + category="pacing", + rule_text="动作先行", + prompt_instruction="先写动作再给判断。", + ) + ], + ) + + overlay = StylePromptOverlayService(repo).build_overlay("novel-1", profile.id) + + assert "风格锚点(检索,不可复刻原句)" in overlay.prompt + assert "灰卡在指间转了一圈" in overlay.prompt diff --git a/tests/unit/application/services/test_topic_idea_service.py b/tests/unit/application/services/test_topic_idea_service.py index 73a7d5ed..7363f5a3 100644 --- a/tests/unit/application/services/test_topic_idea_service.py +++ b/tests/unit/application/services/test_topic_idea_service.py @@ -629,6 +629,33 @@ async def test_generate_fallback_can_fill_five_ideas(): assert len(repo.items) == 5 +@pytest.mark.asyncio +async def test_generate_fallback_uses_market_signals_for_distinct_topics(): + repo = InMemoryTopicIdeaRepository() + service = TopicIdeaService(repo, llm_service=InvalidJsonLLM()) + + ideas = await service.generate( + TopicGenerateRequestDTO( + genre="都市爽文", + market_signals=[ + { + "title": "扔掉的渣男,绝不再捡!", + "genre": "漫画", + "tags": ["漫画", "快速上榜"], + "summary": "未婚夫归来却抱着怀孕女人,女主当场切割。", + } + ], + count=3, + ) + ) + + assert ideas[0].title != "逆风开局的隐藏王牌" + assert "扔掉的渣男" in ideas[0].title + assert "漫画" in ideas[0].market_tags + assert "首卷大纲" in ideas[0].development_notes + assert ideas[0].evaluation["市场匹配度"]["依据"][0] == "扔掉的渣男,绝不再捡!" + + @pytest.mark.asyncio async def test_generate_uses_llm_and_fills_shortage(): repo = InMemoryTopicIdeaRepository() @@ -638,6 +665,8 @@ async def test_generate_uses_llm_and_fills_shortage(): assert len(ideas) == 3 assert ideas[0].title == "AI 选题" + assert "首卷大纲" in ideas[0].development_notes + assert "综合评分" in ideas[0].evaluation assert len(repo.items) == 3 @@ -667,6 +696,9 @@ async def test_generate_prompt_includes_manual_brief_and_market_signals(): assert "市场观察" in llm.prompt.user assert "灵气复苏债务流" in llm.prompt.user assert "负债" in llm.prompt.user + assert "development_notes" in llm.prompt.system + assert "evaluation" in llm.prompt.system + assert "首卷大纲" in llm.prompt.system def test_adopt_is_idempotent_when_already_adopted(): diff --git a/tests/unit/application/workflows/test_auto_novel_generation_workflow.py b/tests/unit/application/workflows/test_auto_novel_generation_workflow.py index 7fa096bb..51e6342d 100644 --- a/tests/unit/application/workflows/test_auto_novel_generation_workflow.py +++ b/tests/unit/application/workflows/test_auto_novel_generation_workflow.py @@ -1,8 +1,11 @@ """AutoNovelGenerationWorkflow 单元测试""" import pytest from unittest.mock import Mock, AsyncMock +from types import SimpleNamespace from application.workflows.auto_novel_generation_workflow import ( AutoNovelGenerationWorkflow, + CHAPTER_GENERATION_MAX_TOKENS, + CHAPTER_GENERATION_TEMPERATURE, CHAPTER_CONTEXT_LAYER2_HEADER, CHAPTER_CONTEXT_LAYER3_HEADER, assemble_chapter_bundle_context_text, @@ -144,6 +147,149 @@ async def test_generate_chapter_success(self, workflow, mock_context_builder, mo ) # 验证 LLM 被调用:至少一次用于生成章节,可能还有一次用于状态提取 assert mock_llm_service.generate.call_count >= 1 + generation_config = mock_llm_service.generate.call_args_list[0].args[1] + assert generation_config.max_tokens == CHAPTER_GENERATION_MAX_TOKENS + assert generation_config.temperature == CHAPTER_GENERATION_TEMPERATURE + + @pytest.mark.asyncio + async def test_generate_chapter_stream_direct_mode_skips_post_processing( + self, + workflow, + mock_llm_service, + mock_context_builder, + ): + """直接写作模式用于对照测试,应跳过节拍拆分、自然化后处理和章后质检。""" + async def direct_stream(*args, **kwargs): + yield "直接写作正文" + + mock_llm_service.stream_generate = direct_stream + workflow.post_process_generated_chapter = AsyncMock() + workflow._naturalize_ai_flavor_if_needed = AsyncMock(return_value="不应出现") + + events = [ + event async for event in workflow.generate_chapter_stream( + novel_id="novel-1", + chapter_number=1, + outline="主角在雨夜拿到一张不该出现的票据。", + direct_writing_mode=True, + ) + ] + + done = next(event for event in events if event["type"] == "done") + assert done["content"] == "直接写作正文" + assert done["direct_writing_mode"] is True + assert "post" not in [event.get("phase") for event in events] + workflow.post_process_generated_chapter.assert_not_called() + workflow._naturalize_ai_flavor_if_needed.assert_not_called() + mock_context_builder.magnify_outline_to_beats.assert_not_called() + + @pytest.mark.asyncio + async def test_generate_chapter_stream_direct_mode_can_light_polish( + self, + workflow, + mock_llm_service, + ): + """直接写作轻修只调用轻修 pass,不进入完整后处理链路。""" + async def direct_stream(*args, **kwargs): + yield "直接写作正文" + + mock_llm_service.stream_generate = direct_stream + workflow._apply_direct_light_polish_if_needed = AsyncMock(return_value="轻修后正文") + workflow.post_process_generated_chapter = AsyncMock() + workflow._naturalize_ai_flavor_if_needed = AsyncMock(return_value="不应出现") + + events = [ + event async for event in workflow.generate_chapter_stream( + novel_id="novel-1", + chapter_number=1, + outline="主角在雨夜拿到一张不该出现的票据。", + direct_writing_mode=True, + direct_light_polish=True, + ) + ] + + done = next(event for event in events if event["type"] == "done") + assert done["content"] == "轻修后正文" + assert done["direct_light_polish"] is True + assert "polish" in [event.get("phase") for event in events] + workflow._apply_direct_light_polish_if_needed.assert_awaited_once() + workflow.post_process_generated_chapter.assert_not_called() + workflow._naturalize_ai_flavor_if_needed.assert_not_called() + + @pytest.mark.asyncio + async def test_generate_chapter_stream_passes_target_words_to_beats( + self, + workflow, + mock_context_builder, + ): + """目标字数应传给节拍拆分器,避免固定 3000-4000 字。""" + async def direct_stream(*args, **kwargs): + yield "正文" + + workflow.llm_service.stream_generate = direct_stream + mock_context_builder.magnify_outline_to_beats.return_value = [] + + events = [ + event async for event in workflow.generate_chapter_stream( + novel_id="novel-1", + chapter_number=1, + outline="主角核对票据时间。", + target_word_count=2500, + ) + ] + + assert events[-1]["type"] == "done" + mock_context_builder.magnify_outline_to_beats.assert_called_once_with( + 1, + "主角核对票据时间。", + target_chapter_words=2500, + ) + + @pytest.mark.asyncio + async def test_direct_light_polish_discards_over_smoothed_candidate( + self, + workflow, + mock_llm_service, + ): + """轻修如果抹掉直接稿的现场余量,应保留原直接稿。""" + draft = ( + "林默把票据往袖口里塞了半寸。\n\n" + "“你刚才看见编号了?”\n\n" + "周正明伸手,又缩回去,鞋底在水里磨了一下。\n\n" + "顾知寒没接话,只把抽屉推回去,停了半秒。\n\n" + ) * 16 + over_smoothed = ( + "林默整理好票据,众人意识到线索已经指向新的方向。\n\n" + "周正明说明了自己的判断,顾知寒也明白事情正在发生变化。\n\n" + "他们决定继续调查,并准备面对接下来的风险。\n\n" + ) * 16 + mock_llm_service.generate = AsyncMock(return_value=LLMResult( + content=over_smoothed, + token_usage=TokenUsage(input_tokens=300, output_tokens=300), + )) + + result = await workflow._apply_direct_light_polish_if_needed( + content=draft, + outline="测试大纲", + ) + + assert result == draft + + def test_human_residue_score_prefers_concrete_draft_texture(self): + """直接写作保护需要能区分现场动作稿和顺滑概述稿。""" + draft = ( + "林默把票据往袖口里塞了半寸。\n\n" + "“你刚才看见编号了?”\n\n" + "周正明伸手,又缩回去,鞋底在水里磨了一下。\n\n" + ) * 18 + smoothed = ( + "林默保存了票据,随后说明线索的重要性。\n\n" + "众人理解了当前情况,并决定继续调查。\n\n" + ) * 18 + + assert AutoNovelGenerationWorkflow._human_residue_score(draft) > ( + AutoNovelGenerationWorkflow._human_residue_score(smoothed) + 2 + ) @pytest.mark.asyncio async def test_generate_chapter_with_scene_director(self, workflow, mock_context_builder, mock_llm_service): @@ -302,6 +448,570 @@ def test_build_prompt_includes_style_bible_overlay(self, workflow): assert "克制悬疑" in prompt.system assert "不复刻样本文字" in prompt.system + def test_build_prompt_includes_coc_canon_overlay(self, workflow): + """CoC 正典 overlay 应注入章节生成 prompt。""" + workflow._current_novel_id = "novel-1" + workflow.coc_canon_service = Mock() + workflow.coc_canon_service.build_overlay.return_value = { + "prompt": "【CoC正典】\n- [absolute] 夜巡制度:每晚三更点名。", + "entries": [ + {"title": "夜巡制度", "level": "absolute"}, + ], + } + + prompt = workflow._build_prompt( + context="CTX", + outline="OL", + ) + + assert "【CoC正典】" in prompt.system + assert "夜巡制度" in prompt.system + + def test_build_prompt_includes_coc_clue_overlay(self, workflow): + """CoC 线索边界 overlay 应注入章节生成 prompt。""" + workflow._current_novel_id = "novel-1" + workflow.coc_clue_service = Mock() + workflow.coc_clue_service.build_overlay.return_value = { + "prompt": "【CoC线索边界】\n- clue_key: ledger_owner | visibility: author_only", + "clues": [ + {"clue_key": "ledger_owner", "visibility": "author_only"}, + ], + } + + prompt = workflow._build_prompt( + context="CTX", + outline="OL", + ) + + assert "【CoC线索边界】" in prompt.system + assert "ledger_owner" in prompt.system + + def test_build_prompt_includes_genre_overlay_for_suspense(self, workflow): + """章节生成应根据上下文/大纲注入类型写法规则。""" + prompt = workflow._build_prompt( + context="类型:悬疑调查。主角正在追查旧档案里的异常案件。", + outline="林默在废弃研究所发现一枚被调包的门禁卡,并误判了嫌疑人的动机。", + ) + + assert "【类型写法规则】" in prompt.system + assert "悬疑/调查" in prompt.system + assert "线索" in prompt.system + assert "误判" in prompt.system + + def test_build_prompt_includes_chapter_contract_and_reader_audit(self, workflow): + """章节生成应先约束本章任务,避免空大纲时写成概念解释。""" + prompt = workflow._build_prompt( + context="类型:悬疑调查。前情:林默拿到旧研究所门禁卡。", + outline="", + ) + + assert "【好看优先:章节戏剧任务】" in prompt.system + assert "内部场景推进表" in prompt.system + assert "场景任务" in prompt.system + assert "检测器分数不是写作目标" in prompt.system + assert "【追读自检】" in prompt.system + assert "未完成问题" in prompt.system + + def test_build_prompt_includes_detector_calibration_fact_anchors(self, workflow): + """章节生成应注入从真人检测样本提炼出的事实锚点写法。""" + prompt = workflow._build_prompt( + context="类型:悬疑调查。前情:林默拿到旧研究所门禁卡。", + outline="主角核对监控日志,发现摄像头角度和票据时间对不上。", + ) + + assert "【检测器校准:事实锚点写法】" in prompt.system + assert "门禁编号" in prompt.system + assert "数据/物件/流程" in prompt.system + + def test_build_prompt_uses_target_word_count_range(self, workflow): + """指定目标字数时,应给模型明确的允许区间。""" + prompt = workflow._build_prompt( + context="CTX", + outline="OL", + target_word_count=2500, + ) + + assert "本章目标 2500 字" in prompt.system + assert "2375-2625 字" in prompt.system + + def test_build_prompt_uses_custom_word_tolerance_range(self, workflow): + """自定义容差应改变区间。""" + prompt = workflow._build_prompt( + context="CTX", + outline="OL", + target_word_count=2500, + word_tolerance_ratio=0.1, + ) + + assert "本章目标 2500 字" in prompt.system + assert "2250-2750 字" in prompt.system + + def test_direct_writing_prompt_includes_detector_calibration(self, workflow): + """直接写作对照模式也应带事实锚点,避免纯文学化顺滑稿。""" + prompt = workflow._build_direct_writing_prompt( + context="都市逆袭,主角手里有项目合同和会议纪要。", + outline="主角在会议上核对合同编号,反制夺功上司。", + ) + + assert "【检测器校准:事实锚点写法】" in prompt.system + assert "合同条款" in prompt.system + assert "事实锚点" in prompt.system + + def test_direct_writing_prompt_uses_target_word_count_range(self, workflow): + """直接写作也要遵守目标字数。""" + prompt = workflow._build_direct_writing_prompt( + context="CTX", + outline="OL", + target_word_count=2500, + ) + + assert "目标 2500 中文字" in prompt.system + assert "2375-2625 字" in prompt.system + + @pytest.mark.asyncio + async def test_enforce_chapter_word_target_trims_when_exceed_max(self, workflow): + """目标字数存在时,应硬裁剪超长正文。""" + long_text = ("监控屏幕闪了一下,白雨翔盯着那条时间戳。") * 220 + result = await workflow._enforce_chapter_word_target( + content=long_text, + outline="白雨翔在站厅核对灰卡时间戳。", + target_word_count=2500, + ) + assert AutoNovelGenerationWorkflow._story_text_units(result) <= 2625 + + @pytest.mark.asyncio + async def test_enforce_chapter_word_target_smooths_hard_truncated_tail(self, workflow): + """裁剪后若停在半句,应自动收束成完整句尾。""" + source = ( + "白雨翔贴着站台边缘走,鞋底每次落下都带起一点潮气。" + "他没有回头,只把旧记者证压在掌心里,指节泛白。" + "许照在后方低声提醒:监控时间戳正在回跳。" + ) + long_tail = "他盯着门缝里的影子一步一步往前没有停下没有回头没有回答" * 180 + long_text = source + long_tail + + result = await workflow._enforce_chapter_word_target( + content=long_text, + outline="白雨翔在地铁站内核对灰卡与监控。", + target_word_count=2500, + ) + assert AutoNovelGenerationWorkflow._story_text_units(result) <= 2625 + assert AutoNovelGenerationWorkflow._is_sentence_tail_complete(result) is True + + @pytest.mark.asyncio + async def test_enforce_chapter_word_target_soft_lands_trimmed_tail(self, workflow, mock_llm_service): + """命中上限时应允许对尾段做软着陆改写,避免突兀硬切。""" + long_text = ( + "白雨翔把灰卡顶在灯下,红点像一粒被烤过的盐。" + "许照没有催,只看着门缝里那只白手套。" + "站台风从隧道口灌进来,把警戒带吹得贴在地砖上。" + ) + ("他盯着门缝往前走没有停下没有回头没有回答" * 220) + + mock_llm_service.generate = AsyncMock(return_value=LLMResult( + content=( + "门缝后的笔尖声突然停了。许照没再说话,只把掌心压在枪套外侧,指节一寸寸收紧。" + "白雨翔把灰卡抬高,让红点正对灯光,灯管在卡面上拖出一截细长的反光。" + "站台尽头传来列车进洞前的低频闷响,警戒带被风扯起又落下,像有人在暗处试着打拍子。" + "白手套在门缝里停了一秒,像在确认什么,然后慢慢伸得更近。" + "陈泊舟没有抬枪,只侧过半步挡住白雨翔的肩线,声音压得很低:“别先给,先看他要哪一面。”" + "白雨翔指尖一紧,卡片边缘硌进掌纹,他把编号那一侧慢慢转过去,让门缝后的人先看到最后三位。" + ), + token_usage=TokenUsage(input_tokens=90, output_tokens=120), + )) + + result = await workflow._enforce_chapter_word_target( + content=long_text, + outline="地铁站灰卡异常,章末抛出更高风险。", + target_word_count=2500, + ) + assert mock_llm_service.generate.await_count >= 1 + assert AutoNovelGenerationWorkflow._story_text_units(result) <= 2625 + assert AutoNovelGenerationWorkflow._is_sentence_tail_complete(result) is True + + def test_smooth_truncated_tail_falls_back_to_punctuation_boundary(self, workflow): + """若尾段无标点,优先回退到最近完整句边界。""" + long_sentence = "现场灯管在潮气里反复嘶鸣,白雨翔沿着站台边缘缓慢移动,脚下每一步都试探着缝隙里的回声," * 24 + text = ( + f"{long_sentence}。" + "第二句完整。" + "第三句前半段没有结束一直往前拖没有句号没有停顿没有收束" + ) + smoothed = workflow._smooth_truncated_tail(text, min_words=700) + assert smoothed.endswith("。") + assert "第三句前半段" not in smoothed + + def test_build_prompt_includes_visible_chapter_strategy(self, workflow): + prompt = workflow._build_prompt( + context="CTX", + outline="OL", + chapter_strategy={ + "dramatic_task": { + "goal": "拿到账本", + "obstacle": "账房先生拖延", + "reader_expectation": "看到主角试探出破绽", + "ending_hook": "账本里藏着另一人的名字", + }, + "scene_plan": [ + { + "label": "试探账房", + "task": "逼对方交出账本", + "resistance": "对方装糊涂", + "info_shift": "主角确认账本被换过", + "relationship_shift": "彼此戒心升级", + "anchor": "沾了墨的账页", + "hook": "账页角落的签名不对", + "target_words": 900, + } + ], + "writing_focus": ["开头立刻进场,不先解释背景。"], + }, + ) + + assert "【本章写作策略(已确认,必须执行)】" in prompt.system + assert "拿到账本" in prompt.system + assert "账页角落的签名不对" in prompt.system + + def test_build_strategy_prompt_requests_show_dont_tell_contract(self, workflow): + prompt = workflow._build_strategy_prompt( + context="CTX", + outline="白雨翔追查灰卡。", + target_word_count=2500, + word_tolerance_ratio=0.05, + ) + + assert "chapter_contract" in prompt.system + assert "show_dont_tell_rules" in prompt.system + assert "visible_action" in prompt.system + assert "subtext_dialogue" in prompt.system + assert "unspoken_emotion" in prompt.system + assert "object_or_clue_change" in prompt.system + assert "少解释,多展示" in prompt.system + + def test_build_strategy_overlay_includes_show_dont_tell_contract(self, workflow): + overlay = workflow._build_strategy_overlay( + { + "chapter_contract": { + "chapter_question": "灰卡是谁写入的?", + "protagonist_want": "白雨翔要确认写卡器来源。", + "opposition": "许照只给半份证据。", + "reader_expectation": "看到两人互相试探。", + "required_information_change": "伪造签名暴露。", + "required_relationship_change": "形成有限合作。", + "ending_question": "谁借用了审计流程?", + "show_dont_tell_rules": ["不能写他感到怀疑,只能写他扣住证物。"], + }, + "dramatic_task": { + "goal": "确认写卡器来源", + "obstacle": "许照保留证据", + "reader_expectation": "看到试探", + "ending_hook": "审计流程异常", + }, + "scene_plan": [], + "writing_focus": [], + } + ) + + assert "章节合同" in overlay + assert "灰卡是谁写入的" in overlay + assert "展示优先" in overlay + assert "扣住证物" in overlay + + def test_build_scene_budget_overlay_includes_showing_fields(self, workflow): + overlay = workflow._build_scene_budget_overlay( + { + "label": "核对签收单", + "task": "确认签名真伪", + "resistance": "许照不交原件", + "info_shift": "签名疑点出现", + "relationship_shift": "有限合作", + "anchor": "证物袋", + "visible_action": "白雨翔按住证物袋封口。", + "subtext_dialogue": "表面问流程,实际逼许照露底。", + "unspoken_emotion": "怀疑不能直说。", + "object_or_clue_change": "灰卡变成伪造链条证据。", + "hook": "审计流程异常", + "target_words": 800, + "min_words": 720, + "max_words": 880, + } + ) + + assert "白雨翔按住证物袋封口" in overlay + assert "表面问流程" in overlay + assert "怀疑不能直说" in overlay + assert "灰卡变成伪造链条证据" in overlay + + def test_resolve_scene_budget_plan_matches_beat_count(self, workflow, monkeypatch): + monkeypatch.setenv("PLOTPILOT_SCENE_BUDGET_ENFORCED", "1") + plan = workflow._resolve_scene_budget_plan( + chapter_strategy={ + "scene_plan": [ + {"label": "场景1", "task": "推进", "resistance": "阻力", "info_shift": "变化", "relationship_shift": "变化", "anchor": "灰卡", "hook": "门响", "target_words": 900}, + {"label": "场景2", "task": "推进", "resistance": "阻力", "info_shift": "变化", "relationship_shift": "变化", "anchor": "监控", "hook": "停电", "target_words": 800}, + ] + }, + target_word_count=2500, + word_tolerance_ratio=0.05, + beat_count=4, + ) + assert len(plan) == 4 + assert all(int(item["target_words"]) > 0 for item in plan) + + def test_extract_forbidden_patterns_from_style_overlay(self, workflow): + overlay = "\n".join( + [ + "【写作手法库】", + "禁用项:", + "- 经过一番交谈后", + "- 很快达成共识", + "", + "执行要求:", + "- 保留动作链", + ] + ) + patterns = workflow._extract_forbidden_patterns_from_style_overlay(overlay) + assert patterns == ["经过一番交谈后", "很快达成共识"] + + def test_direct_writing_prompt_includes_visible_chapter_strategy(self, workflow): + prompt = workflow._build_direct_writing_prompt( + context="CTX", + outline="OL", + chapter_strategy={ + "dramatic_task": { + "goal": "确认嫌疑人身份", + "obstacle": "对方提前封口", + "reader_expectation": "看到现场误判", + "ending_hook": "真正目标提前离场", + }, + "scene_plan": [], + "writing_focus": ["对白里保留试探。"], + }, + ) + + assert "【本章写作策略(已确认,必须执行)】" in prompt.system + assert "确认嫌疑人身份" in prompt.system + + @pytest.mark.asyncio + async def test_generate_chapter_stream_emits_long_draft_plan(self, workflow, mock_llm_service): + async def direct_stream(*args, **kwargs): + yield "第一段。" + yield "第二段。" + + mock_llm_service.stream_generate = direct_stream + events = [ + event async for event in workflow.generate_chapter_stream( + novel_id="novel-1", + chapter_number=1, + outline="主角进入旧档案室调查灰卡来源。", + direct_writing_mode=True, + target_word_count=2500, + long_draft_mode=True, + long_draft_split_count=3, + ) + ] + plan_event = next(event for event in events if event["type"] == "long_draft_plan") + assert plan_event["enabled"] is True + assert plan_event["split_count"] == 3 + assert int(plan_event["target_word_count"]) >= 7000 + done_event = next(event for event in events if event["type"] == "done") + assert done_event["long_draft_mode"] is True + assert done_event["long_draft_split_count"] == 3 + + def test_build_prompt_includes_next_chapter_bridge_overlay(self, workflow): + prompt = workflow._build_prompt( + context="CTX", + outline="OL", + next_chapter_bridge="【下一章承接设定(长章前摄)】\n- 下一章要交付灰卡。", + ) + assert "【下一章承接设定(长章前摄)】" in prompt.system + assert "下一章要交付灰卡" in prompt.system + + def test_build_next_chapter_bridge_overlay_auto_reads_next_story_node(self, workflow): + workflow.context_builder.story_node_repository = SimpleNamespace( + get_by_novel_sync=lambda _novel_id: [ + SimpleNamespace( + node_type=SimpleNamespace(value="chapter"), + number=2, + title="门禁卡背面的名字", + outline="白雨翔确认灰卡背后组织的第一个公开代理人,并发现监控时间戳被二次篡改。", + description="", + content="", + ) + ] + ) + overlay = workflow._build_next_chapter_bridge_overlay( + novel_id="novel-1", + chapter_number=1, + target_word_count=4500, + chapter_strategy=None, + ) + assert "【下一章承接设定(长章前摄)】" in overlay + assert "第2章《门禁卡背面的名字》" in overlay + assert "监控时间戳被二次篡改" in overlay + + def test_build_next_chapter_bridge_overlay_keeps_manual_notes_for_short_chapter(self, workflow): + overlay = workflow._build_next_chapter_bridge_overlay( + novel_id="novel-1", + chapter_number=1, + target_word_count=2500, + chapter_strategy={ + "next_chapter_setup": "下一章主冲突是灰卡交接失败导致身份暴露。", + }, + ) + assert "手动设定" in overlay + assert "灰卡交接失败导致身份暴露" in overlay + + def test_normalize_strategy_payload_has_fallback_shape(self): + payload = AutoNovelGenerationWorkflow._normalize_strategy_payload({}, outline="主角去仓库查账。", target_word_count=2500) + + assert payload["chapter_contract"]["chapter_question"] + assert payload["dramatic_task"]["goal"] + assert len(payload["scene_plan"]) >= 2 + assert payload["scene_plan"][0]["target_words"] >= 500 + assert payload["scene_plan"][0]["visible_action"] + assert payload["writing_focus"] + + def test_normalize_strategy_payload_includes_chapter_contract(self, workflow): + payload = workflow._normalize_strategy_payload( + { + "chapter_contract": { + "chapter_question": "灰卡为什么还能刷开门禁?", + "protagonist_want": "白雨翔要确认 774 写卡器是否被截留。", + "opposition": "许照只交出部分证据。", + "reader_expectation": "看到两个人从对抗到有限合作。", + "required_information_change": "签收记录从嫌疑证据变成伪造证据。", + "required_relationship_change": "白雨翔和许照互相保留但开始交换证据。", + "ending_question": "操盘者是否借用了内部审计流程?", + "show_dont_tell_rules": [ + "不能写白雨翔感到怀疑,只能写他追问和扣住证物。", + "对白不能每句完整回答,允许反问和避重就轻。", + ], + }, + "scene_plan": [ + { + "label": "核对签收单", + "task": "逼出 774 的异常入库记录", + "resistance": "许照不给完整文件", + "info_shift": "扫描件的模糊签名变成疑点", + "relationship_shift": "两人从互相试探进入有限合作", + "anchor": "证物袋和灰卡划痕", + "hook": "签名不属于当前习惯", + "target_words": 900, + "visible_action": "白雨翔把证物袋封口按住,不让许照立刻收走。", + "subtext_dialogue": "表面问流程,实际确认许照掌握多少证据。", + "unspoken_emotion": "怀疑和防备不能直说。", + "object_or_clue_change": "灰卡从拾获物变成伪造链条证据。", + } + ], + "writing_focus": ["少解释,多用动作和证物推进。"], + }, + outline="白雨翔追查灰卡。", + target_word_count=2500, + word_tolerance_ratio=0.05, + ) + + contract = payload["chapter_contract"] + assert contract["chapter_question"] == "灰卡为什么还能刷开门禁?" + assert "扣住证物" in contract["show_dont_tell_rules"][0] + + scene = payload["scene_plan"][0] + assert scene["visible_action"].startswith("白雨翔把证物袋") + assert scene["subtext_dialogue"].startswith("表面问流程") + assert scene["unspoken_emotion"] == "怀疑和防备不能直说。" + assert scene["object_or_clue_change"].startswith("灰卡从拾获物") + + def test_normalize_editorial_review_payload_has_fallback_shape(self): + payload = AutoNovelGenerationWorkflow._normalize_editorial_review_payload({}) + + assert payload["summary"] + assert payload["verdict"] + assert set(payload["scores"].keys()) == {"opening", "conflict", "character", "dialogue", "hook", "pacing", "showing"} + assert payload["strengths"] + assert payload["problems"] + assert payload["actions"] + + def test_normalize_editorial_review_payload_includes_showing_score(self, workflow): + payload = workflow._normalize_editorial_review_payload( + { + "summary": "对白有张力,但解释略多。", + "scores": { + "opening": 88, + "conflict": 90, + "character": 86, + "dialogue": 84, + "hook": 92, + "pacing": 87, + "showing": 79, + }, + "strengths": ["证物动作具体。"], + "problems": ["部分情绪仍被直接命名。"], + "actions": ["把解释改成动作。"], + "verdict": "可优化后使用", + } + ) + + assert payload["scores"]["showing"] == 79 + + def test_coerce_llm_content_to_text_accepts_structured_payload(self): + text = AutoNovelGenerationWorkflow._coerce_llm_content_to_text([{"goal": "拿到账本"}]) + assert "拿到账本" in text + + def test_coerce_llm_content_to_text_accepts_content_parts(self): + text = AutoNovelGenerationWorkflow._coerce_llm_content_to_text( + [ + {"type": "text", "text": '{"dramatic_task":{"goal":"拿到账本"}}'}, + {"type": "reasoning", "text": "思考过程"}, + ] + ) + assert '"dramatic_task"' in text + assert "思考过程" not in text + + def test_parse_llm_json_payload_accepts_list_root(self, workflow): + data, errs = workflow._parse_llm_json_payload([{"goal": "拿到账本"}]) + assert data == {"goal": "拿到账本"} + assert isinstance(errs, list) + + @pytest.mark.parametrize( + ("text", "expected"), + [ + ("都市逆袭,主角在会议上被上司夺功。", "urban"), + ("玄幻仙侠,宗门试炼中法器失控。", "cultivation"), + ("古言宅斗,侯府婚约牵动家族利益。", "historical_romance"), + ("现言甜宠,豪门总裁与替身关系拉扯。", "romance"), + ("漫画信号转小说,保留第一眼视觉冲突。", "comic_adaptation"), + ], + ) + def test_infer_genre_key_for_common_webnovel_types(self, text, expected): + """常见热门网文类型应能映射到对应写法规则。""" + assert AutoNovelGenerationWorkflow._infer_genre_key(text) == expected + + def test_build_prompt_passes_genre_overlay_to_visible_prompt(self, workflow, monkeypatch): + """提示词广场模板可直接使用 genre_overlay 变量。""" + class FakePromptManager: + def ensure_seeded(self): + return True + + def render(self, node_key, variables): + assert node_key == "workflow-chapter-generation" + assert "genre_overlay" in variables + assert "都市爽文" in variables["genre_overlay"] + return { + "system": "VISIBLE SYSTEM\n{genre_overlay}", + "user": "VISIBLE USER", + } + + monkeypatch.setattr( + "infrastructure.ai.prompt_manager.get_prompt_manager", + lambda: FakePromptManager(), + ) + + prompt = workflow._build_prompt( + context="都市逆袭,主角被上司压制,手里握着项目证据。", + outline="主角在会议上被夺功,临场反制并留下更大对手。", + ) + + assert prompt.system.startswith("VISIBLE SYSTEM") + def test_build_prompt_uses_visible_prompt_config(self, workflow, monkeypatch): """工作流章节生成应优先读取提示词广场中的可视配置。""" class FakePromptManager: @@ -594,6 +1304,67 @@ def test_human_texture_risk_detects_short_polished_not_structures(self): assert AutoNovelGenerationWorkflow._needs_human_texture_pass(text) is True + def test_motif_repetition_detects_over_unified_generated_texture(self): + """核心母题词过度复现时,应触发人工余量降噪。""" + text = ( + "虹彩沿着肺泡蔓延,十七次呼吸把坐标推到墙面。\n\n" + "周正明听见虹彩里的呼吸,肺叶按十七次收缩。\n\n" + "拓片上的坐标、虹彩、肺和呼吸再次重合。\n\n" + ) * 10 + + terms = AutoNovelGenerationWorkflow._detector_repetition_terms(text) + + assert "虹彩" in terms + assert "呼吸" in terms + assert "十七" in terms + assert AutoNovelGenerationWorkflow._needs_human_residue_pass(text) is True + + def test_detector_repetition_detects_overused_like_and_weather_texture(self): + """普通比喻和雨水冷等场景母题过密时,也应触发降噪。""" + text = ( + "雨水从门缝里挤进来,冷意贴着手背,像有人在桌下敲门。\n\n" + "雨声又重了一层,水痕沿着纸角散开,冷得像旧铁片。\n\n" + "他看着水痕,雨还在响,门禁扣裂口里有铁锈味,像没晾干的证物袋。\n\n" + ) * 10 + + terms = AutoNovelGenerationWorkflow._detector_repetition_terms(text) + + assert "像" in terms + assert "雨" in terms + assert "水" in terms + assert AutoNovelGenerationWorkflow._needs_human_residue_pass(text) is True + + def test_detector_repetition_dynamically_detects_genre_motifs(self): + """不同题材的高频意象应被动态识别,而不是依赖固定词表。""" + text = ( + "灵气贴着丹田转了一圈,剑光从石壁上掠过去。\n\n" + "他压住丹田里的灵气,仍看见剑光在经脉边缘发亮。\n\n" + "经脉一跳,灵气又回到丹田,剑光却没有散。\n\n" + ) * 12 + + terms = AutoNovelGenerationWorkflow._detector_repetition_terms(text) + + assert "灵气" in terms + assert "丹田" in terms + assert "剑光" in terms + assert AutoNovelGenerationWorkflow._needs_human_residue_pass(text) is True + + def test_human_texture_risk_detects_too_many_plain_like_metaphors(self): + """大量普通“像……”比喻会制造疑似AI的统一镜头感。""" + text = ("雨点像指节,灯光像刀背,纸页像湿掉的皮肤。\n\n") * 25 + + assert AutoNovelGenerationWorkflow._needs_human_texture_pass(text) is True + + def test_soft_cap_detector_motifs_no_longer_mechanically_rewrites_numbers(self): + """最终收尾不能再用字符串替换制造“三旧值/上一组读数”等怪词。""" + text = ("每分钟十七次呼吸。十七。每分钟十九次呼吸。十九。\n") * 12 + + capped = AutoNovelGenerationWorkflow._soft_cap_detector_motifs(text) + + assert capped == text + for artifact in ("旧值", "上一组读数", "那个旧节拍", "刚变过的值"): + assert artifact not in capped + @pytest.mark.asyncio async def test_generate_chapter_naturalizes_ai_flavored_draft_before_returning( self, @@ -812,7 +1583,307 @@ async def test_human_texture_pass_discards_detector_risk_regression( ) assert result == naturalized - assert mock_llm_service.generate.await_count == 1 + assert mock_llm_service.generate.await_count == 2 + + @pytest.mark.asyncio + async def test_human_texture_pass_retries_strict_signature_cleanup( + self, + mock_context_builder, + mock_consistency_checker, + mock_storyline_manager, + mock_plot_arc_repository, + mock_llm_service, + ): + """首轮破整未达标时,应再用硬约束清理检测器敏感句法。""" + naturalized = ( + "不是周正明的。是另一种呼吸,像某种被按动的风箱。\n\n" + "不是承重柱。是肺。或者说,是某种学会呼吸的东西。\n\n" + "虹彩正在蔓延,顾知寒看着节点,像某种旧证据。\n\n" + ) * 12 + still_risky = ( + "不是提示。不是同步。不是结束,是开始。像某种回声。\n\n" + ) * 20 + strict_cleaned = ( + "顾知寒把手电压低,光贴着卷帘底部走了一圈。\n\n" + "水渍沿着砖缝往外爬,周正明伸手去拦,又在半空停住。\n\n" + "她没有解释,只把拓片按进内袋,听见里面轻轻撞了一下。\n\n" + ) * 10 + mock_llm_service.generate = AsyncMock(side_effect=[ + LLMResult(content=still_risky, token_usage=TokenUsage(input_tokens=300, output_tokens=300)), + LLMResult(content=strict_cleaned, token_usage=TokenUsage(input_tokens=300, output_tokens=300)), + ]) + workflow = AutoNovelGenerationWorkflow( + context_builder=mock_context_builder, + consistency_checker=mock_consistency_checker, + storyline_manager=mock_storyline_manager, + plot_arc_repository=mock_plot_arc_repository, + llm_service=mock_llm_service, + cliche_scanner=Mock(), + ) + + result = await workflow._apply_human_texture_pass_if_needed( + content=naturalized, + outline="测试大纲", + ) + + assert result == strict_cleaned.strip() + assert mock_llm_service.generate.await_count == 2 + + @pytest.mark.asyncio + async def test_human_texture_pass_continues_when_first_candidate_still_risky( + self, + mock_context_builder, + mock_consistency_checker, + mock_storyline_manager, + mock_plot_arc_repository, + mock_llm_service, + ): + """首轮已改善但仍超过风险阈值时,不能提前放行。""" + naturalized = ( + "不是周正明的。是另一种呼吸,像某种被按动的风箱。\n\n" + "不是承重柱。是肺。或者说,是某种学会呼吸的东西。\n\n" + "虹彩正在蔓延,顾知寒看着节点,像某种旧证据。\n\n" + ) * 12 + improved_but_still_risky = ( + "顾知寒压低手电,水渍往外爬。\n\n" + "卷帘底部不是承重柱,是肺。孔洞正按十七次收缩。\n\n" + "拓片在内袋里撞了一下,带着某种提示。\n\n" + ) * 12 + strict_cleaned = ( + "顾知寒压低手电,水渍沿着砖缝往外爬。\n\n" + "卷帘底部的孔洞按十七次收缩。周正明伸手,又停住。\n\n" + "拓片在内袋里撞了一下,她把话咽回去。\n\n" + ) * 12 + assert AutoNovelGenerationWorkflow._is_detector_signature_improved( + improved_but_still_risky, + naturalized, + ) + assert AutoNovelGenerationWorkflow._needs_human_texture_pass(improved_but_still_risky) + mock_llm_service.generate = AsyncMock(side_effect=[ + LLMResult(content=improved_but_still_risky, token_usage=TokenUsage(input_tokens=300, output_tokens=300)), + LLMResult(content=strict_cleaned, token_usage=TokenUsage(input_tokens=300, output_tokens=300)), + ]) + workflow = AutoNovelGenerationWorkflow( + context_builder=mock_context_builder, + consistency_checker=mock_consistency_checker, + storyline_manager=mock_storyline_manager, + plot_arc_repository=mock_plot_arc_repository, + llm_service=mock_llm_service, + cliche_scanner=Mock(), + ) + + result = await workflow._apply_human_texture_pass_if_needed( + content=naturalized, + outline="测试大纲", + ) + + assert result == strict_cleaned.strip() + assert mock_llm_service.generate.await_count == 2 + + @pytest.mark.asyncio + async def test_naturalizer_applies_human_residue_pass_for_repeated_motifs( + self, + mock_context_builder, + mock_consistency_checker, + mock_storyline_manager, + mock_plot_arc_repository, + mock_llm_service, + ): + """句法已清理但母题词过密时,应继续做人工余量降噪。""" + from application.services.cliche_scanner import ClicheScanner + + raw = "空气仿佛凝固了。" * 80 + signature_clean_but_repetitive = ( + "虹彩沿着肺泡蔓延,十七次呼吸把坐标推到墙面。\n\n" + "周正明听见虹彩里的呼吸,肺叶按十七次收缩。\n\n" + "拓片上的坐标、虹彩、肺和呼吸再次重合。\n\n" + ) * 10 + residue_cleaned = ( + "应急灯闪了两下,墙皮从潮湿处鼓起来。\n\n" + "周正明踩到水,先骂了一声,又把后半句吞回去。\n\n" + "顾知寒把拓片塞回内袋,没再解释那个数字。\n\n" + ) * 10 + mock_llm_service.generate = AsyncMock(side_effect=[ + LLMResult(content=signature_clean_but_repetitive, token_usage=TokenUsage(input_tokens=300, output_tokens=300)), + LLMResult(content=residue_cleaned, token_usage=TokenUsage(input_tokens=300, output_tokens=300)), + ]) + scanner = Mock(spec=ClicheScanner) + scanner.scan_cliches.return_value = [] + workflow = AutoNovelGenerationWorkflow( + context_builder=mock_context_builder, + consistency_checker=mock_consistency_checker, + storyline_manager=mock_storyline_manager, + plot_arc_repository=mock_plot_arc_repository, + llm_service=mock_llm_service, + cliche_scanner=scanner, + ) + + result = await workflow._naturalize_ai_flavor_if_needed( + content=raw, + outline="测试大纲", + ) + + assert "虹彩" not in result + assert "十七" not in result + assert mock_llm_service.generate.await_count == 2 + + @pytest.mark.asyncio + async def test_naturalizer_applies_structural_audit_for_exposition_cascade( + self, + mock_context_builder, + mock_consistency_checker, + mock_storyline_manager, + mock_plot_arc_repository, + mock_llm_service, + ): + """自然化稿若仍像设定说明连发,应走结构审稿式删改。""" + from application.services.cliche_scanner import ClicheScanner + + raw = "空气仿佛凝固了。" * 80 + exposition_cascade = ( + "周正明解释了研究所的规则,接着说明虹彩为什么会出现。\n\n" + "林默继续解释门禁系统的历史,又补充讲述当年的项目背景。\n\n" + "顾知寒听完后明白了整套机制,于是三人很快达成共识。\n\n" + ) * 8 + structurally_cleaned = ( + "周正明刚开口,门禁屏幕先灭了一格。\n\n" + "林默伸手去挡,指尖碰到读卡槽,里面传出一声迟到的滴响。\n\n" + "顾知寒没有催他说完,只把旧卡翻到背面,看见被刮掉的编号。\n\n" + ) * 8 + mock_llm_service.generate = AsyncMock(side_effect=[ + LLMResult(content=exposition_cascade, token_usage=TokenUsage(input_tokens=300, output_tokens=300)), + LLMResult(content=structurally_cleaned, token_usage=TokenUsage(input_tokens=300, output_tokens=300)), + ]) + scanner = Mock(spec=ClicheScanner) + scanner.scan_cliches.return_value = [] + workflow = AutoNovelGenerationWorkflow( + context_builder=mock_context_builder, + consistency_checker=mock_consistency_checker, + storyline_manager=mock_storyline_manager, + plot_arc_repository=mock_plot_arc_repository, + llm_service=mock_llm_service, + cliche_scanner=scanner, + ) + + result = await workflow._naturalize_ai_flavor_if_needed( + content=raw, + outline="测试大纲", + ) + + assert result == structurally_cleaned.strip() + assert "很快达成共识" not in result + assert mock_llm_service.generate.await_count == 2 + + @pytest.mark.asyncio + async def test_naturalizer_applies_style_bible_after_anti_ai_rewrite( + self, + mock_context_builder, + mock_consistency_checker, + mock_storyline_manager, + mock_plot_arc_repository, + mock_llm_service, + monkeypatch, + ): + """选中手法档案时,章后自然化也应让 Style Bible 参与收束文风。""" + from application.services.cliche_scanner import ClicheScanner + + raw = "空气仿佛凝固了。" * 80 + naturalized = ( + "林默把门禁卡按上读卡槽,屏幕迟了半秒才亮。\n\n" + "顾知寒没有说话,只看他拇指边缘那道新划痕。\n\n" + ) * 12 + style_matched = ( + "林默把门禁卡贴上去。屏幕迟了半秒。\n\n" + "顾知寒看见他拇指边缘的新划痕,没问。\n\n" + ) * 12 + mock_llm_service.generate = AsyncMock(side_effect=[ + LLMResult(content=naturalized, token_usage=TokenUsage(input_tokens=300, output_tokens=300)), + LLMResult(content=style_matched, token_usage=TokenUsage(input_tokens=300, output_tokens=300)), + ]) + + class FakePromptManager: + def ensure_seeded(self): + return True + + def render(self, node_key, variables): + if node_key == "rewrite-ai-flavor-naturalizer": + return {"system": "自然化", "user": variables["draft"]} + if node_key == "style-bible-imitation-pass": + assert "克制悬疑" in variables["style_overlay"] + assert variables["draft"] == naturalized.strip() + assert "测试大纲" in variables["must_keep"] + return {"system": "文风贴合", "user": variables["draft"]} + raise AssertionError(f"unexpected node: {node_key}") + + monkeypatch.setattr( + "infrastructure.ai.prompt_manager.get_prompt_manager", + lambda: FakePromptManager(), + ) + scanner = Mock(spec=ClicheScanner) + scanner.scan_cliches.return_value = [] + workflow = AutoNovelGenerationWorkflow( + context_builder=mock_context_builder, + consistency_checker=mock_consistency_checker, + storyline_manager=mock_storyline_manager, + plot_arc_repository=mock_plot_arc_repository, + llm_service=mock_llm_service, + cliche_scanner=scanner, + ) + + result = await workflow._naturalize_ai_flavor_if_needed( + content=raw, + outline="测试大纲", + style_overlay="【写作手法库】\n使用风格包:克制悬疑", + ) + + assert result == style_matched.strip() + assert mock_llm_service.generate.await_count == 2 + + @pytest.mark.asyncio + async def test_human_residue_pass_continues_when_candidate_still_repetitive( + self, + mock_context_builder, + mock_consistency_checker, + mock_storyline_manager, + mock_plot_arc_repository, + mock_llm_service, + ): + """首轮母题降噪仍超标时,应继续按词频上限压词。""" + draft = ( + "虹彩沿着肺泡蔓延,十七次呼吸把坐标推到墙面。\n\n" + "周正明听见虹彩里的呼吸,肺叶按十七次收缩。\n\n" + "拓片上的坐标、虹彩、肺和呼吸再次重合。\n\n" + ) * 12 + improved_but_repetitive = ( + "水声沿着台阶往上爬,十七次呼吸把坐标推到墙面。\n\n" + "周正明听见呼吸,肺叶按十七次收缩。\n\n" + "拓片上的坐标和呼吸再次重合。\n\n" + ) * 10 + strict_cleaned = ( + "水声沿着台阶往上爬,墙上的旧读数亮了一下。\n\n" + "周正明退了半步,皮鞋在湿处打滑。\n\n" + "顾知寒把拓片塞回内袋,先看门锁有没有反应。\n\n" + ) * 10 + mock_llm_service.generate = AsyncMock(side_effect=[ + LLMResult(content=improved_but_repetitive, token_usage=TokenUsage(input_tokens=300, output_tokens=300)), + LLMResult(content=strict_cleaned, token_usage=TokenUsage(input_tokens=300, output_tokens=300)), + ]) + workflow = AutoNovelGenerationWorkflow( + context_builder=mock_context_builder, + consistency_checker=mock_consistency_checker, + storyline_manager=mock_storyline_manager, + plot_arc_repository=mock_plot_arc_repository, + llm_service=mock_llm_service, + cliche_scanner=Mock(), + ) + + result = await workflow._apply_human_residue_pass_if_needed( + content=draft, + outline="测试大纲", + ) + + assert result == strict_cleaned.strip() + assert mock_llm_service.generate.await_count == 2 @pytest.mark.asyncio async def test_long_streamed_chapter_is_naturalized_even_without_cliche_hits( @@ -986,3 +2057,334 @@ async def test_generate_chapter_stream_includes_style_warnings( assert len(done_event["style_warnings"]) == 1 assert done_event["style_warnings"][0]["pattern"] == "熊熊系列" assert done_event["style_warnings"][0]["text"] == "熊熊烈火" + + +class TestCocCanonWarnings: + """测试 CoC 正典轻告警。""" + + def test_build_coc_overlay_extracts_absolute_titles_from_string(self, workflow): + workflow._current_novel_id = "novel-1" + workflow.coc_canon_service = Mock() + workflow.coc_canon_service.build_overlay.return_value = ( + "【CoC正典(必须保持一致)】\n" + "- [world_rule] 夜巡制度(锁定:absolute)\n" + " 公共事实:每晚三更点名。" + ) + + overlay = workflow._build_coc_canon_overlay() + assert "【CoC正典(必须保持一致)】" in overlay + assert "夜巡制度" in workflow._current_coc_absolute_titles + + @pytest.mark.asyncio + async def test_post_process_adds_warning_when_absolute_canon_is_rewritten( + self, + workflow, + ): + workflow._coc_hard_guard_enabled = False + workflow._current_coc_absolute_titles = ["夜巡制度"] + workflow._extract_chapter_state = AsyncMock(return_value=ChapterState([], [], [], [], [], [])) + workflow._check_consistency = Mock(return_value=ConsistencyReport(issues=[], warnings=[], suggestions=[])) + workflow._detect_conflicts = Mock(return_value=[]) + + post = await workflow.post_process_generated_chapter( + novel_id="novel-1", + chapter_number=3, + outline="测试大纲", + content="他翻着旧档案低声说,夜巡制度并非祖制,而是两年前临时改的。", + scene_director=None, + ) + + warnings = post["consistency_report"].warnings + assert len(warnings) == 1 + assert "CoC正典疑似冲突" in warnings[0].description + assert "夜巡制度" in warnings[0].description + + @pytest.mark.asyncio + async def test_post_process_adds_warning_when_author_truth_is_directly_exposed( + self, + workflow, + ): + workflow._coc_hard_guard_enabled = False + workflow._current_coc_author_truth_snippets = ["信号其实是旧教团的召集暗号"] + workflow._extract_chapter_state = AsyncMock(return_value=ChapterState([], [], [], [], [], [])) + workflow._check_consistency = Mock(return_value=ConsistencyReport(issues=[], warnings=[], suggestions=[])) + workflow._detect_conflicts = Mock(return_value=[]) + + post = await workflow.post_process_generated_chapter( + novel_id="novel-1", + chapter_number=6, + outline="测试大纲", + content="他低声说出真相:信号其实是旧教团的召集暗号,今晚就会动手。", + scene_director=None, + ) + + warnings = post["consistency_report"].warnings + assert len(warnings) == 1 + assert "CoC作者真相疑似直出" in warnings[0].description + + +class TestCocClueWarnings: + """测试 CoC 线索边界轻告警。""" + + def test_direct_writing_prompt_includes_coc_clue_overlay(self, workflow): + workflow._current_novel_id = "novel-1" + workflow.coc_clue_service = Mock() + workflow.coc_clue_service.build_overlay.return_value = { + "prompt": "【CoC线索边界】\n- clue_key: ledger_owner | visibility: author_only", + "clues": [ + {"clue_key": "ledger_owner", "visibility": "author_only"}, + ], + } + + prompt = workflow._build_direct_writing_prompt( + context="CTX", + outline="OL", + ) + + assert "【CoC线索边界】" in prompt.system + assert "ledger_owner" in prompt.system + + @pytest.mark.asyncio + async def test_post_process_adds_warning_when_author_only_clue_leaks( + self, + workflow, + ): + workflow._coc_hard_guard_enabled = False + workflow._current_novel_id = "novel-1" + workflow.coc_clue_service = Mock() + workflow.coc_clue_service.build_overlay.return_value = { + "prompt": "【CoC线索边界】\n- clue_key: ledger_owner | visibility: author_only", + "clues": [ + {"clue_key": "ledger_owner", "visibility": "author_only"}, + ], + } + workflow._build_coc_clue_overlay() + workflow._extract_chapter_state = AsyncMock(return_value=ChapterState([], [], [], [], [], [])) + workflow._check_consistency = Mock(return_value=ConsistencyReport(issues=[], warnings=[], suggestions=[])) + workflow._detect_conflicts = Mock(return_value=[]) + + post = await workflow.post_process_generated_chapter( + novel_id="novel-1", + chapter_number=5, + outline="测试大纲", + content="他在账页背面写下 ledger_owner,再把纸条塞回抽屉。", + scene_director=None, + ) + + warnings = post["consistency_report"].warnings + assert len(warnings) == 1 + assert "CoC线索疑似越级" in warnings[0].description + assert "ledger_owner" in warnings[0].description + + +class TestCocCognitionPrecheck: + """测试 CoC 认知边界生成前预检。""" + + def test_precheck_returns_not_checked_when_services_missing(self, workflow): + result = workflow.precheck_coc_cognition_boundary( + novel_id="novel-1", + chapter_number=1, + outline="主角在雨夜追查旧案。", + ) + assert result["checked"] is False + assert result["allow_generate"] is True + + def test_precheck_blocks_author_truth_or_author_only_leak(self, workflow): + workflow.coc_canon_service = Mock() + workflow.coc_canon_service.get_cognition_layers.return_value = { + "author_truth": ["灯塔信号:信号其实是旧教团的召集暗号。"], + "author_truth_snippets": ["信号其实是旧教团的召集暗号"], + "reader_known": [], + } + workflow.coc_clue_service = Mock() + workflow.coc_clue_service.get_cognition_layers.return_value = { + "author_truth": ["ledger_owner:账本真正持有人是裴家管家(已知角色:未记录)"], + "character_known": [], + "reader_known": [], + } + + result = workflow.precheck_coc_cognition_boundary( + novel_id="novel-1", + chapter_number=3, + outline="这一章明确写出:信号其实是旧教团的召集暗号,且 ledger_owner 就是裴家管家。", + ) + assert result["checked"] is True + assert result["allow_generate"] is False + assert result["risk_level"] == "block" + assert any("author_only" in item for item in result["blocking_issues"]) + + def test_precheck_warns_character_known_visibility(self, workflow): + workflow.coc_canon_service = Mock() + workflow.coc_canon_service.get_cognition_layers.return_value = { + "author_truth": [], + "author_truth_snippets": [], + "reader_known": [], + } + workflow.coc_clue_service = Mock() + workflow.coc_clue_service.get_cognition_layers.return_value = { + "author_truth": [], + "character_known": ["archive_code:档案室门禁码 11473(已知角色:林岚)"], + "reader_known": [], + } + + result = workflow.precheck_coc_cognition_boundary( + novel_id="novel-1", + chapter_number=4, + outline="林岚盯着 archive_code 这串数字,迟迟不敢输入门禁。", + ) + assert result["checked"] is True + assert result["allow_generate"] is True + assert result["risk_level"] == "warning" + assert len(result["warnings"]) >= 1 + + def test_rewrite_outline_for_coc_boundary_replaces_blocking_tokens(self, workflow): + workflow.coc_canon_service = Mock() + workflow.coc_canon_service.get_cognition_layers.return_value = { + "author_truth": ["灯塔信号:信号其实是旧教团的召集暗号。"], + "author_truth_snippets": ["信号其实是旧教团的召集暗号"], + "reader_known": [], + } + workflow.coc_clue_service = Mock() + workflow.coc_clue_service.get_cognition_layers.return_value = { + "author_truth": ["ledger_owner:账本真正持有人是裴家管家(已知角色:未记录)"], + "character_known": [], + "reader_known": [], + } + + result = workflow.rewrite_outline_for_coc_boundary( + novel_id="novel-1", + chapter_number=5, + outline="这一章明确写出:信号其实是旧教团的召集暗号,ledger_owner 已确认是裴家管家。", + ) + assert result["changed"] is True + assert result["rewrite_mode"] == "conservative" + assert result["rewrite_style"] == "generic" + assert "未公开线索" in result["rewritten_outline"] + assert result["precheck_before"]["allow_generate"] is False + + def test_rewrite_outline_for_coc_boundary_no_change_when_safe(self, workflow): + workflow.coc_canon_service = Mock() + workflow.coc_canon_service.get_cognition_layers.return_value = { + "author_truth": [], + "author_truth_snippets": [], + "reader_known": [], + } + workflow.coc_clue_service = Mock() + workflow.coc_clue_service.get_cognition_layers.return_value = { + "author_truth": [], + "character_known": [], + "reader_known": ["archive_code:档案室里有一串旧编号(已知角色:林岚)"], + } + outline = "主角在档案室找到旧编号并继续追查。" + result = workflow.rewrite_outline_for_coc_boundary( + novel_id="novel-1", + chapter_number=2, + outline=outline, + ) + assert result["changed"] is False + assert result["rewrite_mode"] == "conservative" + assert result["rewrite_style"] == "generic" + assert result["rewritten_outline"] == outline + + def test_rewrite_outline_for_coc_boundary_aggressive_mode(self, workflow): + workflow.coc_canon_service = Mock() + workflow.coc_canon_service.get_cognition_layers.return_value = { + "author_truth": [], + "author_truth_snippets": [], + "reader_known": [], + } + workflow.coc_clue_service = Mock() + workflow.coc_clue_service.get_cognition_layers.return_value = { + "author_truth": ["ledger_owner:账本主人暂未公开(已知角色:未记录)"], + "character_known": [], + "reader_known": [], + } + result = workflow.rewrite_outline_for_coc_boundary( + novel_id="novel-1", + chapter_number=7, + outline="主角揭露 ledger_owner,并一口气说出全部真相。", + rewrite_mode="aggressive", + rewrite_style="suspense", + ) + assert result["rewrite_mode"] == "aggressive" + assert result["rewrite_style"] == "suspense" + assert result["changed"] is True + assert "侧面触发" in result["rewritten_outline"] or "话到嘴边又收住" in result["rewritten_outline"] + + def test_rewrite_outline_for_coc_boundary_coc_style(self, workflow): + workflow.coc_canon_service = Mock() + workflow.coc_canon_service.get_cognition_layers.return_value = { + "author_truth": [], + "author_truth_snippets": [], + "reader_known": [], + } + workflow.coc_clue_service = Mock() + workflow.coc_clue_service.get_cognition_layers.return_value = { + "author_truth": ["ledger_owner:账本主人暂未公开(已知角色:未记录)"], + "character_known": [], + "reader_known": [], + } + result = workflow.rewrite_outline_for_coc_boundary( + novel_id="novel-1", + chapter_number=8, + outline="主角揭示邪神仪式成功,并完全理解神明意志。", + rewrite_mode="aggressive", + rewrite_style="coc", + ) + assert result["rewrite_mode"] == "aggressive" + assert result["rewrite_style"] == "coc" + + +class TestCocContentBoundaryValidation: + """测试 CoC 正文级硬约束校验。""" + + def test_content_boundary_blocks_author_only_key(self, workflow): + workflow.coc_canon_service = Mock() + workflow.coc_canon_service.build_overlay.return_value = {"prompt": "【CoC正典】"} + workflow.coc_canon_service.get_cognition_layers.return_value = {} + workflow.coc_canon_service.get_overview.return_value = {"entries": []} + + workflow.coc_clue_service = Mock() + workflow.coc_clue_service.build_overlay.return_value = { + "prompt": "【CoC线索边界】\n- clue_key: clue-zhou-origin | visibility: author_only", + "clues": [{"clue_key": "clue-zhou-origin", "visibility": "author_only"}], + } + workflow.coc_clue_service.get_cognition_layers.return_value = { + "author_truth": [], + "character_known": [], + "reader_known": [], + } + + result = workflow.validate_coc_content_boundary( + novel_id="novel-1", + chapter_number=6, + content="主角终于确认 clue-zhou-origin 的来源。", + ) + assert result["checked"] is True + assert result["allow_save"] is False + assert result["risk_level"] == "block" + assert any("author_only" in item for item in result["blocking_issues"]) + + def test_content_boundary_blocks_strict_entry_negation(self, workflow): + workflow.coc_canon_service = Mock() + workflow.coc_canon_service.build_overlay.return_value = {"prompt": "【CoC正典】"} + workflow.coc_canon_service.get_cognition_layers.return_value = {} + workflow.coc_canon_service.get_overview.return_value = { + "entries": [ + { + "title": "第七次熄灯与十七分钟窗口", + "lock_level": "strict", + } + ] + } + workflow.coc_clue_service = Mock() + workflow.coc_clue_service.build_overlay.return_value = {"prompt": "【CoC线索】"} + workflow.coc_clue_service.get_cognition_layers.return_value = {} + + result = workflow.validate_coc_content_boundary( + novel_id="novel-1", + chapter_number=7, + content="所有人都说第七次熄灯与十七分钟窗口并非关键,只是误传。", + ) + assert result["allow_save"] is False + assert any("硬约束冲突" in item for item in result["blocking_issues"]) diff --git a/tests/unit/infrastructure/ai/providers/test_openai_provider.py b/tests/unit/infrastructure/ai/providers/test_openai_provider.py index 0aee7149..4381cf68 100644 --- a/tests/unit/infrastructure/ai/providers/test_openai_provider.py +++ b/tests/unit/infrastructure/ai/providers/test_openai_provider.py @@ -237,6 +237,66 @@ async def test_generate_responses_joins_multiple_text_parts(self, provider): assert result.content == "Line1\nLine2" + @pytest.mark.anyio + async def test_generate_responses_accepts_output_text_parts(self, provider): + prompt = Prompt(system="s", user="u") + config = GenerationConfig(model="gpt-5.2", temperature=0, max_tokens=32) + response = SimpleNamespace( + output=[ + SimpleNamespace( + type="message", + content=[SimpleNamespace(type="output_text", text="正常")], + ), + ], + usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1), + ) + + with patch.object(provider.async_client.responses, "create", new_callable=AsyncMock) as mock_create: + mock_create.return_value = response + + result = await provider.generate(prompt, config) + + assert result.content == "正常" + + @pytest.mark.anyio + async def test_generate_responses_accepts_responses_usage_names(self, provider): + prompt = Prompt(system="s", user="u") + config = GenerationConfig(model="gpt-5.2", temperature=0, max_tokens=32) + response = SimpleNamespace( + output=[ + SimpleNamespace( + type="message", + content=[SimpleNamespace(type="output_text", text="正常")], + ), + ], + usage=SimpleNamespace(input_tokens=11, output_tokens=7), + ) + + with patch.object(provider.async_client.responses, "create", new_callable=AsyncMock) as mock_create: + mock_create.return_value = response + + result = await provider.generate(prompt, config) + + assert result.token_usage.input_tokens == 11 + assert result.token_usage.output_tokens == 7 + + @pytest.mark.anyio + async def test_generate_responses_accepts_response_output_text_property(self, provider): + prompt = Prompt(system="s", user="u") + config = GenerationConfig(model="gpt-5.2", temperature=0, max_tokens=32) + response = SimpleNamespace( + output_text="写作正常", + output=[], + usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1), + ) + + with patch.object(provider.async_client.responses, "create", new_callable=AsyncMock) as mock_create: + mock_create.return_value = response + + result = await provider.generate(prompt, config) + + assert result.content == "写作正常" + @pytest.mark.anyio async def test_stream_generate(self, provider): prompt = Prompt(system="You are helpful", user="Hello") @@ -257,6 +317,23 @@ async def test_stream_generate(self, provider): assert chunks == ["Hello"] assert mock_create.await_args.kwargs["stream"] is True + @pytest.mark.anyio + async def test_stream_generate_accepts_output_text_delta(self, provider): + prompt = Prompt(system="You are helpful", user="Hello") + config = GenerationConfig(model="gpt-5.2", temperature=0.7, max_tokens=32) + stream = _FakeStream([ + SimpleNamespace(type="response.output_text.delta", delta="正"), + SimpleNamespace(type="response.output_text.delta", delta="常"), + SimpleNamespace(type="response.completed"), + ]) + + with patch.object(provider.async_client.responses, "create", new_callable=AsyncMock) as mock_create: + mock_create.return_value = stream + + chunks = [chunk async for chunk in provider.stream_generate(prompt, config)] + + assert chunks == ["正", "常"] + @pytest.mark.anyio async def test_generate_empty_responses_raises(self, provider): prompt = Prompt(system="You are helpful", user="Hello") diff --git a/tests/unit/infrastructure/ai/test_url_utils.py b/tests/unit/infrastructure/ai/test_url_utils.py new file mode 100644 index 00000000..77f29bb5 --- /dev/null +++ b/tests/unit/infrastructure/ai/test_url_utils.py @@ -0,0 +1,10 @@ +from infrastructure.ai.url_utils import should_trust_env_proxy_for_openai_base + + +def test_should_trust_env_proxy_for_official_openai(): + assert should_trust_env_proxy_for_openai_base("https://api.openai.com/v1") is True + + +def test_should_not_trust_env_proxy_for_compatible_gateway(): + assert should_trust_env_proxy_for_openai_base("https://api.deepseek.com/v1") is False + assert should_trust_env_proxy_for_openai_base("https://coding-intl.dashscope.aliyuncs.com/v1") is False diff --git a/tests/unit/infrastructure/database/test_sqlite_style_bible_repository.py b/tests/unit/infrastructure/database/test_sqlite_style_bible_repository.py index d50335a6..019f3a4d 100644 --- a/tests/unit/infrastructure/database/test_sqlite_style_bible_repository.py +++ b/tests/unit/infrastructure/database/test_sqlite_style_bible_repository.py @@ -130,6 +130,24 @@ def test_sqlite_style_bible_repository_saves_profiles_and_cards(tmp_path): assert [card.enabled for card in repo.list_technique_cards(profile.id)] == [False, False] +def test_sqlite_style_bible_repository_seeds_default_profiles_for_empty_library(tmp_path): + db = DatabaseConnection(str(tmp_path / "style-bible-defaults.db")) + repo = SqliteStyleBibleRepository(db) + + repo.ensure_default_profiles() + + profiles = repo.list_profiles(novel_id="novel-1", status="active") + assert profiles + assert profiles[0].id == "style-profile-default-low-ai-webnovel" + assert profiles[0].novel_id == "" + assert "低AI味" in profiles[0].name + + cards = repo.list_technique_cards(profiles[0].id, enabled=True) + assert len(cards) >= 5 + assert any(card.category == "anti_ai" for card in cards) + assert any("证据" in card.prompt_instruction for card in cards) + + def test_database_connection_creates_style_bible_tables_for_empty_database(tmp_path): db = DatabaseConnection(str(tmp_path / "empty-style-bible.db")) diff --git a/tests/unit/interfaces/api/test_dependencies.py b/tests/unit/interfaces/api/test_dependencies.py index d318a08a..e3f7ba22 100644 --- a/tests/unit/interfaces/api/test_dependencies.py +++ b/tests/unit/interfaces/api/test_dependencies.py @@ -3,6 +3,70 @@ import pytest from unittest.mock import patch, MagicMock import interfaces.api.dependencies as dependencies +from infrastructure.ai.provider_factory import DynamicLLMService + + +def test_topic_idea_service_uses_analysis_llm_route(): + """选题/市场判断属于分析决策任务,应固定走 DS 分析模型路由。""" + analysis_llm = MagicMock(name="analysis-llm") + writing_llm = MagicMock(name="writing-llm") + repository = MagicMock(name="topic-repository") + novel_service = MagicMock(name="novel-service") + + with patch.object(dependencies, "get_analysis_llm_service", return_value=analysis_llm) as analysis_mock: + with patch.object(dependencies, "get_writing_llm_service", return_value=writing_llm) as writing_mock: + with patch.object(dependencies, "get_topic_idea_repository", return_value=repository): + with patch.object(dependencies, "get_novel_service", return_value=novel_service): + service = dependencies.get_topic_idea_service() + + assert service._llm is analysis_llm + analysis_mock.assert_called_once_with() + writing_mock.assert_not_called() + + +def test_auto_bible_generator_uses_analysis_llm_route(): + """新书向导 Bible 是结构化规划/记忆种子,应走 DS 分析模型路由,避免 GPT 写作模型长思考超时。""" + analysis_llm = MagicMock(name="analysis-llm") + writing_llm = MagicMock(name="writing-llm") + bible_service = MagicMock(name="bible-service") + + with patch.object(dependencies, "get_analysis_llm_service", return_value=analysis_llm) as analysis_mock: + with patch.object(dependencies, "get_writing_llm_service", return_value=writing_llm) as writing_mock: + with patch.object(dependencies, "get_bible_service", return_value=bible_service): + generator = dependencies.get_auto_bible_generator() + + assert generator.llm_service is analysis_llm + analysis_mock.assert_called_once_with() + writing_mock.assert_not_called() + + +def test_setup_main_plot_suggestion_service_uses_analysis_llm_route(): + """向导主线候选推演也是结构化规划任务,应走 DS 分析模型路由。""" + analysis_llm = MagicMock(name="analysis-llm") + writing_llm = MagicMock(name="writing-llm") + bible_service = MagicMock(name="bible-service") + novel_service = MagicMock(name="novel-service") + + with patch.object(dependencies, "get_analysis_llm_service", return_value=analysis_llm) as analysis_mock: + with patch.object(dependencies, "get_writing_llm_service", return_value=writing_llm) as writing_mock: + with patch.object(dependencies, "get_bible_service", return_value=bible_service): + with patch.object(dependencies, "get_novel_service", return_value=novel_service): + service = dependencies.get_setup_main_plot_suggestion_service() + + assert service._llm is analysis_llm + analysis_mock.assert_called_once_with() + writing_mock.assert_not_called() + + +def test_get_writing_llm_service_uses_dynamic_profile_runtime(): + """写作路由应跟随后台激活配置,不再固定 profile_id。""" + dependencies.get_writing_llm_service.cache_clear() + factory = MagicMock(name="factory") + with patch.object(dependencies, "get_llm_provider_factory", return_value=factory): + service = dependencies.get_writing_llm_service() + assert isinstance(service, DynamicLLMService) + assert service.factory is factory + dependencies.get_writing_llm_service.cache_clear() class TestGetVectorStore: @@ -10,6 +74,7 @@ class TestGetVectorStore: def setup_method(self): dependencies._vector_store_singleton = None + dependencies._vector_store_init_failed = False def test_get_vector_store_returns_none_when_no_env(self): """未设置环境变量时默认返回 ChromaDB 实例。""" diff --git a/tests/unit/interfaces/api/test_topic_signal_startup.py b/tests/unit/interfaces/api/test_topic_signal_startup.py new file mode 100644 index 00000000..cbf9e74f --- /dev/null +++ b/tests/unit/interfaces/api/test_topic_signal_startup.py @@ -0,0 +1,24 @@ +import pytest + + +@pytest.mark.asyncio +async def test_startup_event_does_not_start_topic_signal_automation(monkeypatch): + from interfaces import main + + started = [] + + class FakeTopicSignalAutomationService: + def start(self): + started.append("start") + + monkeypatch.setattr(main, "_stop_all_running_novels", lambda: None) + monkeypatch.setattr(main, "_start_autopilot_daemon_thread", lambda: None) + monkeypatch.setattr( + main, + "get_topic_signal_automation_service", + lambda: FakeTopicSignalAutomationService(), + ) + + await main.startup_event() + + assert started == [] From ac1a6a092acf9a72f81ba68ea0d563d8fdb48cae Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 2 May 2026 23:45:12 +0800 Subject: [PATCH 97/97] fix: dedupe repeated chapter expansions --- .../auto_novel_generation_workflow.py | 40 ++++++++++++++++++- .../test_auto_novel_generation_workflow.py | 24 +++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/application/workflows/auto_novel_generation_workflow.py b/application/workflows/auto_novel_generation_workflow.py index 64b1fa69..b98effbe 100644 --- a/application/workflows/auto_novel_generation_workflow.py +++ b/application/workflows/auto_novel_generation_workflow.py @@ -774,6 +774,44 @@ def _smooth_truncated_tail(self, text: str, *, min_words: int) -> str: # 以“替换末字符”为优先,避免收束动作把字数上限顶穿 +1。 return trimmed[:-1] + "。" + @staticmethod + def _remove_repeated_leading_paragraph_block(text: str) -> str: + """删除续写阶段偶发的“从开头重写一遍”段落块。""" + normalized = (text or "").strip() + paragraphs = [part.strip() for part in re.split(r"\n\s*\n", normalized) if part.strip()] + if len(paragraphs) < 6: + return normalized + + first = paragraphs[0] + if len(first) < 12: + return normalized + + for start in range(1, len(paragraphs)): + current = paragraphs[start] + first_index = current.find(first) + if first_index < 0: + continue + + repeat_count = 1 + while ( + start + repeat_count < len(paragraphs) + and repeat_count < len(paragraphs) + and paragraphs[start + repeat_count] == paragraphs[repeat_count] + ): + repeat_count += 1 + + if repeat_count < 4: + continue + + prefix = current[:first_index].strip() + cleaned = paragraphs[:start] + if prefix and (not cleaned or cleaned[-1] != prefix): + cleaned.append(prefix) + cleaned.extend(paragraphs[start + repeat_count:]) + return "\n\n".join(cleaned).strip() + + return normalized + @staticmethod def _extract_tail_segment(text: str, *, window_chars: int = 460) -> tuple[str, str]: source = text or "" @@ -920,7 +958,7 @@ async def _expand_to_min_word_target( if not appendix: return content merged = (content.rstrip() + "\n\n" + appendix.lstrip()).strip() - return merged + return self._remove_repeated_leading_paragraph_block(merged) except Exception as exc: logger.warning("word target expansion skipped: %s", exc) return content diff --git a/tests/unit/application/workflows/test_auto_novel_generation_workflow.py b/tests/unit/application/workflows/test_auto_novel_generation_workflow.py index 51e6342d..0929a193 100644 --- a/tests/unit/application/workflows/test_auto_novel_generation_workflow.py +++ b/tests/unit/application/workflows/test_auto_novel_generation_workflow.py @@ -640,6 +640,30 @@ def test_smooth_truncated_tail_falls_back_to_punctuation_boundary(self, workflow assert smoothed.endswith("。") assert "第三句前半段" not in smoothed + def test_remove_repeated_leading_paragraph_block_from_expansion(self, workflow): + """续写若从开头复述整段,应剔除重复块并保留真正新增内容。""" + first = "顾知寒的手电筒光圈在防火卷帘上停住。" + source_paragraphs = [ + first, + "不是切割痕迹。是熔穿。", + "“卷帘编号C-17。”周正明说。", + "她没回头。脚下积水没过鞋帮。", + "“你跟踪我?”", + "“你拿了证物科的备用钥匙。”", + ] + repeated = "\n\n".join( + source_paragraphs + + ["又像在标记位置。" + first] + + source_paragraphs[1:] + + ["顾知寒没有立即动身。"] + ) + + cleaned = workflow._remove_repeated_leading_paragraph_block(repeated) + + assert cleaned.count(first) == 1 + assert "又像在标记位置。" in cleaned + assert cleaned.endswith("顾知寒没有立即动身。") + def test_build_prompt_includes_visible_chapter_strategy(self, workflow): prompt = workflow._build_prompt( context="CTX",