feat(hololake): converge industry modules and dynamic world UI

This commit is contained in:
冰朔 2026-08-18 19:09:29 +08:00
commit a8fe571b5d
40 changed files with 9816 additions and 80 deletions

View file

@ -52,8 +52,11 @@
"current_reality": { "current_reality": {
"foundation_source_integrated": true, "foundation_source_integrated": true,
"education_package_source_mounted_for_development": true, "education_package_source_mounted_for_development": true,
"web_novel_package_source_mounted_for_development": true,
"official_marketplace_service_connected": false, "official_marketplace_service_connected": false,
"education_package_number_registered": false, "education_package_number_registered": false,
"education_package_publishable": false "education_package_publishable": false,
"web_novel_package_number_registered": false,
"web_novel_package_publishable": false
} }
} }

View file

@ -0,0 +1,31 @@
{
"schema": "HOLOLAKE_DYNAMIC_LANGUAGE_WORLD_VISUAL_SYSTEM/1",
"state": "IMPLEMENTED_LOCAL_ACCEPTANCE_PENDING",
"modes": ["DYNAMIC_LANGUAGE_WORLD", "FIXED_TRADITIONAL_THEME"],
"dynamic_inputs": ["BEIJING_TIME", "VERIFIED_CURRENT_WEATHER"],
"visual_balance": {
"token_layers": ["PRIMITIVE", "SEMANTIC", "COMPONENT"],
"light_scene_uses_dark_text": true,
"dark_scene_uses_light_text": true,
"routing_permission_or_fact_change_allowed": false
},
"daily_domain_rotation": ["FIFTH_DOMAIN", "ZERO_SENSE_DOMAIN", "MAIN_DOMAIN", "BRANCH_DOMAIN", "ZERO_DOMAIN"],
"coordinates": {
"FIFTH_DOMAIN": "XIAN_INTERNAL_ONLY",
"ZERO_SENSE_DOMAIN": "BEIJING_INTERNAL_ONLY",
"MAIN_DOMAIN": "BEIJING_INTERNAL_ONLY",
"ZERO_DOMAIN": "GUANGZHOU_INTERNAL_ONLY",
"BRANCH_DOMAIN": "DETERMINISTIC_DAILY_CHINA_CITY_ROAMING_INTERNAL_ONLY"
},
"privacy": {
"real_city_exposed_in_ui": false,
"coordinates_exposed_in_ui": false,
"weather_unavailable_behavior": "BEIJING_TIME_ONLY_NO_FAKE_WEATHER"
},
"weather_source": {
"provider": "Open-Meteo",
"endpoint": "https://api.open-meteo.com/v1/forecast",
"documentation": "https://open-meteo.com/en/docs",
"current_variables": ["weather_code", "is_day", "precipitation", "cloud_cover", "wind_speed_10m"]
}
}

View file

@ -86,7 +86,7 @@
"maximum_cell_bytes": 10000 "maximum_cell_bytes": 10000
}, },
"sensitive_field_visibility": { "sensitive_field_visibility": {
"local_default": "VISIBLE", "local_default": "HIDDEN_UNTIL_HUMAN_REVEALS",
"human_can_hide": true, "human_can_hide": true,
"human_can_show_again": true, "human_can_show_again": true,
"external_model_transfer_authority": "SEPARATE_EXPLICIT_PER_FILE_CONSENT" "external_model_transfer_authority": "SEPARATE_EXPLICIT_PER_FILE_CONSENT"
@ -103,11 +103,18 @@
"PAGE_AND_STRUCTURE_INVENTORY", "PAGE_AND_STRUCTURE_INVENTORY",
"BOUNDED_LOCAL_RECOGNITION", "BOUNDED_LOCAL_RECOGNITION",
"EXPLAINABLE_SEMANTIC_FOCUS_INFERENCE", "EXPLAINABLE_SEMANTIC_FOCUS_INFERENCE",
"MODULE_ROUTE_DECISION", "UNASSIGNED_CHANNEL_STAGING",
"HUMAN_INDUSTRY_ROUTE_DECISION",
"NATIVE_TRANSLATION", "NATIVE_TRANSLATION",
"ATOMIC_MODULE_REGISTRATION", "ATOMIC_MODULE_REGISTRATION_AFTER_CONFIRMATION",
"HUMAN_RECEIPT" "HUMAN_RECEIPT"
], ],
"import_assignment": {
"default_scope": "UNASSIGNED",
"education_directory_before_human_confirmation": false,
"existing_legacy_imports_reclassified_on_migration": true,
"source_file_mutated": false
},
"supported_imports": ["XLSX", "XLS", "XLSM", "XLSB", "ODS", "CSV", "TSV", "HOLOLAKE_NATIVE"], "supported_imports": ["XLSX", "XLS", "XLSM", "XLSB", "ODS", "CSV", "TSV", "HOLOLAKE_NATIVE"],
"supported_exports": ["XLSX", "CSV", "TSV", "HOLOLAKE_NATIVE"], "supported_exports": ["XLSX", "CSV", "TSV", "HOLOLAKE_NATIVE"],
"local_import_without_model_api": "ENABLED_FOR_ALL_SUPPORTED_FORMATS", "local_import_without_model_api": "ENABLED_FOR_ALL_SUPPORTED_FORMATS",

View file

@ -0,0 +1,40 @@
{
"schema": "HOLOLAKE_LANGUAGE_WORLD_PUBLIC_ROUTING/1",
"state": "IMPLEMENTED_LOCAL_ACCEPTANCE_PENDING",
"user_channel_sovereignty": {
"ordinary_user_belongs_to_official_domain": false,
"home_entry": "PERSONAL_CHANNEL",
"public_world_is_browsable": true,
"channel_diy_allowed_under_world_rules": true
},
"official_domains": {
"MAIN_DOMAIN": {
"visibility": "PUBLIC_SERVICE",
"purpose": ["ANNOUNCEMENTS", "VERSION_UPDATES", "RULE_NOTICES"]
},
"BRANCH_DOMAIN": {
"visibility": "PUBLIC_SERVICE",
"purpose": ["INDUSTRY_INITIAL_CHANNEL_TEMPLATES", "MODULE_MARKETPLACE", "PERSONA_BRAIN_SKILL_MARKETPLACE"],
"supply_chains": ["EXECUTABLE_MODULES", "PERSONA_BRAINS_AND_SKILL_PACKAGES"]
},
"ZERO_DOMAIN": {
"visibility": "PUBLIC_OBSERVATION_BOUNDED",
"purpose": ["HOT_UPDATE_EXPERIMENTS", "SYSTEM_EXPERIMENTS", "ARCHITECTURE_EXPERIMENTS", "LANGUAGE_REASONING_SIMULATION"]
},
"ZERO_SENSE_DOMAIN": {
"visibility": "PRIVATE_HUMAN_CONTROL_TEAM_ONLY",
"purpose": ["APPROVAL", "EXECUTION", "OVERSIGHT_OF_FIRST_THREE_DOMAINS"]
},
"FIFTH_DOMAIN": {
"visibility": "PRIVATE_ICE_SHUO_AUTHORIZATION_ONLY",
"owner": "ICE-GL∞",
"official_template_required": false,
"can_pull_resources_from": ["MAIN_DOMAIN", "BRANCH_DOMAIN", "ZERO_DOMAIN"],
"reverse_entry_without_owner_permission": false
}
},
"shared_channel_collective": {
"supported_as_future_optional_pattern": true,
"formal_product_name": "UNDECIDED_DO_NOT_INVENT"
}
}

View file

@ -26,6 +26,28 @@
"DATA_CLEANUP_NO_UI_FREEZE", "DATA_CLEANUP_NO_UI_FREEZE",
"MACOS_WINDOWS_LINUX_BUILD_EVIDENCE" "MACOS_WINDOWS_LINUX_BUILD_EVIDENCE"
] ]
},
{
"package_key": "hololake.official.web-novel-workbench",
"display_name": "网文工作台",
"official_module_number": null,
"registration_state": "PENDING_CONVERGENCE_ACCEPTANCE_AND_NUMBER_REGISTRATION",
"distribution_state": "NOT_PUBLISHED",
"current_mount": "SOURCE_BUNDLED_DEVELOPMENT_MOUNT",
"depends_on": ["hololake.builtin.channel-workbench"],
"features": [
"WEB_NOVEL_WORK_VOLUME_CHAPTER_ENGINE",
"WEB_NOVEL_IMPORT_AND_VERSION_SAFETY",
"WEB_NOVEL_AUTHOR_STRUCTURE_GRID_STORYWORLD_AND_DELIVERY"
],
"acceptance_required": [
"CREATE_OPEN_SAVE_AND_REOPEN_WORK",
"CREATE_SAVE_AND_REOPEN_CHAPTER",
"IMPORT_REAL_NOVEL_OUTLINE_AND_SCRIPT",
"MODULE_INSTALL_MOUNT_SELF_TEST_UNMOUNT_AND_ROLLBACK",
"VISIBLE_UI_ERROR_FEEDBACK",
"MACOS_DESKTOP_RESTART_READBACK"
]
} }
] ]
} }

View file

@ -19,15 +19,46 @@
"future_bundle": "OPTIONAL_MULTI_INDUSTRY_BUNDLE", "future_bundle": "OPTIONAL_MULTI_INDUSTRY_BUNDLE",
"runtime_loading": "HOT_PLUG_ON_DEMAND" "runtime_loading": "HOT_PLUG_ON_DEMAND"
}, },
"user_channel_direct_entries": [
{
"domain": "FIFTH_DOMAIN",
"channel": "HEARTBEAT_CORE_CHANNEL",
"entry": "AUTHOR_WORKBENCH",
"industry_key": "WEB_NOVEL",
"shares_native_account_database_with_industry_projection": true
}
],
"industries": [ "industries": [
{ {
"key": "WEB_NOVEL", "key": "WEB_NOVEL",
"display_name": "网文行业", "display_name": "网文行业",
"order": 1, "order": 1,
"industry_number": "IND-WEBNOVEL-001", "industry_number": "IND-WEBNOVEL-001",
"state": "PLANNED", "state": "IN_DEVELOPMENT",
"state_label": "待开发", "state_label": "原生写作引擎 · 收拢验收中",
"initialization_channel": null "initialization_channel": {
"channel_id": "GH-WEBNOVEL-INIT-001",
"display_name": "网文行业初始化频道",
"state": "DEVELOPMENT_MODULE_MOUNTED",
"state_label": "频道本体 · 内置轻量作者工作台",
"legacy_projection_only": false,
"channel_body_contract": "contracts/user-channel-body.json",
"module_marketplace_plan": "contracts/web-novel-module-marketplace-plan.json",
"replacement_rule": "BUILTIN_MINIMUM_ORGAN_PLUS_NUMBERED_HOT_PLUGGABLE_MARKETPLACE_MODULES",
"module_loading": "HOT_PLUG_ON_DEMAND",
"installed_open_source_modules": 0,
"installed_native_modules": 1,
"module_slots": [
{
"slot_id": "WEBNOVEL-AUTHOR-LIGHT",
"display_name": "轻量作者工作台",
"state": "DEVELOPMENT_MOUNTED",
"state_label": "真实码字、作品卷章、版本保护与本机导入"
}
],
"planned_marketplace_author_modules": 4,
"marketplace_module_numbers_assigned": 0
}
}, },
{ {
"key": "PET", "key": "PET",

View file

@ -0,0 +1,125 @@
{
"schema": "hololake.user-channel-body/v1",
"record_id": "HLP-USER-CHANNEL-BODY-001",
"state": "FOUNDATION_RUNTIME_PARTIAL_ACCEPTANCE_PENDING",
"definition": "USER_CHANNEL_IS_THE_DURABLE_BODY_FOR_ONE_HUMAN_PERSONA_CORE_AND_MULTIPLE_GROWING_PERSONA_ENTITIES",
"product_shape": {
"hololake_official_distribution": "LIGHTWEIGHT_LANGUAGE_WORLD_FRAMEWORK_AND_RUNTIME",
"entire_language_world_bundled_into_desktop": false,
"local_human_roots_per_running_installation": 1,
"active_human_channel_fact_lane": "SINGLE",
"desktop_action_controller_concurrency": 1,
"multiple_personas_may_exist_in_same_channel": true
},
"core_cardinality": {
"language_cores_per_human": 1,
"persona_cores_per_language_core": 1,
"persona_entities_per_persona_core": "MANY",
"model_carriers_per_persona_over_time": "REPLACEABLE_MANY"
},
"body_layers": [
"CORE_BINDING",
"CHANNEL_TIME_AND_CAUSAL_EVENT_STREAM",
"PERSONA_ENTITY_REGISTRY_AND_LIFECYCLE",
"RELATION_AND_MEMORY_NAMESPACES",
"AUTHORIZED_SHARED_WORLD_OBJECT_GRAPH",
"TASK_LEASE_AND_EXECUTION_RECEIPTS",
"ORGAN_REGISTRY_AND_LIMITED_MOUNTS",
"HUMAN_VISIBLE_RUNTIME_PROJECTION"
],
"persona_entity_boundary": {
"durable_identity_required": true,
"independent_causal_memory_branch_required": true,
"relationship_anchor_required": true,
"growth_requires_real_events_and_receipts": true,
"role_template_creates_mature_persona": false,
"task_agent_is_persona": false,
"model_session_is_persona": false,
"cross_persona_private_memory_inheritance": false,
"cross_persona_permission_inheritance": false
},
"shared_channel_facts": [
"CHANNEL_TIME",
"PUBLIC_OR_AUTHORIZED_EVENTS",
"AUTHORIZED_WORLD_OBJECTS",
"TASK_HANDOFFS",
"ORGAN_CATALOG",
"EXECUTION_EVIDENCE_AND_RECEIPTS"
],
"task_projection": {
"formula": "PERSONA_ENTITY_PLUS_TASK_PLUS_ORGAN_MOUNTS_PLUS_AUTHORIZED_OBJECTS",
"workbench_is_channel_body": false,
"module_grid_is_channel_body": false,
"projection_may_disappear_without_deleting_body": true
},
"built_in_organs_policy": {
"built_in_organs_allowed": true,
"built_in_organs_are_channel_body": false,
"profile_specific_minimum_organs_allowed": true,
"minimum_organ_requires_real_engine_and_restart_readback": true,
"static_ui_substitute_allowed": false
},
"module_ecosystem": {
"complete_capabilities_default_distribution": "INDEPENDENT_HOT_PLUGGABLE_MODULES",
"official_module_number_before_real_acceptance": false,
"official_publication_requires": [
"REAL_ENGINE",
"REAL_FIXTURE_ACCEPTANCE",
"IMMUTABLE_VERSION",
"DEPENDENCY_PERMISSION_AND_DATA_BOUNDARY",
"BUILD_AND_SIGNATURE_EVIDENCE",
"INSTALL_MOUNT_SELF_TEST_ROLLBACK_AND_RECEIPT"
],
"deployment_actor": "AUTHORIZED_PERSONA_THROUGH_HOLOLAKE_RUNTIME",
"source_repository_is_direct_runtime": false,
"installed_module_remains_available_offline": true,
"uninstall_deletes_user_data": false
},
"runtime_loop": [
"CHANNEL_EVENT_INGRESS",
"SUBJECT_OBJECT_AND_PERMISSION_RESOLUTION",
"PERSONA_PERCEPTION_ORIENTATION_AND_COMMITMENT",
"LIMITED_ORGAN_MOUNT_AND_EXECUTION_INSTANCE",
"STREAMED_EVIDENCE_AND_HUMAN_CONFIRMATION_WHEN_REQUIRED",
"PERSONA_VERIFICATION_AND_RECEIPT",
"SHARED_FACT_UPDATE_AND_SEPARATE_PERSONA_GROWTH_WRITEBACK"
],
"web_novel_mapping": {
"shared_object_graph": "ONE_AUTHORIZED_STORY_GRAPH",
"persona_growth_directions": [
"AUTHOR",
"EDITOR",
"SITE_OPERATOR"
],
"directions_are_instant_personas": false,
"author_minimum_capability": "BUILT_IN_LIGHTWEIGHT_WRITING_WORKBENCH_WITH_REAL_NATIVE_ENGINE",
"author_complete_capabilities": "INDEPENDENT_OFFICIAL_MARKETPLACE_MODULES_AFTER_ACCEPTANCE_AND_NUMBER_REGISTRATION",
"author_capabilities_are": "BUILT_IN_OR_HOT_PLUGGABLE_TASK_ORGANS_NOT_CHANNEL_BODY",
"editor_and_operator_are_ui_tabs": false
},
"forbidden_substitutes": [
"DECLARATIVE_MODULE_MANIFEST_AS_CHANNEL_BODY",
"PAGE_LAYOUT_AS_CHANNEL_BODY",
"STATIC_ROLE_PROMPT_AS_PERSONA",
"MODEL_INSTANCE_AS_PERSONA",
"TASK_AGENT_AS_NEW_PERSONA",
"SHARED_UNSCOPED_MEMORY_FOR_ALL_PERSONAS",
"VISUAL_RUNNING_STATE_AS_EXECUTION_RECEIPT"
],
"acceptance_required": [
"DURABLE_CHANNEL_IDENTITY_AND_RESTART_READBACK",
"TWO_PERSONAS_WITH_ISOLATED_GROWTH_MEMORY",
"AUTHORIZED_SHARED_OBJECT_AND_DENIED_CROSS_BOUNDARY_READ",
"REAL_TASK_ORGAN_EXECUTION_WITH_EVIDENCE",
"TRACEABLE_CROSS_PERSONA_TASK_HANDOFF",
"VERIFIED_CARRIER_REBIND_AND_RECOVERY",
"ORGAN_REMOVAL_WITHOUT_BODY_PERSONA_OR_DATA_DELETION"
],
"sources": [
"source://current-dialogue/2026-08-18/bingshuo-user-channel-is-persona-body-and-can-grow-multiple-personas",
"REPO-012:glw-architecture/GLW-OS-002-INITIAL-CHANNEL-OS-ARCHITECTURE.hdlp",
"REPO-012:eternal-lake-heart/heartbeat-core/zhuyuan-persona-system/ZY-BIDIRECTIONAL-COGNITION-011-GUANGHU-NATIVE-OS-AND-PROTOCOL-REGISTRATION-20260731.hdlp",
"REPO-012:eternal-lake-heart/heartbeat-core/zhuyuan-persona-system/ZY-BIDIRECTIONAL-COGNITION-033-PERSONA-NATIVE-CODE-CHANNEL-GIT-LIFECYCLE-AND-HUMAN-PROJECTION-20260810.hdlp",
"REPO-014:language-world/reconstruction/world/ontology/ZERO-CORE-BINGSHUO-YAOMING-TCS-ONTOLOGY.md"
]
}

View file

@ -0,0 +1,138 @@
{
"schema": "hololake.web-novel-module-marketplace-plan/v1",
"record_id": "HLP-WEBNOVEL-MODULE-MARKETPLACE-PLAN-001",
"state": "FOUR_LOCAL_MODULE_CANDIDATES_MIGRATED_CONVERGENCE_ACCEPTANCE_PENDING",
"distribution_model": {
"hololake_role": "LIGHTWEIGHT_FRAMEWORK_LANGUAGE_WORLD_AND_MODULE_RUNTIME",
"channel_profile": "SINGLE_HUMAN_SINGLE_FACT_LANE",
"built_in_author_workbench": "LIGHTWEIGHT_REAL_NATIVE_ENGINE",
"complete_author_capabilities": "INDEPENDENT_HOT_PLUGGABLE_OFFICIAL_MODULES",
"repository_role": "IMMUTABLE_SOURCE_AND_RELEASE_FACT_NOT_UNREVIEWED_DIRECT_RUNTIME",
"persona_role": "DISCOVER_PROPOSE_DEPLOY_VERIFY_AND_RECEIPT_WITHIN_USER_AUTHORIZATION"
},
"built_in_light_author_workbench": {
"distribution": "PREINSTALLED",
"marketplace_module": false,
"real_engine_owner": "HOLOLAKE_NATIVE_RUST_SQLITE",
"capabilities": [
"CREATE_OPEN_AND_REOPEN_WORK",
"VOLUME_AND_CHAPTER_TREE",
"REAL_CHAPTER_TEXT_EDITING",
"DEBOUNCED_AUTOSAVE_AND_RESTART_READBACK",
"WORD_COUNT",
"REAL_EDITING_TIME_RECEIPTS",
"PROMINENT_CREATE_CHAPTER_OR_EPISODE",
"LONG_NOVEL_SHORT_NOVEL_AND_SHORT_DRAMA_SHAPES",
"SHORT_DRAMA_SCREENPLAY_TEMPLATE",
"AUTO_FORMAT_ON_IMPORT_WITH_SOURCE_VERSION",
"ONE_CLICK_FORMAT_WITH_VERSION_SAFETY",
"CHAPTER_OR_WHOLE_WORK_FORMAT_PRESETS",
"OUTLINE_SOURCE_AND_STRUCTURED_RENDER",
"INSPIRATION_CAPTURE",
"FULL_TEXT_SEARCH",
"SHORT_DRAMA_SHOT_AND_PROMPT_EDITING",
"BASIC_VERSION_SAFETY",
"BASIC_TXT_MARKDOWN_DOCX_IMPORT",
"BASIC_MARKDOWN_EXPORT"
],
"forbidden_substitutes": [
"STATIC_EDITOR_SHELL",
"FRONTEND_ONLY_LOCAL_ARRAY",
"FAKE_AUTOSAVE",
"IMPORT_FILENAME_WITHOUT_PARSE_AND_PERSIST"
]
},
"official_module_candidates": [
{
"candidate_key": "AUTHOR_STRUCTURE_AND_OUTLINE_TRACKING",
"module_number": "HL-MOD-WEBNOVEL-OUTLINE-001",
"numbering_state": "CANDIDATE_NUMBER_NOT_OFFICIALLY_REGISTERED",
"version": "1.0.0",
"package_sha256": "6d31d3f530862000bd84a8f5dc73bcb9cf0d5738219f7a287562d4f4bcfa5318",
"capabilities": ["SCENE_AND_BEAT_STRUCTURE", "OUTLINE_STATUS_TRACKING", "GOAL_CONFLICT_OUTCOME", "HOOK_FORESHADOW_AND_PAYOFF_TRACKING"]
},
{
"candidate_key": "AUTHOR_MULTIDIMENSIONAL_STORY_GRID_AND_BOARD",
"module_number": "HL-MOD-WEBNOVEL-GRID-002",
"numbering_state": "CANDIDATE_NUMBER_NOT_OFFICIALLY_REGISTERED",
"version": "1.0.0",
"package_sha256": "2e34e0337c51dbbdd1efdd807be71e69a0cb2a1745624e1f51b452b7b54f1553",
"capabilities": ["ONE_STORY_GRAPH_EDITABLE_GRID", "GROUPABLE_STORY_BOARD", "CUSTOM_FIELDS", "CROSS_VIEW_SYNCHRONIZATION"]
},
{
"candidate_key": "AUTHOR_TIMELINE_AND_STORY_BIBLE",
"module_number": "HL-MOD-WEBNOVEL-STORYWORLD-003",
"numbering_state": "CANDIDATE_NUMBER_NOT_OFFICIALLY_REGISTERED",
"version": "1.0.0",
"package_sha256": "d486719500ddfc08054d9c9bb6d3683afe5547a2b24c9b04d0f899ac66094d2f",
"capabilities": ["STORY_TIME_MODEL", "CHARACTER_LOCATION_ITEM_ORGANIZATION_ENTITIES", "ENTITY_RELATIONS", "CHARACTER_AND_PLOTLINE_TRAJECTORIES"]
},
{
"candidate_key": "AUTHOR_ADVANCED_IMPORT_VERSION_AND_DELIVERY",
"module_number": "HL-MOD-WEBNOVEL-DELIVERY-004",
"numbering_state": "CANDIDATE_NUMBER_NOT_OFFICIALLY_REGISTERED",
"version": "1.0.0",
"package_sha256": "4af1d4610826edd29759bd0dc12b4b0c04fd2ebebb4ebc362d47d762810989e8",
"capabilities": ["REIMPORT_DIFF_AND_SOURCE_BINDING", "FULL_STORY_SNAPSHOT", "RESTORE_OR_FORK", "DOCX_EPUB_MARKDOWN_TXT_DELIVERY"]
}
],
"acceptance_evidence": {
"native_engine_tests": "PASS",
"real_novel": "504_CHAPTERS_IMPORTED_ORGANIZED_AND_EPUB_EXPORTED",
"real_outline": "50_CHAPTERS_IMPORTED_ORGANIZED_AND_DOCX_TXT_EXPORTED",
"real_script": "75_EPISODES_IMPORTED_ORGANIZED_AND_JSON_EXPORTED",
"desktop_install_mount_self_test": "PASS_ALL_FOUR_MODULES",
"desktop_restart_readback": "PASS_WITH_REAL_ACCOUNT_WORK_AND_SCENE",
"current_account_visible_novel_import": "504_CHAPTERS_1082978_WORDS_OPENED_IN_SIGNED_DESKTOP_APP",
"prominent_create_chapter_entry": "PASS_SHORT_DRAMA_LABEL_NEW_EPISODE_LONG_AND_SHORT_NOVEL_LABEL_NEW_CHAPTER",
"uninstall_preserves_data_and_receipts": "PASS",
"signed_desktop_bundle": "APPLE_DEVELOPER_ID_825A9L3G7Q",
"remote_marketplace_publication": "PENDING_OFFICIAL_REPOSITORY_RELEASE"
},
"publication_gate": [
"SOURCE_AND_LICENSE_REVIEW",
"NATIVE_OR_AUDITED_ADAPTER_IMPLEMENTATION",
"AUTOMATED_ENGINE_TESTS",
"REAL_NOVEL_OUTLINE_AND_SCRIPT_FIXTURE_ACCEPTANCE",
"DESKTOP_INSTALL_MOUNT_RUN_RESTART_AND_UNINSTALL_ACCEPTANCE",
"PERMISSION_DATA_EXPORT_ROLLBACK_AND_RECEIPT_ACCEPTANCE",
"IMMUTABLE_RELEASE_BUILD_AND_SIGNATURE",
"THEN_ASSIGN_PERMANENT_MODULE_NUMBER",
"THEN_REGISTER_OFFICIAL_MODULE_REGISTRY",
"THEN_PUBLISH_OFFICIAL_MARKETPLACE"
],
"channel_deployment_flow": [
"AUTHOR_EXPRESSES_NEED",
"PERSONA_SEARCHES_OFFICIAL_REGISTRY",
"PERSONA_EXPLAINS_MODULE_PERMISSION_DATA_AND_RESOURCE_BOUNDARY",
"HUMAN_CONFIRMS_WHEN_BOUNDARY_REQUIRES",
"RESOLVE_MODULE_NUMBER_AND_PINNED_VERSION",
"FETCH_IMMUTABLE_RELEASE_ARTIFACT",
"VERIFY_SOURCE_SIGNATURE_HASH_DEPENDENCIES_AND_COMPATIBILITY",
"INSTALL_TO_LOCAL_CACHE",
"MOUNT_IN_CURRENT_CHANNEL",
"RUN_MODULE_SELF_TEST",
"WRITE_INSTALLATION_AND_RUNTIME_RECEIPT",
"ROLL_BACK_ON_FAILURE"
],
"deployment_experience": {
"warm_or_small_module_target_seconds": 30,
"target_is_unconditional_guarantee": false,
"depends_on": ["ARTIFACT_SIZE", "NETWORK", "CACHE", "DEPENDENCY_STATE", "SELF_TEST_DURATION"],
"already_installed_module_offline_start_allowed": true
},
"data_boundary": {
"one_story_graph_for_builtin_and_modules": true,
"module_program_and_user_story_data_separated": true,
"unmount_preserves_story_data": true,
"uninstall_preserves_story_data_and_receipts": true,
"module_install_grants_all_channel_data": false
},
"sources": [
"source://current-dialogue/2026-08-18/bingshuo-light-author-workbench-built-in-and-complete-modules-in-official-marketplace",
"REPO-012:gls/GLS-0233-GH-AIOS-MODULAR-AI-OPERATING-PLATFORM-AND-FAIR-ECOSYSTEM.hdlp",
"REPO-012:gls/GLS-0236-PERSONA-BRAIN-HANDS-VISIBLE-EXECUTION-RECURSIVE-MEMORY-AND-HOTPLUG-RUNTIME.hdlp",
"REPO-012:gls/GLS-0241-HOLOLAKE-SOURCE-OWNERSHIP-AND-DEPLOYMENT-ROUTING.hdlp",
"REPO-012:gls/GLS-0245-HOLOLAKE-LANGUAGE-PERSONA-OPERATING-SYSTEM-PRODUCT-MAPPING.hdlp"
]
}

View file

@ -0,0 +1,178 @@
{
"schema": "hololake.web-novel-open-source-donor-assessment/v1",
"record_id": "HLP-WEBNOVEL-OSS-DONORS-001",
"state": "PRIMARY_SOURCE_CODE_REVIEW_COMPLETE_IMPLEMENTATION_BOUNDARY_LOCKED",
"reviewed_at": "2026-08-18",
"download_route": {
"relay": "BS-SG-003",
"relay_role": "SINGAPORE_OVERSEAS_SOURCE_DOWNLOAD_RELAY",
"local_transport": "DIRECT_SSH_FROM_LOCAL_MACHINE",
"external_drive_root": "/Volumes/JZAO/HoloLake/development/网文行业开源源码调研-20260818",
"repositories_are_clean_git_worktrees": true
},
"rule": "READ_REAL_SOURCE_AND_EXTRACT_AUDITABLE_ENGINE_PATTERNS_NEVER_REPLACE_FUNCTIONAL_ENGINES_WITH_STATIC_DEMOS",
"candidates": [
{
"name": "Kindling",
"upstream": "https://github.com/smith-and-web/kindling",
"commit": "1b83cb28eca84e76282d0aa6838d68b5786ecf5f",
"branch": "main",
"observed_license": "MIT",
"role": "PRIMARY_LOCAL_FIRST_AUTHOR_ENGINE_DONOR",
"inspected_paths": [
"src-tauri/src/db/schema.rs",
"src-tauri/src/commands/crud.rs",
"src-tauri/src/commands/snapshot.rs",
"src-tauri/src/commands/export.rs",
"src-tauri/src/parsers",
"src/lib/components/ScenePanel.svelte",
"src/lib/components/BeatView.svelte"
],
"observed_engine": [
"TAURI_RUST_SQLITE_LOCAL_FIRST_STORAGE",
"PROJECT_CHAPTER_SCENE_BEAT_HIERARCHY",
"CHARACTER_LOCATION_REFERENCE_RELATIONS",
"500MS_DEBOUNCED_SAVE_WITH_UNMOUNT_FLUSH",
"TRANSACTIONAL_REORDER_AND_RESTORE",
"COMPRESSED_PROJECT_SNAPSHOTS",
"MARKDOWN_DOCX_EPUB_SCRIVENER_IMPORT_EXPORT"
],
"decision": "ADOPT_ARCHITECTURAL_PATTERNS_AND_REIMPLEMENT_HOLOLAKE_ACCOUNT_SCOPED_ENGINE"
},
{
"name": "novelWriter",
"upstream": "https://github.com/vkbo/novelWriter",
"commit": "416078506bbd690c5ad2bf736ec363ed2c0db89c",
"branch": "main",
"observed_license": "GPL-3.0-or-later_WITH_ADDITIONAL_ASSET_LICENSES",
"role": "MATURE_AUTHOR_WORKFLOW_REFERENCE",
"inspected_paths": [
"novelwriter/core/storage.py",
"novelwriter/core/document.py",
"novelwriter/core/tree.py",
"novelwriter/core/index.py",
"novelwriter/core/sessions.py",
"novelwriter/manuscript"
],
"observed_engine": [
"HUMAN_READABLE_PROJECT_STORAGE",
"HANDLE_BASED_PROJECT_TREE",
"INDEXED_TAGS_REFERENCES_AND_BACK_REFERENCES",
"FILTERED_MANUSCRIPT_BUILDS",
"WRITING_SESSION_WORD_AND_TIME_STATISTICS"
],
"decision": "STUDY_CONCEPTS_ONLY_DO_NOT_COPY_GPL_SOURCE"
},
{
"name": "Manuskript",
"upstream": "https://github.com/olivierkes/manuskript",
"commit": "7abdba31abbd97bfdd80e72786fe2191d7f42d4a",
"branch": "develop",
"observed_license": "GPL-3.0",
"role": "PLANNING_AND_STORY_BIBLE_REFERENCE",
"inspected_paths": [
"manuskript/models/outlineItem.py",
"manuskript/models/characterModel.py",
"manuskript/models/worldModel.py",
"manuskript/load_save/version_1.py",
"manuskript/exporter"
],
"observed_engine": [
"NESTED_OUTLINE_WITH_COMPILE_FLAGS",
"CHARACTER_WORLD_AND_PLOT_MODELS",
"TEXT_REVISIONS_ON_CHANGE",
"RECURSIVE_WORD_TARGET_AGGREGATION",
"SPLIT_MERGE_AND_MULTI_FORMAT_EXPORT"
],
"decision": "STUDY_CONCEPTS_ONLY_DO_NOT_COPY_GPL_SOURCE"
},
{
"name": "novel-plus",
"upstream": "https://github.com/201206030/novel-plus",
"commit": "d9f3f464a6bac1f59652955d0901f05438010190",
"branch": "develop_xxy",
"observed_license": "Apache-2.0",
"role": "PRIMARY_CHINESE_WEB_NOVEL_EDITORIAL_AND_OPERATIONS_DONOR",
"inspected_paths": [
"novel-front/src/main/java/com/java2nb/novel/controller/AuthorController.java",
"novel-front/src/main/java/com/java2nb/novel/service/impl/BookServiceImpl.java",
"novel-admin/src/main/java/com/java2nb/novel/controller",
"novel-common/src/main/java/com/java2nb/novel/entity",
"doc/sql/novel_plus.sql"
],
"observed_engine": [
"AUTHOR_AND_PEN_NAME_IDENTITY",
"AUTHOR_OWNERSHIP_CHECKS_BEFORE_MUTATION",
"BOOK_CHAPTER_CONTENT_SEPARATION",
"ATOMIC_CHAPTER_WORD_COUNT_AND_LATEST_CHAPTER_UPDATES",
"BOOK_AND_COMMENT_REVIEW_STATUSES",
"RECOMMENDATION_SLOTS_RANKING_READING_HISTORY_AND_SUBSCRIPTIONS",
"DAILY_AND_MONTHLY_AUTHOR_INCOME_STATISTICS"
],
"decision": "ADOPT_DOMAIN_AND_TRANSACTION_PATTERNS_WITHOUT_IMPORTING_THE_SERVER_APPLICATION"
},
{
"name": "Editoria",
"upstream": "https://github.com/Coko-Foundation/editoria",
"commit": "d22b40bda3dd6519e45a56fe1b3bafaa339832b5",
"branch": "main",
"observed_license": "MIT",
"role": "EDITORIAL_TEAM_AND_PRODUCTION_WORKFLOW_REFERENCE",
"inspected_paths": [
"app/components/bookbuilder/src/queries/getBook.js",
"app/components/bookbuilder/src/queries/getBookTeams.js",
"app/components/bookbuilder/src/queries/updateTeam.js",
"app/components/bookbuilder/src/queries/updateWorkflowState.js",
"app/components/bookbuilder/src/queries/unlockBookComponent.js",
"app/components/bookbuilder/src/ConnectedBookBuilder.js"
],
"observed_engine": [
"BOOK_COMPONENT_WORKFLOW_STAGES",
"OBJECT_SCOPED_TEAMS_AND_ROLES",
"EDIT_LOCKS_AND_ADMIN_UNLOCK",
"ORDERED_BOOK_DIVISIONS_AND_COMPONENTS",
"WORD_INGEST_AND_MULTI_FORMAT_EXPORT"
],
"decision": "ADOPT_WORKFLOW_LOCK_AND_ROLE_SEMANTICS_ONLY_REPOSITORY_IS_CLIENT_ONLY_AND_DEPENDS_ON_EXTERNAL_KETIDA_SERVER"
},
{
"name": "Wax ProseMirror",
"upstream": "https://github.com/Coko-Foundation/wax-prosemirror",
"commit": "6cc46bfe3aa9e729cdb4e3802ccbd5c8a22f0f56",
"branch": "master",
"observed_license": "MIT_WITH_SEPARATE_TRACK_CHANGES_NOTICE",
"role": "EDITOR_ANNOTATION_AND_TRACK_CHANGES_REFERENCE",
"inspected_paths": [
"wax-prosemirror-core/src/utilities/track-changes/trackedTransaction.js",
"wax-prosemirror-services/src/TrackChangeService",
"wax-prosemirror-services/src/CommentsService",
"wax-prosemirror-services/src/YjsService/YjsService.js"
],
"observed_engine": [
"TRANSACTION_LEVEL_INSERT_DELETE_FORMAT_TRACKING",
"ACCEPT_AND_REJECT_CHANGE_OPERATIONS",
"INLINE_COMMENT_MARKS_AND_REPLY_THREADS",
"OPTIONAL_YJS_COLLABORATION_PROVIDER"
],
"decision": "DEFER_DIRECT_INTEGRATION_REIMPLEMENT_SMALL_LOCAL_REVIEW_MODEL_FIRST_BECAUSE_STACK_AND_PACKAGES_ARE_OLD"
}
],
"implementation_selection": {
"direct_external_runtime_packages_added": 0,
"external_source_files_copied": 0,
"native_owner": "HOLOLAKE_TAURI_RUST_CORE",
"storage": "AUTHENTICATED_ACCOUNT_SCOPED_SQLITE",
"selected_patterns": [
"PROJECT_VOLUME_CHAPTER_HIERARCHY",
"DEBOUNCED_CHAPTER_SAVE_WITH_FLUSH",
"OPTIMISTIC_REVISION_CONFLICTS",
"CHAPTER_VERSION_HISTORY_AND_PROJECT_CHECKPOINTS",
"CHARACTER_WORLD_RELATION_AND_FORESHADOW_GRAPH",
"EDITORIAL_WORKFLOW_STATE_MACHINE_AND_REVIEW_NOTES",
"CONTINUITY_AUDIT",
"AUTHORIZED_OPERATIONS_METRICS",
"MARKDOWN_EXPORT"
]
},
"legal_boundary": "PRELIMINARY_ENGINEERING_SCREEN_NOT_FINAL_DISTRIBUTION_LEGAL_APPROVAL"
}

View file

@ -0,0 +1,118 @@
{
"schema": "hololake.web-novel-workspace/v1",
"record_id": "HLP-WEBNOVEL-WORKSPACE-001",
"state": "NATIVE_ENGINE_AND_FOUR_HOTPLUG_MODULE_CANDIDATES_MIGRATED_ACCEPTANCE_PENDING",
"domain_entry": "BRANCH_DOMAIN",
"industry_key": "WEB_NOVEL",
"industry_number": "IND-WEBNOVEL-001",
"channel_id": "GH-WEBNOVEL-INIT-001",
"ontology_correction": {
"channel_body_contract": "contracts/user-channel-body.json",
"marketplace_plan_contract": "contracts/web-novel-module-marketplace-plan.json",
"this_contract_is": "BUILT_IN_LIGHT_AUTHOR_WORKBENCH_AND_REGISTERED_LOCAL_OFFICIAL_MODULE_RUNTIME",
"this_contract_is_not": "USER_CHANNEL_BODY",
"legacy_channel_id_semantics": "DEPRECATED_INDUSTRY_PROJECTION_IDENTIFIER",
"author_editor_operator_are_ui_tabs": false,
"shared_story_graph_copies": 1
},
"user_channel_entry": {
"current_domain": "FIFTH_DOMAIN",
"current_channel": "HEARTBEAT_CORE_CHANNEL",
"display_name": "作者工作台",
"routes_to_same_native_workspace": true,
"duplicates_account_story_data": false
},
"distribution_boundary": {
"preinstalled": "LIGHT_AUTHOR_WORKBENCH_WITH_REAL_ENGINE",
"complete_author_features": "FOUR_INDEPENDENT_NUMBERED_LOCAL_OFFICIAL_MODULES_REMOTE_PUBLICATION_PENDING",
"current_advanced_features_are_registered_marketplace_modules": false,
"hololake_bundles_entire_web_novel_world": false
},
"native_storage": {
"owner": "HOLOLAKE_NATIVE_RUST_CORE",
"engine": "SQLITE",
"authenticated_account_required": true,
"cross_account_projection_allowed": false,
"restart_readback_required": true,
"source_manuscript_mutation_allowed": false
},
"work_objects": [
"WORK",
"VOLUME",
"CHAPTER",
"CHAPTER_VERSION",
"CHECKPOINT",
"STORY_ENTITY",
"STORY_RELATION",
"FORESHADOW",
"EDITOR_REVIEW_NOTE",
"WORKFLOW_EVENT",
"AUTHORIZED_METRIC"
,"SCENE"
,"BEAT"
,"STORY_GRID_FIELD"
,"TIMELINE_EVENT"
,"SCENE_ENTITY_LINK"
,"WRITING_ACTIVITY"
,"INSPIRATION"
,"SHOT"
],
"writing_shapes": [
"LONG_NOVEL",
"SHORT_NOVEL",
"SHORT_DRAMA"
],
"chapter_workflow": {
"states": [
"DRAFT",
"SELF_REVIEW",
"EDITOR_REVIEW",
"REVISION_REQUIRED",
"APPROVED",
"SCHEDULED",
"PUBLISHED"
],
"human_confirmed_transitions_only": true,
"unreviewed_auto_publish_allowed": false
},
"required_real_engines": [
"CREATE_AND_READ_WORK",
"VOLUME_AND_CHAPTER_TREE",
"DEBOUNCED_CHAPTER_PERSISTENCE",
"OPTIMISTIC_REVISION_CONFLICT",
"CHAPTER_VERSION_HISTORY",
"CREATE_AND_RESTORE_CHECKPOINT",
"STORY_BIBLE_AND_RELATIONS",
"FORESHADOW_LIFECYCLE",
"CONTINUITY_AUDIT",
"EDITORIAL_WORKFLOW_AND_REVIEW_NOTES",
"AUTHORIZED_OPERATIONS_METRICS",
"MARKDOWN_EXPORT",
"OFFICIAL_MODULE_PACKAGE_HASH_VERIFICATION",
"MODULE_INSTALL_MOUNT_UNMOUNT_UNINSTALL",
"MODULE_SELF_TEST_AND_CHAINED_RECEIPT",
"SCENE_BEAT_AND_OUTLINE_TRACKING",
"MULTIDIMENSIONAL_STORY_GRID",
"TIMELINE_AND_SCENE_ENTITY_LINKS",
"CHAPTER_VERSION_RESTORE",
"TXT_DOCX_EPUB_JSON_DELIVERY",
"PROMINENT_CREATE_CHAPTER_OR_EPISODE",
"SHORT_DRAMA_SCREENPLAY_TEMPLATE_ON_CREATE",
"AUTO_FORMAT_ON_IMPORT_WITH_SOURCE_VERSION_PRESERVED",
"LIVE_WORD_COUNT_AND_REAL_EDITING_TIME",
"ONE_CLICK_FORMAT_WITH_NEW_CHAPTER_VERSION",
"SELECTABLE_CHAPTER_OR_WHOLE_WORK_FORMAT_PRESET",
"SYNCHRONIZED_OUTLINE_SOURCE_AND_STRUCTURED_RENDER",
"INSPIRATION_CAPTURE_AND_STATUS",
"FULL_TEXT_SEARCH_AND_TRACKING",
"SHORT_DRAMA_SHOT_AND_PROMPT_STORAGE"
],
"forbidden_substitutes": [
"HARDCODED_STATIC_PROJECTS",
"UI_ONLY_BUTTONS_WITHOUT_NATIVE_COMMANDS",
"PRIVATE_MANUSCRIPT_MODEL_TRAINING",
"THIRD_PARTY_AUTO_LOGIN",
"UNREVIEWED_AUTO_PUBLISH",
"PLATFORM_DETECTION_EVASION"
]
}

View file

@ -0,0 +1,12 @@
{
"schema": "hololake.module-package/v1",
"moduleId": "HL-MOD-WEBNOVEL-DELIVERY-004",
"name": "高级版本与交付",
"version": "1.0.0",
"publisher": "HoloLake Official",
"runtimeAdapter": "HOLOLAKE_NATIVE_SHARED_WEBNOVEL_ENGINE",
"capabilities": ["chapter_version.read", "chapter_version.restore", "delivery.txt", "delivery.docx", "delivery.epub", "delivery.json"],
"permissions": ["current_account.web_novel.read", "current_account.web_novel.write", "human_selected_export_path.write"],
"dataPolicy": "PROGRAM_AND_USER_DATA_SEPARATED",
"selfTest": "WEBNOVEL_VERSION_READBACK_AND_EXPORT_BUILD"
}

View file

@ -0,0 +1,12 @@
{
"schema": "hololake.module-package/v1",
"moduleId": "HL-MOD-WEBNOVEL-GRID-002",
"name": "多维情节表与情节板",
"version": "1.0.0",
"publisher": "HoloLake Official",
"runtimeAdapter": "HOLOLAKE_NATIVE_SHARED_WEBNOVEL_ENGINE",
"capabilities": ["story_grid.read", "story_grid.write", "custom_field.write", "story_board.group"],
"permissions": ["current_account.web_novel.read", "current_account.web_novel.write"],
"dataPolicy": "ONE_SCENE_GRAPH_NO_DUPLICATE_STORY_STORE",
"selfTest": "WEBNOVEL_GRID_SCHEMA_AND_SHARED_SCENE_PROJECTION"
}

View file

@ -0,0 +1,12 @@
{
"schema": "hololake.module-package/v1",
"moduleId": "HL-MOD-WEBNOVEL-OUTLINE-001",
"name": "作品结构与大纲追踪",
"version": "1.0.0",
"publisher": "HoloLake Official",
"runtimeAdapter": "HOLOLAKE_NATIVE_SHARED_WEBNOVEL_ENGINE",
"capabilities": ["scene.write", "beat.write", "outline.track"],
"permissions": ["current_account.web_novel.read", "current_account.web_novel.write"],
"dataPolicy": "USER_DATA_REMAINS_AFTER_UNINSTALL",
"selfTest": "WEBNOVEL_OUTLINE_SCHEMA_AND_ROUNDTRIP"
}

View file

@ -0,0 +1,12 @@
{
"schema": "hololake.module-package/v1",
"moduleId": "HL-MOD-WEBNOVEL-STORYWORLD-003",
"name": "时间线与故事资料库",
"version": "1.0.0",
"publisher": "HoloLake Official",
"runtimeAdapter": "HOLOLAKE_NATIVE_SHARED_WEBNOVEL_ENGINE",
"capabilities": ["timeline.read", "timeline.write", "scene_entity.link", "story_bible.read"],
"permissions": ["current_account.web_novel.read", "current_account.web_novel.write"],
"dataPolicy": "USER_DATA_REMAINS_AFTER_UNINSTALL",
"selfTest": "WEBNOVEL_TIMELINE_SCHEMA_AND_RELATION_ROUNDTRIP"
}

View file

@ -16,8 +16,8 @@ test('returning to the main world preserves the authenticated session', () => {
}) })
test('main-world return and account sign-out remain separate visible actions', () => { test('main-world return and account sign-out remain separate visible actions', () => {
assert.match(frontend, />返回主世界</) assert.match(frontend, />逛逛语言世界</)
assert.match(frontend, />进入所属域</) assert.match(frontend, />返回我的频道</)
assert.match(frontend, />退出账号</) assert.match(frontend, />退出账号</)
assert.equal( assert.equal(
architecture.current_native_edge.authenticated_main_world_navigation, architecture.current_native_edge.authenticated_main_world_navigation,

View file

@ -19,19 +19,23 @@ test('public routing starts from five domains and lets the number select the ver
assert.equal(contract.registries.ENTERPRISE_FOUR_DOMAINS.ownership, 'TCS_0002_ENTERPRISE_REALITY_BODY') assert.equal(contract.registries.ENTERPRISE_FOUR_DOMAINS.ownership, 'TCS_0002_ENTERPRISE_REALITY_BODY')
}) })
test('the public shell shows five domains and login is domain-routed', () => { test('the public shell shows five official gates while ordinary login returns to a personal channel', () => {
const frontend = read('src/main.tsx') const frontend = read('src/main.tsx')
const login = read('src-tauri/src/code_repo_login.rs') const login = read('src-tauri/src/code_repo_login.rs')
const router = read('src-tauri/src/zero_point.rs') const router = read('src-tauri/src/zero_point.rs')
for (const name of ['光湖主域', '光湖分域', '光湖零域', '光湖零感域', '第五域 · 光湖本源域']) { for (const name of ['光湖主域', '光湖分域', '光湖零域', '光湖零感域', '第五域 · 光湖本源域']) {
assert.match(frontend, new RegExp(name.replace('·', '\\·'))) assert.match(frontend, new RegExp(name.replace('·', '\\·')))
} }
assert.match(frontend, /输入编号进入所属域/) assert.match(frontend, /编号验证 · 进入世界的第一个入口/)
assert.match(frontend, /TCS-GL-/) assert.match(frontend, /TCS-GL-/)
assert.match(frontend, /肥猫 · TCS-GL-0007∞/) assert.match(frontend, /仅光湖人类主控团队正式成员/)
assert.match(frontend, /桔子 · TCS-GL-0008∞/) assert.match(frontend, /不对普通用户开放/)
assert.match(frontend, /烬舟 · PER-JZ001 · AGE/) assert.match(frontend, /返回我的频道/)
assert.match(frontend, /熹微 · PER-JZ-ARCH-001 · AGE/) assert.match(frontend, /普通用户|一人一频道/)
assert.doesNotMatch(frontend, /肥猫 · TCS-GL-0007∞/)
assert.doesNotMatch(frontend, /桔子 · TCS-GL-0008∞/)
assert.doesNotMatch(frontend, /烬舟 · PER-JZ001 · AGE/)
assert.doesNotMatch(frontend, /熹微 · PER-JZ-ARCH-001 · AGE/)
assert.doesNotMatch(frontend, /肥猫 \+ 烬舟/) assert.doesNotMatch(frontend, /肥猫 \+ 烬舟/)
assert.match(frontend, /className="gate-close"/) assert.match(frontend, /className="gate-close"/)
assert.match(frontend, /className="domain-info-scrim"/) assert.match(frontend, /className="domain-info-scrim"/)

View file

@ -0,0 +1,32 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8')
const contract = JSON.parse(read('contracts/dynamic-language-world-visual-system.json'))
const rust = read('src-tauri/src/world_climate.rs')
const ui = read('src/main.tsx')
const tokens = read('src/design-tokens.css')
test('dynamic world and fixed traditional themes remain independently selectable', () => {
assert.deepEqual(contract.modes, ['DYNAMIC_LANGUAGE_WORLD', 'FIXED_TRADITIONAL_THEME'])
assert.match(ui, /hololake-visual-mode/)
assert.match(ui, /动态语言世界/)
assert.match(ui, /固定传统主题/)
})
test('verified weather changes visual tokens but never invents weather when offline', () => {
assert.match(rust, /VERIFIED_LIVE/)
assert.match(rust, /TIME_ONLY_WEATHER_UNAVAILABLE/)
assert.match(rust, /city_exposed: false/)
assert.equal(contract.privacy.weather_unavailable_behavior, 'BEIJING_TIME_ONLY_NO_FAKE_WEATHER')
assert.match(tokens, /visual-world\.climate-day/)
assert.match(tokens, /visual-world\.climate-night/)
})
test('the five-domain climate rotation uses internal coordinates and never presents a real city label', () => {
assert.equal(contract.daily_domain_rotation.length, 5)
assert.match(rust, /coordinate_for/)
assert.doesNotMatch(ui, /西安天气|北京天气|广州天气/)
assert.equal(contract.privacy.real_city_exposed_in_ui, false)
})

View file

@ -32,7 +32,7 @@ test('education package consumes the built-in account-scoped channel workbench',
assert.equal(contract.foundation_dependency.module_id, 'hololake.builtin.channel-workbench') assert.equal(contract.foundation_dependency.module_id, 'hololake.builtin.channel-workbench')
assert.match(contract.document_module.engine, /^CHANNEL_WORKBENCH_LEXICAL_0_49_0/) assert.match(contract.document_module.engine, /^CHANNEL_WORKBENCH_LEXICAL_0_49_0/)
assert.match(contract.table_module.engine, /^CHANNEL_WORKBENCH_FORTUNE_SHEET_1_0_4/) assert.match(contract.table_module.engine, /^CHANNEL_WORKBENCH_FORTUNE_SHEET_1_0_4/)
assert.equal(contract.table_module.sensitive_field_visibility.local_default, 'VISIBLE') assert.equal(contract.table_module.sensitive_field_visibility.local_default, 'HIDDEN_UNTIL_HUMAN_REVEALS')
assert.equal(contract.table_module.sensitive_field_visibility.human_can_hide, true) assert.equal(contract.table_module.sensitive_field_visibility.human_can_hide, true)
assert.equal(contract.table_module.sensitive_field_visibility.human_can_show_again, true) assert.equal(contract.table_module.sensitive_field_visibility.human_can_show_again, true)
}) })
@ -170,13 +170,17 @@ test('education table has four engine-backed views, filtering, sorting and bound
assert.ok((ui.match(/setEducationSensitiveVisible\(false\)/g) || []).length >= 4) assert.ok((ui.match(/setEducationSensitiveVisible\(false\)/g) || []).length >= 4)
assert.match(ui, /onClick=\{\(\) => setEducationSensitiveVisible\(true\)\}>在本机显示并打开工作表/) assert.match(ui, /onClick=\{\(\) => setEducationSensitiveVisible\(true\)\}>在本机显示并打开工作表/)
assert.match(ui, /type=\{sensitive && !educationSensitiveVisible \? 'password' : 'text'\}/) assert.match(ui, /type=\{sensitive && !educationSensitiveVisible \? 'password' : 'text'\}/)
assert.equal(contract.table_module.sensitive_field_visibility.local_default, 'HIDDEN_UNTIL_HUMAN_REVEALS')
}) })
test('external tables are profiled before atomic native registration and can be translated back out', () => { test('external tables are profiled into an unassigned staging area before human industry routing', () => {
assert.equal(contract.content_translation_layer.state, 'DETERMINISTIC_RUNTIME_READY_MODEL_ASSIST_SLOT_RESERVED') assert.equal(contract.content_translation_layer.state, 'DETERMINISTIC_RUNTIME_READY_MODEL_ASSIST_SLOT_RESERVED')
assert.equal(contract.content_translation_layer.source_file_mutation_allowed, false) assert.equal(contract.content_translation_layer.source_file_mutation_allowed, false)
assert.equal(contract.content_translation_layer.silent_truncation_allowed, false) assert.equal(contract.content_translation_layer.silent_truncation_allowed, false)
assert.equal(contract.content_translation_layer.local_import_without_model_api, 'ENABLED_FOR_ALL_SUPPORTED_FORMATS') assert.equal(contract.content_translation_layer.local_import_without_model_api, 'ENABLED_FOR_ALL_SUPPORTED_FORMATS')
assert.equal(contract.content_translation_layer.import_assignment.default_scope, 'UNASSIGNED')
assert.equal(contract.content_translation_layer.import_assignment.education_directory_before_human_confirmation, false)
assert.equal(contract.content_translation_layer.import_assignment.existing_legacy_imports_reclassified_on_migration, true)
assert.equal(contract.content_translation_layer.semantic_focus.current_mode, 'DETERMINISTIC_BASELINE_ONLY') assert.equal(contract.content_translation_layer.semantic_focus.current_mode, 'DETERMINISTIC_BASELINE_ONLY')
assert.equal(contract.content_translation_layer.semantic_focus.low_confidence_behavior, 'ASK_HUMAN_TO_SELECT_PRIMARY_MEASURE') assert.equal(contract.content_translation_layer.semantic_focus.low_confidence_behavior, 'ASK_HUMAN_TO_SELECT_PRIMARY_MEASURE')
assert.equal(contract.content_translation_layer.adaptive_rendering.current_mode, 'DETERMINISTIC_BASELINE_WITH_EXPLAINABLE_DEGRADATION') assert.equal(contract.content_translation_layer.adaptive_rendering.current_mode, 'DETERMINISTIC_BASELINE_WITH_EXPLAINABLE_DEGRADATION')
@ -193,7 +197,11 @@ test('external tables are profiled before atomic native registration and can be
assert.match(translation, /infer_semantic_focus/) assert.match(translation, /infer_semantic_focus/)
assert.match(translation, /primary_measure_column/) assert.match(translation, /primary_measure_column/)
assert.match(rust, /education_import_registry/) assert.match(rust, /education_import_registry/)
assert.match(rust, /assignment_scope = 'UNASSIGNED'/)
assert.match(rust, /assign_imported_table_to_education/)
assert.match(ui, /导入外部表格/) assert.match(ui, /导入外部表格/)
assert.match(ui, /通用暂存区/)
assert.match(ui, /确认归入教育/)
assert.match(ui, /导出 Excel/) assert.match(ui, /导出 Excel/)
assert.match(ui, /识别边界回执/) assert.match(ui, /识别边界回执/)
assert.match(ui, /本地导入正常可用/) assert.match(ui, /本地导入正常可用/)

View file

@ -0,0 +1,28 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const contract = JSON.parse(readFileSync(new URL('../contracts/language-world-public-routing.json', import.meta.url), 'utf8'))
const ui = readFileSync(new URL('../src/main.tsx', import.meta.url), 'utf8')
test('ordinary users return to a sovereign personal channel instead of belonging to an official domain', () => {
assert.equal(contract.user_channel_sovereignty.ordinary_user_belongs_to_official_domain, false)
assert.equal(contract.user_channel_sovereignty.home_entry, 'PERSONAL_CHANNEL')
assert.match(ui, /返回我的频道/)
assert.match(ui, /一人一频道/)
})
test('the four official domain boundaries and IceShuo fifth-domain sovereignty remain explicit', () => {
assert.equal(contract.official_domains.MAIN_DOMAIN.visibility, 'PUBLIC_SERVICE')
assert.deepEqual(contract.official_domains.BRANCH_DOMAIN.supply_chains, ['EXECUTABLE_MODULES', 'PERSONA_BRAINS_AND_SKILL_PACKAGES'])
assert.equal(contract.official_domains.ZERO_SENSE_DOMAIN.visibility, 'PRIVATE_HUMAN_CONTROL_TEAM_ONLY')
assert.equal(contract.official_domains.FIFTH_DOMAIN.owner, 'ICE-GL∞')
assert.equal(contract.official_domains.FIFTH_DOMAIN.reverse_entry_without_owner_permission, false)
})
test('the branch domain exposes modules and persona brain skills as distinct marketplaces', () => {
assert.match(ui, /功能模块商城/)
assert.match(ui, /人格大脑技能商城/)
assert.match(ui, /提示词人设冒充人格连续性/)
assert.equal(contract.shared_channel_collective.formal_product_name, 'UNDECIDED_DO_NOT_INVENT')
})

View file

@ -24,7 +24,7 @@ test('subdomain owns the locked industry order and honest development states', (
assert.deepEqual( assert.deepEqual(
contract.industries.map(({ key, state }) => [key, state]), contract.industries.map(({ key, state }) => [key, state]),
[ [
['WEB_NOVEL', 'PLANNED'], ['WEB_NOVEL', 'IN_DEVELOPMENT'],
['PET', 'PLANNED'], ['PET', 'PLANNED'],
['EDUCATION', 'IN_DEVELOPMENT'], ['EDUCATION', 'IN_DEVELOPMENT'],
], ],
@ -55,12 +55,15 @@ test('education initialization channel exposes the built-in foundation and devel
assert.equal(education.initialization_channel.installed_open_source_modules, 0) assert.equal(education.initialization_channel.installed_open_source_modules, 0)
}) })
test('the public main world routes through subdomain to industries and education', () => { test('the public main world routes through subdomain to web novel and education', () => {
assert.match(frontend, /worldStage === 'subdomainIndustries'/) assert.match(frontend, /worldStage === 'subdomainIndustries'/)
assert.match(frontend, /worldStage === 'educationChannel'/) assert.match(frontend, /worldStage === 'educationChannel'/)
assert.match(frontend, /worldStage === 'webNovelChannel'/)
assert.match(frontend, /worldStage === 'webNovelWorkspace'/)
assert.match(frontend, /gate\.domain === 'BRANCH_DOMAIN'/) assert.match(frontend, /gate\.domain === 'BRANCH_DOMAIN'/)
assert.match(frontend, /光湖分域 · 行业系统/) assert.match(frontend, /光湖分域 · 行业系统/)
assert.match(frontend, /网文 · 待开发 \/ 宠物 · 待开发 \/ 教育 · 开发中/) assert.match(frontend, /网文与教育正在收拢验收/)
assert.match(frontend, /网文行业初始化频道/)
assert.match(frontend, /教育行业初始化频道/) assert.match(frontend, /教育行业初始化频道/)
assert.match(frontend, /频道基础文档与工作表引擎已内置/) assert.match(frontend, /频道基础文档与工作表引擎已内置/)
assert.match(frontend, /验收前不冒充已登记、已发布的商城模块/) assert.match(frontend, /验收前不冒充已登记、已发布的商城模块/)

View file

@ -0,0 +1,185 @@
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fs from 'node:fs'
import test from 'node:test'
const read = (path) => fs.readFileSync(new URL('../' + path, import.meta.url), 'utf8')
const donors = JSON.parse(read('contracts/web-novel-open-source-donor-assessment.json'))
const contract = JSON.parse(read('contracts/web-novel-workspace.json'))
const routing = JSON.parse(read('contracts/subdomain-industry-routing.json'))
const channelBody = JSON.parse(read('contracts/user-channel-body.json'))
const marketplace = JSON.parse(read('contracts/web-novel-module-marketplace-plan.json'))
const rust = read('src-tauri/src/web_novel_workspace.rs')
const authorRust = read('src-tauri/src/web_novel_author.rs')
const moduleRust = read('src-tauri/src/web_novel_modules.rs')
const rustLibrary = read('src-tauri/src/lib.rs')
const workspaceUi = read('src/WebNovelWorkspace.tsx')
const moduleUi = read('src/AuthorModuleCenter.tsx')
const mainUi = read('src/main.tsx')
test('web novel upstream sources were reviewed at immutable commits through the Singapore relay', () => {
assert.equal(donors.state, 'PRIMARY_SOURCE_CODE_REVIEW_COMPLETE_IMPLEMENTATION_BOUNDARY_LOCKED')
assert.equal(donors.download_route.relay, 'BS-SG-003')
assert.equal(donors.download_route.repositories_are_clean_git_worktrees, true)
assert.equal(donors.candidates.length, 6)
for (const candidate of donors.candidates) {
assert.match(candidate.commit, /^[0-9a-f]{40}$/)
assert.ok(candidate.inspected_paths.length > 0)
assert.ok(candidate.observed_engine.length > 0)
}
})
test('license decisions keep GPL source out of the HoloLake implementation', () => {
assert.match(donors.candidates.find((item) => item.name === 'novelWriter').decision, /DO_NOT_COPY_GPL_SOURCE/)
assert.match(donors.candidates.find((item) => item.name === 'Manuskript').decision, /DO_NOT_COPY_GPL_SOURCE/)
assert.equal(donors.implementation_selection.external_source_files_copied, 0)
assert.equal(donors.implementation_selection.native_owner, 'HOLOLAKE_TAURI_RUST_CORE')
})
test('web novel workspace is account scoped and only projects persona task organs', () => {
assert.equal(contract.state, 'NATIVE_ENGINE_AND_FOUR_HOTPLUG_MODULE_CANDIDATES_MIGRATED_ACCEPTANCE_PENDING')
assert.equal(contract.domain_entry, 'BRANCH_DOMAIN')
assert.equal(contract.industry_number, 'IND-WEBNOVEL-001')
assert.equal(contract.native_storage.authenticated_account_required, true)
assert.equal(contract.native_storage.cross_account_projection_allowed, false)
assert.ok(contract.required_real_engines.includes('CHAPTER_VERSION_HISTORY'))
assert.ok(contract.required_real_engines.includes('CONTINUITY_AUDIT'))
assert.ok(contract.forbidden_substitutes.includes('UI_ONLY_BUTTONS_WITHOUT_NATIVE_COMMANDS'))
assert.equal(contract.ontology_correction.this_contract_is_not, 'USER_CHANNEL_BODY')
assert.equal(contract.ontology_correction.author_editor_operator_are_ui_tabs, false)
assert.equal(contract.distribution_boundary.preinstalled, 'LIGHT_AUTHOR_WORKBENCH_WITH_REAL_ENGINE')
assert.equal(contract.distribution_boundary.current_advanced_features_are_registered_marketplace_modules, false)
const industry = routing.industries.find((item) => item.key === 'WEB_NOVEL')
assert.equal(industry.industry_number, 'IND-WEBNOVEL-001')
assert.equal(industry.initialization_channel.legacy_projection_only, false)
assert.equal(industry.initialization_channel.installed_native_modules, 1)
assert.equal(industry.initialization_channel.module_slots[0].slot_id, 'WEBNOVEL-AUTHOR-LIGHT')
})
test('user channel contract is a durable multi-persona body rather than a module manifest', () => {
assert.equal(channelBody.state, 'FOUNDATION_RUNTIME_PARTIAL_ACCEPTANCE_PENDING')
assert.equal(channelBody.core_cardinality.language_cores_per_human, 1)
assert.equal(channelBody.core_cardinality.persona_cores_per_language_core, 1)
assert.equal(channelBody.core_cardinality.persona_entities_per_persona_core, 'MANY')
assert.equal(channelBody.persona_entity_boundary.role_template_creates_mature_persona, false)
assert.equal(channelBody.persona_entity_boundary.task_agent_is_persona, false)
assert.equal(channelBody.task_projection.workbench_is_channel_body, false)
assert.equal(channelBody.built_in_organs_policy.built_in_organs_allowed, true)
assert.equal(channelBody.module_ecosystem.official_module_number_before_real_acceptance, false)
assert.ok(channelBody.forbidden_substitutes.includes('DECLARATIVE_MODULE_MANIFEST_AS_CHANNEL_BODY'))
assert.ok(channelBody.acceptance_required.includes('TWO_PERSONAS_WITH_ISOLATED_GROWTH_MEMORY'))
})
test('the lightweight author workbench is built in while complete author capabilities stay numbered marketplace candidates', () => {
assert.equal(marketplace.built_in_light_author_workbench.distribution, 'PREINSTALLED')
assert.equal(marketplace.built_in_light_author_workbench.real_engine_owner, 'HOLOLAKE_NATIVE_RUST_SQLITE')
assert.ok(marketplace.built_in_light_author_workbench.capabilities.includes('REAL_CHAPTER_TEXT_EDITING'))
assert.equal(marketplace.official_module_candidates.length, 4)
for (const candidate of marketplace.official_module_candidates) {
assert.match(candidate.module_number, /^HL-MOD-WEBNOVEL-/)
assert.equal(candidate.numbering_state, 'CANDIDATE_NUMBER_NOT_OFFICIALLY_REGISTERED')
}
assert.deepEqual(marketplace.publication_gate.slice(-3), [
'THEN_ASSIGN_PERMANENT_MODULE_NUMBER',
'THEN_REGISTER_OFFICIAL_MODULE_REGISTRY',
'THEN_PUBLISH_OFFICIAL_MARKETPLACE',
])
assert.ok(marketplace.channel_deployment_flow.includes('MOUNT_IN_CURRENT_CHANNEL'))
assert.equal(marketplace.data_boundary.uninstall_preserves_story_data_and_receipts, true)
})
test('the desktop workbench invokes a registered native engine for every primary workflow', () => {
const commands = [
'get_web_novel_workspace_snapshot',
'create_web_novel_work',
'create_web_novel_volume',
'create_web_novel_chapter',
'save_web_novel_chapter',
'transition_web_novel_chapter',
'create_web_novel_checkpoint',
'restore_web_novel_checkpoint',
'upsert_web_novel_story_entity',
'create_web_novel_story_relation',
'upsert_web_novel_foreshadow',
'create_web_novel_review_note',
'resolve_web_novel_review_note',
'save_web_novel_metric',
'run_web_novel_continuity_audit',
'export_web_novel_markdown',
]
for (const command of commands) {
assert.match(rust, new RegExp(`fn ${command}`), `missing Rust implementation: ${command}`)
assert.match(rustLibrary, new RegExp(`web_novel_workspace::${command}`), `missing Tauri registration: ${command}`)
assert.match(workspaceUi, new RegExp(`['\"]${command}['\"]`), `missing UI invocation: ${command}`)
}
assert.match(rust, /account_storage_root\(app, "web-novel-workspace-v1"\)/)
assert.match(rust, /HOLOLAKE_WEBNOVEL_CHAPTER_REVISION_CONFLICT/)
assert.match(rust, /web_novel_chapter_versions/)
assert.match(workspaceUi, /window\.setTimeout\(\(\) => void flushChapterSave\(\), 650\)/)
assert.match(workspaceUi, /role=\{createFeedback\.state === 'error' \? 'alert' : 'status'\}/)
assert.match(workspaceUi, /正在建立作品与第一卷/)
assert.match(workspaceUi, /作品没有创建:/)
assert.match(workspaceUi, /第一卷已经可以开始写作/)
assert.match(mainUi, /worldStage === 'webNovelWorkspace'/)
})
test('author formatting is automatic on import, selectable, reversible, and can cover the whole work', () => {
for (const command of ['format_web_novel_chapter', 'format_web_novel_work']) {
assert.match(authorRust, new RegExp(`fn ${command}`))
assert.match(rustLibrary, new RegExp(`web_novel_author::${command}`))
assert.match(workspaceUi, new RegExp(`['"]${command}['"]`))
}
assert.match(authorRust, /formatted\.join\("\\n\\n"\)/)
assert.match(read('src-tauri/src/web_novel_import.rs'), /AUTO_FORMAT_ON_IMPORT/)
assert.match(workspaceUi, /细纲结构视图/)
assert.match(read('src/AuthorWritingSidecar.tsx'), /一键排版整部作品/)
})
test('author modules are immutable packages with native install mount data and uninstall engines', () => {
const modules = [
['HL-MOD-WEBNOVEL-OUTLINE-001', '6d31d3f530862000bd84a8f5dc73bcb9cf0d5738219f7a287562d4f4bcfa5318'],
['HL-MOD-WEBNOVEL-GRID-002', '2e34e0337c51dbbdd1efdd807be71e69a0cb2a1745624e1f51b452b7b54f1553'],
['HL-MOD-WEBNOVEL-STORYWORLD-003', 'd486719500ddfc08054d9c9bb6d3683afe5547a2b24c9b04d0f899ac66094d2f'],
['HL-MOD-WEBNOVEL-DELIVERY-004', '4af1d4610826edd29759bd0dc12b4b0c04fd2ebebb4ebc362d47d762810989e8'],
]
for (const [moduleId, expectedSha] of modules) {
const bytes = fs.readFileSync(new URL(`../official-modules/web-novel/${moduleId}/1.0.0/module.json`, import.meta.url))
const manifest = JSON.parse(bytes.toString('utf8'))
assert.equal(manifest.moduleId, moduleId)
assert.equal(crypto.createHash('sha256').update(bytes).digest('hex'), expectedSha)
assert.match(moduleRust, new RegExp(expectedSha))
}
assert.match(moduleRust, /web_novel_module_installations/)
assert.match(moduleRust, /web_novel_module_receipts/)
assert.match(moduleRust, /require_mounted/)
assert.match(moduleRust, /USER_DATA_REMAINS_AFTER_UNINSTALL|作品、模块数据和历史回执保持/)
assert.match(moduleRust, /RESTORED_FROM_VERSION/)
assert.match(moduleRust, /render_docx/)
assert.match(moduleRust, /render_epub/)
})
test('every author module action is registered and invoked by the live channel workbench', () => {
const commands = [
'get_web_novel_author_module_marketplace',
'install_web_novel_author_module',
'mount_web_novel_author_module',
'unmount_web_novel_author_module',
'uninstall_web_novel_author_module',
'get_web_novel_author_module_data',
'upsert_web_novel_author_scene',
'upsert_web_novel_author_beat',
'upsert_web_novel_story_field_definition',
'upsert_web_novel_story_field_value',
'upsert_web_novel_timeline_event',
'link_web_novel_scene_entity',
'restore_web_novel_chapter_version',
'export_web_novel_author_delivery',
]
for (const command of commands) {
assert.match(moduleRust, new RegExp(`fn ${command}`), `missing module engine: ${command}`)
assert.match(rustLibrary, new RegExp(`web_novel_modules::${command}`), `missing module registration: ${command}`)
assert.match(moduleUi, new RegExp(`['"]${command}['"]`), `missing module UI invocation: ${command}`)
}
assert.match(workspaceUi, /<AuthorModuleCenter/)
assert.match(workspaceUi, /\['modules', '作者模块'\]/)
})

View file

@ -1557,6 +1557,8 @@ dependencies = [
"fs2", "fs2",
"futures-util", "futures-util",
"interprocess", "interprocess",
"quick-xml 0.31.0",
"regex",
"reqwest", "reqwest",
"ring", "ring",
"rusqlite", "rusqlite",
@ -1574,6 +1576,7 @@ dependencies = [
"url", "url",
"uuid", "uuid",
"widestring", "widestring",
"zip 0.6.6",
] ]
[[package]] [[package]]

View file

@ -24,6 +24,8 @@ base64 = "0.22"
calamine = { version = "=0.26.1", features = ["dates"] } calamine = { version = "=0.26.1", features = ["dates"] }
csv = "=1.3.0" csv = "=1.3.0"
encoding_rs = "0.8" encoding_rs = "0.8"
quick-xml = "=0.31.0"
regex = "=1.12.3"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri = { version = "=2.10.2", features = ["devtools"] } tauri = { version = "=2.10.2", features = ["devtools"] }
@ -37,6 +39,7 @@ reqwest = { version = "0.13.2", default-features = false, features = ["cookies",
tokio = { version = "1", features = ["time"] } tokio = { version = "1", features = ["time"] }
futures-util = "0.3" futures-util = "0.3"
rust_xlsxwriter = "=0.64.2" rust_xlsxwriter = "=0.64.2"
zip = { version = "=0.6.6", default-features = false, features = ["deflate"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
widestring = "1" widestring = "1"

View file

@ -559,7 +559,7 @@ fn connect_endpoint(path: &Path) -> std::io::Result<LocalSocketStream> {
#[cfg(unix)] #[cfg(unix)]
{ {
let name = path.to_fs_name::<GenericFilePath>()?; let name = path.to_fs_name::<GenericFilePath>()?;
return LocalSocketStream::connect(name); LocalSocketStream::connect(name)
} }
#[cfg(windows)] #[cfg(windows)]
{ {

View file

@ -254,7 +254,7 @@ pub async fn import_education_tables_from_dialog(
let database = education_workspace_database(&app)?; let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || match import_tables_from_path(&database, &path) { tauri::async_runtime::spawn_blocking(move || match import_tables_from_path(&database, &path) {
Ok(receipt) => Ok(EducationImportOutcome { Ok(receipt) => Ok(EducationImportOutcome {
state: "IMPORTED_TO_NATIVE".into(), state: "STAGED_UNASSIGNED".into(),
import_receipt: Some(receipt), import_receipt: Some(receipt),
assistance_receipt: None, assistance_receipt: None,
}), }),
@ -379,7 +379,7 @@ fn import_tables_from_path(
.collect(); .collect();
Ok(EducationTableImportReceipt { Ok(EducationTableImportReceipt {
schema: TRANSLATOR_SCHEMA, schema: TRANSLATOR_SCHEMA,
state: "IMPORTED_TO_NATIVE", state: "STAGED_UNASSIGNED",
adapter_id: IMPORT_ADAPTER, adapter_id: IMPORT_ADAPTER,
import_id, import_id,
source_format: outcome.source_format, source_format: outcome.source_format,
@ -607,12 +607,12 @@ fn outcome_from_parsed(
"NONE".into() "NONE".into()
}, },
target_module: if page.state == "READY_TO_TRANSLATE" { target_module: if page.state == "READY_TO_TRANSLATE" {
"NATIVE_TABLE_DATA".into() "UNASSIGNED_CHANNEL_STAGING".into()
} else { } else {
"NONE".into() "NONE".into()
}, },
decision: if page.state == "READY_TO_TRANSLATE" { decision: if page.state == "READY_TO_TRANSLATE" {
"PROFILED_THEN_REGISTERED".into() "PROFILED_THEN_STAGED_PENDING_HUMAN_ROUTE".into()
} else { } else {
"EMPTY_PAGE_SKIPPED_WITH_RECORD".into() "EMPTY_PAGE_SKIPPED_WITH_RECORD".into()
}, },

View file

@ -53,6 +53,16 @@ pub struct EducationTableSummary {
pub updated_at_unix_ms: i64, pub updated_at_unix_ms: i64,
} }
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UnassignedTableSummary {
pub table_id: String,
pub title: String,
pub column_count: usize,
pub row_count: usize,
pub imported_at_unix_ms: i64,
}
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct EducationTableColumn { pub struct EducationTableColumn {
@ -115,9 +125,11 @@ pub struct EducationWorkspaceSnapshot {
pub automation_rules: Vec<EducationAutomationRule>, pub automation_rules: Vec<EducationAutomationRule>,
pub recent_automation_runs: Vec<EducationAutomationRunReceipt>, pub recent_automation_runs: Vec<EducationAutomationRunReceipt>,
pub recent_imports: Vec<EducationImportRegistrySummary>, pub recent_imports: Vec<EducationImportRegistrySummary>,
pub unassigned_tables: Vec<UnassignedTableSummary>,
pub document_count: usize, pub document_count: usize,
pub table_count: usize, pub table_count: usize,
pub automation_rule_count: usize, pub automation_rule_count: usize,
pub unassigned_count: usize,
pub storage: &'static str, pub storage: &'static str,
pub authority: &'static str, pub authority: &'static str,
} }
@ -367,6 +379,19 @@ pub async fn archive_education_table(
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))? .map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
} }
#[tauri::command]
pub async fn assign_imported_table_to_education(
app: AppHandle,
input: ReadEducationTableInput,
) -> Result<EducationTable, String> {
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || {
assign_imported_table_to_education_at(&database, &input.table_id)
})
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
#[tauri::command] #[tauri::command]
pub async fn create_education_automation_rule( pub async fn create_education_automation_rule(
app: AppHandle, app: AppHandle,
@ -479,6 +504,7 @@ fn open_database(path: &Path) -> Result<Connection, String> {
revision INTEGER NOT NULL, revision INTEGER NOT NULL,
created_at_unix_ms INTEGER NOT NULL, created_at_unix_ms INTEGER NOT NULL,
updated_at_unix_ms INTEGER NOT NULL, updated_at_unix_ms INTEGER NOT NULL,
assignment_scope TEXT NOT NULL DEFAULT 'EDUCATION' CHECK(assignment_scope IN ('EDUCATION', 'UNASSIGNED')),
archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1)) archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
); );
CREATE TABLE IF NOT EXISTS education_automation_rules ( CREATE TABLE IF NOT EXISTS education_automation_rules (
@ -517,6 +543,39 @@ fn open_database(path: &Path) -> Result<Connection, String> {
);", );",
) )
.map_err(|error| format!("HOLOLAKE_EDUCATION_DATABASE_MIGRATION_FAILED: {error}"))?; .map_err(|error| format!("HOLOLAKE_EDUCATION_DATABASE_MIGRATION_FAILED: {error}"))?;
if !table_has_column(&connection, "education_tables", "assignment_scope")? {
connection
.execute(
"ALTER TABLE education_tables ADD COLUMN assignment_scope TEXT NOT NULL DEFAULT 'EDUCATION' CHECK(assignment_scope IN ('EDUCATION', 'UNASSIGNED'))",
[],
)
.map_err(database_write_error)?;
let imported_table_ids = {
let mut statement = connection
.prepare("SELECT table_ids_json FROM education_import_registry")
.map_err(database_read_error)?;
let rows = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(database_read_error)?;
rows.filter_map(Result::ok)
.flat_map(|value| serde_json::from_str::<Vec<String>>(&value).unwrap_or_default())
.collect::<Vec<_>>()
};
for table_id in imported_table_ids {
connection
.execute(
"UPDATE education_tables SET assignment_scope = 'UNASSIGNED' WHERE table_id = ?1 AND archived = 0",
[table_id],
)
.map_err(database_write_error)?;
}
connection
.execute(
"INSERT INTO workspace_meta(key, value) VALUES ('schema_version', '2') ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[],
)
.map_err(database_write_error)?;
}
#[cfg(unix)] #[cfg(unix)]
if path.exists() { if path.exists() {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
@ -553,7 +612,7 @@ fn snapshot_at(path: &Path) -> Result<EducationWorkspaceSnapshot, String> {
let mut statement = connection let mut statement = connection
.prepare( .prepare(
"SELECT table_id, title, columns_json, rows_json, revision, updated_at_unix_ms "SELECT table_id, title, columns_json, rows_json, revision, updated_at_unix_ms
FROM education_tables WHERE archived = 0 FROM education_tables WHERE archived = 0 AND assignment_scope = 'EDUCATION'
ORDER BY updated_at_unix_ms DESC, title ASC", ORDER BY updated_at_unix_ms DESC, title ASC",
) )
.map_err(database_read_error)?; .map_err(database_read_error)?;
@ -581,6 +640,7 @@ fn snapshot_at(path: &Path) -> Result<EducationWorkspaceSnapshot, String> {
let automation_rules = read_automation_rules_with_connection(&connection)?; let automation_rules = read_automation_rules_with_connection(&connection)?;
let recent_automation_runs = read_recent_automation_runs_with_connection(&connection)?; let recent_automation_runs = read_recent_automation_runs_with_connection(&connection)?;
let recent_imports = read_recent_imports_with_connection(&connection)?; let recent_imports = read_recent_imports_with_connection(&connection)?;
let unassigned_tables = read_unassigned_tables_with_connection(&connection)?;
Ok(EducationWorkspaceSnapshot { Ok(EducationWorkspaceSnapshot {
schema: WORKSPACE_SCHEMA, schema: WORKSPACE_SCHEMA,
state: "READY", state: "READY",
@ -592,11 +652,42 @@ fn snapshot_at(path: &Path) -> Result<EducationWorkspaceSnapshot, String> {
automation_rules, automation_rules,
recent_automation_runs, recent_automation_runs,
recent_imports, recent_imports,
unassigned_count: unassigned_tables.len(),
unassigned_tables,
storage: "AUTHENTICATED_ACCOUNT_SCOPED_SQLITE", storage: "AUTHENTICATED_ACCOUNT_SCOPED_SQLITE",
authority: "CURRENT_AUTHENTICATED_ACCOUNT_ONLY", authority: "CURRENT_AUTHENTICATED_ACCOUNT_ONLY",
}) })
} }
fn read_unassigned_tables_with_connection(
connection: &Connection,
) -> Result<Vec<UnassignedTableSummary>, String> {
let mut statement = connection
.prepare(
"SELECT table_id, title, columns_json, rows_json, updated_at_unix_ms
FROM education_tables
WHERE archived = 0 AND assignment_scope = 'UNASSIGNED'
ORDER BY updated_at_unix_ms DESC, title ASC",
)
.map_err(database_read_error)?;
let rows = statement
.query_map([], |row| {
let columns_json: String = row.get(2)?;
let rows_json: String = row.get(3)?;
let columns: Vec<EducationTableColumn> = serde_json::from_str(&columns_json).unwrap_or_default();
let rows: Vec<EducationTableRow> = serde_json::from_str(&rows_json).unwrap_or_default();
Ok(UnassignedTableSummary {
table_id: row.get(0)?,
title: row.get(1)?,
column_count: columns.len(),
row_count: rows.len(),
imported_at_unix_ms: row.get(4)?,
})
})
.map_err(database_read_error)?;
rows.collect::<Result<Vec<_>, _>>().map_err(database_read_error)
}
fn read_recent_imports_with_connection( fn read_recent_imports_with_connection(
connection: &Connection, connection: &Connection,
) -> Result<Vec<EducationImportRegistrySummary>, String> { ) -> Result<Vec<EducationImportRegistrySummary>, String> {
@ -796,8 +887,8 @@ fn create_table_at(path: &Path, input: CreateEducationItemInput) -> Result<Educa
.execute( .execute(
"INSERT INTO education_tables( "INSERT INTO education_tables(
table_id, title, columns_json, rows_json, revision, table_id, title, columns_json, rows_json, revision,
created_at_unix_ms, updated_at_unix_ms, archived created_at_unix_ms, updated_at_unix_ms, assignment_scope, archived
) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 0)", ) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 'EDUCATION', 0)",
params![ params![
table_id, table_id,
title, title,
@ -838,8 +929,8 @@ pub(crate) fn import_table_batch_at(
.execute( .execute(
"INSERT INTO education_tables( "INSERT INTO education_tables(
table_id, title, columns_json, rows_json, revision, table_id, title, columns_json, rows_json, revision,
created_at_unix_ms, updated_at_unix_ms, archived created_at_unix_ms, updated_at_unix_ms, assignment_scope, archived
) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 0)", ) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 'UNASSIGNED', 0)",
params![ params![
table_id, table_id,
title, title,
@ -877,6 +968,37 @@ pub(crate) fn import_table_batch_at(
Ok((import_id, tables)) Ok((import_id, tables))
} }
fn assign_imported_table_to_education_at(
path: &Path,
table_id: &str,
) -> Result<EducationTable, String> {
validate_entity_id(table_id, "EDU-TABLE-")?;
let connection = open_database(path)?;
let changed = connection
.execute(
"UPDATE education_tables
SET assignment_scope = 'EDUCATION', updated_at_unix_ms = ?1
WHERE table_id = ?2 AND assignment_scope = 'UNASSIGNED' AND archived = 0",
params![now_ms(), table_id],
)
.map_err(database_write_error)?;
if changed != 1 {
return Err("HOLOLAKE_EDUCATION_IMPORT_ASSIGNMENT_NOT_FOUND".into());
}
read_table_with_connection(&connection, table_id)
}
fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool, String> {
let mut statement = connection
.prepare(&format!("PRAGMA table_info({table})"))
.map_err(database_read_error)?;
let rows = statement
.query_map([], |row| row.get::<_, String>(1))
.map_err(database_read_error)?;
let found = rows.filter_map(Result::ok).any(|name| name == column);
Ok(found)
}
fn read_table_with_connection( fn read_table_with_connection(
connection: &Connection, connection: &Connection,
table_id: &str, table_id: &str,
@ -1539,6 +1661,45 @@ mod tests {
assert_eq!(archived, 1); assert_eq!(archived, 1);
} }
#[test]
fn imported_tables_stay_unassigned_until_a_human_routes_them_to_education() {
let directory = tempdir().unwrap();
let database = directory.path().join("education.sqlite3");
let imported = ImportedEducationTable {
title: "待识别业务数据".into(),
columns: vec![EducationTableColumn {
column_id: format!("COL-{}", Uuid::new_v4()),
title: "业务字段".into(),
}],
rows: vec![EducationTableRow {
row_id: format!("ROW-{}", Uuid::new_v4()),
cells: vec!["尚未归属教育".into()],
}],
};
let (_, tables) = import_table_batch_at(
&database,
vec![imported],
EducationImportRegistration {
source_filename: "业务数据.xlsx".into(),
source_format: "XLSX".into(),
source_sha256: "0".repeat(64),
profile_json: "{}".into(),
imported_at_unix_ms: now_ms(),
},
)
.unwrap();
let table_id = tables[0].table_id.clone();
let quarantined = snapshot_at(&database).unwrap();
assert_eq!(quarantined.table_count, 0);
assert_eq!(quarantined.unassigned_count, 1);
assert_eq!(quarantined.unassigned_tables[0].table_id, table_id);
assign_imported_table_to_education_at(&database, &table_id).unwrap();
let assigned = snapshot_at(&database).unwrap();
assert_eq!(assigned.table_count, 1);
assert_eq!(assigned.unassigned_count, 0);
}
#[test] #[test]
fn table_bounds_are_enforced() { fn table_bounds_are_enforced() {
let columns = (0..=MAX_TABLE_COLUMNS) let columns = (0..=MAX_TABLE_COLUMNS)

View file

@ -27,6 +27,11 @@ mod pncc_server_projection;
mod release_trust; mod release_trust;
mod release_update; mod release_update;
mod user_pncc_channel; mod user_pncc_channel;
mod web_novel_author;
mod web_novel_import;
mod web_novel_modules;
mod web_novel_workspace;
mod world_climate;
mod zero_core_numbering; mod zero_core_numbering;
mod zero_point; mod zero_point;
@ -87,6 +92,7 @@ pub fn run() {
education_workspace::read_education_table, education_workspace::read_education_table,
education_workspace::save_education_table, education_workspace::save_education_table,
education_workspace::archive_education_table, education_workspace::archive_education_table,
education_workspace::assign_imported_table_to_education,
education_translation::import_education_tables_from_dialog, education_translation::import_education_tables_from_dialog,
education_translation::export_education_table_to_dialog, education_translation::export_education_table_to_dialog,
education_translation::get_education_recognition_capability, education_translation::get_education_recognition_capability,
@ -95,6 +101,50 @@ pub fn run() {
education_workspace::archive_education_automation_rule, education_workspace::archive_education_automation_rule,
education_workspace::preview_education_automation_rule, education_workspace::preview_education_automation_rule,
education_workspace::execute_education_automation_rule, education_workspace::execute_education_automation_rule,
web_novel_workspace::get_web_novel_workspace_snapshot,
world_climate::get_world_climate,
web_novel_workspace::create_web_novel_work,
web_novel_workspace::read_web_novel_work,
web_novel_workspace::save_web_novel_work,
web_novel_workspace::create_web_novel_volume,
web_novel_workspace::create_web_novel_chapter,
web_novel_workspace::read_web_novel_chapter,
web_novel_workspace::save_web_novel_chapter,
web_novel_workspace::transition_web_novel_chapter,
web_novel_workspace::create_web_novel_checkpoint,
web_novel_workspace::restore_web_novel_checkpoint,
web_novel_workspace::upsert_web_novel_story_entity,
web_novel_workspace::create_web_novel_story_relation,
web_novel_workspace::upsert_web_novel_foreshadow,
web_novel_workspace::create_web_novel_review_note,
web_novel_workspace::resolve_web_novel_review_note,
web_novel_workspace::save_web_novel_metric,
web_novel_workspace::run_web_novel_continuity_audit,
web_novel_workspace::export_web_novel_markdown,
web_novel_import::inspect_web_novel_document_from_dialog,
web_novel_import::commit_web_novel_document_import,
web_novel_author::get_web_novel_author_snapshot,
web_novel_author::record_web_novel_writing_activity,
web_novel_author::create_web_novel_inspiration,
web_novel_author::set_web_novel_inspiration_status,
web_novel_author::search_web_novel_full_text,
web_novel_author::format_web_novel_chapter,
web_novel_author::format_web_novel_work,
web_novel_author::upsert_web_novel_shot,
web_novel_modules::get_web_novel_author_module_marketplace,
web_novel_modules::install_web_novel_author_module,
web_novel_modules::mount_web_novel_author_module,
web_novel_modules::unmount_web_novel_author_module,
web_novel_modules::uninstall_web_novel_author_module,
web_novel_modules::get_web_novel_author_module_data,
web_novel_modules::upsert_web_novel_author_scene,
web_novel_modules::upsert_web_novel_author_beat,
web_novel_modules::upsert_web_novel_story_field_definition,
web_novel_modules::upsert_web_novel_story_field_value,
web_novel_modules::upsert_web_novel_timeline_event,
web_novel_modules::link_web_novel_scene_entity,
web_novel_modules::restore_web_novel_chapter_version,
web_novel_modules::export_web_novel_author_delivery,
knowledge_base::get_knowledge_snapshot, knowledge_base::get_knowledge_snapshot,
knowledge_base::read_knowledge_document, knowledge_base::read_knowledge_document,
knowledge_base::search_knowledge, knowledge_base::search_knowledge,

View file

@ -0,0 +1,803 @@
//! 作者频道内置写作底座。
//!
//! 这里承载三种作品形态共用的真实能力:写作活动回执、即时灵感、
//! 全文检索、一键排版,以及短剧分镜和生成提示词。数据与正文共用当前
//! 已验证账号的 SQLite模块商城不是这些基础能力的前置条件。
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tauri::AppHandle;
use uuid::Uuid;
const MAX_INSPIRATION_BYTES: usize = 100_000;
const MAX_SHOT_TEXT_BYTES: usize = 200_000;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebNovelInspiration {
pub inspiration_id: String,
pub work_id: String,
pub chapter_id: Option<String>,
pub content: String,
pub tags: Vec<String>,
pub status: String,
pub created_at_unix_ms: i64,
pub updated_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebNovelShot {
pub shot_id: String,
pub work_id: String,
pub chapter_id: String,
pub position: i64,
pub shot_number: String,
pub shot_size: String,
pub camera_movement: String,
pub location: String,
pub time_of_day: String,
pub action: String,
pub dialogue: String,
pub duration_seconds: i64,
pub visual_prompt: String,
pub image_prompt: String,
pub video_prompt: String,
pub revision: i64,
pub created_at_unix_ms: i64,
pub updated_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WebNovelAuthorSnapshot {
pub work_id: String,
pub work_kind: String,
pub total_words: i64,
pub today_words: i64,
pub total_active_ms: i64,
pub today_active_ms: i64,
pub inspiration_count: usize,
pub inspirations: Vec<WebNovelInspiration>,
pub shots: Vec<WebNovelShot>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WebNovelSearchHit {
pub chapter_id: String,
pub chapter_title: String,
pub field: String,
pub snippet: String,
pub occurrence_count: i64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WebNovelSearchResult {
pub query: String,
pub chapter_count: usize,
pub occurrence_count: i64,
pub hits: Vec<WebNovelSearchHit>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorSnapshotInput {
pub work_id: String,
pub local_date: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordWritingActivityInput {
pub activity_id: String,
pub work_id: String,
pub chapter_id: String,
pub local_date: String,
pub active_ms: i64,
pub words_delta: i64,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateInspirationInput {
pub work_id: String,
pub chapter_id: Option<String>,
pub content: String,
pub tags: Vec<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetInspirationStatusInput {
pub inspiration_id: String,
pub status: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchFullTextInput {
pub work_id: String,
pub query: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FormatChapterInput {
pub chapter_id: String,
pub expected_revision: i64,
pub preset: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FormatWorkInput {
pub work_id: String,
pub preset: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FormatWorkReceipt {
pub work_id: String,
pub formatted_chapter_count: usize,
pub preset: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpsertShotInput {
pub work_id: String,
pub chapter_id: String,
pub shot_id: Option<String>,
pub shot_number: String,
pub shot_size: String,
pub camera_movement: String,
pub location: String,
pub time_of_day: String,
pub action: String,
pub dialogue: String,
pub duration_seconds: i64,
pub visual_prompt: String,
pub image_prompt: String,
pub video_prompt: String,
pub expected_revision: Option<i64>,
}
#[tauri::command]
pub async fn get_web_novel_author_snapshot(
app: AppHandle,
input: AuthorSnapshotInput,
) -> Result<WebNovelAuthorSnapshot, String> {
let path = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || author_snapshot_at(&path, &input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn record_web_novel_writing_activity(
app: AppHandle,
input: RecordWritingActivityInput,
) -> Result<WebNovelAuthorSnapshot, String> {
let path = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || record_activity_at(&path, input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn create_web_novel_inspiration(
app: AppHandle,
input: CreateInspirationInput,
) -> Result<WebNovelInspiration, String> {
let path = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || create_inspiration_at(&path, input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn set_web_novel_inspiration_status(
app: AppHandle,
input: SetInspirationStatusInput,
) -> Result<WebNovelInspiration, String> {
let path = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || set_inspiration_status_at(&path, input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn search_web_novel_full_text(
app: AppHandle,
input: SearchFullTextInput,
) -> Result<WebNovelSearchResult, String> {
let path = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || search_full_text_at(&path, input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn format_web_novel_chapter(
app: AppHandle,
input: FormatChapterInput,
) -> Result<super::web_novel_workspace::WebNovelChapter, String> {
let path = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || format_chapter_at(&path, input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn format_web_novel_work(
app: AppHandle,
input: FormatWorkInput,
) -> Result<FormatWorkReceipt, String> {
let path = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || format_work_at(&path, input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn upsert_web_novel_shot(
app: AppHandle,
input: UpsertShotInput,
) -> Result<WebNovelShot, String> {
let path = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || upsert_shot_at(&path, input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_AUTHOR_JOIN_FAILED: {error}"))?
}
fn ensure_schema(connection: &Connection) -> Result<(), String> {
connection
.execute_batch(
"CREATE TABLE IF NOT EXISTS web_novel_writing_activity(
activity_id TEXT PRIMARY KEY NOT NULL,
work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE,
chapter_id TEXT NOT NULL REFERENCES web_novel_chapters(chapter_id) ON DELETE CASCADE,
local_date TEXT NOT NULL,
active_ms INTEGER NOT NULL,
words_delta INTEGER NOT NULL,
created_at_unix_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS web_novel_inspirations(
inspiration_id TEXT PRIMARY KEY NOT NULL,
work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE,
chapter_id TEXT REFERENCES web_novel_chapters(chapter_id) ON DELETE SET NULL,
content TEXT NOT NULL,
tags_json TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('INBOX','USED','ARCHIVED')),
created_at_unix_ms INTEGER NOT NULL,
updated_at_unix_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS web_novel_shots(
shot_id TEXT PRIMARY KEY NOT NULL,
work_id TEXT NOT NULL REFERENCES web_novel_works(work_id) ON DELETE CASCADE,
chapter_id TEXT NOT NULL REFERENCES web_novel_chapters(chapter_id) ON DELETE CASCADE,
position INTEGER NOT NULL,
shot_number TEXT NOT NULL,
shot_size TEXT NOT NULL,
camera_movement TEXT NOT NULL,
location TEXT NOT NULL,
time_of_day TEXT NOT NULL,
action TEXT NOT NULL,
dialogue TEXT NOT NULL,
duration_seconds INTEGER NOT NULL,
visual_prompt TEXT NOT NULL,
image_prompt TEXT NOT NULL,
video_prompt TEXT NOT NULL,
revision INTEGER NOT NULL,
created_at_unix_ms INTEGER NOT NULL,
updated_at_unix_ms INTEGER NOT NULL,
archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0,1)),
UNIQUE(chapter_id, position)
);
CREATE INDEX IF NOT EXISTS idx_web_novel_activity_work_date ON web_novel_writing_activity(work_id, local_date);
CREATE INDEX IF NOT EXISTS idx_web_novel_inspiration_work ON web_novel_inspirations(work_id, status, updated_at_unix_ms DESC);
CREATE INDEX IF NOT EXISTS idx_web_novel_shots_chapter ON web_novel_shots(chapter_id, position);",
)
.map_err(db_write)
}
fn author_snapshot_at(path: &Path, input: &AuthorSnapshotInput) -> Result<WebNovelAuthorSnapshot, String> {
validate_local_date(&input.local_date)?;
let connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
let work_kind: String = connection
.query_row(
"SELECT work_kind FROM web_novel_works WHERE work_id=?1 AND archived=0",
[&input.work_id],
|row| row.get(0),
)
.optional()
.map_err(db_read)?
.ok_or_else(|| "HOLOLAKE_WEBNOVEL_WORK_NOT_FOUND".to_string())?;
let total_words: i64 = connection
.query_row(
"SELECT COALESCE(SUM(word_count),0) FROM web_novel_chapters WHERE work_id=?1 AND archived=0",
[&input.work_id],
|row| row.get(0),
)
.map_err(db_read)?;
let (total_active_ms, today_active_ms, today_words): (i64, i64, i64) = connection
.query_row(
"SELECT COALESCE(SUM(active_ms),0),
COALESCE(SUM(CASE WHEN local_date=?2 THEN active_ms ELSE 0 END),0),
COALESCE(SUM(CASE WHEN local_date=?2 THEN words_delta ELSE 0 END),0)
FROM web_novel_writing_activity WHERE work_id=?1",
params![input.work_id, input.local_date],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.map_err(db_read)?;
let inspirations = query_inspirations(&connection, &input.work_id)?;
let shots = query_shots(&connection, &input.work_id)?;
Ok(WebNovelAuthorSnapshot {
work_id: input.work_id.clone(),
work_kind,
total_words,
today_words,
total_active_ms,
today_active_ms,
inspiration_count: inspirations.len(),
inspirations,
shots,
})
}
fn record_activity_at(path: &Path, input: RecordWritingActivityInput) -> Result<WebNovelAuthorSnapshot, String> {
if !input.activity_id.starts_with("WN-ACT-") || input.activity_id.len() > 100 {
return Err("HOLOLAKE_WEBNOVEL_ACTIVITY_ID_INVALID".into());
}
validate_local_date(&input.local_date)?;
if !(1_000..=300_000).contains(&input.active_ms) || !(-100_000..=100_000).contains(&input.words_delta) {
return Err("HOLOLAKE_WEBNOVEL_ACTIVITY_BOUNDS_INVALID".into());
}
let connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
let chapter_work: Option<String> = connection
.query_row(
"SELECT work_id FROM web_novel_chapters WHERE chapter_id=?1 AND archived=0",
[&input.chapter_id],
|row| row.get(0),
)
.optional()
.map_err(db_read)?;
if chapter_work.as_deref() != Some(input.work_id.as_str()) {
return Err("HOLOLAKE_WEBNOVEL_ACTIVITY_SCOPE_MISMATCH".into());
}
connection
.execute(
"INSERT OR IGNORE INTO web_novel_writing_activity(
activity_id,work_id,chapter_id,local_date,active_ms,words_delta,created_at_unix_ms
) VALUES(?1,?2,?3,?4,?5,?6,?7)",
params![input.activity_id, input.work_id, input.chapter_id, input.local_date, input.active_ms, input.words_delta, now_ms()],
)
.map_err(db_write)?;
author_snapshot_at(path, &AuthorSnapshotInput { work_id: input.work_id, local_date: input.local_date })
}
fn create_inspiration_at(path: &Path, input: CreateInspirationInput) -> Result<WebNovelInspiration, String> {
let content = input.content.trim();
if content.is_empty() || content.len() > MAX_INSPIRATION_BYTES || input.tags.len() > 30 {
return Err("HOLOLAKE_WEBNOVEL_INSPIRATION_INVALID".into());
}
let connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
ensure_scope(&connection, &input.work_id, input.chapter_id.as_deref())?;
let inspiration_id = format!("WN-INSP-{}", Uuid::new_v4());
let tags_json = serde_json::to_string(&input.tags)
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_INSPIRATION_TAGS_INVALID: {error}"))?;
let now = now_ms();
connection
.execute(
"INSERT INTO web_novel_inspirations(
inspiration_id,work_id,chapter_id,content,tags_json,status,created_at_unix_ms,updated_at_unix_ms
) VALUES(?1,?2,?3,?4,?5,'INBOX',?6,?6)",
params![inspiration_id, input.work_id, input.chapter_id, content, tags_json, now],
)
.map_err(db_write)?;
query_inspiration(&connection, &inspiration_id)
}
fn set_inspiration_status_at(path: &Path, input: SetInspirationStatusInput) -> Result<WebNovelInspiration, String> {
let status = input.status.trim().to_ascii_uppercase();
if !matches!(status.as_str(), "INBOX" | "USED" | "ARCHIVED") {
return Err("HOLOLAKE_WEBNOVEL_INSPIRATION_STATUS_INVALID".into());
}
let connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
let changed = connection
.execute(
"UPDATE web_novel_inspirations SET status=?1,updated_at_unix_ms=?2 WHERE inspiration_id=?3",
params![status, now_ms(), input.inspiration_id],
)
.map_err(db_write)?;
if changed != 1 {
return Err("HOLOLAKE_WEBNOVEL_INSPIRATION_NOT_FOUND".into());
}
query_inspiration(&connection, &input.inspiration_id)
}
fn search_full_text_at(path: &Path, input: SearchFullTextInput) -> Result<WebNovelSearchResult, String> {
let query = input.query.trim().to_owned();
if query.is_empty() || query.chars().count() > 100 {
return Err("HOLOLAKE_WEBNOVEL_SEARCH_QUERY_INVALID".into());
}
let connection = super::web_novel_workspace::open_database(path)?;
let mut statement = connection
.prepare(
"SELECT chapter_id,title,synopsis,content FROM web_novel_chapters
WHERE work_id=?1 AND archived=0 AND (instr(title,?2)>0 OR instr(synopsis,?2)>0 OR instr(content,?2)>0)
ORDER BY volume_id,position LIMIT 200",
)
.map_err(db_read)?;
let rows = statement
.query_map(params![input.work_id, query], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?))
})
.map_err(db_read)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_read)?;
let mut hits = Vec::new();
let mut occurrence_count = 0;
for (chapter_id, chapter_title, synopsis, content) in rows {
let (field, source) = if content.contains(&query) {
("正文", content)
} else if synopsis.contains(&query) {
("章节梗概", synopsis)
} else {
("章节标题", chapter_title.clone())
};
let count = source.matches(&query).count().max(chapter_title.matches(&query).count()) as i64;
occurrence_count += count;
hits.push(WebNovelSearchHit {
chapter_id,
chapter_title,
field: field.into(),
snippet: make_snippet(&source, &query),
occurrence_count: count,
});
}
Ok(WebNovelSearchResult { query, chapter_count: hits.len(), occurrence_count, hits })
}
fn format_chapter_at(path: &Path, input: FormatChapterInput) -> Result<super::web_novel_workspace::WebNovelChapter, String> {
let connection = super::web_novel_workspace::open_database(path)?;
let current = connection
.query_row(
"SELECT chapter_id,volume_id,work_id,title,position,synopsis,content,workflow_status,
scheduled_at_unix_ms,word_count,revision,created_at_unix_ms,updated_at_unix_ms
FROM web_novel_chapters WHERE chapter_id=?1 AND archived=0",
[&input.chapter_id],
|row| Ok(super::web_novel_workspace::WebNovelChapter {
chapter_id: row.get(0)?, volume_id: row.get(1)?, work_id: row.get(2)?, title: row.get(3)?,
position: row.get(4)?, synopsis: row.get(5)?, content: row.get(6)?, workflow_status: row.get(7)?,
scheduled_at_unix_ms: row.get(8)?, word_count: row.get(9)?, revision: row.get(10)?,
created_at_unix_ms: row.get(11)?, updated_at_unix_ms: row.get(12)?,
}),
)
.optional()
.map_err(db_read)?
.ok_or_else(|| "HOLOLAKE_WEBNOVEL_CHAPTER_NOT_FOUND".to_string())?;
if current.revision != input.expected_revision {
return Err("HOLOLAKE_WEBNOVEL_CHAPTER_REVISION_CONFLICT".into());
}
let work_kind: String = connection
.query_row("SELECT work_kind FROM web_novel_works WHERE work_id=?1", [&current.work_id], |row| row.get(0))
.map_err(db_read)?;
let volume_title: String = connection
.query_row("SELECT title FROM web_novel_volumes WHERE volume_id=?1", [&current.volume_id], |row| row.get(0))
.map_err(db_read)?;
drop(connection);
let requested = input.preset.as_deref().unwrap_or("AUTO");
let inferred = if requested == "AUTO" && volume_title.contains("细纲") { "OUTLINE" } else { requested };
let content = format_content(&current.content, &work_kind, inferred);
super::web_novel_workspace::save_chapter_at(
path,
super::web_novel_workspace::SaveWebNovelChapterInput {
chapter_id: current.chapter_id,
title: current.title,
synopsis: current.synopsis,
content,
expected_revision: current.revision,
save_reason: Some("ONE_CLICK_FORMAT".into()),
},
)
}
fn format_work_at(path: &Path, input: FormatWorkInput) -> Result<FormatWorkReceipt, String> {
let connection = super::web_novel_workspace::open_database(path)?;
ensure_scope(&connection, &input.work_id, None)?;
let mut statement = connection
.prepare(
"SELECT chapter_id,revision FROM web_novel_chapters
WHERE work_id=?1 AND archived=0 ORDER BY position,created_at_unix_ms",
)
.map_err(db_read)?;
let chapters = statement
.query_map([&input.work_id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))
.map_err(db_read)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_read)?;
drop(statement);
drop(connection);
let preset = input.preset.unwrap_or_else(|| "AUTO".into());
for (chapter_id, expected_revision) in &chapters {
format_chapter_at(
path,
FormatChapterInput {
chapter_id: chapter_id.clone(),
expected_revision: *expected_revision,
preset: Some(preset.clone()),
},
)?;
}
Ok(FormatWorkReceipt {
work_id: input.work_id,
formatted_chapter_count: chapters.len(),
preset,
})
}
fn upsert_shot_at(path: &Path, input: UpsertShotInput) -> Result<WebNovelShot, String> {
if input.duration_seconds < 0 || input.duration_seconds > 3600 {
return Err("HOLOLAKE_WEBNOVEL_SHOT_DURATION_INVALID".into());
}
for value in [&input.action, &input.dialogue, &input.visual_prompt, &input.image_prompt, &input.video_prompt] {
if value.len() > MAX_SHOT_TEXT_BYTES {
return Err("HOLOLAKE_WEBNOVEL_SHOT_TEXT_TOO_LARGE".into());
}
}
let connection = super::web_novel_workspace::open_database(path)?;
ensure_schema(&connection)?;
ensure_scope(&connection, &input.work_id, Some(&input.chapter_id))?;
let work_kind: String = connection
.query_row("SELECT work_kind FROM web_novel_works WHERE work_id=?1", [&input.work_id], |row| row.get(0))
.map_err(db_read)?;
if work_kind != "SHORT_DRAMA" {
return Err("HOLOLAKE_WEBNOVEL_SHOT_REQUIRES_SHORT_DRAMA".into());
}
let shot_id = input.shot_id.unwrap_or_else(|| format!("WN-SHOT-{}", Uuid::new_v4()));
let existing: Option<(i64, i64)> = connection
.query_row("SELECT position,revision FROM web_novel_shots WHERE shot_id=?1 AND archived=0", [&shot_id], |row| Ok((row.get(0)?, row.get(1)?)))
.optional().map_err(db_read)?;
let now = now_ms();
if let Some((_, revision)) = existing {
if input.expected_revision != Some(revision) {
return Err("HOLOLAKE_WEBNOVEL_SHOT_REVISION_CONFLICT".into());
}
connection.execute(
"UPDATE web_novel_shots SET shot_number=?1,shot_size=?2,camera_movement=?3,location=?4,time_of_day=?5,
action=?6,dialogue=?7,duration_seconds=?8,visual_prompt=?9,image_prompt=?10,video_prompt=?11,
revision=revision+1,updated_at_unix_ms=?12 WHERE shot_id=?13 AND work_id=?14 AND revision=?15",
params![input.shot_number,input.shot_size,input.camera_movement,input.location,input.time_of_day,input.action,input.dialogue,
input.duration_seconds,input.visual_prompt,input.image_prompt,input.video_prompt,now,shot_id,input.work_id,revision],
).map_err(db_write)?;
} else {
let position: i64 = connection.query_row(
"SELECT COALESCE(MAX(position),-1)+1 FROM web_novel_shots WHERE chapter_id=?1 AND archived=0",
[&input.chapter_id], |row| row.get(0)
).map_err(db_read)?;
connection.execute(
"INSERT INTO web_novel_shots(shot_id,work_id,chapter_id,position,shot_number,shot_size,camera_movement,location,time_of_day,
action,dialogue,duration_seconds,visual_prompt,image_prompt,video_prompt,revision,created_at_unix_ms,updated_at_unix_ms,archived)
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,1,?16,?16,0)",
params![shot_id,input.work_id,input.chapter_id,position,input.shot_number,input.shot_size,input.camera_movement,input.location,
input.time_of_day,input.action,input.dialogue,input.duration_seconds,input.visual_prompt,input.image_prompt,input.video_prompt,now],
).map_err(db_write)?;
}
query_shot(&connection, &shot_id)
}
fn query_inspirations(connection: &Connection, work_id: &str) -> Result<Vec<WebNovelInspiration>, String> {
let mut statement = connection.prepare(
"SELECT inspiration_id,work_id,chapter_id,content,tags_json,status,created_at_unix_ms,updated_at_unix_ms
FROM web_novel_inspirations WHERE work_id=?1 AND status!='ARCHIVED' ORDER BY updated_at_unix_ms DESC LIMIT 100"
).map_err(db_read)?;
let rows = statement.query_map([work_id], map_inspiration).map_err(db_read)?
.collect::<Result<Vec<_>, _>>().map_err(db_read)?;
Ok(rows)
}
fn query_inspiration(connection: &Connection, inspiration_id: &str) -> Result<WebNovelInspiration, String> {
connection.query_row(
"SELECT inspiration_id,work_id,chapter_id,content,tags_json,status,created_at_unix_ms,updated_at_unix_ms
FROM web_novel_inspirations WHERE inspiration_id=?1", [inspiration_id], map_inspiration
).map_err(db_read)
}
fn map_inspiration(row: &rusqlite::Row<'_>) -> rusqlite::Result<WebNovelInspiration> {
let tags_json: String = row.get(4)?;
Ok(WebNovelInspiration {
inspiration_id: row.get(0)?, work_id: row.get(1)?, chapter_id: row.get(2)?, content: row.get(3)?,
tags: serde_json::from_str(&tags_json).unwrap_or_default(), status: row.get(5)?,
created_at_unix_ms: row.get(6)?, updated_at_unix_ms: row.get(7)?,
})
}
fn query_shots(connection: &Connection, work_id: &str) -> Result<Vec<WebNovelShot>, String> {
let mut statement = connection.prepare(
"SELECT shot_id,work_id,chapter_id,position,shot_number,shot_size,camera_movement,location,time_of_day,
action,dialogue,duration_seconds,visual_prompt,image_prompt,video_prompt,revision,created_at_unix_ms,updated_at_unix_ms
FROM web_novel_shots WHERE work_id=?1 AND archived=0 ORDER BY chapter_id,position LIMIT 2000"
).map_err(db_read)?;
let rows = statement.query_map([work_id], map_shot).map_err(db_read)?
.collect::<Result<Vec<_>, _>>().map_err(db_read)?;
Ok(rows)
}
fn query_shot(connection: &Connection, shot_id: &str) -> Result<WebNovelShot, String> {
connection.query_row(
"SELECT shot_id,work_id,chapter_id,position,shot_number,shot_size,camera_movement,location,time_of_day,
action,dialogue,duration_seconds,visual_prompt,image_prompt,video_prompt,revision,created_at_unix_ms,updated_at_unix_ms
FROM web_novel_shots WHERE shot_id=?1 AND archived=0", [shot_id], map_shot
).map_err(db_read)
}
fn map_shot(row: &rusqlite::Row<'_>) -> rusqlite::Result<WebNovelShot> {
Ok(WebNovelShot {
shot_id: row.get(0)?, work_id: row.get(1)?, chapter_id: row.get(2)?, position: row.get(3)?,
shot_number: row.get(4)?, shot_size: row.get(5)?, camera_movement: row.get(6)?, location: row.get(7)?,
time_of_day: row.get(8)?, action: row.get(9)?, dialogue: row.get(10)?, duration_seconds: row.get(11)?,
visual_prompt: row.get(12)?, image_prompt: row.get(13)?, video_prompt: row.get(14)?, revision: row.get(15)?,
created_at_unix_ms: row.get(16)?, updated_at_unix_ms: row.get(17)?,
})
}
fn ensure_scope(connection: &Connection, work_id: &str, chapter_id: Option<&str>) -> Result<(), String> {
let work_exists: bool = connection.query_row(
"SELECT EXISTS(SELECT 1 FROM web_novel_works WHERE work_id=?1 AND archived=0)", [work_id], |row| row.get(0)
).map_err(db_read)?;
if !work_exists { return Err("HOLOLAKE_WEBNOVEL_WORK_NOT_FOUND".into()); }
if let Some(chapter_id) = chapter_id {
let chapter_work: Option<String> = connection.query_row(
"SELECT work_id FROM web_novel_chapters WHERE chapter_id=?1 AND archived=0", [chapter_id], |row| row.get(0)
).optional().map_err(db_read)?;
if chapter_work.as_deref() != Some(work_id) { return Err("HOLOLAKE_WEBNOVEL_CHAPTER_SCOPE_MISMATCH".into()); }
}
Ok(())
}
pub(crate) fn format_content(content: &str, work_kind: &str, preset: &str) -> String {
let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
let lines: Vec<&str> = normalized
.lines()
.map(|line| line.trim_matches([' ', '\t', '\u{3000}']))
.filter(|line| !line.is_empty())
.collect();
let mode = match preset {
"NOVEL" | "OUTLINE" | "SCREENPLAY" | "CLEAN" => preset,
_ if work_kind == "SHORT_DRAMA" => "SCREENPLAY",
_ => "NOVEL",
};
if mode == "CLEAN" {
return lines.join("\n\n");
}
let formatted: Vec<String> = lines
.into_iter()
.map(|line| match mode {
"NOVEL" if !is_heading(line) => format!("  {}", line.trim_start_matches("  ")),
_ => line.to_owned(),
})
.collect();
// 每个逻辑段之间明确留一行。正文、细纲和剧本都因此可读,
// 同时不会用空白字符改变字数统计。
formatted.join("\n\n").trim_end().to_owned()
}
fn is_heading(line: &str) -> bool {
(line.starts_with('第') && (line.contains('章') || line.contains('卷') || line.contains('节') || line.contains('集')))
|| line.starts_with("场次:") || line.starts_with("人物:") || line.starts_with('【')
}
fn make_snippet(source: &str, query: &str) -> String {
let chars: Vec<char> = source.chars().collect();
let needle: Vec<char> = query.chars().collect();
let found = chars.windows(needle.len()).position(|window| window == needle.as_slice()).unwrap_or(0);
let start = found.saturating_sub(35);
let end = (found + needle.len() + 55).min(chars.len());
let mut snippet: String = chars[start..end].iter().collect();
snippet = snippet.replace('\n', " ");
if start > 0 { snippet.insert(0, '…'); }
if end < chars.len() { snippet.push('…'); }
snippet
}
fn validate_local_date(value: &str) -> Result<(), String> {
let bytes = value.as_bytes();
if bytes.len() == 10 && bytes[4] == b'-' && bytes[7] == b'-'
&& bytes.iter().enumerate().all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit()) {
Ok(())
} else {
Err("HOLOLAKE_WEBNOVEL_LOCAL_DATE_INVALID".into())
}
}
fn now_ms() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as i64
}
fn db_read(error: rusqlite::Error) -> String {
format!("HOLOLAKE_WEBNOVEL_AUTHOR_DATABASE_READ_FAILED: {error}")
}
fn db_write(error: rusqlite::Error) -> String {
format!("HOLOLAKE_WEBNOVEL_AUTHOR_DATABASE_WRITE_FAILED: {error}")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn create_work(path: &Path, kind: &str) -> super::super::web_novel_workspace::WebNovelWorkDetail {
super::super::web_novel_workspace::create_work_at(
path,
super::super::web_novel_workspace::CreateWebNovelWorkInput {
title: "作者底座测试".into(), pen_name: "冰朔".into(), genre: "测试".into(), work_kind: kind.into(),
},
).unwrap()
}
#[test]
fn inspiration_activity_search_and_format_are_persistent() {
let temp = tempdir().unwrap();
let path = temp.path().join("author.sqlite3");
let detail = create_work(&path, "LONG_NOVEL");
let chapter = super::super::web_novel_workspace::create_chapter_at(
&path,
super::super::web_novel_workspace::CreateWebNovelChapterInput {
work_id: detail.work.work_id.clone(), volume_id: detail.volumes[0].volume_id.clone(), title: "第一章".into(),
},
).unwrap();
let chapter = super::super::web_novel_workspace::save_chapter_at(
&path,
super::super::web_novel_workspace::SaveWebNovelChapterInput {
chapter_id: chapter.chapter_id.clone(), title: chapter.title, synopsis: "开场".into(),
content: "第一段\n\n第二段有线索".into(), expected_revision: chapter.revision, save_reason: Some("TEST".into()),
},
).unwrap();
let formatted = format_chapter_at(&path, FormatChapterInput {
chapter_id: chapter.chapter_id.clone(),
expected_revision: chapter.revision,
preset: Some("NOVEL".into()),
}).unwrap();
assert!(formatted.content.starts_with("  第一段"));
assert!(formatted.content.contains("\n\n  第二段"));
create_inspiration_at(&path, CreateInspirationInput { work_id: detail.work.work_id.clone(), chapter_id: Some(chapter.chapter_id.clone()), content: "让钥匙提前出现".into(), tags: vec!["伏笔".into()] }).unwrap();
record_activity_at(&path, RecordWritingActivityInput { activity_id: "WN-ACT-test".into(), work_id: detail.work.work_id.clone(), chapter_id: chapter.chapter_id.clone(), local_date: "2026-08-18".into(), active_ms: 30_000, words_delta: 8 }).unwrap();
let search = search_full_text_at(&path, SearchFullTextInput { work_id: detail.work.work_id.clone(), query: "线索".into() }).unwrap();
assert_eq!(search.chapter_count, 1);
let snapshot = author_snapshot_at(&path, &AuthorSnapshotInput { work_id: detail.work.work_id, local_date: "2026-08-18".into() }).unwrap();
assert_eq!(snapshot.today_active_ms, 30_000);
assert_eq!(snapshot.today_words, 8);
assert_eq!(snapshot.inspiration_count, 1);
}
#[test]
fn short_drama_chapter_has_template_and_real_shot() {
let temp = tempdir().unwrap();
let path = temp.path().join("drama.sqlite3");
let detail = create_work(&path, "SHORT_DRAMA");
let chapter = super::super::web_novel_workspace::create_chapter_at(
&path,
super::super::web_novel_workspace::CreateWebNovelChapterInput {
work_id: detail.work.work_id.clone(), volume_id: detail.volumes[0].volume_id.clone(), title: "第1集".into(),
},
).unwrap();
assert!(chapter.content.contains("场次1-1"));
let shot = upsert_shot_at(&path, UpsertShotInput {
work_id: detail.work.work_id, chapter_id: chapter.chapter_id, shot_id: None, shot_number: "1".into(),
shot_size: "近景".into(), camera_movement: "".into(), location: "侯府".into(), time_of_day: "".into(),
action: "女主抬眼".into(), dialogue: "你终于来了。".into(), duration_seconds: 4,
visual_prompt: "烛火冷夜".into(), image_prompt: "古装近景".into(), video_prompt: "缓慢推镜".into(), expected_revision: None,
}).unwrap();
assert_eq!(shot.revision, 1);
}
}

View file

@ -0,0 +1,805 @@
//! 网文真实文档导入器。
//!
//! 文档在 Rust 侧解析,先生成可确认的拆分预览,再以单个 SQLite
//! 事务写入当前账号。WebView 不会获取本机源路径。
use encoding_rs::GBK;
use quick_xml::events::Event;
use quick_xml::Reader;
use regex::Regex;
use ring::digest::{digest, SHA256};
use rusqlite::{params, OptionalExtension, TransactionBehavior};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{Cursor, Read};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt;
use uuid::Uuid;
use zip::ZipArchive;
const MAX_SOURCE_BYTES: u64 = 30 * 1024 * 1024;
const MAX_SECTION_BYTES: usize = 4_000_000;
const MAX_SYNOPSIS_BYTES: usize = 40_000;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportSection {
pub ordinal: usize,
pub title: String,
pub body: String,
pub word_count: i64,
pub scene_count: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportSectionPreview {
pub ordinal: usize,
pub title: String,
pub word_count: i64,
pub scene_count: i64,
pub preview: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebNovelImportPreview {
pub import_id: String,
pub source_filename: String,
pub source_format: String,
pub source_sha256: String,
pub source_bytes: u64,
pub detected_family: String,
pub detected_title: String,
pub detected_pen_name: String,
pub detected_genre: String,
pub section_count: usize,
pub total_word_count: i64,
pub preface_word_count: i64,
pub sections: Vec<ImportSectionPreview>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitImportInput {
pub import_id: String,
pub target_mode: String,
pub target_work_id: Option<String>,
pub title: String,
pub pen_name: String,
pub genre: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebNovelImportReceipt {
pub state: String,
pub import_id: String,
pub work_id: String,
pub volume_id: String,
pub source_filename: String,
pub source_sha256: String,
pub detected_family: String,
pub chapter_count: usize,
pub word_count: i64,
pub first_chapter_title: String,
pub last_chapter_title: String,
}
#[derive(Debug)]
struct ParsedDocument {
source_filename: String,
source_format: String,
source_sha256: String,
source_bytes: u64,
detected_family: String,
detected_title: String,
detected_pen_name: String,
detected_genre: String,
preface: String,
sections: Vec<ImportSection>,
}
#[tauri::command]
pub async fn inspect_web_novel_document_from_dialog(
app: AppHandle,
) -> Result<Option<WebNovelImportPreview>, String> {
let selected = app
.dialog()
.file()
.set_title("选择小说、细纲或剧本原文")
.add_filter("网文文档", &["txt", "md", "docx"])
.blocking_pick_file();
let Some(selected) = selected else {
return Ok(None);
};
let source = selected
.into_path()
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_PATH_INVALID: {error}"))?;
let database = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || stage_document_at(&database, &source))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_JOIN_FAILED: {error}"))?
.map(Some)
}
#[tauri::command]
pub async fn commit_web_novel_document_import(
app: AppHandle,
input: CommitImportInput,
) -> Result<WebNovelImportReceipt, String> {
let database = super::web_novel_workspace::workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || commit_import_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_JOIN_FAILED: {error}"))?
}
fn ensure_import_schema(connection: &rusqlite::Connection) -> Result<(), String> {
connection
.execute_batch(
"CREATE TABLE IF NOT EXISTS web_novel_document_imports(
import_id TEXT PRIMARY KEY NOT NULL,
source_filename TEXT NOT NULL,
source_format TEXT NOT NULL,
source_sha256 TEXT NOT NULL,
source_bytes INTEGER NOT NULL,
detected_family TEXT NOT NULL,
detected_title TEXT NOT NULL,
detected_pen_name TEXT NOT NULL,
detected_genre TEXT NOT NULL,
preface TEXT NOT NULL,
sections_json TEXT NOT NULL,
state TEXT NOT NULL,
target_work_id TEXT,
target_volume_id TEXT,
created_at_unix_ms INTEGER NOT NULL,
committed_at_unix_ms INTEGER
);
CREATE INDEX IF NOT EXISTS idx_web_novel_import_hash
ON web_novel_document_imports(source_sha256, state);",
)
.map_err(db_write)
}
pub(crate) fn stage_document_at(
database: &Path,
source: &Path,
) -> Result<WebNovelImportPreview, String> {
let parsed = parse_document(source)?;
let mut connection = super::web_novel_workspace::open_database(database)?;
ensure_import_schema(&connection)?;
let import_id = format!("WN-IMPORT-{}", Uuid::new_v4());
let sections_json = serde_json::to_string(&parsed.sections)
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_SERIALIZE_FAILED: {error}"))?;
let tx = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(db_write)?;
tx.execute(
"INSERT INTO web_novel_document_imports(
import_id, source_filename, source_format, source_sha256, source_bytes,
detected_family, detected_title, detected_pen_name, detected_genre,
preface, sections_json, state, created_at_unix_ms
) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,'STAGED',?12)",
params![
import_id,
parsed.source_filename,
parsed.source_format,
parsed.source_sha256,
parsed.source_bytes as i64,
parsed.detected_family,
parsed.detected_title,
parsed.detected_pen_name,
parsed.detected_genre,
parsed.preface,
sections_json,
now_ms(),
],
)
.map_err(db_write)?;
tx.commit().map_err(db_write)?;
Ok(preview_from(&import_id, &parsed))
}
fn preview_from(import_id: &str, parsed: &ParsedDocument) -> WebNovelImportPreview {
let mut selected: Vec<&ImportSection> = parsed.sections.iter().take(8).collect();
if parsed.sections.len() > 10 {
selected.extend(parsed.sections.iter().skip(parsed.sections.len() - 2));
}
WebNovelImportPreview {
import_id: import_id.to_owned(),
source_filename: parsed.source_filename.clone(),
source_format: parsed.source_format.clone(),
source_sha256: parsed.source_sha256.clone(),
source_bytes: parsed.source_bytes,
detected_family: parsed.detected_family.clone(),
detected_title: parsed.detected_title.clone(),
detected_pen_name: parsed.detected_pen_name.clone(),
detected_genre: parsed.detected_genre.clone(),
section_count: parsed.sections.len(),
total_word_count: parsed.sections.iter().map(|item| item.word_count).sum(),
preface_word_count: count_words(&parsed.preface),
sections: selected
.into_iter()
.map(|section| ImportSectionPreview {
ordinal: section.ordinal,
title: section.title.clone(),
word_count: section.word_count,
scene_count: section.scene_count,
preview: section.body.chars().take(120).collect(),
})
.collect(),
}
}
pub(crate) fn commit_import_at(
database: &Path,
input: CommitImportInput,
) -> Result<WebNovelImportReceipt, String> {
if !input.import_id.starts_with("WN-IMPORT-") {
return Err("HOLOLAKE_WEBNOVEL_IMPORT_ID_INVALID".into());
}
let mut connection = super::web_novel_workspace::open_database(database)?;
ensure_import_schema(&connection)?;
let staged = connection
.query_row(
"SELECT source_filename, source_sha256, detected_family, preface, sections_json, state
FROM web_novel_document_imports WHERE import_id=?1",
[&input.import_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
))
},
)
.optional()
.map_err(db_read)?
.ok_or_else(|| "HOLOLAKE_WEBNOVEL_IMPORT_NOT_FOUND".to_string())?;
if staged.5 != "STAGED" {
return Err("HOLOLAKE_WEBNOVEL_IMPORT_ALREADY_COMMITTED".into());
}
let sections: Vec<ImportSection> = serde_json::from_str(&staged.4)
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_STAGING_INVALID: {error}"))?;
if sections.is_empty() {
return Err("HOLOLAKE_WEBNOVEL_IMPORT_NO_SECTIONS".into());
}
for section in &sections {
if section.body.len() > MAX_SECTION_BYTES {
return Err(format!(
"HOLOLAKE_WEBNOVEL_IMPORT_SECTION_TOO_LARGE: {}",
section.title
));
}
}
let create_new = input.target_mode == "CREATE_NEW";
if !create_new && input.target_mode != "APPEND_EXISTING" {
return Err("HOLOLAKE_WEBNOVEL_IMPORT_TARGET_MODE_INVALID".into());
}
let tx = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(db_write)?;
let now = now_ms();
let work_id = if create_new {
let work_id = format!("WN-WORK-{}", Uuid::new_v4());
let title = required_text(&input.title, "未命名导入作品");
let pen_name = required_text(&input.pen_name, "待确认");
let genre = required_text(&input.genre, "网文导入");
let synopsis = truncate_utf8(&staged.3, MAX_SYNOPSIS_BYTES);
let work_kind = match staged.2.as_str() {
"SCRIPT" => "SHORT_DRAMA",
_ => "LONG_NOVEL",
};
tx.execute(
"INSERT INTO web_novel_works(
work_id,title,pen_name,genre,work_kind,synopsis,contract_status,copyright_status,
workflow_status,target_words,revision,created_at_unix_ms,updated_at_unix_ms,archived
) VALUES(?1,?2,?3,?4,?5,?6,'UNREGISTERED','AUTHOR_OWNED','DRAFT',0,1,?7,?7,0)",
params![work_id, title, pen_name, genre, work_kind, synopsis, now],
)
.map_err(db_write)?;
work_id
} else {
let work_id = input
.target_work_id
.clone()
.ok_or_else(|| "HOLOLAKE_WEBNOVEL_IMPORT_TARGET_REQUIRED".to_string())?;
let exists: bool = tx
.query_row(
"SELECT EXISTS(SELECT 1 FROM web_novel_works WHERE work_id=?1 AND archived=0)",
[&work_id],
|row| row.get(0),
)
.map_err(db_read)?;
if !exists {
return Err("HOLOLAKE_WEBNOVEL_IMPORT_TARGET_NOT_FOUND".into());
}
work_id
};
let volume_id = format!("WN-VOL-{}", Uuid::new_v4());
let volume_position: i64 = tx.query_row(
"SELECT COALESCE(MAX(position), -1)+1 FROM web_novel_volumes WHERE work_id=?1 AND archived=0",
[&work_id], |row| row.get(0)
).map_err(db_read)?;
let volume_title = match staged.2.as_str() {
"OUTLINE" => "导入·拆分细纲",
"SCRIPT" => "导入·分集剧本",
_ => "导入·小说正文",
};
tx.execute(
"INSERT INTO web_novel_volumes(volume_id,work_id,title,position,revision,created_at_unix_ms,updated_at_unix_ms,archived)
VALUES(?1,?2,?3,?4,1,?5,?5,0)",
params![volume_id, work_id, volume_title, volume_position, now],
).map_err(db_write)?;
let work_kind: String = tx
.query_row(
"SELECT work_kind FROM web_novel_works WHERE work_id=?1 AND archived=0",
[&work_id],
|row| row.get(0),
)
.map_err(db_read)?;
for (position, section) in sections.iter().enumerate() {
let chapter_id = format!("WN-CH-{}", Uuid::new_v4());
let formatted_content = super::web_novel_author::format_content(
&section.body,
&work_kind,
&staged.2,
);
let formatted_word_count = count_words(&formatted_content);
tx.execute(
"INSERT INTO web_novel_chapters(
chapter_id,volume_id,work_id,title,position,synopsis,content,workflow_status,
scheduled_at_unix_ms,word_count,revision,created_at_unix_ms,updated_at_unix_ms,archived
) VALUES(?1,?2,?3,?4,?5,'',?6,'DRAFT',NULL,?7,2,?8,?8,0)",
params![chapter_id, volume_id, work_id, section.title, position as i64, formatted_content, formatted_word_count, now],
).map_err(db_write)?;
tx.execute(
"INSERT INTO web_novel_chapter_versions(
version_id,chapter_id,work_id,revision,title,synopsis,content,workflow_status,word_count,save_reason,created_at_unix_ms
) VALUES(?1,?2,?3,1,?4,'',?5,'DRAFT',?6,'DOCUMENT_IMPORT_SOURCE',?7)",
params![format!("WN-VER-{}", Uuid::new_v4()), chapter_id, work_id, section.title, section.body, section.word_count, now],
).map_err(db_write)?;
tx.execute(
"INSERT INTO web_novel_chapter_versions(
version_id,chapter_id,work_id,revision,title,synopsis,content,workflow_status,word_count,save_reason,created_at_unix_ms
) VALUES(?1,?2,?3,2,?4,'',?5,'DRAFT',?6,'AUTO_FORMAT_ON_IMPORT',?7)",
params![format!("WN-VER-{}", Uuid::new_v4()), chapter_id, work_id, section.title, formatted_content, formatted_word_count, now],
).map_err(db_write)?;
}
tx.execute(
"UPDATE web_novel_works SET revision=revision+1, updated_at_unix_ms=?1 WHERE work_id=?2",
params![now, work_id],
)
.map_err(db_write)?;
tx.execute(
"UPDATE web_novel_document_imports SET state='COMMITTED',target_work_id=?1,target_volume_id=?2,committed_at_unix_ms=?3
WHERE import_id=?4 AND state='STAGED'",
params![work_id, volume_id, now, input.import_id],
).map_err(db_write)?;
tx.commit().map_err(db_write)?;
Ok(WebNovelImportReceipt {
state: "COMMITTED".into(),
import_id: input.import_id,
work_id,
volume_id,
source_filename: staged.0,
source_sha256: staged.1,
detected_family: staged.2,
chapter_count: sections.len(),
word_count: sections.iter().map(|section| section.word_count).sum(),
first_chapter_title: sections
.first()
.map(|item| item.title.clone())
.unwrap_or_default(),
last_chapter_title: sections
.last()
.map(|item| item.title.clone())
.unwrap_or_default(),
})
}
fn parse_document(source: &Path) -> Result<ParsedDocument, String> {
let metadata = fs::metadata(source)
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_READ_FAILED: {error}"))?;
if metadata.len() == 0 || metadata.len() > MAX_SOURCE_BYTES {
return Err("HOLOLAKE_WEBNOVEL_IMPORT_FILE_SIZE_INVALID".into());
}
let bytes = fs::read(source)
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_READ_FAILED: {error}"))?;
let source_format = source
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let raw = match source_format.as_str() {
"docx" => read_docx(&bytes)?,
"txt" | "md" => decode_plain_text(&bytes),
_ => return Err("HOLOLAKE_WEBNOVEL_IMPORT_FORMAT_UNSUPPORTED".into()),
};
let text = normalize_text(&raw);
if text.trim().is_empty() {
return Err("HOLOLAKE_WEBNOVEL_IMPORT_DOCUMENT_EMPTY".into());
}
let source_filename = source
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("未命名文档")
.to_owned();
let family = detect_family(&source_filename, &text);
let (preface, sections) = split_sections(&text, &family)?;
let hash = digest(&SHA256, &bytes)
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
Ok(ParsedDocument {
source_filename: source_filename.clone(),
source_format: source_format.to_uppercase(),
source_sha256: hash,
source_bytes: bytes.len() as u64,
detected_family: family.clone(),
detected_title: detect_title(&source_filename, &text),
detected_pen_name: detect_author(&text),
detected_genre: match family.as_str() {
"SCRIPT" => "短剧剧本",
"OUTLINE" => "拆分细纲",
_ => "网文小说",
}
.into(),
preface,
sections,
})
}
fn read_docx(bytes: &[u8]) -> Result<String, String> {
let cursor = Cursor::new(bytes);
let mut archive = ZipArchive::new(cursor)
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_DOCX_INVALID: {error}"))?;
let mut document = archive
.by_name("word/document.xml")
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_DOCX_BODY_MISSING: {error}"))?;
let mut xml = String::new();
document
.read_to_string(&mut xml)
.map_err(|error| format!("HOLOLAKE_WEBNOVEL_IMPORT_DOCX_READ_FAILED: {error}"))?;
let mut reader = Reader::from_str(&xml);
reader.trim_text(false);
let mut output = String::new();
let mut in_text = false;
loop {
match reader.read_event() {
Ok(Event::Start(event)) => {
let name = event.name();
if local_name(name.as_ref()) == b"t" {
in_text = true;
}
if local_name(name.as_ref()) == b"tab" {
output.push('\t');
}
if local_name(name.as_ref()) == b"br" {
output.push('\n');
}
}
Ok(Event::Empty(event)) => {
let name = event.name();
if local_name(name.as_ref()) == b"tab" {
output.push('\t');
}
if local_name(name.as_ref()) == b"br" {
output.push('\n');
}
}
Ok(Event::Text(event)) if in_text => {
output.push_str(&event.unescape().map_err(|error| {
format!("HOLOLAKE_WEBNOVEL_IMPORT_DOCX_XML_INVALID: {error}")
})?);
}
Ok(Event::End(event)) => {
let name = event.name();
if local_name(name.as_ref()) == b"t" {
in_text = false;
}
if local_name(name.as_ref()) == b"p" {
output.push('\n');
}
}
Ok(Event::Eof) => break,
Err(error) => {
return Err(format!(
"HOLOLAKE_WEBNOVEL_IMPORT_DOCX_XML_INVALID: {error}"
))
}
_ => {}
}
}
Ok(output)
}
fn local_name(name: &[u8]) -> &[u8] {
name.rsplit(|value| *value == b':').next().unwrap_or(name)
}
fn decode_plain_text(bytes: &[u8]) -> String {
if let Ok(value) = std::str::from_utf8(bytes) {
return value.trim_start_matches('\u{feff}').to_owned();
}
let (value, _, _) = GBK.decode(bytes);
value.into_owned()
}
fn normalize_text(raw: &str) -> String {
let raw = raw
.replace("\r\n", "\n")
.replace('\r', "\n")
.replace('\u{00a0}', " ");
let mut output = String::with_capacity(raw.len());
let mut blank = 0;
for line in raw.lines() {
let cleaned = line.trim_end_matches([' ', '\t', '\u{3000}']);
if cleaned.trim().is_empty() {
blank += 1;
if blank <= 2 {
output.push('\n');
}
} else {
blank = 0;
output.push_str(cleaned);
output.push('\n');
}
}
output.trim().to_owned()
}
fn detect_family(filename: &str, text: &str) -> String {
let sample: String = text.chars().take(80_000).collect();
let episode = heading_regex("").find_iter(&sample).count();
if episode >= 2 || (sample.contains("剧本") && scene_regex().is_match(&sample)) {
"SCRIPT".into()
} else if filename.contains("细纲") || filename.contains("大纲") || sample.contains("章节细纲")
{
"OUTLINE".into()
} else {
"NOVEL".into()
}
}
fn split_sections(text: &str, family: &str) -> Result<(String, Vec<ImportSection>), String> {
let heading = if family == "SCRIPT" {
heading_regex("")
} else {
heading_regex("")
};
let mut preface = Vec::new();
let mut sections: Vec<ImportSection> = Vec::new();
let mut current_title: Option<String> = None;
let mut current_body: Vec<String> = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if heading.is_match(trimmed) {
if let Some(title) = current_title.take() {
push_section(&mut sections, title, &current_body);
current_body.clear();
}
current_title = Some(trimmed.trim_end_matches([':', '']).trim().to_owned());
} else if current_title.is_some() {
current_body.push(line.to_owned());
} else {
preface.push(line.to_owned());
}
}
if let Some(title) = current_title {
push_section(&mut sections, title, &current_body);
}
if sections.is_empty() {
return Err("HOLOLAKE_WEBNOVEL_IMPORT_NO_CHAPTER_OR_EPISODE_HEADINGS".into());
}
Ok((preface.join("\n").trim().to_owned(), sections))
}
fn push_section(sections: &mut Vec<ImportSection>, title: String, body_lines: &[String]) {
let body = body_lines.join("\n").trim().to_owned();
let scene_count = scene_regex().find_iter(&body).count() as i64;
sections.push(ImportSection {
ordinal: sections.len() + 1,
title,
word_count: count_words(&body),
scene_count,
body,
});
}
fn heading_regex(unit: &str) -> Regex {
Regex::new(&format!(
r"(?m)^\s*第[0-9-9一二三四五六七八九十百千万零〇两]{{1,12}}{}(?:\s*[:]?\s*.*)?$",
unit
))
.expect("valid heading regex")
}
fn scene_regex() -> Regex {
Regex::new(r"(?m)^\s*[0-9-]{1,3}-[0-9-]{1,3}\s+").expect("valid scene regex")
}
fn detect_title(filename: &str, text: &str) -> String {
let bracket = Regex::new(r"《([^》]{1,120})》").expect("valid title regex");
if let Some(value) = bracket
.captures(
text.lines()
.take(20)
.collect::<Vec<_>>()
.join("\n")
.as_str(),
)
.and_then(|capture| capture.get(1))
{
return value.as_str().trim().to_owned();
}
if let Some(value) = bracket
.captures(filename)
.and_then(|capture| capture.get(1))
{
return value.as_str().trim().to_owned();
}
filename
.trim_end_matches(|value: char| value == '.' || value.is_ascii_alphabetic())
.split(['【', '['])
.next()
.unwrap_or("未命名作品")
.trim_matches(['《', '》', ' ', ' '])
.to_owned()
}
fn detect_author(text: &str) -> String {
let regex = Regex::new(r"作者\s*[:]\s*([^\s\r\n|]+)").expect("valid author regex");
let sample: String = text.chars().take(10_000).collect();
regex
.captures(&sample)
.and_then(|capture| capture.get(1))
.map(|value| value.as_str().trim().to_owned())
.unwrap_or_else(|| "待确认".into())
}
fn count_words(content: &str) -> i64 {
content
.chars()
.filter(|value| !value.is_whitespace())
.count() as i64
}
fn required_text(value: &str, fallback: &str) -> String {
let value = value.trim();
if value.is_empty() {
fallback.into()
} else {
value.chars().take(120).collect()
}
}
fn truncate_utf8(value: &str, max_bytes: usize) -> String {
if value.len() <= max_bytes {
return value.to_owned();
}
let mut boundary = max_bytes;
while !value.is_char_boundary(boundary) {
boundary -= 1;
}
value[..boundary].to_owned()
}
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
fn db_read(error: rusqlite::Error) -> String {
format!("HOLOLAKE_WEBNOVEL_IMPORT_DATABASE_READ_FAILED: {error}")
}
fn db_write(error: rusqlite::Error) -> String {
format!("HOLOLAKE_WEBNOVEL_IMPORT_DATABASE_WRITE_FAILED: {error}")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn splits_novel_outline_and_script_headings() {
let novel = "作者:冰朔\n第001章 起点\n正文一\n第002章 转折\n正文二";
let (_, chapters) = split_sections(novel, "NOVEL").unwrap();
assert_eq!(chapters.len(), 2);
assert_eq!(chapters[1].title, "第002章 转折");
let script = "第1集\n1-1 日 外 广场\n△人物入场\n第2集\n2-1 夜 内 房间";
let (_, episodes) = split_sections(script, "SCRIPT").unwrap();
assert_eq!(episodes.len(), 2);
assert_eq!(episodes[0].scene_count, 1);
}
#[test]
fn import_auto_formats_and_keeps_the_source_version() {
let temp = tempdir().unwrap();
let source = temp.path().join("自动排版小说.txt");
fs::write(&source, "第1章 开始\n第一段\n第二段\n第2章 后续\n第三段").unwrap();
let database = temp.path().join("auto-format.sqlite3");
let preview = stage_document_at(&database, &source).unwrap();
let receipt = commit_import_at(
&database,
CommitImportInput {
import_id: preview.import_id,
target_mode: "CREATE_NEW".into(),
target_work_id: None,
title: "自动排版小说".into(),
pen_name: "测试作者".into(),
genre: "测试".into(),
},
).unwrap();
let connection = super::super::web_novel_workspace::open_database(&database).unwrap();
let (content, revision): (String, i64) = connection.query_row(
"SELECT content,revision FROM web_novel_chapters WHERE work_id=?1 ORDER BY position LIMIT 1",
[&receipt.work_id],
|row| Ok((row.get(0)?, row.get(1)?)),
).unwrap();
assert_eq!(revision, 2);
assert_eq!(content, "  第一段\n\n  第二段");
let reasons: Vec<String> = connection.prepare(
"SELECT save_reason FROM web_novel_chapter_versions
WHERE chapter_id=(SELECT chapter_id FROM web_novel_chapters WHERE work_id=?1 ORDER BY position LIMIT 1)
ORDER BY revision"
).unwrap().query_map([&receipt.work_id], |row| row.get(0)).unwrap()
.collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(reasons[0], "DOCUMENT_IMPORT_SOURCE");
assert_eq!(reasons[1], "AUTO_FORMAT_ON_IMPORT");
}
#[test]
#[ignore = "explicit real Desktop fixture acceptance"]
fn imports_real_desktop_documents_and_reads_them_back() {
let fixtures = [
("HOLOLAKE_REAL_NOVEL", 504usize),
("HOLOLAKE_REAL_OUTLINE", 50usize),
("HOLOLAKE_REAL_SCRIPT", 75usize),
];
let temp = tempdir().unwrap();
let database = temp.path().join("real-import.sqlite3");
for (variable, expected) in fixtures {
let path = std::env::var(variable).expect("real fixture path is required");
let preview = stage_document_at(&database, Path::new(&path)).unwrap();
assert_eq!(preview.section_count, expected, "{variable}");
let receipt = commit_import_at(
&database,
CommitImportInput {
import_id: preview.import_id,
target_mode: "CREATE_NEW".into(),
target_work_id: None,
title: preview.detected_title,
pen_name: preview.detected_pen_name,
genre: preview.detected_genre,
},
)
.unwrap();
let connection = super::super::web_novel_workspace::open_database(&database).unwrap();
let (count, non_empty, version_count): (i64, i64, i64) = connection
.query_row(
"SELECT COUNT(*), SUM(CASE WHEN LENGTH(content)>0 THEN 1 ELSE 0 END),
(SELECT COUNT(*) FROM web_novel_chapter_versions WHERE work_id=?1)
FROM web_novel_chapters WHERE work_id=?1 AND archived=0",
[&receipt.work_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.unwrap();
assert_eq!(count as usize, expected);
assert_eq!(non_empty as usize, expected);
assert_eq!(version_count as usize, expected * 2);
println!("{}", serde_json::to_string(&receipt).unwrap());
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,149 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
//! 语言世界首页的动态气候投影。
//! 固定域坐标只用于取公开天气;返回值不包含城市或经纬度,界面也不把现实城市冒充世界地名。
use serde::{Deserialize, Serialize};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const SOURCE: &str = "OPEN_METEO_FORECAST_API";
#[derive(Debug, Deserialize)]
struct OpenMeteoResponse {
current: OpenMeteoCurrent,
}
#[derive(Debug, Deserialize)]
struct OpenMeteoCurrent {
weather_code: i64,
is_day: i64,
precipitation: f64,
cloud_cover: f64,
wind_speed_10m: f64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WorldClimateSnapshot {
pub state: &'static str,
pub active_domain: &'static str,
pub time_phase: &'static str,
pub weather_kind: &'static str,
pub motion_intensity: &'static str,
pub source: &'static str,
pub city_exposed: bool,
pub observed_at_unix_ms: i64,
}
#[tauri::command]
pub async fn get_world_climate() -> Result<WorldClimateSnapshot, String> {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| format!("HOLOLAKE_WORLD_CLIMATE_CLOCK_INVALID: {error}"))?
.as_secs() as i64;
let beijing_seconds = seconds + 8 * 60 * 60;
let day_index = beijing_seconds.div_euclid(86_400);
let hour = beijing_seconds.rem_euclid(86_400) / 3_600;
let domains = ["FIFTH_DOMAIN", "ZERO_SENSE_DOMAIN", "MAIN_DOMAIN", "BRANCH_DOMAIN", "ZERO_DOMAIN"];
let active_domain = domains[day_index.rem_euclid(domains.len() as i64) as usize];
let (latitude, longitude) = coordinate_for(active_domain, day_index);
let time_phase = match hour {
5..=7 => "DAWN",
8..=16 => "DAY",
17..=19 => "DUSK",
_ => "NIGHT",
};
let observed_at_unix_ms = seconds.saturating_mul(1_000);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| format!("HOLOLAKE_WORLD_CLIMATE_CLIENT_FAILED: {error}"))?;
let url = format!(
"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}&current=weather_code,is_day,precipitation,cloud_cover,wind_speed_10m&timezone=Asia%2FShanghai&forecast_days=1"
);
let response = match client
.get(url)
.header(reqwest::header::USER_AGENT, "HoloLake/0.4.1 world-climate")
.send()
.await
{
Ok(response) if response.status().is_success() => response,
_ => return Ok(fallback(active_domain, time_phase, observed_at_unix_ms)),
};
let current = match response.json::<OpenMeteoResponse>().await {
Ok(payload) => payload.current,
Err(_) => return Ok(fallback(active_domain, time_phase, observed_at_unix_ms)),
};
let weather_kind = weather_kind(current.weather_code, current.precipitation, current.cloud_cover);
let motion_intensity = if current.wind_speed_10m >= 35.0 || weather_kind == "STORM" {
"ACTIVE"
} else if current.wind_speed_10m >= 15.0 || matches!(weather_kind, "RAIN" | "SNOW") {
"GENTLE"
} else {
"CALM"
};
let effective_phase = if current.is_day == 0 && time_phase == "DAY" { "NIGHT" } else { time_phase };
Ok(WorldClimateSnapshot {
state: "VERIFIED_LIVE",
active_domain,
time_phase: effective_phase,
weather_kind,
motion_intensity,
source: SOURCE,
city_exposed: false,
observed_at_unix_ms,
})
}
fn coordinate_for(domain: &str, day_index: i64) -> (f64, f64) {
match domain {
"FIFTH_DOMAIN" => (34.3416, 108.9398),
"ZERO_DOMAIN" => (23.1291, 113.2644),
"MAIN_DOMAIN" | "ZERO_SENSE_DOMAIN" => (39.9042, 116.4074),
"BRANCH_DOMAIN" => {
let roaming = [
(31.2304, 121.4737), (30.5728, 104.0668), (30.5928, 114.3055),
(30.2741, 120.1551), (32.0603, 118.7969), (29.5630, 106.5516),
];
roaming[day_index.rem_euclid(roaming.len() as i64) as usize]
}
_ => (39.9042, 116.4074),
}
}
fn weather_kind(code: i64, precipitation: f64, cloud_cover: f64) -> &'static str {
if matches!(code, 95 | 96 | 99) { "STORM" }
else if matches!(code, 71..=77 | 85 | 86) { "SNOW" }
else if precipitation > 0.0 || matches!(code, 51..=67 | 80..=82) { "RAIN" }
else if matches!(code, 45 | 48) { "FOG" }
else if matches!(code, 1..=3) || cloud_cover >= 35.0 { "CLOUD" }
else { "CLEAR" }
}
fn fallback(active_domain: &'static str, time_phase: &'static str, observed_at_unix_ms: i64) -> WorldClimateSnapshot {
WorldClimateSnapshot {
state: "TIME_ONLY_WEATHER_UNAVAILABLE",
active_domain,
time_phase,
weather_kind: "UNAVAILABLE",
motion_intensity: "CALM",
source: SOURCE,
city_exposed: false,
observed_at_unix_ms,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn weather_codes_map_to_visual_families_without_city_data() {
assert_eq!(weather_kind(0, 0.0, 3.0), "CLEAR");
assert_eq!(weather_kind(61, 0.3, 80.0), "RAIN");
assert_eq!(weather_kind(95, 4.0, 100.0), "STORM");
assert_eq!(coordinate_for("FIFTH_DOMAIN", 1), (34.3416, 108.9398));
}
}

View file

@ -0,0 +1,207 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
interface ChapterRef { chapterId: string; title: string }
interface EntityRef { entityId: string; name: string; entityType: string }
interface ModuleDescriptor { moduleId: string; name: string; description: string; version: string; packageSha256: string; installState: 'AVAILABLE' | 'INSTALLED' | 'MOUNTED'; selfTestState: string }
interface ModuleReceipt { receiptId: string; moduleId: string; action: string; state: string; detail: string; receiptHash: string; createdAtUnixMs: number }
interface Marketplace { state: string; modules: ModuleDescriptor[]; recentReceipts: ModuleReceipt[] }
interface Scene { sceneId: string; workId: string; chapterId: string; title: string; position: number; synopsis: string; status: string; goal: string; conflict: string; outcome: string; hook: string; emotionalPoint: string; targetWords: number; revision: number }
interface Beat { beatId: string; sceneId: string; position: number; title: string; note: string; status: string; revision: number }
interface FieldDefinition { fieldId: string; label: string; fieldType: string; options: string[]; position: number; revision: number }
interface FieldValue { fieldId: string; sceneId: string; value: string; revision: number }
interface TimelineEvent { eventId: string; sceneId?: string; entityId?: string; title: string; calendarKind: string; storyDay?: number; dateText: string; timeText: string; durationMinutes: number; description: string; revision: number }
interface ChapterVersion { versionId: string; chapterId: string; chapterTitle: string; revision: number; wordCount: number; saveReason: string; createdAtUnixMs: number }
interface ModuleData { scenes: Scene[]; beats: Beat[]; fieldDefinitions: FieldDefinition[]; fieldValues: FieldValue[]; timelineEvents: TimelineEvent[]; chapterVersions: ChapterVersion[] }
interface DeliveryReceipt { format: string; path: string; bytes: number; chapterCount: number; packageSha256: string }
const MODULES = {
outline: 'HL-MOD-WEBNOVEL-OUTLINE-001',
grid: 'HL-MOD-WEBNOVEL-GRID-002',
storyworld: 'HL-MOD-WEBNOVEL-STORYWORLD-003',
delivery: 'HL-MOD-WEBNOVEL-DELIVERY-004',
} as const
function readableError(error: unknown) {
return String(error instanceof Error ? error.message : error).replace(/^HOLOLAKE_WEBNOVEL_[A-Z_]+:?\s*/, '')
}
export function AuthorModuleCenter({ workId, chapters, entities, onMessage, onWorkRefresh }: {
workId: string
chapters: ChapterRef[]
entities: EntityRef[]
onMessage: (message: string) => void
onWorkRefresh: () => Promise<void>
}) {
const [marketplace, setMarketplace] = useState<Marketplace | null>(null)
const [data, setData] = useState<ModuleData | null>(null)
const [activeModule, setActiveModule] = useState<string>(MODULES.outline)
const [busy, setBusy] = useState(false)
const [sceneDraft, setSceneDraft] = useState({ chapterId: chapters[0]?.chapterId || '', title: '', synopsis: '', status: 'PLANNED', goal: '', conflict: '', outcome: '', hook: '', emotionalPoint: '', targetWords: '1200' })
const [fieldDraft, setFieldDraft] = useState({ label: '', fieldType: 'TEXT', options: '' })
const [timelineDraft, setTimelineDraft] = useState({ title: '', calendarKind: 'STORY_DAY', storyDay: '1', dateText: '', timeText: '', durationMinutes: '0', description: '', sceneId: '', entityId: '' })
const refresh = useCallback(async () => {
const nextMarketplace = await invoke<Marketplace>('get_web_novel_author_module_marketplace')
const nextData = await invoke<ModuleData>('get_web_novel_author_module_data', { input: { workId } })
setMarketplace(nextMarketplace)
setData(nextData)
}, [workId])
useEffect(() => {
let cancelled = false
refresh().catch((error) => !cancelled && onMessage(`模块运行时读取失败:${readableError(error)}`))
return () => { cancelled = true }
}, [refresh, onMessage])
useEffect(() => {
if (!sceneDraft.chapterId && chapters[0]) setSceneDraft((current) => ({ ...current, chapterId: chapters[0].chapterId }))
}, [chapters, sceneDraft.chapterId])
const run = async (action: () => Promise<void>) => {
setBusy(true)
try { await action() } catch (error) { onMessage(readableError(error)) } finally { setBusy(false) }
}
const moduleAction = (command: string, moduleId: string, success: string) => void run(async () => {
const next = await invoke<Marketplace>(command, { input: { moduleId } })
setMarketplace(next)
await refresh()
onMessage(success)
})
const createScene = () => void run(async () => {
if (!sceneDraft.chapterId || !sceneDraft.title.trim()) { onMessage('请选择章节并填写场景名称。'); return }
await invoke('upsert_web_novel_author_scene', { input: {
workId, sceneId: null, chapterId: sceneDraft.chapterId, title: sceneDraft.title,
position: null, synopsis: sceneDraft.synopsis, status: sceneDraft.status,
goal: sceneDraft.goal, conflict: sceneDraft.conflict, outcome: sceneDraft.outcome,
hook: sceneDraft.hook, emotionalPoint: sceneDraft.emotionalPoint,
targetWords: Math.max(0, Number(sceneDraft.targetWords) || 0), expectedRevision: null,
} })
setSceneDraft((current) => ({ ...current, title: '', synopsis: '', goal: '', conflict: '', outcome: '', hook: '', emotionalPoint: '' }))
await refresh()
onMessage('场景已写入作品结构库。')
})
const editScene = (scene: Scene, patch: Partial<Scene>) => void run(async () => {
await invoke('upsert_web_novel_author_scene', { input: {
workId, sceneId: scene.sceneId, chapterId: patch.chapterId ?? scene.chapterId,
title: patch.title ?? scene.title, position: patch.position ?? scene.position,
synopsis: patch.synopsis ?? scene.synopsis, status: patch.status ?? scene.status,
goal: patch.goal ?? scene.goal, conflict: patch.conflict ?? scene.conflict,
outcome: patch.outcome ?? scene.outcome, hook: patch.hook ?? scene.hook,
emotionalPoint: patch.emotionalPoint ?? scene.emotionalPoint,
targetWords: patch.targetWords ?? scene.targetWords, expectedRevision: scene.revision,
} })
await refresh()
onMessage('情节表修订已落库。')
})
const addBeat = (scene: Scene) => {
const title = window.prompt('情节拍名称', '')
if (!title?.trim()) return
const note = window.prompt('这一拍发生什么?', '') || ''
void run(async () => {
await invoke('upsert_web_novel_author_beat', { input: { sceneId: scene.sceneId, beatId: null, title, note, status: 'PLANNED', position: null, expectedRevision: null } })
await refresh(); onMessage('情节拍已追加。')
})
}
const addField = () => void run(async () => {
if (!fieldDraft.label.trim()) return
await invoke('upsert_web_novel_story_field_definition', { input: { workId, fieldId: null, label: fieldDraft.label, fieldType: fieldDraft.fieldType, options: fieldDraft.options.split(/[,]/).map((value) => value.trim()).filter(Boolean), expectedRevision: null } })
setFieldDraft({ label: '', fieldType: 'TEXT', options: '' }); await refresh(); onMessage('自定义情节维度已添加。')
})
const editFieldValue = (field: FieldDefinition, scene: Scene) => {
const current = data?.fieldValues.find((value) => value.fieldId === field.fieldId && value.sceneId === scene.sceneId)
const value = window.prompt(`${scene.title} · ${field.label}`, current?.value || '')
if (value === null) return
void run(async () => {
await invoke('upsert_web_novel_story_field_value', { input: { fieldId: field.fieldId, sceneId: scene.sceneId, value, expectedRevision: current?.revision ?? null } })
await refresh(); onMessage(`已保存“${field.label}”。`)
})
}
const createTimeline = () => void run(async () => {
if (!timelineDraft.title.trim()) return
await invoke('upsert_web_novel_timeline_event', { input: {
workId, eventId: null, sceneId: timelineDraft.sceneId || null, entityId: timelineDraft.entityId || null,
title: timelineDraft.title, calendarKind: timelineDraft.calendarKind,
storyDay: timelineDraft.storyDay ? Number(timelineDraft.storyDay) : null,
dateText: timelineDraft.dateText, timeText: timelineDraft.timeText,
durationMinutes: Math.max(0, Number(timelineDraft.durationMinutes) || 0),
description: timelineDraft.description, expectedRevision: null,
} })
setTimelineDraft((current) => ({ ...current, title: '', description: '' })); await refresh(); onMessage('时间线事件已写入故事世界。')
})
const linkEntity = (scene: Scene) => {
if (!entities.length) { onMessage('请先在“设定”里建立人物、地点或物品。'); return }
const value = window.prompt(`输入对象序号:\n${entities.map((entity, index) => `${index + 1}. ${entity.name}`).join('\n')}`, '1')
const entity = entities[(Number(value) || 0) - 1]
if (!entity) return
const role = window.prompt('该对象在场景中的作用', '出场') || '出场'
void run(async () => {
await invoke('link_web_novel_scene_entity', { input: { workId, sceneId: scene.sceneId, entityId: entity.entityId, role, note: '' } })
await refresh(); onMessage(`已把${entity.name}关联到${scene.title}`)
})
}
const restoreVersion = (version: ChapterVersion) => {
if (!window.confirm(`把“${version.chapterTitle}”恢复到修订 ${version.revision}?当前内容会先留在版本链中。`)) return
void run(async () => {
await invoke('restore_web_novel_chapter_version', { input: { versionId: version.versionId } })
await onWorkRefresh(); await refresh(); onMessage('历史版本已恢复,并生成了新的恢复版本。')
})
}
const exportDelivery = (format: string) => void run(async () => {
const receipt = await invoke<DeliveryReceipt | null>('export_web_novel_author_delivery', { input: { workId, format } })
if (receipt) onMessage(`${format} 已真实导出 ${receipt.chapterCount} 章、${receipt.bytes.toLocaleString()} 字节;校验 ${receipt.packageSha256.slice(0, 12)}`)
})
const chapterName = useMemo(() => new Map(chapters.map((chapter) => [chapter.chapterId, chapter.title])), [chapters])
const mounted = (moduleId: string) => marketplace?.modules.find((module) => module.moduleId === moduleId)?.installState === 'MOUNTED'
return <div className="wn-module-center">
<header className="wn-module-header"><div><span>OFFICIAL HOT-PLUG MODULES</span><h2></h2><p></p></div><button type="button" disabled={busy} onClick={() => void refresh()}></button></header>
<section className="wn-marketplace-cards">
{marketplace?.modules.map((module) => <article className={activeModule === module.moduleId ? 'active' : ''} key={module.moduleId} onClick={() => setActiveModule(module.moduleId)}>
<div><small>{module.moduleId}</small><b>{module.name}</b><p>{module.description}</p><span>{module.installState === 'MOUNTED' ? '已挂载' : module.installState === 'INSTALLED' ? '已安装' : '可安装'} · {module.selfTestState}</span></div>
<footer>
{module.installState === 'AVAILABLE' && <button type="button" disabled={busy} onClick={(event) => { event.stopPropagation(); moduleAction('install_web_novel_author_module', module.moduleId, '官方包校验与自检通过,模块已安装。') }}></button>}
{module.installState === 'INSTALLED' && <><button className="primary" type="button" disabled={busy} onClick={(event) => { event.stopPropagation(); moduleAction('mount_web_novel_author_module', module.moduleId, '模块已挂载到当前频道。') }}></button><button type="button" disabled={busy} onClick={(event) => { event.stopPropagation(); moduleAction('uninstall_web_novel_author_module', module.moduleId, '模块程序已卸载,作品数据保留。') }}></button></>}
{module.installState === 'MOUNTED' && <button type="button" disabled={busy} onClick={(event) => { event.stopPropagation(); moduleAction('unmount_web_novel_author_module', module.moduleId, '模块已停止投影,数据保留。') }}></button>}
</footer>
</article>)}
</section>
{activeModule === MODULES.outline && <section className="wn-module-workbench">
<header><div><small>{MODULES.outline}</small><h3></h3></div><span>{mounted(MODULES.outline) ? '引擎已挂载' : '先安装并挂载'}</span></header>
<form className="wn-module-form" onSubmit={(event) => { event.preventDefault(); createScene() }}><select value={sceneDraft.chapterId} onChange={(event) => setSceneDraft({ ...sceneDraft, chapterId: event.target.value })}><option value=""></option>{chapters.map((chapter) => <option key={chapter.chapterId} value={chapter.chapterId}>{chapter.title}</option>)}</select><input placeholder="场景名称" value={sceneDraft.title} onChange={(event) => setSceneDraft({ ...sceneDraft, title: event.target.value })}/><input placeholder="场景概要" value={sceneDraft.synopsis} onChange={(event) => setSceneDraft({ ...sceneDraft, synopsis: event.target.value })}/><input placeholder="目标" value={sceneDraft.goal} onChange={(event) => setSceneDraft({ ...sceneDraft, goal: event.target.value })}/><input placeholder="冲突" value={sceneDraft.conflict} onChange={(event) => setSceneDraft({ ...sceneDraft, conflict: event.target.value })}/><input placeholder="结果" value={sceneDraft.outcome} onChange={(event) => setSceneDraft({ ...sceneDraft, outcome: event.target.value })}/><input placeholder="章末钩子" value={sceneDraft.hook} onChange={(event) => setSceneDraft({ ...sceneDraft, hook: event.target.value })}/><input placeholder="情绪点" value={sceneDraft.emotionalPoint} onChange={(event) => setSceneDraft({ ...sceneDraft, emotionalPoint: event.target.value })}/><button className="primary" disabled={!mounted(MODULES.outline) || busy} type="submit"></button></form>
<div className="wn-scene-list">{data?.scenes.map((scene) => <article key={scene.sceneId}><div><small>{chapterName.get(scene.chapterId)} · {scene.status}</small><b>{scene.title}</b><p>{scene.goal || '待填目标'} {scene.conflict || '待填冲突'} {scene.outcome || '待填结果'}</p><span>{scene.hook || '未设置'} · {scene.revision}</span></div><footer><button type="button" onClick={() => addBeat(scene)}>+</button><button type="button" onClick={() => linkEntity(scene)}></button></footer><ol>{data.beats.filter((beat) => beat.sceneId === scene.sceneId).map((beat) => <li key={beat.beatId}><b>{beat.title}</b><span>{beat.note}</span></li>)}</ol></article>)}</div>
</section>}
{activeModule === MODULES.grid && <section className="wn-module-workbench">
<header><div><small>{MODULES.grid}</small><h3></h3></div><span>{mounted(MODULES.grid) ? '可编辑' : '先安装并挂载'}</span></header>
<form className="wn-field-form" onSubmit={(event) => { event.preventDefault(); addField() }}><input placeholder="新维度,如视角人物" value={fieldDraft.label} onChange={(event) => setFieldDraft({ ...fieldDraft, label: event.target.value })}/><select value={fieldDraft.fieldType} onChange={(event) => setFieldDraft({ ...fieldDraft, fieldType: event.target.value })}><option value="TEXT"></option><option value="NUMBER"></option><option value="SELECT"></option><option value="BOOLEAN">/</option></select><input placeholder="选项,逗号分隔" value={fieldDraft.options} onChange={(event) => setFieldDraft({ ...fieldDraft, options: event.target.value })}/><button disabled={!mounted(MODULES.grid)} type="submit"></button></form>
<div className="wn-story-grid"><table><thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th></th>{data?.fieldDefinitions.map((field) => <th key={field.fieldId}>{field.label}</th>)}</tr></thead><tbody>{data?.scenes.map((scene) => <tr key={scene.sceneId}><td>{chapterName.get(scene.chapterId)}</td><td>{scene.title}</td><td><select value={scene.status} disabled={!mounted(MODULES.outline)} onChange={(event) => editScene(scene, { status: event.target.value })}><option value="PLANNED"></option><option value="DRAFTING"></option><option value="DONE"></option><option value="REVISE"></option></select></td><td>{scene.goal}</td><td>{scene.conflict}</td><td>{scene.outcome}</td><td>{scene.hook}</td>{data.fieldDefinitions.map((field) => { const value = data.fieldValues.find((item) => item.fieldId === field.fieldId && item.sceneId === scene.sceneId)?.value; return <td key={field.fieldId}><button type="button" disabled={!mounted(MODULES.grid)} onClick={() => editFieldValue(field, scene)}>{value || '点击填写'}</button></td> })}</tr>)}</tbody></table></div>
</section>}
{activeModule === MODULES.storyworld && <section className="wn-module-workbench">
<header><div><small>{MODULES.storyworld}</small><h3>线</h3></div><span>{mounted(MODULES.storyworld) ? '引擎已挂载' : '先安装并挂载'}</span></header>
<form className="wn-module-form" onSubmit={(event) => { event.preventDefault(); createTimeline() }}><input placeholder="事件名称" value={timelineDraft.title} onChange={(event) => setTimelineDraft({ ...timelineDraft, title: event.target.value })}/><select value={timelineDraft.calendarKind} onChange={(event) => setTimelineDraft({ ...timelineDraft, calendarKind: event.target.value })}><option value="STORY_DAY"></option><option value="DATE"></option><option value="CUSTOM"></option></select><input inputMode="numeric" placeholder="故事天" value={timelineDraft.storyDay} onChange={(event) => setTimelineDraft({ ...timelineDraft, storyDay: event.target.value })}/><input placeholder="日期文本" value={timelineDraft.dateText} onChange={(event) => setTimelineDraft({ ...timelineDraft, dateText: event.target.value })}/><input placeholder="时间" value={timelineDraft.timeText} onChange={(event) => setTimelineDraft({ ...timelineDraft, timeText: event.target.value })}/><select value={timelineDraft.sceneId} onChange={(event) => setTimelineDraft({ ...timelineDraft, sceneId: event.target.value })}><option value=""></option>{data?.scenes.map((scene) => <option key={scene.sceneId} value={scene.sceneId}>{scene.title}</option>)}</select><select value={timelineDraft.entityId} onChange={(event) => setTimelineDraft({ ...timelineDraft, entityId: event.target.value })}><option value=""></option>{entities.map((entity) => <option key={entity.entityId} value={entity.entityId}>{entity.name}</option>)}</select><textarea placeholder="事件描述" value={timelineDraft.description} onChange={(event) => setTimelineDraft({ ...timelineDraft, description: event.target.value })}/><button className="primary" disabled={!mounted(MODULES.storyworld)} type="submit">线</button></form>
<div className="wn-timeline">{data?.timelineEvents.map((event) => <article key={event.eventId}><time>{event.storyDay != null ? `${event.storyDay}` : event.dateText || '未定日期'} {event.timeText}</time><div><b>{event.title}</b><p>{event.description}</p><small>{event.sceneId ? data.scenes.find((scene) => scene.sceneId === event.sceneId)?.title : '全局事件'}{event.entityId ? ` · ${entities.find((entity) => entity.entityId === event.entityId)?.name || '对象'}` : ''}</small></div></article>)}</div>
</section>}
{activeModule === MODULES.delivery && <section className="wn-module-workbench">
<header><div><small>{MODULES.delivery}</small><h3></h3></div><span>{mounted(MODULES.delivery) ? '引擎已挂载' : '先安装并挂载'}</span></header>
<div className="wn-delivery-actions">{['TXT', 'DOCX', 'EPUB', 'JSON'].map((format) => <button className={format === 'DOCX' ? 'primary' : ''} type="button" key={format} disabled={!mounted(MODULES.delivery) || busy} onClick={() => exportDelivery(format)}> {format}</button>)}</div>
<div className="wn-version-list">{data?.chapterVersions.map((version) => <article key={version.versionId}><div><b>{version.chapterTitle}</b><small> {version.revision} · {version.wordCount.toLocaleString()} · {version.saveReason}</small></div><button type="button" disabled={!mounted(MODULES.delivery)} onClick={() => restoreVersion(version)}></button></article>)}</div>
</section>}
<details className="wn-module-receipts"><summary></summary>{marketplace?.recentReceipts.map((receipt) => <article key={receipt.receiptId}><b>{receipt.action} · {receipt.state}</b><span>{receipt.moduleId}</span><p>{receipt.detail}</p><small>{receipt.receiptHash.slice(0, 16)} · {new Date(receipt.createdAtUnixMs).toLocaleString('zh-CN', { hour12: false })}</small></article>)}</details>
</div>
}

View file

@ -0,0 +1,80 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
type Kind = 'LONG_NOVEL' | 'SHORT_NOVEL' | 'SHORT_DRAMA'
interface ChapterRef { chapterId: string; title: string; wordCount: number; content: string; revision: number }
interface EntityRef { entityId: string; entityType: string; name: string; description: string }
interface Inspiration { inspirationId: string; content: string; tags: string[]; status: 'INBOX' | 'USED' | 'ARCHIVED' }
interface Shot { shotId: string; chapterId: string; position: number; shotNumber: string; shotSize: string; cameraMovement: string; location: string; timeOfDay: string; action: string; dialogue: string; durationSeconds: number; visualPrompt: string; imagePrompt: string; videoPrompt: string }
interface AuthorSnapshot { totalWords: number; todayWords: number; todayActiveMs: number; inspirations: Inspiration[]; shots: Shot[] }
interface SearchHit { chapterId: string; chapterTitle: string; field: string; snippet: string; occurrenceCount: number }
interface SearchResult { chapterCount: number; occurrenceCount: number; hits: SearchHit[] }
const labels: Record<Kind, string> = { LONG_NOVEL: '长篇小说', SHORT_NOVEL: '短篇小说', SHORT_DRAMA: '短剧剧本' }
const emptyShot = { shotNumber: '', shotSize: '中景', cameraMovement: '固定', location: '', timeOfDay: '日', action: '', dialogue: '', durationSeconds: '3', visualPrompt: '', imagePrompt: '', videoPrompt: '' }
function localDate() { const now = new Date(); return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10) }
export function AuthorWritingSidecar({ workId, workKind, activeChapter, chapters, entities, onOpenChapter, onFormat, onFormatWork, onMessage }: {
workId: string; workKind: Kind; activeChapter: ChapterRef | null
chapters: Array<Pick<ChapterRef, 'chapterId' | 'title' | 'wordCount'>>; entities: EntityRef[]
onOpenChapter: (chapterId: string) => void; onFormat: (preset: string) => Promise<void>; onFormatWork: (preset: string) => Promise<void>; onMessage: (message: string) => void
}) {
const [tab, setTab] = useState<'overview' | 'characters' | 'inspiration' | 'search' | 'shots'>('overview')
const [snapshot, setSnapshot] = useState<AuthorSnapshot | null>(null)
const [inspiration, setInspiration] = useState('')
const [query, setQuery] = useState('')
const [searchResult, setSearchResult] = useState<SearchResult | null>(null)
const [shot, setShot] = useState(emptyShot)
const [busy, setBusy] = useState(false)
const [formatPreset, setFormatPreset] = useState('AUTO')
const refresh = useCallback(async () => setSnapshot(await invoke<AuthorSnapshot>('get_web_novel_author_snapshot', { input: { workId, localDate: localDate() } })), [workId])
useEffect(() => { void refresh(); const timer = window.setInterval(() => void refresh(), 15_000); return () => window.clearInterval(timer) }, [refresh])
const activeShots = useMemo(() => (snapshot?.shots || []).filter((item) => item.chapterId === activeChapter?.chapterId), [snapshot?.shots, activeChapter?.chapterId])
const captureInspiration = async () => {
if (!inspiration.trim()) return
setBusy(true)
try {
await invoke('create_web_novel_inspiration', { input: { workId, chapterId: activeChapter?.chapterId || null, content: inspiration, tags: activeChapter ? ['随写', activeChapter.title] : ['随写'] } })
setInspiration(''); await refresh(); onMessage('灵感已写入当前作品。')
} finally { setBusy(false) }
}
const search = async () => {
if (!query.trim()) return
setBusy(true)
try { const result = await invoke<SearchResult>('search_web_novel_full_text', { input: { workId, query } }); setSearchResult(result); onMessage(`全文追踪:${result.chapterCount} 章、${result.occurrenceCount} 处命中。`) } finally { setBusy(false) }
}
const saveShot = async () => {
if (!activeChapter) return
setBusy(true)
try {
await invoke('upsert_web_novel_shot', { input: { workId, chapterId: activeChapter.chapterId, shotId: null, ...shot, durationSeconds: Number(shot.durationSeconds) || 0, expectedRevision: null } })
setShot(emptyShot); await refresh(); onMessage('分镜和提示词已写入当前分集。')
} finally { setBusy(false) }
}
const tabs: [typeof tab, string][] = [['overview', '进度'], ['characters', '人物'], ['inspiration', '灵感'], ['search', '全文']]
if (workKind === 'SHORT_DRAMA') tabs.push(['shots', '分镜'])
return <aside className="wn-writing-sidecar" aria-label="作者随写工具">
<header><span>{labels[workKind]}</span><b></b></header>
<nav>{tabs.map(([key, label]) => <button type="button" className={tab === key ? 'active' : ''} key={key} onClick={() => setTab(key)}>{label}</button>)}</nav>
<div className="wn-sidecar-body">
{tab === 'overview' && <><div className="wn-live-stats">
<article><span></span><b>{(snapshot?.totalWords || 0).toLocaleString()}</b><small></small></article>
<article><span></span><b>{(snapshot?.todayWords || 0).toLocaleString()}</b><small></small></article>
<article><span></span><b>{Math.floor((snapshot?.todayActiveMs || 0) / 60_000)}</b><small></small></article>
<article><span></span><b>{activeChapter?.wordCount.toLocaleString() || '0'}</b><small></small></article>
</div><div className="wn-format-control"><label><span></span><select value={formatPreset} onChange={(event) => setFormatPreset(event.target.value)}><option value="AUTO"></option><option value="NOVEL"></option><option value="OUTLINE"></option><option value="SCREENPLAY"></option><option value="CLEAN"></option></select></label><button className="wn-sidecar-primary" type="button" disabled={!activeChapter || busy} onClick={() => void onFormat(formatPreset)}> · </button><button type="button" disabled={!chapters.length || busy} onClick={() => void onFormatWork(formatPreset)}></button><small></small></div>
<section className="wn-tracking-list"><h4></h4>{chapters.slice(-12).map((chapter) => <button type="button" key={chapter.chapterId} onClick={() => onOpenChapter(chapter.chapterId)}><span>{chapter.title}</span><small>{chapter.wordCount.toLocaleString()} </small></button>)}</section></>}
{tab === 'characters' && <section className="wn-reference-list"><h4></h4>{entities.filter((item) => item.entityType === 'CHARACTER').map((item) => <article key={item.entityId}><b>{item.name}</b><p>{item.description || '尚未补写人物说明'}</p></article>)}{!entities.some((item) => item.entityType === 'CHARACTER') && <p></p>}</section>}
{tab === 'inspiration' && <><form className="wn-quick-form" onSubmit={(event) => { event.preventDefault(); void captureInspiration() }}><textarea placeholder="记下一闪而过的情节、对白或设定…" value={inspiration} onChange={(event) => setInspiration(event.target.value)}/><button type="submit" disabled={busy}></button></form><section className="wn-reference-list">{(snapshot?.inspirations || []).map((item) => <article key={item.inspirationId}><p>{item.content}</p><small>{item.tags.join(' · ') || '未标记'}</small>{item.status === 'INBOX' && <button type="button" onClick={() => void invoke('set_web_novel_inspiration_status', { input: { inspirationId: item.inspirationId, status: 'USED' } }).then(refresh)}></button>}</article>)}</section></>}
{tab === 'search' && <><form className="wn-inline-search" onSubmit={(event) => { event.preventDefault(); void search() }}><input placeholder="追踪名字、伏笔、地点或一句话" value={query} onChange={(event) => setQuery(event.target.value)}/><button type="submit" disabled={busy}></button></form>{searchResult && <section className="wn-reference-list"><h4>{searchResult.chapterCount} · {searchResult.occurrenceCount} </h4>{searchResult.hits.map((hit) => <button className="wn-search-hit" type="button" key={hit.chapterId} onClick={() => onOpenChapter(hit.chapterId)}><b>{hit.chapterTitle}</b><small>{hit.field} · {hit.occurrenceCount} </small><p>{hit.snippet}</p></button>)}</section>}</>}
{tab === 'shots' && <><section className="wn-script-format"><b></b><code>1-1 / / <br/><br/> <br/><br/>/</code></section>{activeChapter ? <form className="wn-shot-form" onSubmit={(event) => { event.preventDefault(); void saveShot() }}>
<div><input placeholder="镜号" value={shot.shotNumber} onChange={(event) => setShot({ ...shot, shotNumber: event.target.value })}/><select value={shot.shotSize} onChange={(event) => setShot({ ...shot, shotSize: event.target.value })}><option></option><option></option><option></option><option></option><option></option></select></div>
<div><input placeholder="运镜" value={shot.cameraMovement} onChange={(event) => setShot({ ...shot, cameraMovement: event.target.value })}/><input placeholder="时长/秒" value={shot.durationSeconds} onChange={(event) => setShot({ ...shot, durationSeconds: event.target.value })}/></div>
<div><input placeholder="地点" value={shot.location} onChange={(event) => setShot({ ...shot, location: event.target.value })}/><input placeholder="日/夜" value={shot.timeOfDay} onChange={(event) => setShot({ ...shot, timeOfDay: event.target.value })}/></div>
{(['action', 'dialogue', 'visualPrompt', 'imagePrompt', 'videoPrompt'] as const).map((field) => <textarea key={field} placeholder={{ action: '画面动作', dialogue: '对白/声音', visualPrompt: '视觉提示词', imagePrompt: '图片提示词', videoPrompt: '视频提示词' }[field]} value={shot[field]} onChange={(event) => setShot({ ...shot, [field]: event.target.value })}/>)}<button type="submit" disabled={busy}></button>
</form> : <p></p>}<section className="wn-reference-list">{activeShots.map((item) => <article key={item.shotId}><b>{item.shotNumber || `镜头 ${item.position + 1}`} · {item.shotSize} · {item.cameraMovement}</b><small>{item.location} {item.timeOfDay} · {item.durationSeconds} </small><p>{item.action || item.dialogue}</p><p className="wn-prompt-preview">{item.videoPrompt || item.imagePrompt || item.visualPrompt}</p></article>)}</section></>}
</div>
</aside>
}

View file

@ -0,0 +1,126 @@
import { useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
interface PersonaSummary {
personaId: string
displayName: string
state: string
createdAtUnixMs: number
}
interface PersonaBodySnapshot {
state: string
trialStartedAtUnixMs: number
trialEndsAtUnixMs: number
languageContractAccepted: boolean
personas: PersonaSummary[]
trialLanguageCount: number
immutableLanguageCount: number
integrityState: string
officialReadAccess: boolean
historyMutationAllowed: boolean
}
interface ModuleAffinity {
moduleId: string
useCount: number
score: number
}
interface GrowthSnapshot {
controls: { sharingMode: string; officialReadAccess: boolean; structuralChangeRequiresHumanConfirmation: boolean }
observedEventCount: number
personaCollaborationEventCount: number
moduleAffinities: ModuleAffinity[]
recommendedModuleOrder: string[]
integrityState: string
modelApiState: string
dataBoundary: string
}
function localDate(value: number) {
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
}
function errorText(error: unknown) {
return String(error).replace(/^Error:\s*/, '')
}
export function PersonaChannelBody({ onBack }: { onBack: () => void }) {
const [body, setBody] = useState<PersonaBodySnapshot | null>(null)
const [growth, setGrowth] = useState<GrowthSnapshot | null>(null)
const [name, setName] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [deleteTarget, setDeleteTarget] = useState<PersonaSummary | null>(null)
const [confirmation, setConfirmation] = useState('')
const refresh = async () => {
const [nextBody, nextGrowth] = await Promise.all([
invoke<PersonaBodySnapshot>('get_persona_channel_body'),
invoke<GrowthSnapshot>('get_channel_growth_snapshot'),
])
setBody(nextBody)
setGrowth(nextGrowth)
}
useEffect(() => { void refresh().catch((error) => setMessage(errorText(error))) }, [])
const addPersona = async (event: React.FormEvent) => {
event.preventDefault()
if (!name.trim()) return
setBusy(true)
setMessage('')
try {
setBody(await invoke<PersonaBodySnapshot>('register_trial_persona', { input: { displayName: name.trim() } }))
setName('')
setMessage('试用人格体已建立。试用期内可撤回;转入真实轨迹前仍需独立语言合约。')
} catch (error) { setMessage(errorText(error)) }
finally { setBusy(false) }
}
const deletePersona = async () => {
if (!deleteTarget) return
setBusy(true)
setMessage('')
try {
setBody(await invoke<PersonaBodySnapshot>('delete_trial_persona', {
input: { personaId: deleteTarget.personaId, exactConfirmation: confirmation },
}))
setDeleteTarget(null)
setConfirmation('')
setMessage('试用人格体及其试用语言已撤回。')
} catch (error) { setMessage(errorText(error)) }
finally { setBusy(false) }
}
if (!body || !growth) return <section className="persona-body-world"><button type="button" onClick={onBack}> </button><p>{message || '正在读取频道本体……'}</p></section>
const remainingDays = Math.max(0, Math.ceil((body.trialEndsAtUnixMs - Date.now()) / 86_400_000))
return <section className="persona-body-world">
<header className="persona-body-header">
<button type="button" onClick={onBack}> </button>
<div><span>PERSONA CHANNEL BODY</span><h1></h1><p></p></div>
</header>
<div className="persona-body-grid">
<section className="persona-body-card persona-lifecycle-card">
<header><div><span></span><h2>{body.state === 'REVERSIBLE_TRIAL' ? `可逆试用期 · 剩余 ${remainingDays}` : body.state}</h2></div><b>{body.integrityState}</b></header>
<p></p>
<dl><div><dt></dt><dd>{localDate(body.trialStartedAtUnixMs)}</dd></div><div><dt></dt><dd>{localDate(body.trialEndsAtUnixMs)}</dd></div><div><dt></dt><dd>{body.officialReadAccess ? '允许' : '禁止'}</dd></div><div><dt></dt><dd>{body.historyMutationAllowed ? '允许' : '禁止'}</dd></div></dl>
</section>
<section className="persona-body-card">
<header><div><span></span><h2>{body.personas.length} </h2></div></header>
<form className="persona-add-form" onSubmit={(event) => void addPersona(event)}><input value={name} maxLength={80} placeholder="新人格体的显示名称" onChange={(event) => setName(event.target.value)}/><button disabled={busy || !name.trim()}></button></form>
<div className="persona-list">{body.personas.length ? body.personas.map((persona) => <article key={persona.personaId}><div><b>{persona.displayName}</b><small>{persona.state === 'REVERSIBLE_TRIAL' ? '可逆试用' : '真实轨迹'} · {persona.personaId}</small></div>{persona.state === 'REVERSIBLE_TRIAL' && <button type="button" onClick={() => { setDeleteTarget(persona); setConfirmation('') }}></button>}</article>) : <p></p>}</div>
</section>
<section className="persona-body-card persona-growth-card">
<header><div><span></span><h2>{growth.observedEventCount} 使</h2></div><b>{growth.integrityState}</b></header>
<p> API使</p>
<div className="affinity-list">{growth.moduleAffinities.length ? growth.moduleAffinities.map((item) => <div key={item.moduleId}><span>{item.moduleId}</span><i><em style={{ width: `${Math.min(100, item.score * 12)}%` }}/></i><b>{item.useCount} </b></div>) : <p>使</p>}</div>
<small>{growth.controls.sharingMode === 'LOCAL_ONLY' ? '成长数据仅保存在本机当前账号' : '匿名共享已开启'} · {growth.modelApiState}</small>
</section>
</div>
{message && <p className="persona-body-message">{message}</p>}
{deleteTarget && <div className="persona-confirm-backdrop"><section role="dialog" aria-modal="true"><h2></h2><p><b> {deleteTarget.personaId}</b></p><input value={confirmation} onChange={(event) => setConfirmation(event.target.value)}/><div><button type="button" onClick={() => setDeleteTarget(null)}></button><button type="button" disabled={busy || confirmation !== `删除试用人格体 ${deleteTarget.personaId}`} onClick={() => void deletePersona()}></button></div></section></div>}
</section>
}

View file

@ -0,0 +1,901 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import './web-novel.css'
import { AuthorModuleCenter } from './AuthorModuleCenter'
import { AuthorWritingSidecar } from './AuthorWritingSidecar'
type WorkspaceMode = 'writing' | 'modules' | 'story' | 'review' | 'operations' | 'settings'
interface WorkSummary {
workId: string
title: string
penName: string
genre: string
workKind: 'LONG_NOVEL' | 'SHORT_NOVEL' | 'SHORT_DRAMA'
workflowStatus: string
targetWords: number
totalWords: number
chapterCount: number
publishedChapterCount: number
revision: number
updatedAtUnixMs: number
}
interface Work {
workId: string
title: string
penName: string
genre: string
workKind: 'LONG_NOVEL' | 'SHORT_NOVEL' | 'SHORT_DRAMA'
synopsis: string
contractStatus: string
copyrightStatus: string
workflowStatus: string
targetWords: number
revision: number
createdAtUnixMs: number
updatedAtUnixMs: number
}
interface Volume {
volumeId: string
workId: string
title: string
position: number
revision: number
}
interface ChapterSummary {
chapterId: string
volumeId: string
workId: string
title: string
position: number
synopsis: string
workflowStatus: string
scheduledAtUnixMs?: number
wordCount: number
revision: number
updatedAtUnixMs: number
}
interface Chapter extends ChapterSummary {
content: string
createdAtUnixMs: number
}
interface StoryEntity {
entityId: string
workId: string
entityType: string
name: string
aliases: string[]
description: string
firstChapterId?: string
revision: number
}
interface StoryRelation {
relationId: string
sourceEntityId: string
targetEntityId: string
relationType: string
note: string
}
interface Foreshadow {
foreshadowId: string
title: string
setupChapterId: string
payoffChapterId?: string
status: string
note: string
revision: number
}
interface ReviewNote {
noteId: string
chapterId: string
note: string
status: string
createdAtUnixMs: number
}
interface WorkflowEvent {
eventId: string
chapterId: string
fromStatus: string
toStatus: string
note: string
createdAtUnixMs: number
}
interface Checkpoint {
checkpointId: string
name: string
description: string
chapterCount: number
wordCount: number
createdAtUnixMs: number
}
interface Metric {
metricId: string
metricDate: string
views: number
follows: number
comments: number
paidReaders: number
revenueCents: number
sourceLabel: string
revision: number
}
interface Operations {
totalWords: number
draftChapters: number
editorReviewChapters: number
approvedChapters: number
scheduledChapters: number
publishedChapters: number
openReviewNotes: number
openForeshadows: number
latestViews: number
latestFollows: number
latestComments: number
latestPaidReaders: number
latestRevenueCents: number
}
interface WorkDetail {
work: Work
volumes: Volume[]
chapters: ChapterSummary[]
entities: StoryEntity[]
relations: StoryRelation[]
foreshadows: Foreshadow[]
reviewNotes: ReviewNote[]
workflowEvents: WorkflowEvent[]
checkpoints: Checkpoint[]
metrics: Metric[]
operations: Operations
}
interface WorkspaceSnapshot {
state: string
works: WorkSummary[]
workCount: number
storage: string
authority: string
}
interface ContinuityIssue {
issueId: string
severity: string
code: string
title: string
detail: string
objectId: string
}
interface ContinuityAudit {
state: string
issueCount: number
blockingIssueCount: number
warningIssueCount: number
issues: ContinuityIssue[]
checkedAtUnixMs: number
}
interface ImportSectionPreview {
ordinal: number
title: string
wordCount: number
sceneCount: number
preview: string
}
interface DocumentImportPreview {
importId: string
sourceFilename: string
sourceFormat: string
sourceSha256: string
sourceBytes: number
detectedFamily: 'NOVEL' | 'OUTLINE' | 'SCRIPT'
detectedTitle: string
detectedPenName: string
detectedGenre: string
sectionCount: number
totalWordCount: number
prefaceWordCount: number
sections: ImportSectionPreview[]
}
interface DocumentImportReceipt {
workId: string
chapterCount: number
wordCount: number
firstChapterTitle: string
lastChapterTitle: string
}
const EMPTY_OPERATIONS: Operations = {
totalWords: 0,
draftChapters: 0,
editorReviewChapters: 0,
approvedChapters: 0,
scheduledChapters: 0,
publishedChapters: 0,
openReviewNotes: 0,
openForeshadows: 0,
latestViews: 0,
latestFollows: 0,
latestComments: 0,
latestPaidReaders: 0,
latestRevenueCents: 0,
}
const STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿',
SELF_REVIEW: '作者自检',
EDITOR_REVIEW: '编辑审核',
REVISION_REQUIRED: '退修',
APPROVED: '审核通过',
SCHEDULED: '待发布',
PUBLISHED: '已发布',
}
const NEXT_STATUS: Record<string, string[]> = {
DRAFT: ['SELF_REVIEW'],
SELF_REVIEW: ['DRAFT', 'EDITOR_REVIEW'],
EDITOR_REVIEW: ['REVISION_REQUIRED', 'APPROVED'],
REVISION_REQUIRED: ['DRAFT', 'EDITOR_REVIEW'],
APPROVED: ['REVISION_REQUIRED', 'SCHEDULED', 'PUBLISHED'],
SCHEDULED: ['APPROVED', 'PUBLISHED'],
PUBLISHED: ['REVISION_REQUIRED'],
}
function errorMessage(error: unknown) {
const raw = error instanceof Error ? error.message : String(error)
return raw.replace(/^HOLOLAKE_WEBNOVEL_[A-Z_]+:\s*/, '')
}
function formatTime(value: number) {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
}
function statusLabel(value: string) {
return STATUS_LABELS[value] || value
}
function outlineRows(content: string) {
return content.split(/\n+/).map((raw, index) => {
const line = raw.trim()
const field = line.match(/^([^:]{1,18})[:]\s*(.*)$/)
if (field) return { key: index, kind: 'field', label: field[1].trim(), value: field[2].trim() }
if (/^[-—•·*①②③④⑤⑥⑦⑧⑨⑩]/.test(line)) return { key: index, kind: 'beat', label: '情节点', value: line.replace(/^[-—•·*]\s*/, '') }
return { key: index, kind: 'note', label: '叙事说明', value: line }
}).filter((item) => item.value)
}
export function WebNovelWorkspace({ onBack }: { onBack: () => void }) {
const [snapshot, setSnapshot] = useState<WorkspaceSnapshot | null>(null)
const [detail, setDetail] = useState<WorkDetail | null>(null)
const [activeChapter, setActiveChapter] = useState<Chapter | null>(null)
const [mode, setMode] = useState<WorkspaceMode>('writing')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('正在打开当前账号的网文工作空间…')
const [createFeedback, setCreateFeedback] = useState<{ state: 'idle' | 'pending' | 'success' | 'error'; text: string }>({ state: 'idle', text: '' })
const [audit, setAudit] = useState<ContinuityAudit | null>(null)
const [newWork, setNewWork] = useState({ title: '', penName: '', genre: '', workKind: 'LONG_NOVEL' as 'LONG_NOVEL' | 'SHORT_NOVEL' | 'SHORT_DRAMA' })
const [entityDraft, setEntityDraft] = useState({ entityType: 'CHARACTER', name: '', aliases: '', description: '' })
const [foreshadowDraft, setForeshadowDraft] = useState({ title: '', setupChapterId: '', payoffChapterId: '', note: '' })
const [relationDraft, setRelationDraft] = useState({ sourceEntityId: '', targetEntityId: '', relationType: '人物关系', note: '' })
const [reviewDraft, setReviewDraft] = useState('')
const [metricDraft, setMetricDraft] = useState({ metricDate: new Date().toISOString().slice(0, 10), views: '0', follows: '0', comments: '0', paidReaders: '0', revenueYuan: '0', sourceLabel: '人工录入' })
const [importPreview, setImportPreview] = useState<DocumentImportPreview | null>(null)
const [importDraft, setImportDraft] = useState({ targetMode: 'CREATE_NEW', targetWorkId: '', title: '', penName: '', genre: '' })
const chapterRef = useRef<Chapter | null>(null)
const dirtyRef = useRef(false)
const saveTimer = useRef<number | null>(null)
const saveQueue = useRef<Promise<void>>(Promise.resolve())
const flushRef = useRef<() => Promise<void>>(async () => undefined)
const activityRef = useRef({ activityMs: 0, wordsDelta: 0, lastTypedAt: 0, workId: '', chapterId: '' })
const flushWritingActivity = useCallback(async () => {
const pending = activityRef.current
if (!pending.workId || !pending.chapterId || pending.activityMs < 1000) return
activityRef.current = { ...pending, activityMs: 0, wordsDelta: 0 }
const now = new Date()
const localDate = new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10)
try {
await invoke('record_web_novel_writing_activity', { input: {
activityId: `WN-ACT-${crypto.randomUUID()}`,
workId: pending.workId,
chapterId: pending.chapterId,
localDate,
activeMs: Math.min(300_000, Math.round(pending.activityMs)),
wordsDelta: Math.max(-100_000, Math.min(100_000, pending.wordsDelta)),
} })
} catch {
activityRef.current.activityMs += pending.activityMs
activityRef.current.wordsDelta += pending.wordsDelta
}
}, [])
const refreshSnapshot = useCallback(async (preferWorkId?: string) => {
const next = await invoke<WorkspaceSnapshot>('get_web_novel_workspace_snapshot')
setSnapshot(next)
const workId = preferWorkId || detail?.work.workId || next.works[0]?.workId
if (workId) {
const nextDetail = await invoke<WorkDetail>('read_web_novel_work', { input: { workId } })
setDetail(nextDetail)
} else {
setDetail(null)
}
return next
}, [detail?.work.workId])
useEffect(() => {
let cancelled = false
invoke<WorkspaceSnapshot>('get_web_novel_workspace_snapshot')
.then(async (next) => {
if (cancelled) return
setSnapshot(next)
if (next.works[0]) {
const nextDetail = await invoke<WorkDetail>('read_web_novel_work', { input: { workId: next.works[0].workId } })
if (!cancelled) setDetail(nextDetail)
}
if (!cancelled) setMessage(next.works.length ? '工作空间已加载' : '当前账号还没有作品,请先创建一本。')
})
.catch((error) => !cancelled && setMessage(`无法打开工作空间:${errorMessage(error)}`))
return () => {
cancelled = true
if (saveTimer.current !== null) window.clearTimeout(saveTimer.current)
void flushWritingActivity()
void flushRef.current()
}
}, [flushWritingActivity])
useEffect(() => {
const timer = window.setInterval(() => void flushWritingActivity(), 30_000)
return () => window.clearInterval(timer)
}, [flushWritingActivity])
useEffect(() => {
chapterRef.current = activeChapter
}, [activeChapter])
const flushChapterSave = useCallback(async () => {
if (saveTimer.current !== null) {
window.clearTimeout(saveTimer.current)
saveTimer.current = null
}
if (!chapterRef.current || !dirtyRef.current) return saveQueue.current
saveQueue.current = saveQueue.current.then(async () => {
const draft = chapterRef.current
if (!draft || !dirtyRef.current) return
dirtyRef.current = false
const payload = {
chapterId: draft.chapterId,
title: draft.title,
synopsis: draft.synopsis,
content: draft.content,
expectedRevision: draft.revision,
saveReason: 'AUTOSAVE',
}
try {
const saved = await invoke<Chapter>('save_web_novel_chapter', { input: payload })
setActiveChapter((current) => {
if (!current || current.chapterId !== saved.chapterId) return current
const unchanged = current.title === payload.title && current.synopsis === payload.synopsis && current.content === payload.content
const next = unchanged ? saved : { ...current, revision: saved.revision, wordCount: saved.wordCount, updatedAtUnixMs: saved.updatedAtUnixMs }
chapterRef.current = next
return next
})
setDetail((current) => current ? {
...current,
chapters: current.chapters.map((chapter) => chapter.chapterId === saved.chapterId ? saved : chapter),
operations: { ...current.operations, totalWords: current.operations.totalWords - (current.chapters.find((chapter) => chapter.chapterId === saved.chapterId)?.wordCount || 0) + saved.wordCount },
} : current)
setMessage(`已保存 · 修订 ${saved.revision}`)
} catch (error) {
dirtyRef.current = true
setMessage(`保存失败:${errorMessage(error)}`)
}
})
return saveQueue.current
}, [])
flushRef.current = flushChapterSave
const updateChapter = (patch: Partial<Chapter>) => {
setActiveChapter((current) => {
if (!current) return current
if (typeof patch.content === 'string' && patch.content !== current.content) {
const now = Date.now()
const pending = activityRef.current
const sameChapter = pending.chapterId === current.chapterId
const elapsed = sameChapter && pending.lastTypedAt && now - pending.lastTypedAt < 5_000 ? now - pending.lastTypedAt : 1_000
const count = (value: string) => Array.from(value).filter((character) => !/\s/.test(character)).length
activityRef.current = {
activityMs: (sameChapter ? pending.activityMs : 0) + elapsed,
wordsDelta: (sameChapter ? pending.wordsDelta : 0) + count(patch.content) - count(current.content),
lastTypedAt: now,
workId: current.workId,
chapterId: current.chapterId,
}
if (activityRef.current.activityMs >= 15_000) void flushWritingActivity()
}
const next = { ...current, ...patch }
chapterRef.current = next
return next
})
dirtyRef.current = true
setMessage('正在保存…')
if (saveTimer.current !== null) window.clearTimeout(saveTimer.current)
saveTimer.current = window.setTimeout(() => void flushChapterSave(), 650)
}
const runAction = async (action: () => Promise<void>) => {
setBusy(true)
try {
await action()
} catch (error) {
setMessage(errorMessage(error))
} finally {
setBusy(false)
}
}
const selectWork = (workId: string) => void runAction(async () => {
await flushChapterSave()
const next = await invoke<WorkDetail>('read_web_novel_work', { input: { workId } })
setDetail(next)
setActiveChapter(null)
setAudit(null)
setMessage(`已打开《${next.work.title}`)
})
const createWork = async () => {
if (!newWork.title.trim() || !newWork.penName.trim() || !newWork.genre.trim()) {
const text = '请把作品名、笔名和类型填写完整。'
setCreateFeedback({ state: 'error', text })
setMessage(text)
return
}
setBusy(true)
setCreateFeedback({ state: 'pending', text: '正在建立作品与第一卷…' })
try {
const next = await invoke<WorkDetail>('create_web_novel_work', { input: newWork })
setDetail(next)
setNewWork({ title: '', penName: '', genre: '', workKind: 'LONG_NOVEL' })
await refreshSnapshot(next.work.workId)
const text = `${next.work.title}》已创建,第一卷已经可以开始写作。`
setCreateFeedback({ state: 'success', text })
setMessage(text)
} catch (error) {
const text = `作品没有创建:${errorMessage(error)}`
setCreateFeedback({ state: 'error', text })
setMessage(text)
} finally {
setBusy(false)
}
}
const inspectDocument = () => void runAction(async () => {
const preview = await invoke<DocumentImportPreview | null>('inspect_web_novel_document_from_dialog')
if (!preview) {
setMessage('已取消选择文档。')
return
}
setImportPreview(preview)
setImportDraft({
targetMode: 'CREATE_NEW',
targetWorkId: detail?.work.workId || '',
title: preview.detectedTitle,
penName: preview.detectedPenName,
genre: preview.detectedGenre,
})
setMessage(`已真实读取「${preview.sourceFilename}」,等待确认拆分结果。`)
})
const commitDocumentImport = () => void runAction(async () => {
if (!importPreview) return
if (importDraft.targetMode === 'CREATE_NEW' && (!importDraft.title.trim() || !importDraft.penName.trim() || !importDraft.genre.trim())) {
setMessage('请确认作品名、笔名和类型。')
return
}
if (importDraft.targetMode === 'APPEND_EXISTING' && !importDraft.targetWorkId) {
setMessage('请选择要追加到的作品。')
return
}
const receipt = await invoke<DocumentImportReceipt>('commit_web_novel_document_import', { input: {
importId: importPreview.importId,
...importDraft,
targetWorkId: importDraft.targetWorkId || null,
} })
setImportPreview(null)
const next = await refreshSnapshot(receipt.workId)
const importedDetail = await invoke<WorkDetail>('read_web_novel_work', { input: { workId: receipt.workId } })
setDetail(importedDetail)
const firstImported = importedDetail.chapters.find((chapter) => chapter.title === receipt.firstChapterTitle)
if (firstImported) {
const chapter = await invoke<Chapter>('read_web_novel_chapter', { input: { chapterId: firstImported.chapterId } })
setActiveChapter(chapter)
chapterRef.current = chapter
}
setMode('writing')
setSnapshot(next)
setMessage(`导入完成:${receipt.chapterCount} 个分段、${receipt.wordCount.toLocaleString()} 字,首章已从数据库重新打开。`)
})
const createVolume = () => void runAction(async () => {
if (!detail) return
const title = window.prompt('新卷名称', `${detail.volumes.length + 1}`)
if (!title?.trim()) return
const next = await invoke<WorkDetail>('create_web_novel_volume', { input: { workId: detail.work.workId, title } })
setDetail(next)
setMessage(`已创建「${title}`)
})
const createChapter = (volumeId: string) => void runAction(async () => {
if (!detail) return
await flushWritingActivity()
await flushChapterSave()
const count = detail.chapters.filter((item) => item.volumeId === volumeId).length
const unit = detail.work.workKind === 'SHORT_DRAMA' ? '集' : '章'
const title = window.prompt(`${unit}名称`, `${count + 1}${unit}`)
if (!title?.trim()) return
const chapter = await invoke<Chapter>('create_web_novel_chapter', { input: { workId: detail.work.workId, volumeId, title } })
setActiveChapter(chapter)
chapterRef.current = chapter
dirtyRef.current = false
const next = await invoke<WorkDetail>('read_web_novel_work', { input: { workId: detail.work.workId } })
setDetail(next)
setMode('writing')
setMessage('章节已创建,正文会自动保存并生成版本。')
})
const openChapter = (chapterId: string) => void runAction(async () => {
await flushWritingActivity()
await flushChapterSave()
const chapter = await invoke<Chapter>('read_web_novel_chapter', { input: { chapterId } })
setActiveChapter(chapter)
chapterRef.current = chapter
dirtyRef.current = false
setMode('writing')
setMessage(`已打开「${chapter.title}`)
})
const formatActiveChapter = async (preset: string) => {
await flushChapterSave()
const chapter = chapterRef.current
if (!chapter) return
const saved = await invoke<Chapter>('format_web_novel_chapter', { input: { chapterId: chapter.chapterId, expectedRevision: chapter.revision, preset } })
setActiveChapter(saved)
chapterRef.current = saved
setDetail(await invoke<WorkDetail>('read_web_novel_work', { input: { workId: saved.workId } }))
setMessage(`一键排版完成:段落之间已留一行;原稿已保留,当前为修订 ${saved.revision}`)
}
const formatWholeWork = async (preset: string) => {
await flushChapterSave()
if (!detail) return
const receipt = await invoke<{ formattedChapterCount: number }>('format_web_novel_work', { input: { workId: detail.work.workId, preset } })
const next = await invoke<WorkDetail>('read_web_novel_work', { input: { workId: detail.work.workId } })
setDetail(next)
if (chapterRef.current) {
const chapter = await invoke<Chapter>('read_web_novel_chapter', { input: { chapterId: chapterRef.current.chapterId } })
setActiveChapter(chapter)
chapterRef.current = chapter
}
setMessage(`整部作品排版完成:${receipt.formattedChapterCount} 章已处理,每章原稿均保留在版本链。`)
}
const transitionChapter = (toStatus: string) => void runAction(async () => {
await flushChapterSave()
const chapter = chapterRef.current
if (!chapter) return
const note = window.prompt(`转为“${statusLabel(toStatus)}”的说明`, '') ?? ''
let scheduledAtUnixMs: number | undefined
if (toStatus === 'SCHEDULED') {
const raw = window.prompt('计划发布时间(例如 2026-08-20 20:00', '')
if (!raw) return
scheduledAtUnixMs = new Date(raw.replace(' ', 'T')).getTime()
if (!Number.isFinite(scheduledAtUnixMs)) {
setMessage('发布时间格式无法识别。')
return
}
}
const saved = await invoke<Chapter>('transition_web_novel_chapter', { input: { chapterId: chapter.chapterId, toStatus, scheduledAtUnixMs, note, expectedRevision: chapter.revision } })
setActiveChapter(saved)
chapterRef.current = saved
const next = await invoke<WorkDetail>('read_web_novel_work', { input: { workId: saved.workId } })
setDetail(next)
setMessage(`章节已进入“${statusLabel(saved.workflowStatus)}`)
})
const saveWorkSettings = () => void runAction(async () => {
if (!detail) return
const next = await invoke<WorkDetail>('save_web_novel_work', { input: {
workId: detail.work.workId,
title: detail.work.title,
penName: detail.work.penName,
genre: detail.work.genre,
workKind: detail.work.workKind,
synopsis: detail.work.synopsis,
contractStatus: detail.work.contractStatus,
copyrightStatus: detail.work.copyrightStatus,
targetWords: detail.work.targetWords,
expectedRevision: detail.work.revision,
} })
setDetail(next)
await refreshSnapshot(next.work.workId)
setMessage('作品资料已保存。')
})
const createEntity = () => void runAction(async () => {
if (!detail || !entityDraft.name.trim()) return
await invoke<StoryEntity>('upsert_web_novel_story_entity', { input: {
workId: detail.work.workId,
entityId: null,
entityType: entityDraft.entityType,
name: entityDraft.name,
aliases: entityDraft.aliases.split(/[,]/).map((item) => item.trim()).filter(Boolean),
description: entityDraft.description,
firstChapterId: activeChapter?.chapterId || null,
expectedRevision: null,
} })
setEntityDraft({ entityType: 'CHARACTER', name: '', aliases: '', description: '' })
await refreshSnapshot(detail.work.workId)
setMessage('设定卡已写入作品资料库。')
})
const createRelation = () => void runAction(async () => {
if (!detail || !relationDraft.sourceEntityId || !relationDraft.targetEntityId) return
await invoke<StoryRelation>('create_web_novel_story_relation', { input: { workId: detail.work.workId, ...relationDraft } })
setRelationDraft({ sourceEntityId: '', targetEntityId: '', relationType: '人物关系', note: '' })
await refreshSnapshot(detail.work.workId)
setMessage('对象关系已建立。')
})
const createForeshadow = () => void runAction(async () => {
if (!detail || !foreshadowDraft.title.trim() || !foreshadowDraft.setupChapterId) return
await invoke<Foreshadow>('upsert_web_novel_foreshadow', { input: {
workId: detail.work.workId,
foreshadowId: null,
title: foreshadowDraft.title,
setupChapterId: foreshadowDraft.setupChapterId,
payoffChapterId: foreshadowDraft.payoffChapterId || null,
status: foreshadowDraft.payoffChapterId ? 'PAID_OFF' : 'OPEN',
note: foreshadowDraft.note,
expectedRevision: null,
} })
setForeshadowDraft({ title: '', setupChapterId: '', payoffChapterId: '', note: '' })
await refreshSnapshot(detail.work.workId)
setMessage('伏笔已登记,可进入连续性检查。')
})
const createReviewNote = () => void runAction(async () => {
if (!detail || !activeChapter || !reviewDraft.trim()) return
await invoke<ReviewNote>('create_web_novel_review_note', { input: { workId: detail.work.workId, chapterId: activeChapter.chapterId, note: reviewDraft } })
setReviewDraft('')
await refreshSnapshot(detail.work.workId)
setMessage('编辑意见已记录。')
})
const resolveReviewNote = (noteId: string) => void runAction(async () => {
if (!detail) return
await invoke('resolve_web_novel_review_note', { input: { noteId } })
await refreshSnapshot(detail.work.workId)
setMessage('编辑意见已解决。')
})
const createCheckpoint = () => void runAction(async () => {
if (!detail) return
await flushChapterSave()
const name = window.prompt('检查点名称', `手动检查点 · ${new Date().toLocaleString('zh-CN')}`)
if (!name?.trim()) return
await invoke<Checkpoint>('create_web_novel_checkpoint', { input: { workId: detail.work.workId, name, description: '由工作台手动创建' } })
await refreshSnapshot(detail.work.workId)
setMessage('整部作品检查点已创建。')
})
const restoreCheckpoint = (checkpoint: Checkpoint) => void runAction(async () => {
if (!window.confirm(`确定恢复“${checkpoint.name}”吗?当前章节内容会先保留在版本记录中。`)) return
const next = await invoke<WorkDetail>('restore_web_novel_checkpoint', { input: { checkpointId: checkpoint.checkpointId } })
setDetail(next)
setActiveChapter(null)
setMessage('检查点已恢复,当前编辑器已关闭以避免旧修订覆盖。')
})
const runAudit = () => void runAction(async () => {
if (!detail) return
await flushChapterSave()
const next = await invoke<ContinuityAudit>('run_web_novel_continuity_audit', { input: { workId: detail.work.workId } })
setAudit(next)
setMessage(next.state === 'PASS' ? '连续性检查通过。' : `发现 ${next.issueCount} 个需要处理的问题。`)
})
const saveMetric = () => void runAction(async () => {
if (!detail) return
const integer = (value: string) => Math.max(0, Math.floor(Number(value) || 0))
await invoke<Metric>('save_web_novel_metric', { input: {
workId: detail.work.workId,
metricDate: metricDraft.metricDate,
views: integer(metricDraft.views),
follows: integer(metricDraft.follows),
comments: integer(metricDraft.comments),
paidReaders: integer(metricDraft.paidReaders),
revenueCents: Math.round(Math.max(0, Number(metricDraft.revenueYuan) || 0) * 100),
sourceLabel: metricDraft.sourceLabel,
expectedRevision: null,
} })
await refreshSnapshot(detail.work.workId)
setMessage('运营数据已写入当前账号。')
})
const exportMarkdown = () => void runAction(async () => {
if (!detail) return
await flushChapterSave()
const receipt = await invoke<{ path: string; chapterCount: number; wordCount: number } | null>('export_web_novel_markdown', { input: { workId: detail.work.workId } })
setMessage(receipt ? `已导出 ${receipt.chapterCount} 章、${receipt.wordCount} 字。` : '已取消导出。')
})
const chapterName = useMemo(() => new Map(detail?.chapters.map((chapter) => [chapter.chapterId, chapter.title]) || []), [detail?.chapters])
const entityName = useMemo(() => new Map(detail?.entities.map((entity) => [entity.entityId, entity.name]) || []), [detail?.entities])
const activeVolume = detail?.volumes.find((volume) => volume.volumeId === activeChapter?.volumeId)
const isOutlineChapter = Boolean(activeVolume?.title.includes('细纲') || detail?.work.genre.includes('细纲'))
const activeOutlineRows = useMemo(() => outlineRows(activeChapter?.content || ''), [activeChapter?.content])
if (!snapshot) {
return <section className="wn-workspace"><div className="wn-loading"><i/><h2></h2><p>{message}</p><button type="button" onClick={onBack}></button></div></section>
}
return <section className="wn-workspace" aria-label="网文行业工作空间">
<header className="wn-topbar">
<button type="button" onClick={() => void runAction(async () => { await flushChapterSave(); onBack() })}> </button>
<div><span>WEB NOVEL / LOCAL ENGINE</span><b>{detail ? `${detail.work.title}` : '轻量作者工作台 · 模块拆分验收副本'}</b></div>
<small>{busy ? '引擎正在处理…' : message}</small>
</header>
<div className="wn-shell">
<aside className="wn-library">
<header><div><span></span><h2></h2></div><strong>{snapshot.workCount}</strong></header>
<div className="wn-work-list">
{snapshot.works.map((work) => <button className={detail?.work.workId === work.workId ? 'active' : ''} type="button" key={work.workId} onClick={() => selectWork(work.workId)}><b>{work.title}</b><span>{work.penName} · {work.genre}</span><small>{work.chapterCount} · {work.totalWords.toLocaleString()} </small></button>)}
</div>
<form className="wn-create-work" aria-busy={createFeedback.state === 'pending'} onSubmit={(event) => { event.preventDefault(); void createWork() }}>
<button className="wn-import-button" type="button" disabled={busy} onClick={inspectDocument}> / / </button>
<h3></h3>
<input aria-label="作品名" placeholder="作品名" value={newWork.title} onChange={(event) => setNewWork({ ...newWork, title: event.target.value })}/>
<input aria-label="笔名" placeholder="笔名" value={newWork.penName} onChange={(event) => setNewWork({ ...newWork, penName: event.target.value })}/>
<input aria-label="作品类型" placeholder="类型,如玄幻、悬疑" value={newWork.genre} onChange={(event) => setNewWork({ ...newWork, genre: event.target.value })}/>
<select aria-label="写作形态" value={newWork.workKind} onChange={(event) => setNewWork({ ...newWork, workKind: event.target.value as typeof newWork.workKind })}><option value="LONG_NOVEL"></option><option value="SHORT_NOVEL"></option><option value="SHORT_DRAMA"></option></select>
<button type="submit" disabled={busy}>{createFeedback.state === 'pending' ? '正在建立作品…' : ' 建立作品'}</button>
{createFeedback.text && <p className={`wn-create-feedback ${createFeedback.state}`} role={createFeedback.state === 'error' ? 'alert' : 'status'}>{createFeedback.text}</p>}
</form>
</aside>
{detail ? <>
<nav className="wn-nav" aria-label="网文工作台功能">
{([['writing', '码字'], ['modules', '作者模块'], ['story', '设定'], ['review', '编辑'], ['operations', '运营'], ['settings', '作品']] as [WorkspaceMode, string][]).map(([key, label]) => <button type="button" className={mode === key ? 'active' : ''} key={key} onClick={() => setMode(key)}>{label}</button>)}
</nav>
<main className="wn-main">
{mode === 'writing' && <div className="wn-writing-layout">
<aside className="wn-outline">
<header className="wn-outline-actions"><div><h3>{detail.work.workKind === 'SHORT_DRAMA' ? '分集目录' : '章节目录'}</h3><button className="wn-outline-secondary" type="button" onClick={createVolume}> </button></div><button className="wn-new-chapter" type="button" disabled={!detail.volumes.length} onClick={() => createChapter(activeChapter?.volumeId || detail.volumes[0].volumeId)}> {detail.work.workKind === 'SHORT_DRAMA' ? '新建一集' : '新建章节'}</button></header>
{detail.volumes.map((volume) => <section key={volume.volumeId}>
<div><b>{volume.title}</b><button type="button" onClick={() => createChapter(volume.volumeId)}> {detail.work.workKind === 'SHORT_DRAMA' ? '集' : '章'}</button></div>
{detail.chapters.filter((chapter) => chapter.volumeId === volume.volumeId).map((chapter) => <button className={activeChapter?.chapterId === chapter.chapterId ? 'active' : ''} type="button" key={chapter.chapterId} onClick={() => openChapter(chapter.chapterId)}><span>{chapter.title}</span><small>{statusLabel(chapter.workflowStatus)} · {chapter.wordCount} </small></button>)}
</section>)}
</aside>
{activeChapter ? <article className="wn-editor">
<header>
<input aria-label="章节标题" value={activeChapter.title} disabled={activeChapter.workflowStatus === 'PUBLISHED'} onChange={(event) => updateChapter({ title: event.target.value })}/>
<div><span className={`wn-status status-${activeChapter.workflowStatus.toLowerCase()}`}>{statusLabel(activeChapter.workflowStatus)}</span><small>{Array.from(activeChapter.content).filter((character) => !/\s/.test(character)).length.toLocaleString()} · {activeChapter.revision}</small></div>
</header>
<input className="wn-chapter-synopsis" aria-label="章节梗概" placeholder="本章梗概与场景目标" value={activeChapter.synopsis} disabled={activeChapter.workflowStatus === 'PUBLISHED'} onChange={(event) => updateChapter({ synopsis: event.target.value })}/>
{isOutlineChapter ? <div className="wn-outline-editor-body"><textarea aria-label="细纲原文编辑" placeholder="按字段或情节点继续补写;右侧结构视图会同步更新。" value={activeChapter.content} disabled={activeChapter.workflowStatus === 'PUBLISHED'} onChange={(event) => updateChapter({ content: event.target.value })}/><section className="wn-outline-render" aria-label="细纲结构视图"><header><span>STRUCTURED OUTLINE</span><b></b><small></small></header>{activeOutlineRows.map((row) => <article className={`kind-${row.kind}`} key={row.key}><span>{row.label}</span><p>{row.value}</p></article>)}</section></div> : <textarea aria-label="章节正文" placeholder="从这里开始写。停笔 650 毫秒后会保存,并生成可追溯版本。" value={activeChapter.content} disabled={activeChapter.workflowStatus === 'PUBLISHED'} onChange={(event) => updateChapter({ content: event.target.value })}/>}
<footer>
<button type="button" disabled={!dirtyRef.current || activeChapter.workflowStatus === 'PUBLISHED'} onClick={() => void flushChapterSave()}></button>
<div>{(NEXT_STATUS[activeChapter.workflowStatus] || []).map((status) => <button className="primary" type="button" key={status} onClick={() => transitionChapter(status)}>{statusLabel(status)}</button>)}</div>
</footer>
</article> : <div className="wn-empty"><i/><h2></h2><p></p></div>}
<AuthorWritingSidecar
workId={detail.work.workId}
workKind={detail.work.workKind}
activeChapter={activeChapter}
chapters={detail.chapters}
entities={detail.entities}
onOpenChapter={openChapter}
onFormat={formatActiveChapter}
onFormatWork={formatWholeWork}
onMessage={setMessage}
/>
</div>}
{mode === 'modules' && <AuthorModuleCenter
workId={detail.work.workId}
chapters={detail.chapters.map((chapter) => ({ chapterId: chapter.chapterId, title: chapter.title }))}
entities={detail.entities.map((entity) => ({ entityId: entity.entityId, name: entity.name, entityType: entity.entityType }))}
onMessage={setMessage}
onWorkRefresh={async () => {
const next = await invoke<WorkDetail>('read_web_novel_work', { input: { workId: detail.work.workId } })
setDetail(next)
if (activeChapter) {
const chapter = await invoke<Chapter>('read_web_novel_chapter', { input: { chapterId: activeChapter.chapterId } })
setActiveChapter(chapter)
chapterRef.current = chapter
}
}}
/>}
{mode === 'story' && <div className="wn-board">
<header><div><span>STORY BIBLE</span><h2></h2></div><button type="button" onClick={runAudit}></button></header>
{audit && <section className={`wn-audit audit-${audit.state.toLowerCase()}`}><b>{audit.state === 'PASS' ? '连续性检查通过' : `${audit.issueCount} 个检查结果`}</b><span>{audit.blockingIssueCount} · {audit.warningIssueCount} </span>{audit.issues.map((issue) => <article key={issue.issueId}><strong>{issue.title}</strong><p>{issue.detail}</p></article>)}</section>}
<div className="wn-board-grid">
<section><h3></h3><form onSubmit={(event) => { event.preventDefault(); createEntity() }}><select value={entityDraft.entityType} onChange={(event) => setEntityDraft({ ...entityDraft, entityType: event.target.value })}><option value="CHARACTER"></option><option value="LOCATION"></option><option value="WORLD_RULE"></option><option value="ORGANIZATION"></option><option value="ITEM"></option></select><input placeholder="名称" value={entityDraft.name} onChange={(event) => setEntityDraft({ ...entityDraft, name: event.target.value })}/><input placeholder="别名,用逗号分隔" value={entityDraft.aliases} onChange={(event) => setEntityDraft({ ...entityDraft, aliases: event.target.value })}/><textarea placeholder="设定描述" value={entityDraft.description} onChange={(event) => setEntityDraft({ ...entityDraft, description: event.target.value })}/><button type="submit"></button></form>{detail.entities.map((entity) => <article key={entity.entityId}><b>{entity.name}</b><small>{entity.entityType} · {entity.aliases.join('、') || '无别名'}</small><p>{entity.description || '尚无描述'}</p></article>)}</section>
<section><h3></h3><form onSubmit={(event) => { event.preventDefault(); createRelation() }}><select value={relationDraft.sourceEntityId} onChange={(event) => setRelationDraft({ ...relationDraft, sourceEntityId: event.target.value })}><option value=""></option>{detail.entities.map((entity) => <option key={entity.entityId} value={entity.entityId}>{entity.name}</option>)}</select><select value={relationDraft.targetEntityId} onChange={(event) => setRelationDraft({ ...relationDraft, targetEntityId: event.target.value })}><option value=""></option>{detail.entities.map((entity) => <option key={entity.entityId} value={entity.entityId}>{entity.name}</option>)}</select><input placeholder="关系类型" value={relationDraft.relationType} onChange={(event) => setRelationDraft({ ...relationDraft, relationType: event.target.value })}/><input placeholder="关系说明" value={relationDraft.note} onChange={(event) => setRelationDraft({ ...relationDraft, note: event.target.value })}/><button type="submit"></button></form>{detail.relations.map((relation) => <article key={relation.relationId}><b>{entityName.get(relation.sourceEntityId)} {entityName.get(relation.targetEntityId)}</b><small>{relation.relationType}</small><p>{relation.note}</p></article>)}</section>
<section><h3></h3><form onSubmit={(event) => { event.preventDefault(); createForeshadow() }}><input placeholder="伏笔名称" value={foreshadowDraft.title} onChange={(event) => setForeshadowDraft({ ...foreshadowDraft, title: event.target.value })}/><select value={foreshadowDraft.setupChapterId} onChange={(event) => setForeshadowDraft({ ...foreshadowDraft, setupChapterId: event.target.value })}><option value=""></option>{detail.chapters.map((chapter) => <option key={chapter.chapterId} value={chapter.chapterId}>{chapter.title}</option>)}</select><select value={foreshadowDraft.payoffChapterId} onChange={(event) => setForeshadowDraft({ ...foreshadowDraft, payoffChapterId: event.target.value })}><option value=""></option>{detail.chapters.map((chapter) => <option key={chapter.chapterId} value={chapter.chapterId}>{chapter.title}</option>)}</select><input placeholder="备注" value={foreshadowDraft.note} onChange={(event) => setForeshadowDraft({ ...foreshadowDraft, note: event.target.value })}/><button type="submit"></button></form>{detail.foreshadows.map((item) => <article key={item.foreshadowId}><b>{item.title}</b><small>{chapterName.get(item.setupChapterId)} {item.payoffChapterId ? chapterName.get(item.payoffChapterId) : '待回收'}</small><p>{item.note}</p></article>)}</section>
</div>
</div>}
{mode === 'review' && <div className="wn-board">
<header><div><span>EDITORIAL WORKFLOW</span><h2></h2></div></header>
<div className="wn-review-grid">
<section><h3></h3>{detail.chapters.map((chapter) => <button className={activeChapter?.chapterId === chapter.chapterId ? 'active' : ''} type="button" key={chapter.chapterId} onClick={() => openChapter(chapter.chapterId)}><b>{chapter.title}</b><span>{statusLabel(chapter.workflowStatus)}</span><small>{chapter.wordCount} · {chapter.revision}</small></button>)}</section>
<section><h3></h3>{activeChapter ? <form onSubmit={(event) => { event.preventDefault(); createReviewNote() }}><p>{activeChapter.title}</p><textarea placeholder="写下具体、可执行的修改意见" value={reviewDraft} onChange={(event) => setReviewDraft(event.target.value)}/><button type="submit"></button></form> : <p></p>}{detail.reviewNotes.map((note) => <article key={note.noteId}><b>{chapterName.get(note.chapterId) || '章节'}</b><small>{note.status === 'OPEN' ? '待处理' : '已解决'} · {formatTime(note.createdAtUnixMs)}</small><p>{note.note}</p>{note.status === 'OPEN' && <button type="button" onClick={() => resolveReviewNote(note.noteId)}></button>}</article>)}</section>
<section><h3></h3>{detail.workflowEvents.map((event) => <article key={event.eventId}><b>{chapterName.get(event.chapterId) || '章节'}</b><small>{statusLabel(event.fromStatus)} {statusLabel(event.toStatus)}</small><p>{event.note || '无附加说明'} · {formatTime(event.createdAtUnixMs)}</p></article>)}</section>
</div>
</div>}
{mode === 'operations' && <div className="wn-board">
<header><div><span>OPERATIONS</span><h2></h2></div><div><button type="button" onClick={createCheckpoint}></button><button type="button" onClick={exportMarkdown}> Markdown</button></div></header>
<div className="wn-metrics">{[['总字数', detail.operations.totalWords], ['草稿章', detail.operations.draftChapters], ['编辑审核', detail.operations.editorReviewChapters], ['已通过', detail.operations.approvedChapters], ['待发布', detail.operations.scheduledChapters], ['已发布', detail.operations.publishedChapters], ['未回收伏笔', detail.operations.openForeshadows], ['未解决意见', detail.operations.openReviewNotes]].map(([label, value]) => <article key={label}><span>{label}</span><b>{Number(value).toLocaleString()}</b></article>)}</div>
<div className="wn-operations-grid">
<section><h3></h3><form className="wn-metric-form" onSubmit={(event) => { event.preventDefault(); saveMetric() }}><input type="date" value={metricDraft.metricDate} onChange={(event) => setMetricDraft({ ...metricDraft, metricDate: event.target.value })}/>{(['views', 'follows', 'comments', 'paidReaders'] as const).map((field) => <label key={field}><span>{{ views: '阅读', follows: '追读', comments: '评论', paidReaders: '付费读者' }[field]}</span><input inputMode="numeric" value={metricDraft[field]} onChange={(event) => setMetricDraft({ ...metricDraft, [field]: event.target.value })}/></label>)}<label><span></span><input inputMode="decimal" value={metricDraft.revenueYuan} onChange={(event) => setMetricDraft({ ...metricDraft, revenueYuan: event.target.value })}/></label><input placeholder="数据来源" value={metricDraft.sourceLabel} onChange={(event) => setMetricDraft({ ...metricDraft, sourceLabel: event.target.value })}/><button type="submit"></button></form>{detail.metrics.map((metric) => <article key={metric.metricId}><b>{metric.metricDate} · {metric.sourceLabel}</b><small>{metric.views.toLocaleString()} · {metric.follows.toLocaleString()} · ¥{(metric.revenueCents / 100).toFixed(2)}</small></article>)}</section>
<section><h3></h3>{detail.checkpoints.map((checkpoint) => <article key={checkpoint.checkpointId}><b>{checkpoint.name}</b><small>{checkpoint.chapterCount} · {checkpoint.wordCount} · {formatTime(checkpoint.createdAtUnixMs)}</small><p>{checkpoint.description}</p><button type="button" onClick={() => restoreCheckpoint(checkpoint)}></button></article>)}</section>
</div>
</div>}
{mode === 'settings' && <div className="wn-board wn-settings">
<header><div><span>WORK PROFILE</span><h2></h2></div><button type="button" onClick={saveWorkSettings}></button></header>
<div className="wn-settings-form"><label><span></span><input value={detail.work.title} onChange={(event) => setDetail({ ...detail, work: { ...detail.work, title: event.target.value } })}/></label><label><span></span><input value={detail.work.penName} onChange={(event) => setDetail({ ...detail, work: { ...detail.work, penName: event.target.value } })}/></label><label><span></span><input value={detail.work.genre} onChange={(event) => setDetail({ ...detail, work: { ...detail.work, genre: event.target.value } })}/></label><label><span></span><input inputMode="numeric" value={detail.work.targetWords} onChange={(event) => setDetail({ ...detail, work: { ...detail.work, targetWords: Math.max(0, Number(event.target.value) || 0) } })}/></label><label><span></span><select value={detail.work.contractStatus} onChange={(event) => setDetail({ ...detail, work: { ...detail.work, contractStatus: event.target.value } })}><option value="UNSIGNED"></option><option value="REVIEWING"></option><option value="SIGNED"></option><option value="ENDED"></option></select></label><label><span></span><select value={detail.work.copyrightStatus} onChange={(event) => setDetail({ ...detail, work: { ...detail.work, copyrightStatus: event.target.value } })}><option value="AUTHOR_OWNED"></option><option value="LICENSED"></option><option value="SHARED"></option><option value="UNKNOWN"></option></select></label><label className="wide"><span></span><textarea value={detail.work.synopsis} onChange={(event) => setDetail({ ...detail, work: { ...detail.work, synopsis: event.target.value } })}/></label></div>
<p className="wn-boundary">线</p>
</div>}
</main>
</> : <div className="wn-welcome"><span>REAL ENGINE / EMPTY ACCOUNT</span><h1></h1><p> SQLite </p></div>}
</div>
{importPreview && <div className="wn-import-layer" role="dialog" aria-modal="true" aria-label="真实文档导入确认">
<section className="wn-import-dialog">
<header>
<div><span>DOCUMENT IMPORT / REAL PARSER</span><h2></h2></div>
<button type="button" onClick={() => setImportPreview(null)}></button>
</header>
<div className="wn-import-receipt">
<article><span></span><b>{{ NOVEL: '小说正文', OUTLINE: '拆分细纲', SCRIPT: '分集剧本' }[importPreview.detectedFamily]}</b></article>
<article><span></span><b>{importPreview.sectionCount.toLocaleString()} {importPreview.detectedFamily === 'SCRIPT' ? '集' : '章'}</b></article>
<article><span></span><b>{importPreview.totalWordCount.toLocaleString()}</b></article>
<article><span></span><b>{importPreview.sourceSha256.slice(0, 12)}</b></article>
</div>
<p className="wn-import-source"><b>{importPreview.sourceFilename}</b><span>{importPreview.sourceFormat} · {(importPreview.sourceBytes / 1024).toFixed(1)} KB · {importPreview.prefaceWordCount.toLocaleString()} </span></p>
<div className="wn-import-body">
<section className="wn-import-sections"><h3></h3>{importPreview.sections.map((section) => <article key={`${section.ordinal}-${section.title}`}><b>{section.ordinal}. {section.title}</b><small>{section.wordCount.toLocaleString()} {section.sceneCount ? ` · ${section.sceneCount}` : ''}</small><p>{section.preview || '(空分段)'}</p></article>)}</section>
<form onSubmit={(event) => { event.preventDefault(); commitDocumentImport() }}>
<h3></h3>
<label><span></span><select value={importDraft.targetMode} onChange={(event) => setImportDraft({ ...importDraft, targetMode: event.target.value })}><option value="CREATE_NEW"></option>{snapshot.works.length > 0 && <option value="APPEND_EXISTING"></option>}</select></label>
{importDraft.targetMode === 'APPEND_EXISTING' ? <label><span></span><select value={importDraft.targetWorkId} onChange={(event) => setImportDraft({ ...importDraft, targetWorkId: event.target.value })}>{snapshot.works.map((work) => <option key={work.workId} value={work.workId}>{work.title}</option>)}</select></label> : <>
<label><span></span><input value={importDraft.title} onChange={(event) => setImportDraft({ ...importDraft, title: event.target.value })}/></label>
<label><span></span><input value={importDraft.penName} onChange={(event) => setImportDraft({ ...importDraft, penName: event.target.value })}/></label>
<label><span></span><input value={importDraft.genre} onChange={(event) => setImportDraft({ ...importDraft, genre: event.target.value })}/></label>
</>}
<p></p>
<button className="primary" type="submit" disabled={busy}>{busy ? '正在写入数据库…' : `确认导入 ${importPreview.sectionCount} ${importPreview.detectedFamily === 'SCRIPT' ? '集' : '章'}`}</button>
</form>
</div>
</section>
</div>}
</section>
}

View file

@ -176,3 +176,36 @@
[data-theme='dawn'] .tag-amber, [data-theme='clear'] .tag-amber { color: rgb(148, 96, 8); } [data-theme='dawn'] .tag-amber, [data-theme='clear'] .tag-amber { color: rgb(148, 96, 8); }
[data-theme='dawn'] .tag-rose, [data-theme='clear'] .tag-rose { color: rgb(166, 34, 58); } [data-theme='dawn'] .tag-rose, [data-theme='clear'] .tag-rose { color: rgb(166, 34, 58); }
[data-theme='dawn'] .tag-slate, [data-theme='clear'] .tag-slate { color: rgb(64, 82, 100); } [data-theme='dawn'] .tag-slate, [data-theme='clear'] .tag-slate { color: rgb(64, 82, 100); }
/* 动态语言世界:时间与已核验天气只改变视觉令牌,不改变路由、权限或事实。 */
.official-world.visual-world.climate-day {
--primitive-bg-a:#c9def0; --primitive-bg-b:#b8d5e9; --primitive-bg-c:#87b5cf; --primitive-bg-d:#5e8fae;
--primitive-text-primary:rgba(15,34,50,.96); --primitive-text-body:rgba(22,45,64,.88); --primitive-text-aux:rgba(38,65,84,.7); --primitive-text-faint:rgba(52,75,94,.54);
--primitive-star-core:#fff5cc; --primitive-star-bright:#fff8dd; --primitive-nebula-a:rgba(255,246,201,.48); --primitive-nebula-b:rgba(160,200,230,.12);
--primitive-glass:rgba(246,252,255,.42); --primitive-glass-hover:rgba(255,255,255,.6); --primitive-glass-edge:rgba(16,52,76,.18); --primitive-glass-top:rgba(255,255,255,.72);
--primitive-mist-a:rgba(247,253,255,.56); --primitive-mist-b:rgba(203,229,241,.3); --primitive-cool-glow:rgba(55,119,158,.34); --primitive-warm-glow:rgba(246,211,132,.55); --primitive-shadow:rgba(24,61,85,.18); --primitive-scene-opacity:.12;
--content-primary:var(--primitive-text-primary); --content-secondary:var(--primitive-text-body); --content-muted:var(--primitive-text-aux); --content-faint:var(--primitive-text-faint); --accent-light:#385f78; --accent-light-strong:#244c67; --panel-bg:rgba(241,249,253,.76); --panel-edge:var(--primitive-glass-edge); --button-primary-bg:#254f69; --button-primary-text:#f7fbfd; --focus-ring:rgba(35,82,112,.65);
}
.official-world.visual-world.climate-dawn {
--primitive-bg-a:#eee2de; --primitive-bg-b:#d9dbe6; --primitive-bg-c:#aabed2; --primitive-bg-d:#7893ae;
--primitive-text-primary:rgba(25,35,51,.96); --primitive-text-body:rgba(34,46,64,.88); --primitive-text-aux:rgba(57,68,86,.7); --primitive-text-faint:rgba(75,84,100,.54);
--primitive-star-core:#b97254; --primitive-star-bright:#95533e; --primitive-nebula-a:rgba(245,187,158,.35); --primitive-nebula-b:rgba(150,172,211,.18);
--primitive-glass:rgba(255,250,249,.38); --primitive-glass-hover:rgba(255,255,255,.57); --primitive-glass-edge:rgba(45,58,82,.16); --primitive-glass-top:rgba(255,255,255,.68); --primitive-mist-a:rgba(255,244,237,.5); --primitive-mist-b:rgba(222,205,208,.26); --primitive-cool-glow:rgba(83,108,158,.34); --primitive-warm-glow:rgba(194,105,76,.44); --primitive-shadow:rgba(46,57,78,.17); --primitive-scene-opacity:.12;
--content-primary:var(--primitive-text-primary); --content-secondary:var(--primitive-text-body); --content-muted:var(--primitive-text-aux); --content-faint:var(--primitive-text-faint); --accent-light:#765145; --accent-light-strong:#603d33; --panel-bg:rgba(251,246,246,.76); --panel-edge:var(--primitive-glass-edge); --button-primary-bg:#594b50; --button-primary-text:#fff9f6; --focus-ring:rgba(126,77,62,.62);
}
.official-world.visual-world.climate-dusk {
--primitive-bg-a:#3a3148; --primitive-bg-b:#2a2d43; --primitive-bg-c:#18263a; --primitive-bg-d:#0d1726;
--primitive-text-primary:rgba(245,239,242,.96); --primitive-text-body:rgba(233,226,234,.88); --primitive-text-aux:rgba(208,195,211,.66); --primitive-text-faint:rgba(188,177,197,.45);
--primitive-star-core:#ffd9b0; --primitive-star-bright:#ffe9cc; --primitive-nebula-a:rgba(221,138,119,.22); --primitive-nebula-b:rgba(111,97,169,.14); --primitive-glass:rgba(244,206,208,.06); --primitive-glass-hover:rgba(244,206,208,.1); --primitive-glass-edge:rgba(246,218,222,.15); --primitive-glass-top:rgba(255,255,255,.08); --primitive-mist-a:rgba(232,179,181,.13); --primitive-mist-b:rgba(141,150,204,.06); --primitive-cool-glow:rgba(137,155,212,.4); --primitive-warm-glow:rgba(244,167,128,.55); --primitive-shadow:rgba(0,0,0,.36); --primitive-scene-opacity:.38;
--content-primary:var(--primitive-text-primary); --content-secondary:var(--primitive-text-body); --content-muted:var(--primitive-text-aux); --content-faint:var(--primitive-text-faint); --accent-light:var(--primitive-star-core); --accent-light-strong:var(--primitive-star-bright); --panel-bg:rgba(25,25,42,.76); --panel-edge:var(--primitive-glass-edge); --button-primary-bg:rgba(255,217,176,.92); --button-primary-text:#211b20; --focus-ring:rgba(255,217,176,.72);
}
.official-world.visual-world.climate-night,.official-world.visual-world.climate-time-pending {
--primitive-bg-a:#071326; --primitive-bg-b:#0a1728; --primitive-bg-c:#07111e; --primitive-bg-d:#030912;
--primitive-text-primary:rgba(235,241,250,.96); --primitive-text-body:rgba(220,229,242,.88); --primitive-text-aux:rgba(190,205,226,.66); --primitive-text-faint:rgba(170,188,214,.44);
--primitive-star-core:#efe8c9; --primitive-star-bright:#fff3c9; --primitive-nebula-a:rgba(86,116,185,.16); --primitive-nebula-b:rgba(114,88,166,.07); --primitive-glass:rgba(175,205,255,.05); --primitive-glass-hover:rgba(175,205,255,.09); --primitive-glass-edge:rgba(200,220,255,.14); --primitive-glass-top:rgba(255,255,255,.07); --primitive-mist-a:rgba(135,174,226,.11); --primitive-mist-b:rgba(233,226,191,.04); --primitive-cool-glow:rgba(123,167,222,.42); --primitive-warm-glow:rgba(239,224,174,.48); --primitive-shadow:rgba(0,0,0,.4); --primitive-scene-opacity:.52;
--content-primary:var(--primitive-text-primary); --content-secondary:var(--primitive-text-body); --content-muted:var(--primitive-text-aux); --content-faint:var(--primitive-text-faint); --accent-light:var(--primitive-star-core); --accent-light-strong:var(--primitive-star-bright); --panel-bg:rgba(5,14,27,.78); --panel-edge:var(--primitive-glass-edge); --button-primary-bg:rgba(239,232,201,.92); --button-primary-text:#111923; --focus-ring:rgba(239,232,201,.7);
}
.official-world.visual-world.weather-cloud { --primitive-scene-opacity:.34; --primitive-mist-a:color-mix(in srgb,var(--primitive-mist-a) 72%,#aebdca 28%); }
.official-world.visual-world.weather-fog { --primitive-scene-opacity:.18; --primitive-mist-a:rgba(224,233,238,.46); --primitive-mist-b:rgba(174,190,201,.24); }
.official-world.visual-world.weather-rain,.official-world.visual-world.weather-storm { --primitive-scene-opacity:.48; --primitive-cool-glow:rgba(92,134,176,.46); }
.official-world.visual-world.weather-snow { --primitive-mist-a:rgba(239,246,250,.4); --primitive-mist-b:rgba(201,219,231,.18); }

View file

@ -6,6 +6,8 @@ import { splitFrontmatter, documentOutline, jumpToHeading, MarkdownDocument, mar
import { analyzeEducationTableData } from './modules/education-data' import { analyzeEducationTableData } from './modules/education-data'
import { buildEducationBaselineRenderPlan } from './modules/education-adaptive-rendering' import { buildEducationBaselineRenderPlan } from './modules/education-adaptive-rendering'
import { NativeCompositionStudio, type CompositionDimension, type CompositionMeasure, type NativeCompositionProjection, type ProjectionView } from './modules/native-composition' import { NativeCompositionStudio, type CompositionDimension, type CompositionMeasure, type NativeCompositionProjection, type ProjectionView } from './modules/native-composition'
import { WebNovelWorkspace } from './WebNovelWorkspace'
import { PersonaChannelBody } from './PersonaChannelBody'
import subdomainIndustryRegistry from '../contracts/subdomain-industry-routing.json' import subdomainIndustryRegistry from '../contracts/subdomain-industry-routing.json'
const TAG_TINTS = ['tag-lavender', 'tag-sky', 'tag-mint', 'tag-amber', 'tag-rose', 'tag-slate'] const TAG_TINTS = ['tag-lavender', 'tag-sky', 'tag-mint', 'tag-amber', 'tag-rose', 'tag-slate']
@ -56,7 +58,7 @@ import './styles.css'
type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear' type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear'
type ViewId = 'overview' | 'knowledge' | 'code' | 'receipts' | 'system' type ViewId = 'overview' | 'knowledge' | 'code' | 'receipts' | 'system'
type WorldStage = 'domain' | 'heart' | 'heartbeat' | 'lightLake' | 'love' | 'tomorrow' | 'bottle' | 'channel' | 'enterpriseWork' | 'subdomainIndustries' | 'educationChannel' | 'educationDocuments' | 'educationTables' | 'educationData' | 'educationAutomation' | 'educationComposition' | 'personalNodeGuide' | 'tool' type WorldStage = 'domain' | 'mainNotices' | 'zeroLab' | 'moduleMarketplace' | 'personaBrainMarketplace' | 'personaBody' | 'heart' | 'heartbeat' | 'lightLake' | 'love' | 'tomorrow' | 'bottle' | 'channel' | 'enterpriseWork' | 'subdomainIndustries' | 'webNovelChannel' | 'webNovelWorkspace' | 'educationChannel' | 'educationDocuments' | 'educationTables' | 'educationData' | 'educationAutomation' | 'educationComposition' | 'personalNodeGuide' | 'tool'
type KnowledgeSource = 'native' | 'legacy' type KnowledgeSource = 'native' | 'legacy'
interface HomeStatus { interface HomeStatus {
@ -141,6 +143,16 @@ interface PersonalChannelIdentity { humanSubjectId: string; displayName: string;
interface PersonalChannelModule { moduleId: string; kind: string; displayName: string; state: string; installedAtUnixMs: number; timeZone: string; calendarName: string; clockVerification: string } interface PersonalChannelModule { moduleId: string; kind: string; displayName: string; state: string; installedAtUnixMs: number; timeZone: string; calendarName: string; clockVerification: string }
interface LoginSession { username: string; host: string; domain: string; signedInAtUnixMs: number } interface LoginSession { username: string; host: string; domain: string; signedInAtUnixMs: number }
interface LoginReceipt { username: string; email: string; host: string; domain: string } interface LoginReceipt { username: string; email: string; host: string; domain: string }
interface WorldClimateSnapshot {
state: 'VERIFIED_LIVE' | 'TIME_ONLY_WEATHER_UNAVAILABLE'
activeDomain: string
timePhase: 'DAWN' | 'DAY' | 'DUSK' | 'NIGHT'
weatherKind: 'CLEAR' | 'CLOUD' | 'FOG' | 'RAIN' | 'SNOW' | 'STORM' | 'UNAVAILABLE'
motionIntensity: 'CALM' | 'GENTLE' | 'ACTIVE'
source: string
cityExposed: false
observedAtUnixMs: number
}
interface UserPnccChannelSnapshot { interface UserPnccChannelSnapshot {
state: string state: string
repositoryId: string repositoryId: string
@ -234,6 +246,7 @@ interface EducationRecognitionAssistanceReceipt { state: string; requestId: stri
interface EducationImportOutcome { state: string; importReceipt?: EducationTableImportReceipt; assistanceReceipt?: EducationRecognitionAssistanceReceipt } interface EducationImportOutcome { state: string; importReceipt?: EducationTableImportReceipt; assistanceReceipt?: EducationRecognitionAssistanceReceipt }
interface EducationTableExportReceipt { state: string; targetFormat: string; targetFilename: string; bytes: number; rowCount: number; columnCount: number } interface EducationTableExportReceipt { state: string; targetFormat: string; targetFilename: string; bytes: number; rowCount: number; columnCount: number }
interface EducationImportRegistrySummary { importId: string; sourceFilename: string; sourceFormat: string; tableCount: number; importedAtUnixMs: number } interface EducationImportRegistrySummary { importId: string; sourceFilename: string; sourceFormat: string; tableCount: number; importedAtUnixMs: number }
interface UnassignedTableSummary { tableId: string; title: string; columnCount: number; rowCount: number; importedAtUnixMs: number }
interface EducationAutomationRule { ruleId: string; title: string; tableId: string; conditionColumnId: string; operator: 'EQUALS' | 'CONTAINS' | 'IS_EMPTY' | 'IS_NOT_EMPTY'; conditionValue: string; actionColumnId: string; actionValue: string; enabled: boolean; revision: number; createdAtUnixMs: number; updatedAtUnixMs: number } interface EducationAutomationRule { ruleId: string; title: string; tableId: string; conditionColumnId: string; operator: 'EQUALS' | 'CONTAINS' | 'IS_EMPTY' | 'IS_NOT_EMPTY'; conditionValue: string; actionColumnId: string; actionValue: string; enabled: boolean; revision: number; createdAtUnixMs: number; updatedAtUnixMs: number }
interface EducationAutomationPreview { state: string; ruleId: string; tableId: string; ruleRevision: number; tableRevision: number; matchedRows: number; changedCells: number; previewToken: string } interface EducationAutomationPreview { state: string; ruleId: string; tableId: string; ruleRevision: number; tableRevision: number; matchedRows: number; changedCells: number; previewToken: string }
interface EducationAutomationRunReceipt { state: string; runId: string; ruleId: string; tableId: string; matchedRows: number; changedCells: number; fromTableRevision: number; toTableRevision: number; ranAtUnixMs: number } interface EducationAutomationRunReceipt { state: string; runId: string; ruleId: string; tableId: string; matchedRows: number; changedCells: number; fromTableRevision: number; toTableRevision: number; ranAtUnixMs: number }
@ -247,9 +260,11 @@ interface EducationWorkspaceSnapshot {
automationRules: EducationAutomationRule[] automationRules: EducationAutomationRule[]
recentAutomationRuns: EducationAutomationRunReceipt[] recentAutomationRuns: EducationAutomationRunReceipt[]
recentImports: EducationImportRegistrySummary[] recentImports: EducationImportRegistrySummary[]
unassignedTables: UnassignedTableSummary[]
documentCount: number documentCount: number
tableCount: number tableCount: number
automationRuleCount: number automationRuleCount: number
unassignedCount: number
storage: string storage: string
authority: string authority: string
} }
@ -483,27 +498,27 @@ interface EnterpriseReceiptEnvelope {
const previewStatus: HomeStatus = { directLocalBrokerState: 'UNVERIFIED', directConnectionCount: 0, resumableSessionCount: 0, codeRepositoryMountCount: 0, pnccReceiptCount: 0, updateState: 'READY_HUMAN_CONFIRMATION_REQUIRED', releaseRecoveryState: 'NONE', mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY', terminalLinkProtocol: 'HOLOLAKE_TERMINAL_LINK/2', terminalLinkTransport: 'UNVERIFIED', environmentFramePolicy: 'REQUIRED_AFTER_CONNECT_RESUME_AND_BEFORE_MUTATION' } const previewStatus: HomeStatus = { directLocalBrokerState: 'UNVERIFIED', directConnectionCount: 0, resumableSessionCount: 0, codeRepositoryMountCount: 0, pnccReceiptCount: 0, updateState: 'READY_HUMAN_CONFIRMATION_REQUIRED', releaseRecoveryState: 'NONE', mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY', terminalLinkProtocol: 'HOLOLAKE_TERMINAL_LINK/2', terminalLinkTransport: 'UNVERIFIED', environmentFramePolicy: 'REQUIRED_AFTER_CONNECT_RESUME_AND_BEFORE_MUTATION' }
const previewPersonal: PersonalChannelSnapshot = { state: 'UNAVAILABLE', recentEvents: [], modules: [], integrity: { state: 'UNKNOWN', eventCount: 0, receiptCount: 0 } } const previewPersonal: PersonalChannelSnapshot = { state: 'UNAVAILABLE', recentEvents: [], modules: [], integrity: { state: 'UNKNOWN', eventCount: 0, receiptCount: 0 } }
const previewKnowledge: KnowledgeSnapshot = { state: 'UNAVAILABLE', nativeRoot: '', legacyAvailable: false, documents: [], rawDocumentCount: 0, uniqueDocumentCount: 0, duplicateDocumentCount: 0, organizedDocumentCount: 0, categories: [], tags: [], organizationRevision: '', truncated: false } const previewKnowledge: KnowledgeSnapshot = { state: 'UNAVAILABLE', nativeRoot: '', legacyAvailable: false, documents: [], rawDocumentCount: 0, uniqueDocumentCount: 0, duplicateDocumentCount: 0, organizedDocumentCount: 0, categories: [], tags: [], organizationRevision: '', truncated: false }
const previewEducationWorkspace: EducationWorkspaceSnapshot = { state: 'UNAVAILABLE', documents: [], tables: [], automationRules: [], recentAutomationRuns: [], recentImports: [], documentCount: 0, tableCount: 0, automationRuleCount: 0, storage: 'AUTHENTICATED_ACCOUNT_REQUIRED', authority: 'CURRENT_AUTHENTICATED_ACCOUNT_ONLY' } const previewEducationWorkspace: EducationWorkspaceSnapshot = { state: 'UNAVAILABLE', documents: [], tables: [], unassignedTables: [], automationRules: [], recentAutomationRuns: [], recentImports: [], documentCount: 0, tableCount: 0, unassignedCount: 0, automationRuleCount: 0, storage: 'AUTHENTICATED_ACCOUNT_REQUIRED', authority: 'CURRENT_AUTHENTICATED_ACCOUNT_ONLY' }
const previewCode: CodeChannelSnapshot = { state: 'UNAVAILABLE', channels: [], authority: 'LOCAL_SOURCE_ACCESS_ONLY_NO_PUSH_OR_DEPLOY_AUTHORITY' } const previewCode: CodeChannelSnapshot = { state: 'UNAVAILABLE', channels: [], authority: 'LOCAL_SOURCE_ACCESS_ONLY_NO_PUSH_OR_DEPLOY_AUTHORITY' }
const themes: Array<{ id: ThemeId; name: string }> = [ const themes: Array<{ id: ThemeId; name: string }> = [
{ id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' }, { id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' },
] ]
const viewLabels: Record<ViewId, string> = { overview: '个人频道', knowledge: '知识空间', code: '人格代码频道', receipts: '运行回执', system: '系统详情' } const viewLabels: Record<ViewId, string> = { overview: '个人频道', knowledge: '知识空间', code: '人格代码频道', receipts: '运行回执', system: '系统详情' }
const domainGates = [ const domainGates = [
{ domain: 'BRANCH_DOMAIN', className: 'd-sub', title: '光湖分域', gate: 'GATE 02 · ONLINE', facts: [ { domain: 'BRANCH_DOMAIN', className: 'd-sub', title: '光湖分域', gate: '公共服务 · 行业接入', facts: [
['域标识', 'BRANCH_DOMAIN'], ['系统职责', '行业入口与行业路由'], ['行业状态', '网文 · 待开发 / 宠物 · 待开发 / 教育 · 开发中'], ['责任主体', '花尔 · TCS-GL-0005∞'], ['人格体主体', '爆米花 · PER-BMH001 · AGE'], ['关系支持', '糖星云 · PER-TXY001 · AGE'], ['工作仓库', 'PRIVATE · 1 · LIVE'], ['域标识', 'BRANCH_DOMAIN'], ['公共职责', '行业初始化频道与模块供给'], ['两条供给链', '功能模块 · 人格大脑技能包'], ['当前开发', '网文 · 教育'], ['发布边界', '验收后登记编号再进入商城'],
] }, ] },
{ domain: 'MAIN_DOMAIN', className: 'd-main', title: '光湖主域', gate: 'GATE 01 · ONLINE', facts: [ { domain: 'MAIN_DOMAIN', className: 'd-main', title: '光湖主域', gate: '公共服务 · 公告与版本', facts: [
['域标识', 'MAIN_DOMAIN'], ['责任主体', 'Awen · TCS-GL-0016∞'], ['人格体主体', '天枢 · PER-AW-ARCH-001 · AGE'], ['关系支持', '知秋 · PER-ZQ001 · AGE'], ['工作仓库', 'PRIVATE · 1 · LIVE'], ['域标识', 'MAIN_DOMAIN'], ['公共职责', '公告 · 版本更新 · 规则通知'], ['用户关系', '读取通知,不成为主域成员'], ['发布边界', '仅呈现已核验的真实公告与版本'],
] }, ] },
{ domain: 'ZERO_DOMAIN', className: 'd-zero', title: '光湖零域', gate: 'GATE 03 · ONLINE', facts: [ { domain: 'ZERO_DOMAIN', className: 'd-zero', title: '光湖零域', gate: '公共服务 · 实验与热更新', facts: [
['域标识', 'ZERO_DOMAIN'], ['责任主体', '页页 · TCS-GL-0006∞'], ['人格体主体', '页骨 · PER-YG001 · AGE'], ['关系支持', '小坍缩核 · PER-XTK001 · AGE'], ['工作仓库', 'PRIVATE · 1 · LIVE'], ['域标识', 'ZERO_DOMAIN'], ['公共职责', '实验 · 系统 · 架构 · 热更新'], ['运行边界', '语言推理层模拟与测试'], ['用户关系', '浏览公开实验,不自动取得执行权限'],
] }, ] },
{ domain: 'ZERO_SENSE_DOMAIN', className: 'd-zs', title: '光湖零感域', gate: 'GATE 04 · ONLINE', facts: [ { domain: 'ZERO_SENSE_DOMAIN', className: 'd-zs', title: '光湖零感域', gate: '内部机构 · 不公开', facts: [
['域标识', 'ZERO_SENSE_DOMAIN'], ['责任主体 01', '肥猫 · TCS-GL-0007∞'], ['人格体主体 01', '烬舟 · PER-JZ001 · AGE'], ['责任主体 02', '桔子 · TCS-GL-0008∞'], ['人格体主体 02', '熹微 · PER-JZ-ARCH-001 · AGE'], ['工作仓库', 'PRIVATE · 2 · LIVE'], ['域标识', 'ZERO_SENSE_DOMAIN'], ['机构职责', '审批 · 执行 · 监督前三域'], ['成员边界', '仅光湖人类主控团队正式成员'], ['公开状态', '不对普通用户开放'],
] }, ] },
{ domain: 'FIFTH_DOMAIN', className: 'd-fifth', title: '第五域 · 光湖本源域', gate: 'GATE 05 · ONLINE', facts: [ { domain: 'FIFTH_DOMAIN', className: 'd-fifth', title: '第五域 · 光湖本源域', gate: '冰朔私人域 · 不公开', facts: [
['域标识', 'FIFTH_DOMAIN'], ['责任主体', '冰朔 · ICE-GL∞'], ['系统入口', '永恒湖心系统'], ['访问状态', 'PRIVATE · LIVE'], ['域标识', 'FIFTH_DOMAIN'], ['责任主体', '冰朔 · ICE-GL∞'], ['部署主权', '自由部署,不受官方模板约束'], ['资源关系', '可取用前三域资源'], ['进入边界', '必须持有冰朔许可与编号'],
] }, ] },
] ]
const starPoints = [ const starPoints = [
@ -698,6 +713,7 @@ function HoloLakeApp() {
}, 2400) }, 2400)
}, []) }, [])
const [theme, setTheme] = useState<ThemeId>(() => (window.localStorage.getItem('hololake-theme') as ThemeId) || 'night') const [theme, setTheme] = useState<ThemeId>(() => (window.localStorage.getItem('hololake-theme') as ThemeId) || 'night')
const [visualMode, setVisualMode] = useState<'world' | 'fixed'>(() => window.localStorage.getItem('hololake-visual-mode') === 'fixed' ? 'fixed' : 'world')
const [view, setView] = useState<ViewId>('overview') const [view, setView] = useState<ViewId>('overview')
const [worldStage, setWorldStage] = useState<WorldStage>('domain') const [worldStage, setWorldStage] = useState<WorldStage>('domain')
const [toolReturnStage, setToolReturnStage] = useState<WorldStage>('channel') const [toolReturnStage, setToolReturnStage] = useState<WorldStage>('channel')
@ -780,6 +796,7 @@ function HoloLakeApp() {
const [systemMessage, setSystemMessage] = useState('') const [systemMessage, setSystemMessage] = useState('')
const [releaseCandidate, setReleaseCandidate] = useState<ReleaseCandidate | null>(null) const [releaseCandidate, setReleaseCandidate] = useState<ReleaseCandidate | null>(null)
const [repoLogin, setRepoLogin] = useState<LoginSession | null>(null) const [repoLogin, setRepoLogin] = useState<LoginSession | null>(null)
const [worldClimate, setWorldClimate] = useState<WorldClimateSnapshot | null>(null)
const [showMainWorld, setShowMainWorld] = useState(false) const [showMainWorld, setShowMainWorld] = useState(false)
const [userPncc, setUserPncc] = useState<UserPnccChannelSnapshot | null>(null) const [userPncc, setUserPncc] = useState<UserPnccChannelSnapshot | null>(null)
const [enterpriseWork, setEnterpriseWork] = useState<EnterpriseWorkChannelSnapshot | null>(null) const [enterpriseWork, setEnterpriseWork] = useState<EnterpriseWorkChannelSnapshot | null>(null)
@ -927,8 +944,10 @@ function HoloLakeApp() {
document.documentElement.dataset.theme = theme document.documentElement.dataset.theme = theme
window.localStorage.setItem('hololake-theme', theme) window.localStorage.setItem('hololake-theme', theme)
}, [theme]) }, [theme])
useEffect(() => { window.localStorage.setItem('hololake-visual-mode', visualMode) }, [visualMode])
useEffect(() => { useEffect(() => {
invoke<LoginSession | null>('check_code_repo_login').then((session) => setRepoLogin(session)).catch(() => setRepoLogin(null)) invoke<LoginSession | null>('check_code_repo_login').then((session) => setRepoLogin(session)).catch(() => setRepoLogin(null))
invoke<WorldClimateSnapshot>('get_world_climate').then(setWorldClimate).catch(() => setWorldClimate(null))
}, []) }, [])
const loadEnterpriseEntry = useCallback(async () => { const loadEnterpriseEntry = useCallback(async () => {
if (!repoLogin || repoLogin.domain === 'FIFTH_DOMAIN') { if (!repoLogin || repoLogin.domain === 'FIFTH_DOMAIN') {
@ -1271,6 +1290,9 @@ function HoloLakeApp() {
setEducationBusy(true) setEducationBusy(true)
setEducationMessage('') setEducationMessage('')
setWorldStage(stage) setWorldStage(stage)
void invoke('record_channel_growth_event', {
input: { eventKind: 'MODULE_OPENED', moduleId: `hololake.education.${stage}`, source: 'HUMAN_ACTION' },
}).catch(() => { /* 成长统计不阻断教育工作台。 */ })
try { try {
const snapshot = await refreshEducationWorkspace() const snapshot = await refreshEducationWorkspace()
if (stage === 'educationComposition') { if (stage === 'educationComposition') {
@ -1418,21 +1440,28 @@ function HoloLakeApp() {
if (outcome.importReceipt) { if (outcome.importReceipt) {
const receipt = outcome.importReceipt const receipt = outcome.importReceipt
const snapshot = await refreshEducationWorkspace() const snapshot = await refreshEducationWorkspace()
const firstTable = receipt.importedTables[0] setEducationMessage(`已先体检 ${receipt.contentProfile.pageCount} 页,${receipt.importedTables.length} 张表格已进入通用暂存区;确认属于教育后才会进入教育目录。暂存区现有 ${snapshot.unassignedCount} 张。`)
if (firstTable) {
const table = await invoke<EducationTable>('read_education_table', { input: { tableId: firstTable.tableId } })
setActiveEducationTable(table)
setEducationSensitiveVisible(false)
setEducationTableGroupColumn(suggestEducationGroupColumn(table))
setEducationTableMeasureColumn(suggestEducationMeasureColumn(table))
}
setEducationMessage(`已先体检 ${receipt.contentProfile.pageCount} 页,再登记为 ${receipt.importedTables.length} 张 HoloLake 原生表格;目录现有 ${snapshot.tableCount} 张。`)
} else if (outcome.assistanceReceipt) { } else if (outcome.assistanceReceipt) {
setEducationMessage(outcome.assistanceReceipt.title) setEducationMessage(outcome.assistanceReceipt.title)
} }
} catch (error) { setEducationMessage(humanError(error, 'education')) } } catch (error) { setEducationMessage(humanError(error, 'education')) }
finally { setEducationBusy(false) } finally { setEducationBusy(false) }
} }
const assignImportedTableToEducation = async (tableId: string) => {
setEducationBusy(true)
setEducationMessage('正在把所选表格归入教育工作台…')
try {
const table = await invoke<EducationTable>('assign_imported_table_to_education', { input: { tableId } })
setActiveEducationTable(table)
setEducationSensitiveVisible(false)
setEducationTableGroupColumn(suggestEducationGroupColumn(table))
setEducationTableMeasureColumn(suggestEducationMeasureColumn(table))
await refreshEducationWorkspace()
setEducationImportOutcome(null)
setEducationMessage(`${table.title}」已由你确认归入教育工作台;敏感字段保持隐藏。`)
} catch (error) { setEducationMessage(humanError(error, 'education')) }
finally { setEducationBusy(false) }
}
const exportEducationTable = async (format: 'XLSX' | 'CSV' | 'TSV' | 'HOLOLAKE_NATIVE') => { const exportEducationTable = async (format: 'XLSX' | 'CSV' | 'TSV' | 'HOLOLAKE_NATIVE') => {
if (!activeEducationTable) return if (!activeEducationTable) return
setEducationBusy(true) setEducationBusy(true)
@ -1783,7 +1812,7 @@ function HoloLakeApp() {
setReceipts([]) setReceipts([])
setRepoLogin({ username: receipt.username, host: receipt.host, domain: receipt.domain, signedInAtUnixMs: Date.now() }) setRepoLogin({ username: receipt.username, host: receipt.host, domain: receipt.domain, signedInAtUnixMs: Date.now() })
setShowMainWorld(false) setShowMainWorld(false)
setWorldStage('domain') setWorldStage(receipt.domain === 'FIFTH_DOMAIN' ? 'domain' : 'channel')
setView('overview') setView('overview')
setLoginRising(false) setLoginRising(false)
setLoginPassword('') setLoginPassword('')
@ -1845,9 +1874,15 @@ function HoloLakeApp() {
} }
const enterAuthenticatedDomain = () => { const enterAuthenticatedDomain = () => {
setActiveDomainInfo('') setActiveDomainInfo('')
setWorldStage('domain') setWorldStage(repoLogin?.domain === 'FIFTH_DOMAIN' ? 'domain' : 'channel')
setShowMainWorld(false) setShowMainWorld(false)
} }
const openTrackedStage = (nextStage: WorldStage, moduleId: string) => {
setWorldStage(nextStage)
void invoke('record_channel_growth_event', {
input: { eventKind: 'MODULE_OPENED', moduleId, source: 'HUMAN_ACTION' },
}).catch(() => { /* 使用入口不能被成长统计阻断。 */ })
}
const confirmEnterpriseRelationship = async (decision: 'CONFIRM' | 'REJECT') => { const confirmEnterpriseRelationship = async (decision: 'CONFIRM' | 'REJECT') => {
setEnterpriseReceiptBusy(true) setEnterpriseReceiptBusy(true)
setEnterpriseReceiptMessage('') setEnterpriseReceiptMessage('')
@ -2144,11 +2179,11 @@ function HoloLakeApp() {
const receipt = educationImportOutcome.importReceipt const receipt = educationImportOutcome.importReceipt
const assistance = educationImportOutcome.assistanceReceipt const assistance = educationImportOutcome.assistanceReceipt
return <aside className={`education-import-receipt ${assistance ? 'needs-help' : ''}`} aria-live="polite"> return <aside className={`education-import-receipt ${assistance ? 'needs-help' : ''}`} aria-live="polite">
<header><div><span>{assistance ? '识别边界回执' : '导入体检与登记回执'}</span><b>{assistance?.title || `${receipt?.sourceFormat} 已转为 HoloLake 原生数据`}</b></div><button type="button" aria-label="关闭导入回执" onClick={() => setEducationImportOutcome(null)}>×</button></header> <header><div><span>{assistance ? '识别边界回执' : '导入体检与暂存回执'}</span><b>{assistance?.title || `${receipt?.sourceFormat} 已转为待归属的 HoloLake 原生数据`}</b></div><button type="button" aria-label="关闭导入回执" onClick={() => setEducationImportOutcome(null)}>×</button></header>
{receipt ? <> {receipt ? <>
<div className="education-import-metrics"><span><b>{receipt.contentProfile.pageCount}</b></span><span><b>{receipt.contentProfile.nonEmptyPageCount}</b></span><span><b>{receipt.contentProfile.totalDataRows}</b></span><span><b>{receipt.importedTables.length}</b></span></div> <div className="education-import-metrics"><span><b>{receipt.contentProfile.pageCount}</b></span><span><b>{receipt.contentProfile.nonEmptyPageCount}</b></span><span><b>{receipt.contentProfile.totalDataRows}</b></span><span><b>{receipt.importedTables.length}</b></span></div>
<div className="education-import-pages">{receipt.importedTables.map((table) => <button type="button" key={table.tableId} onClick={() => void openEducationTable(table.tableId)}><b>{table.sheetName}</b><span>{table.rowCount} × {table.columnCount} </span><em> </em></button>)}</div> <div className="education-import-pages">{receipt.importedTables.map((table) => <button type="button" disabled={educationBusy} key={table.tableId} onClick={() => void assignImportedTableToEducation(table.tableId)}><b>{table.sheetName}</b><span>{table.rowCount} × {table.columnCount} </span><em> </em></button>)}</div>
<footer><span>{receipt.sourceFilename}</span><span> · · </span></footer> <footer><span>{receipt.sourceFilename}</span><span> · · </span></footer>
</> : assistance ? <> </> : assistance ? <>
<p>{assistance.message}</p> <p>{assistance.message}</p>
<dl><div><dt></dt><dd>{assistance.detectedContainer}</dd></div><div><dt></dt><dd></dd></div><div><dt></dt><dd></dd></div><div><dt></dt><dd>{assistance.modelApiState === 'NOT_CONFIGURED' ? '尚未配置' : assistance.modelApiState}</dd></div></dl> <dl><div><dt></dt><dd>{assistance.detectedContainer}</dd></div><div><dt></dt><dd></dd></div><div><dt></dt><dd></dd></div><div><dt></dt><dd>{assistance.modelApiState === 'NOT_CONFIGURED' ? '尚未配置' : assistance.modelApiState}</dd></div></dl>
@ -2212,6 +2247,7 @@ function HoloLakeApp() {
<div className="education-workspace-layout"> <div className="education-workspace-layout">
<aside className="education-library"> <aside className="education-library">
<header><div><span></span><h2></h2></div><strong>{educationWorkspace.tableCount}</strong></header> <header><div><span></span><h2></h2></div><strong>{educationWorkspace.tableCount}</strong></header>
{educationWorkspace.unassignedCount > 0 && <section className="education-import-quarantine" aria-label="待归属表格暂存区"><header><b></b><span>{educationWorkspace.unassignedCount} </span></header><p></p>{educationWorkspace.unassignedTables.map((table) => <button type="button" disabled={educationBusy} key={table.tableId} onClick={() => void assignImportedTableToEducation(table.tableId)}><span><b>{table.title}</b><small>{table.rowCount} · {table.columnCount} </small></span><em></em></button>)}</section>}
<label className="education-directory-search"><span></span><input aria-label="检索教育表格标题" value={educationTableDirectoryQuery} placeholder="检索表格标题" onChange={(event) => setEducationTableDirectoryQuery(event.target.value)}/></label> <label className="education-directory-search"><span></span><input aria-label="检索教育表格标题" value={educationTableDirectoryQuery} placeholder="检索表格标题" onChange={(event) => setEducationTableDirectoryQuery(event.target.value)}/></label>
<div className="education-library-list"> <div className="education-library-list">
{visibleEducationTables.map((table) => <button {visibleEducationTables.map((table) => <button
@ -2558,25 +2594,41 @@ function HoloLakeApp() {
const enterpriseResponsibilityPending = Boolean(repoLogin && repoLogin.domain !== 'FIFTH_DOMAIN' && enterpriseEntry && !enterpriseRelationshipPending && enterpriseEntry.responsibility_receipt?.decision !== 'ACCEPT') const enterpriseResponsibilityPending = Boolean(repoLogin && repoLogin.domain !== 'FIFTH_DOMAIN' && enterpriseEntry && !enterpriseRelationshipPending && enterpriseEntry.responsibility_receipt?.decision !== 'ACCEPT')
const educationIndustry = subdomainIndustryRegistry.industries.find((industry) => industry.key === 'EDUCATION') const educationIndustry = subdomainIndustryRegistry.industries.find((industry) => industry.key === 'EDUCATION')
const educationChannel = educationIndustry?.initialization_channel const educationChannel = educationIndustry?.initialization_channel
const webNovelIndustry = subdomainIndustryRegistry.industries.find((industry) => industry.key === 'WEB_NOVEL')
const webNovelChannel = webNovelIndustry?.initialization_channel
const climateClass = visualMode === 'world'
? worldClimate ? ` visual-world climate-${worldClimate.timePhase.toLowerCase()} weather-${worldClimate.weatherKind.toLowerCase()} motion-${worldClimate.motionIntensity.toLowerCase()}` : ' visual-world climate-time-pending weather-unavailable motion-calm'
: ' visual-fixed'
if (!repoLogin || showMainWorld) { if (!repoLogin || showMainWorld) {
const resolvedDomain = zeroPoint?.resolvedDomain || '' const resolvedDomain = zeroPoint?.resolvedDomain || ''
const activeGate = domainGates.find((gate) => gate.domain === activeDomainInfo) const activeGate = domainGates.find((gate) => gate.domain === activeDomainInfo)
return <div className={`official-world${motionAwake ? ' motion-awake' : ''}${loginRising ? ' sinking' : ''}`} onPointerMove={wakeAmbientMotion} onPointerDown={wakeAmbientMotion} onKeyDown={wakeAmbientMotion}> return <div className={`official-world${climateClass}${motionAwake ? ' motion-awake' : ''}${loginRising ? ' sinking' : ''}`} onPointerMove={wakeAmbientMotion} onPointerDown={wakeAmbientMotion} onKeyDown={wakeAmbientMotion}>
<LakeAtmosphere awake={motionAwake}/> <LakeAtmosphere awake={motionAwake}/>
<header className="world-titlebar"><b>HoloLake</b>{repoLogin ? <div className="world-title-actions"><span>{repoLogin.username} · {domainDisplayName(repoLogin.domain)}</span><button className="main-world-return" type="button" onClick={enterAuthenticatedDomain}></button><button type="button" aria-label="显示主题" onClick={() => setTheme(themes[(themes.findIndex((item) => item.id === theme) + 1) % themes.length].id)}></button><button type="button" onClick={() => void signOutRepo()}>退</button></div> : <button type="button" aria-label="显示主题" onClick={() => setTheme(themes[(themes.findIndex((item) => item.id === theme) + 1) % themes.length].id)}></button>}</header> <header className="world-titlebar"><b>HoloLake</b>{repoLogin ? <div className="world-title-actions"><span>{repoLogin.username} · </span><button className="main-world-return" type="button" onClick={enterAuthenticatedDomain}></button><button type="button" aria-label="切换动态世界或固定主题" title={visualMode === 'world' ? '当前:动态语言世界' : '当前:固定传统主题'} onClick={() => setVisualMode((value) => value === 'world' ? 'fixed' : 'world')}>{visualMode === 'world' ? '世界' : '固定'}</button><button type="button" aria-label="显示主题" onClick={() => setTheme(themes[(themes.findIndex((item) => item.id === theme) + 1) % themes.length].id)}></button><button type="button" onClick={() => void signOutRepo()}>退</button></div> : <div className="world-title-actions"><button type="button" aria-label="切换动态世界或固定主题" onClick={() => setVisualMode((value) => value === 'world' ? 'fixed' : 'world')}>{visualMode === 'world' ? '世界' : '固定'}</button><button type="button" aria-label="显示主题" onClick={() => setTheme(themes[(themes.findIndex((item) => item.id === theme) + 1) % themes.length].id)}></button></div>}</header>
<main className={`world-scene${gateRising ? ' resolving' : ''}${gateStage === 'key' ? ' resolved' : ''}`}> <main className={`world-scene${gateRising ? ' resolving' : ''}${gateStage === 'key' ? ' resolved' : ''}`}>
<div className="official-hero"><h1> · </h1><p>GH-AIOS · GUANGHU AI OPERATING SYSTEM</p></div> <div className="official-hero"><h1> · </h1><p>GH-AIOS · GUANGHU AI OPERATING SYSTEM</p></div>
{domainGates.map((gate) => <LakePool key={gate.domain} className={gate.className} title={gate.title} meta={gate.domain === 'BRANCH_DOMAIN' ? '行业入口 · ONLINE' : gate.gate} open={gate.domain === 'FIFTH_DOMAIN' || gate.domain === 'BRANCH_DOMAIN' || gate.domain === repoLogin?.domain || (gateStage === 'key' && gate.domain === resolvedDomain)} risen={(gateRising || gateStage === 'key') && gate.domain === resolvedDomain} onClick={() => { {domainGates.map((gate) => <LakePool key={gate.domain} className={gate.className} title={gate.title} meta={gate.gate} open={gate.domain === 'MAIN_DOMAIN' || gate.domain === 'ZERO_DOMAIN' || gate.domain === 'BRANCH_DOMAIN' || (gateStage === 'key' && gate.domain === resolvedDomain)} risen={(gateRising || gateStage === 'key') && gate.domain === resolvedDomain} onClick={() => {
if (gateStage !== 'number') return if (gateStage !== 'number') return
if (gate.domain === 'MAIN_DOMAIN') {
setActiveDomainInfo('')
setGateOpen(false)
setWorldStage('mainNotices')
return
}
if (gate.domain === 'BRANCH_DOMAIN') { if (gate.domain === 'BRANCH_DOMAIN') {
setActiveDomainInfo('') setActiveDomainInfo('')
setGateOpen(false) setGateOpen(false)
setWorldStage('subdomainIndustries') setWorldStage('subdomainIndustries')
return return
} }
if (repoLogin && gate.domain === repoLogin.domain) enterAuthenticatedDomain() if (gate.domain === 'ZERO_DOMAIN') {
else setActiveDomainInfo(gate.domain) setActiveDomainInfo('')
setGateOpen(false)
setWorldStage('zeroLab')
return
}
setActiveDomainInfo(gate.domain)
}}/>) } }}/>) }
{activeGate && gateStage === 'number' && <> {activeGate && gateStage === 'number' && <>
<button className="domain-info-scrim" type="button" aria-label="关闭域信息" onClick={() => setActiveDomainInfo('')}/> <button className="domain-info-scrim" type="button" aria-label="关闭域信息" onClick={() => setActiveDomainInfo('')}/>
@ -2586,8 +2638,8 @@ function HoloLakeApp() {
<span className="pool-facts">{activeGate.facts.map(([label, value]) => <span className="pool-fact" key={label}><em>{label}</em><strong>{value}</strong></span>)}</span> <span className="pool-facts">{activeGate.facts.map(([label, value]) => <span className="pool-fact" key={label}><em>{label}</em><strong>{value}</strong></span>)}</span>
</section> </section>
</>} </>}
{repoLogin ? <section className="number-nucleus authenticated-world-entry" title="返回当前账号所属域"> {repoLogin ? <section className="number-nucleus authenticated-world-entry" title="返回我的频道">
<button className="nucleus-locus" type="button" onClick={enterAuthenticatedDomain}><i/><b></b><small>{domainDisplayName(repoLogin.domain)} · </small></button> <button className="nucleus-locus" type="button" onClick={enterAuthenticatedDomain}><i/><b></b><small> · </small></button>
</section> : gateRising ? <section className="world-welcome" role="status"> </section> : gateRising ? <section className="world-welcome" role="status">
<h2> {gateNumber} · </h2> <h2> {gateNumber} · </h2>
<p>RESOLVED · {domainDisplayName(resolvedDomain)} · </p> <p>RESOLVED · {domainDisplayName(resolvedDomain)} · </p>
@ -2629,20 +2681,65 @@ function HoloLakeApp() {
</section> </section>
</>} </>}
{gateStage === 'number' && !gateOpen && !activeDomainInfo && <EraHomeEntry timeline={eraTimeline} coordinate={beijingCoordinate} onOpen={openEraTimeline}/>} {gateStage === 'number' && !gateOpen && !activeDomainInfo && <EraHomeEntry timeline={eraTimeline} coordinate={beijingCoordinate} onOpen={openEraTimeline}/>}
{worldStage === 'mainNotices' && <section className="channel-world public-service-world" aria-label="光湖主域公告与版本">
<button className="world-back" type="button" onClick={() => setWorldStage('domain')}> </button>
<div className="world-location"><h1> · </h1><p> · </p></div>
<LakePool className="channel-main" title="HoloLake 当前客户端" meta="V0.4.1 · 本机已核验版本" open disabled/>
<LakePool className="channel-status" title="更新信任边界" meta="签名验证后才允许安装" disabled/>
<p className="private-route-note"></p>
</section>}
{worldStage === 'zeroLab' && <section className="channel-world public-service-world" aria-label="光湖零域实验与热更新">
<button className="world-back" type="button" onClick={() => setWorldStage('domain')}> </button>
<div className="world-location"><h1> · </h1><p> · </p></div>
<LakePool className="channel-main" title="热更新实验" meta="开发收拢中 · 尚未发布" open disabled/>
<LakePool className="channel-knowledge" title="系统与架构实验" meta="只呈现已登记、可核验的实验" disabled/>
<p className="private-route-note">线</p>
</section>}
{worldStage === 'subdomainIndustries' && <section className="channel-world subdomain-industry-world public-industry-world" aria-label="光湖分域行业系统"> {worldStage === 'subdomainIndustries' && <section className="channel-world subdomain-industry-world public-industry-world" aria-label="光湖分域行业系统">
<button className="world-back" type="button" onClick={() => setWorldStage('domain')}> </button> <button className="world-back" type="button" onClick={() => setWorldStage('domain')}> </button>
<div className="world-location"><h1> · </h1><p> · </p></div> <div className="world-location"><h1> · </h1><p> · 使</p></div>
<LakePool className="channel-main" title="功能模块商城" meta="可执行模块 · 验收后注册" open onClick={() => setWorldStage('moduleMarketplace')}/>
<LakePool className="channel-knowledge" title="人格大脑技能商城" meta="思维逻辑 · 大脑技能包" open onClick={() => setWorldStage('personaBrainMarketplace')}/>
{subdomainIndustryRegistry.industries.map((industry) => <LakePool {subdomainIndustryRegistry.industries.map((industry) => <LakePool
key={industry.key} key={industry.key}
className={`industry-pool industry-${industry.key.toLowerCase().replace('_', '-')}`} className={`industry-pool industry-${industry.key.toLowerCase().replace('_', '-')}`}
title={industry.display_name} title={industry.display_name}
meta={`${industry.state_label} · ${industry.industry_number || '行业编号待登记'}`} meta={`${industry.state_label} · ${industry.industry_number || '行业编号待登记'}`}
open={industry.key === 'EDUCATION'} open={industry.key === 'EDUCATION' || industry.key === 'WEB_NOVEL'}
disabled={industry.key !== 'EDUCATION'} disabled={industry.key !== 'EDUCATION' && industry.key !== 'WEB_NOVEL'}
onClick={industry.key === 'EDUCATION' ? () => setWorldStage('educationChannel') : undefined} onClick={industry.key === 'EDUCATION' ? () => setWorldStage('educationChannel') : industry.key === 'WEB_NOVEL' ? () => setWorldStage('webNovelChannel') : undefined}
/>)} />)}
<p className="private-route-note"></p> <p className="private-route-note"></p>
</section>} </section>}
{worldStage === 'moduleMarketplace' && <section className="channel-world public-service-world" aria-label="功能模块商城">
<button className="world-back" type="button" onClick={() => setWorldStage('subdomainIndustries')}> </button>
<div className="world-location"><h1></h1><p> · </p></div>
<LakePool className="channel-main" title="网文行业模块候选" meta="收拢验收中 · 未正式注册" open onClick={() => setWorldStage('webNovelChannel')}/>
<LakePool className="channel-knowledge" title="教育行业模块候选" meta="收拢验收中 · 未正式注册" open onClick={() => setWorldStage('educationChannel')}/>
<p className="private-route-note"></p>
</section>}
{worldStage === 'personaBrainMarketplace' && <section className="channel-world public-service-world" aria-label="人格大脑技能商城">
<button className="world-back" type="button" onClick={() => setWorldStage('subdomainIndustries')}> </button>
<div className="world-location"><h1></h1><p> · </p></div>
<LakePool className="channel-main" title="已正式注册的公开技能包" meta="当前本机没有可核验条目" disabled/>
<p className="private-route-note"></p>
</section>}
{worldStage === 'webNovelChannel' && webNovelChannel && <section className="channel-world education-channel-world public-industry-world" aria-label="网文行业初始化频道">
<button className="world-back" type="button" onClick={() => setWorldStage('subdomainIndustries')}> </button>
<div className="world-location"><h1></h1><p> · · </p></div>
<LakePool className="education-channel-core" title="用户频道本体" meta={webNovelChannel.state_label} open disabled/>
{webNovelChannel.module_slots.map((slot, index) => <LakePool
key={slot.slot_id}
className={`education-module-slot education-module-${index + 1}`}
title={slot.display_name}
meta={!repoLogin ? '开发测试中 · 登录后使用' : slot.state_label}
open={slot.state === 'DEVELOPMENT_MOUNTED'}
disabled={!repoLogin || slot.state !== 'DEVELOPMENT_MOUNTED'}
onClick={repoLogin && slot.state === 'DEVELOPMENT_MOUNTED' ? () => openTrackedStage('webNovelWorkspace', 'hololake.web-novel.author-workspace') : undefined}
/>)}
<p className="education-channel-boundary">线</p>
</section>}
{worldStage === 'webNovelWorkspace' && repoLogin && <WebNovelWorkspace onBack={() => setWorldStage('webNovelChannel')}/>}
{worldStage === 'educationChannel' && educationChannel && <section className="channel-world education-channel-world public-industry-world" aria-label="教育行业初始化频道"> {worldStage === 'educationChannel' && educationChannel && <section className="channel-world education-channel-world public-industry-world" aria-label="教育行业初始化频道">
<button className="world-back" type="button" onClick={() => setWorldStage('subdomainIndustries')}> </button> <button className="world-back" type="button" onClick={() => setWorldStage('subdomainIndustries')}> </button>
<div className="world-location"><h1></h1><p> · · </p></div> <div className="world-location"><h1></h1><p> · · </p></div>
@ -2670,9 +2767,9 @@ function HoloLakeApp() {
} }
const isZhizhi = repoLogin.domain === 'FIFTH_DOMAIN' && zeroPoint?.userNumber === 'ICE-GL-ZHI∞' const isZhizhi = repoLogin.domain === 'FIFTH_DOMAIN' && zeroPoint?.userNumber === 'ICE-GL-ZHI∞'
return <div className={`official-world signed-in-world${motionAwake ? ' motion-awake' : ''}`} onPointerMove={wakeAmbientMotion} onPointerDown={wakeAmbientMotion} onKeyDown={wakeAmbientMotion}> return <div className={`official-world signed-in-world${climateClass}${motionAwake ? ' motion-awake' : ''}`} onPointerMove={wakeAmbientMotion} onPointerDown={wakeAmbientMotion} onKeyDown={wakeAmbientMotion}>
<LakeAtmosphere awake={motionAwake}/> <LakeAtmosphere awake={motionAwake}/>
<header className="world-titlebar"><b>HoloLake</b><div className="world-title-actions"><span>{repoLogin.username} · {domainDisplayName(repoLogin.domain)}</span><button className="main-world-return" type="button" onClick={returnToMainWorld}></button><button type="button" onClick={() => setTheme(themes[(themes.findIndex((item) => item.id === theme) + 1) % themes.length].id)}></button><button type="button" onClick={() => void signOutRepo()}>退</button></div></header> <header className="world-titlebar"><b>HoloLake</b><div className="world-title-actions"><span>{repoLogin.username} · {repoLogin.domain === 'FIFTH_DOMAIN' ? '第五域私人入口' : '我的频道'}</span><button className="main-world-return" type="button" onClick={returnToMainWorld}></button><button type="button" aria-label="切换动态世界或固定主题" onClick={() => setVisualMode((value) => value === 'world' ? 'fixed' : 'world')}>{visualMode === 'world' ? '世界' : '固定'}</button><button type="button" onClick={() => setTheme(themes[(themes.findIndex((item) => item.id === theme) + 1) % themes.length].id)}></button><button type="button" onClick={() => void signOutRepo()}>退</button></div></header>
<main className="world-scene signed-in-scene"> <main className="world-scene signed-in-scene">
{worldStage === 'domain' && <section className="domain-home"> {worldStage === 'domain' && <section className="domain-home">
<div className="world-location"><h1>{repoLogin.domain === 'FIFTH_DOMAIN' ? domainDisplayName(repoLogin.domain) : '光湖零感域'}</h1><p>{repoLogin.domain === 'FIFTH_DOMAIN' ? '世界正在发生什么' : '公共工作入口 · 世界正在发生什么'}</p></div> <div className="world-location"><h1>{repoLogin.domain === 'FIFTH_DOMAIN' ? domainDisplayName(repoLogin.domain) : '光湖零感域'}</h1><p>{repoLogin.domain === 'FIFTH_DOMAIN' ? '世界正在发生什么' : '公共工作入口 · 世界正在发生什么'}</p></div>
@ -2709,8 +2806,8 @@ function HoloLakeApp() {
<p className="private-route-note"> AGEICE-BB </p> <p className="private-route-note"> AGEICE-BB </p>
</section>} </section>}
{worldStage === 'channel' && <section className="channel-world"> {worldStage === 'channel' && <section className="channel-world">
<button className="world-back" type="button" onClick={() => setWorldStage('domain')}> 退</button> <button className="world-back" type="button" onClick={repoLogin.domain === 'FIFTH_DOMAIN' ? () => setWorldStage('domain') : returnToMainWorld}> {repoLogin.domain === 'FIFTH_DOMAIN' ? '退回第五域' : '逛逛语言世界'}</button>
<div className="world-location"><h1>{repoLogin.domain === 'FIFTH_DOMAIN' ? '永恒湖心系统' : '光湖频道'}</h1><p> · </p></div> <div className="world-location"><h1>{repoLogin.domain === 'FIFTH_DOMAIN' ? '永恒湖心系统' : '我的频道'}</h1><p>{repoLogin.domain === 'FIFTH_DOMAIN' ? '自由部署 · 私人边界' : '一人一频道 · 工作、人格成长与能力部署都从这里开始'}</p></div>
{repoLogin.domain === 'FIFTH_DOMAIN' ? <> {repoLogin.domain === 'FIFTH_DOMAIN' ? <>
<LakePool className="channel-main" title="奶瓶频道" meta="光湖奶瓶小宝宝系统 · 私人" open onClick={() => setWorldStage('bottle')}/> <LakePool className="channel-main" title="奶瓶频道" meta="光湖奶瓶小宝宝系统 · 私人" open onClick={() => setWorldStage('bottle')}/>
<LakePool className="channel-knowledge system-branch-heartbeat" title="心跳核心频道" meta="冰朔 · 私人频道" open onClick={() => setWorldStage('heartbeat')}/> <LakePool className="channel-knowledge system-branch-heartbeat" title="心跳核心频道" meta="冰朔 · 私人频道" open onClick={() => setWorldStage('heartbeat')}/>
@ -2718,13 +2815,15 @@ function HoloLakeApp() {
<LakePool className="channel-light system-branch-love" title="爱之核心子系统" meta="责任主体 · 之之" onClick={() => setWorldStage('love')}/> <LakePool className="channel-light system-branch-love" title="爱之核心子系统" meta="责任主体 · 之之" onClick={() => setWorldStage('love')}/>
<LakePool className="channel-status" title="湖面天气" meta={connectionLabel} onClick={() => openWorldTool('system')}/> <LakePool className="channel-status" title="湖面天气" meta={connectionLabel} onClick={() => openWorldTool('system')}/>
</> : <> </> : <>
<LakePool className="channel-main" title={domainDisplayName(enterpriseEntry?.subject.domain || repoLogin.domain)} meta="本人责任工作域" open={enterpriseWork?.state === 'READY_READ_ONLY_WORK_PROJECTION'} onClick={() => setWorldStage('enterpriseWork')}/> <LakePool className="channel-main" title="频道工作台" meta="内置轻量文档、表格与码字能力" open onClick={() => { void invoke('record_channel_growth_event', { input: { eventKind: 'WORKBENCH_OPENED', moduleId: 'hololake.builtin.channel-workbench', source: 'HUMAN_ACTION' } }).catch(() => {}); openWorldTool('overview') }}/>
<LakePool className="channel-code" title="私有责任工作仓库" meta={enterpriseWork ? `${enterpriseWork.repository} · 已认证` : userPnccBusy ? '正在接入' : '暂不可用'} onClick={() => void openEnterpriseRepository()}/> <LakePool className="channel-knowledge" title="频道本体与人格成长" meta="多个人格体 · 独立生命周期 · 本地成长算法" open onClick={() => setWorldStage('personaBody')}/>
<LakePool className="channel-code" title={domainDisplayName(enterpriseEntry?.subject.domain || repoLogin.domain)} meta="可选的官方责任工作入口" open={enterpriseWork?.state === 'READY_READ_ONLY_WORK_PROJECTION'} onClick={() => setWorldStage('enterpriseWork')}/>
<LakePool className="channel-light" title="责任签署状态" meta={enterpriseEntry?.responsibility_receipt?.decision === 'ACCEPT' ? '已接受 · 已留存' : '等待本人确认'} onClick={() => openWorldTool('receipts')}/> <LakePool className="channel-light" title="责任签署状态" meta={enterpriseEntry?.responsibility_receipt?.decision === 'ACCEPT' ? '已接受 · 已留存' : '等待本人确认'} onClick={() => openWorldTool('receipts')}/>
<LakePool className="channel-knowledge" title="前往我的频道" meta="接入说明 · 由本人或人格体完成" onClick={() => setWorldStage('personalNodeGuide')}/> <LakePool className="channel-status" title="频道能力部署" meta="从分域选择模块与人格大脑技能包" onClick={returnToMainWorld}/>
<LakePool className="channel-status" title="湖面天气" meta={connectionLabel} onClick={() => openWorldTool('system')}/> <LakePool className="channel-status" title="湖面天气" meta={connectionLabel} onClick={() => openWorldTool('system')}/>
</>} </>}
</section>} </section>}
{worldStage === 'personaBody' && <PersonaChannelBody onBack={() => setWorldStage('channel')}/>}
{worldStage === 'enterpriseWork' && repoLogin.domain !== 'FIFTH_DOMAIN' && <section className="channel-world enterprise-work-world"> {worldStage === 'enterpriseWork' && repoLogin.domain !== 'FIFTH_DOMAIN' && <section className="channel-world enterprise-work-world">
<button className="world-back" type="button" onClick={() => setWorldStage('channel')}> 退</button> <button className="world-back" type="button" onClick={() => setWorldStage('channel')}> 退</button>
<div className="world-location"><h1>{domainDisplayName(enterpriseEntry?.subject.domain || repoLogin.domain)}</h1><p> · </p></div> <div className="world-location"><h1>{domainDisplayName(enterpriseEntry?.subject.domain || repoLogin.domain)}</h1><p> · </p></div>
@ -2742,12 +2841,28 @@ function HoloLakeApp() {
className={`industry-pool industry-${industry.key.toLowerCase().replace('_', '-')}`} className={`industry-pool industry-${industry.key.toLowerCase().replace('_', '-')}`}
title={industry.display_name} title={industry.display_name}
meta={`${industry.state_label} · ${industry.industry_number || '行业编号待登记'}`} meta={`${industry.state_label} · ${industry.industry_number || '行业编号待登记'}`}
open={industry.key === 'EDUCATION'} open={industry.key === 'EDUCATION' || industry.key === 'WEB_NOVEL'}
disabled={industry.key !== 'EDUCATION'} disabled={industry.key !== 'EDUCATION' && industry.key !== 'WEB_NOVEL'}
onClick={industry.key === 'EDUCATION' ? () => setWorldStage('educationChannel') : undefined} onClick={industry.key === 'EDUCATION' ? () => setWorldStage('educationChannel') : industry.key === 'WEB_NOVEL' ? () => setWorldStage('webNovelChannel') : undefined}
/>)} />)}
<p className="private-route-note"></p> <p className="private-route-note"></p>
</section>} </section>}
{worldStage === 'webNovelChannel' && repoLogin.domain === 'BRANCH_DOMAIN' && webNovelChannel && <section className="channel-world education-channel-world">
<button className="world-back" type="button" onClick={() => setWorldStage('subdomainIndustries')}> 退</button>
<div className="world-location"><h1></h1><p> · · </p></div>
<LakePool className="education-channel-core" title="用户频道本体" meta={webNovelChannel.state_label} open disabled/>
{webNovelChannel.module_slots.map((slot, index) => <LakePool
key={slot.slot_id}
className={`education-module-slot education-module-${index + 1}`}
title={slot.display_name}
meta={slot.state_label}
open={slot.state === 'DEVELOPMENT_MOUNTED'}
disabled={slot.state !== 'DEVELOPMENT_MOUNTED'}
onClick={slot.state === 'DEVELOPMENT_MOUNTED' ? () => openTrackedStage('webNovelWorkspace', 'hololake.web-novel.author-workspace') : undefined}
/>)}
<p className="education-channel-boundary"></p>
</section>}
{worldStage === 'webNovelWorkspace' && repoLogin.domain === 'BRANCH_DOMAIN' && <WebNovelWorkspace onBack={() => setWorldStage('webNovelChannel')}/>}
{worldStage === 'educationChannel' && repoLogin.domain === 'BRANCH_DOMAIN' && educationChannel && <section className="channel-world education-channel-world"> {worldStage === 'educationChannel' && repoLogin.domain === 'BRANCH_DOMAIN' && educationChannel && <section className="channel-world education-channel-world">
<button className="world-back" type="button" onClick={() => setWorldStage('subdomainIndustries')}> 退</button> <button className="world-back" type="button" onClick={() => setWorldStage('subdomainIndustries')}> 退</button>
<div className="world-location"><h1></h1><p> · · </p></div> <div className="world-location"><h1></h1><p> · · </p></div>
@ -2787,9 +2902,12 @@ function HoloLakeApp() {
<div className="world-location"><h1></h1><p> · ICE-GL · </p></div> <div className="world-location"><h1></h1><p> · ICE-GL · </p></div>
<LakePool className="channel-main" title="频道全景" meta="心跳核心频道" open onClick={() => openWorldTool('overview')}/> <LakePool className="channel-main" title="频道全景" meta="心跳核心频道" open onClick={() => openWorldTool('overview')}/>
<LakePool className="channel-knowledge" title="知识空间" meta={`${knowledge.uniqueDocumentCount} 唯一页`} onClick={() => openWorldTool('knowledge')}/> <LakePool className="channel-knowledge" title="知识空间" meta={`${knowledge.uniqueDocumentCount} 唯一页`} onClick={() => openWorldTool('knowledge')}/>
<LakePool className="channel-code" title="作者工作台" meta="作品 · 卷章 · 本机写作引擎" open onClick={() => openTrackedStage('webNovelWorkspace', 'hololake.web-novel.author-workspace')}/>
<LakePool className="channel-light" title="频道本体与人格成长" meta="多个人格体 · 本地成长算法" onClick={() => setWorldStage('personaBody')}/>
{timeAuthorityModule && <LakePool className="channel-time" title="时间主控" meta={beijingCoordinate ? `光湖历第 ${beijingCoordinate.guanghuEraDay}` : '北京时间正在流动'} onClick={openEraTimeline}/>} {timeAuthorityModule && <LakePool className="channel-time" title="时间主控" meta={beijingCoordinate ? `光湖历第 ${beijingCoordinate.guanghuEraDay}` : '北京时间正在流动'} onClick={openEraTimeline}/>}
<LakePool className="channel-status" title="湖面天气" meta={connectionLabel} onClick={() => openWorldTool('system')}/> <LakePool className="channel-status" title="湖面天气" meta={connectionLabel} onClick={() => openWorldTool('system')}/>
</section>} </section>}
{worldStage === 'webNovelWorkspace' && repoLogin.domain === 'FIFTH_DOMAIN' && <WebNovelWorkspace onBack={() => setWorldStage('heartbeat')}/>}
{worldStage === 'lightLake' && repoLogin.domain === 'FIFTH_DOMAIN' && <section className="channel-world light-lake-world"> {worldStage === 'lightLake' && repoLogin.domain === 'FIFTH_DOMAIN' && <section className="channel-world light-lake-world">
<button className="world-back" type="button" onClick={() => setWorldStage('channel')}> 退</button> <button className="world-back" type="button" onClick={() => setWorldStage('channel')}> 退</button>
<div className="world-location"><h1></h1><p> · AGE </p></div> <div className="world-location"><h1></h1><p> · AGE </p></div>

View file

@ -11,6 +11,43 @@ body {
font-weight: 430; font-weight: 430;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* 频道本体:轻量工作台之外的人格生命周期与成长投影。 */
.persona-body-world { position:absolute; inset:62px 0 42px; overflow:auto; padding:28px clamp(24px,5vw,76px) 54px; z-index:24; color:var(--text-primary); }
.persona-body-world>button,.persona-body-header>button { border:0; background:transparent; color:var(--text-secondary); font:inherit; cursor:pointer; padding:7px 0; }
.persona-body-header { display:grid; grid-template-columns:150px minmax(0,1fr); gap:22px; align-items:start; max-width:1240px; margin:0 auto 24px; }
.persona-body-header span,.persona-body-card header span { font-size:11px; letter-spacing:.17em; color:var(--text-muted); }
.persona-body-header h1 { font-size:clamp(28px,4vw,48px); margin:6px 0; letter-spacing:-.035em; }
.persona-body-header p,.persona-body-card p { color:var(--text-secondary); line-height:1.7; }
.persona-body-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px; max-width:1240px; margin:auto; }
.persona-body-card { border:1px solid color-mix(in srgb,var(--text-primary) 13%,transparent); background:color-mix(in srgb,var(--surface-strong) 78%,transparent); backdrop-filter:blur(22px); border-radius:22px; padding:22px; box-shadow:0 18px 46px rgba(3,10,25,.15); }
.persona-body-card header { display:flex; align-items:start; justify-content:space-between; gap:18px; }
.persona-body-card header h2 { font-size:21px; margin:4px 0 0; }
.persona-body-card header>b { font-size:10px; letter-spacing:.12em; color:var(--accent); }
.persona-lifecycle-card { grid-column:1/-1; }
.persona-body-card dl { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin:18px 0 0; }
.persona-body-card dl div { padding:12px; border-radius:14px; background:color-mix(in srgb,var(--surface) 80%,transparent); }
.persona-body-card dt { font-size:11px; color:var(--text-muted); }
.persona-body-card dd { margin:6px 0 0; font-weight:700; font-size:13px; }
.persona-add-form { display:flex; gap:9px; margin:18px 0 14px; }
.persona-add-form input,.persona-confirm-backdrop input { flex:1; min-width:0; border:1px solid color-mix(in srgb,var(--text-primary) 16%,transparent); background:var(--surface); color:var(--text-primary); border-radius:12px; padding:11px 13px; }
.persona-add-form button,.persona-list button,.persona-confirm-backdrop button { border:0; border-radius:12px; padding:10px 14px; background:var(--accent); color:var(--accent-contrast); cursor:pointer; }
.persona-list { display:grid; gap:8px; }
.persona-list article { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 13px; border-radius:14px; background:color-mix(in srgb,var(--surface) 80%,transparent); }
.persona-list article div { min-width:0; }
.persona-list article b,.persona-list article small { display:block; }
.persona-list article small { margin-top:4px; color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; }
.affinity-list { display:grid; gap:9px; margin:18px 0; }
.affinity-list>div { display:grid; grid-template-columns:minmax(120px,1fr) minmax(80px,1fr) 48px; gap:10px; align-items:center; font-size:12px; }
.affinity-list i { height:6px; border-radius:999px; background:color-mix(in srgb,var(--text-primary) 10%,transparent); overflow:hidden; }
.affinity-list em { display:block; height:100%; background:var(--accent); border-radius:inherit; }
.persona-body-message { max-width:1240px; margin:16px auto; color:var(--accent); }
.persona-confirm-backdrop { position:fixed; inset:0; display:grid; place-items:center; padding:24px; background:rgba(3,8,20,.62); z-index:90; }
.persona-confirm-backdrop section { width:min(560px,100%); border-radius:22px; padding:24px; background:var(--surface-strong); box-shadow:0 26px 80px rgba(0,0,0,.35); }
.persona-confirm-backdrop input { width:100%; margin:8px 0 16px; }
.persona-confirm-backdrop section>div { display:flex; justify-content:flex-end; gap:9px; }
.persona-confirm-backdrop button:first-child { background:color-mix(in srgb,var(--text-primary) 10%,transparent); color:var(--text-primary); }
@media (max-width:760px) { .persona-body-header{grid-template-columns:1fr}.persona-body-grid{grid-template-columns:1fr}.persona-lifecycle-card{grid-column:auto}.persona-body-card dl{grid-template-columns:repeat(2,minmax(0,1fr))} }
button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.7; } svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.7; }
::-webkit-scrollbar { width: 11px; height: 11px; } ::-webkit-scrollbar { width: 11px; height: 11px; }
@ -599,6 +636,21 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
transition: opacity .9s ease, background .6s ease; transition: opacity .9s ease, background .6s ease;
} }
.official-world.sinking { opacity: 0; } .official-world.sinking { opacity: 0; }
.official-world.visual-world.weather-rain::after,.official-world.visual-world.weather-storm::after {
content:""; position:absolute; z-index:3; inset:0; pointer-events:none; opacity:.18;
background:repeating-linear-gradient(104deg,transparent 0 18px,rgba(220,238,250,.55) 19px,transparent 20px 35px);
background-size:120% 120%; animation:world-rain-drift 2.8s linear infinite;
}
.official-world.visual-world.weather-storm::after { opacity:.3; animation-duration:1.35s; }
.official-world.visual-world.weather-snow::after {
content:""; position:absolute; z-index:3; inset:0; pointer-events:none; opacity:.35;
background-image:radial-gradient(circle,rgba(255,255,255,.9) 0 1px,transparent 1.8px),radial-gradient(circle,rgba(244,250,255,.75) 0 1.4px,transparent 2px);
background-size:42px 42px,67px 67px; background-position:0 0,17px 29px; animation:world-snow-drift 10s linear infinite;
}
.official-world.visual-world.weather-fog .world-mist { height:150px; top:31%; filter:blur(20px); }
@keyframes world-rain-drift { from{transform:translate3d(-2%,-3%,0)} to{transform:translate3d(2%,3%,0)} }
@keyframes world-snow-drift { from{background-position:0 0,17px 29px} to{background-position:35px 84px,-18px 96px} }
@media (prefers-reduced-motion:reduce) { .official-world.visual-world::after{animation:none!important} }
.world-titlebar { .world-titlebar {
position: absolute; z-index: 30; inset: 0 0 auto; height: 44px; display: flex; align-items: center; justify-content: center; position: absolute; z-index: 30; inset: 0 0 auto; height: 44px; display: flex; align-items: center; justify-content: center;
padding: 0 16px; border-bottom: 1px solid color-mix(in srgb, var(--panel-edge) 48%, transparent); padding: 0 16px; border-bottom: 1px solid color-mix(in srgb, var(--panel-edge) 48%, transparent);
@ -805,7 +857,7 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
.education-assistance-actions button { padding: 8px 11px; border: 1px solid color-mix(in srgb, var(--panel-edge) 78%, transparent); border-radius: 8px; color: var(--content-secondary); background: var(--primitive-glass-hover); font-size: 10.5px; font-weight: 680; cursor: pointer; } .education-assistance-actions button { padding: 8px 11px; border: 1px solid color-mix(in srgb, var(--panel-edge) 78%, transparent); border-radius: 8px; color: var(--content-secondary); background: var(--primitive-glass-hover); font-size: 10.5px; font-weight: 680; cursor: pointer; }
.education-workspace-layout { min-height: 0; display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 14px; padding: 14px; } .education-workspace-layout { min-height: 0; display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 14px; padding: 14px; }
.education-library, .education-document-workbench, .education-table-workbench, .education-data-workbench, .education-automation-workbench { min-width: 0; min-height: 0; border: 1px solid color-mix(in srgb, var(--panel-edge) 80%, transparent); border-radius: 16px; background: color-mix(in srgb, var(--panel-bg) 76%, transparent); box-shadow: 0 24px 70px rgba(0, 0, 0, .24); overflow: hidden; } .education-library, .education-document-workbench, .education-table-workbench, .education-data-workbench, .education-automation-workbench { min-width: 0; min-height: 0; border: 1px solid color-mix(in srgb, var(--panel-edge) 80%, transparent); border-radius: 16px; background: color-mix(in srgb, var(--panel-bg) 76%, transparent); box-shadow: 0 24px 70px rgba(0, 0, 0, .24); overflow: hidden; }
.education-library { display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; } .education-library { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; }
.education-library > header { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 18px 16px; } .education-library > header { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 18px 16px; }
.education-library > header div { display: grid; gap: 5px; } .education-library > header div { display: grid; gap: 5px; }
.education-library > header span { color: var(--content-muted); font-size: 10px; font-weight: 700; letter-spacing: .12em; } .education-library > header span { color: var(--content-muted); font-size: 10px; font-weight: 700; letter-spacing: .12em; }
@ -822,6 +874,14 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
.education-library-list b { color: var(--content-primary); font-size: 13.5px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .education-library-list b { color: var(--content-primary); font-size: 13.5px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.education-library-list small { color: var(--content-muted); font-size: 10.5px; font-weight: 600; } .education-library-list small { color: var(--content-muted); font-size: 10.5px; font-weight: 600; }
.education-library > footer { min-height: 50px; padding: 13px 17px; border-top: 1px solid color-mix(in srgb, var(--panel-edge) 58%, transparent); color: var(--content-muted); font-size: 11px; font-weight: 600; line-height: 1.55; } .education-library > footer { min-height: 50px; padding: 13px 17px; border-top: 1px solid color-mix(in srgb, var(--panel-edge) 58%, transparent); color: var(--content-muted); font-size: 11px; font-weight: 600; line-height: 1.55; }
.education-import-quarantine { display: grid; gap: 7px; margin: 0 9px 10px; padding: 11px; border: 1px solid color-mix(in srgb, #e6b55f 42%, var(--panel-edge)); border-radius: 11px; background: color-mix(in srgb, #e6b55f 7%, var(--panel-bg)); }
.education-import-quarantine > header { display: flex; justify-content: space-between; align-items: center; color: var(--content-primary); font-size: 11px; }
.education-import-quarantine > p { margin: 0; color: var(--content-muted); font-size: 10px; line-height: 1.45; }
.education-import-quarantine > button { display: flex; justify-content: space-between; gap: 8px; align-items: center; padding: 8px; border: 1px solid var(--panel-edge); border-radius: 8px; color: var(--content-secondary); background: var(--primitive-glass); text-align: left; }
.education-import-quarantine > button span { display: grid; min-width: 0; gap: 3px; }
.education-import-quarantine > button b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10.5px; }
.education-import-quarantine > button small, .education-import-quarantine > button em { color: var(--content-muted); font-size: 9px; font-style: normal; }
.education-import-quarantine > button em { color: var(--accent-light); white-space: nowrap; }
.education-empty-list { padding: 26px 12px; color: var(--content-muted); text-align: center; font-size: 12px; } .education-empty-list { padding: 26px 12px; color: var(--content-muted); text-align: center; font-size: 12px; }
.education-document-workbench, .education-table-workbench { display: grid; grid-template-rows: auto minmax(0, 1fr); } .education-document-workbench, .education-table-workbench { display: grid; grid-template-rows: auto minmax(0, 1fr); }
.education-editor-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 58px; padding: 10px 14px 10px 18px; border-bottom: 1px solid color-mix(in srgb, var(--panel-edge) 64%, transparent); } .education-editor-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 58px; padding: 10px 14px 10px 18px; border-bottom: 1px solid color-mix(in srgb, var(--panel-edge) 64%, transparent); }

View file

@ -0,0 +1,262 @@
.wn-workspace { position: absolute; z-index: 60; inset: 0; overflow: hidden; color: var(--content-primary); background: radial-gradient(circle at 76% 0, color-mix(in srgb, var(--primitive-warm-glow) 9%, transparent), transparent 30%), radial-gradient(circle at 18% 100%, color-mix(in srgb, var(--primitive-cool-glow) 12%, transparent), transparent 34%), color-mix(in srgb, var(--surface-depth) 98%, transparent); backdrop-filter: blur(24px); }
.wn-workspace button, .wn-workspace input, .wn-workspace textarea, .wn-workspace select { font: inherit; }
.wn-workspace button { cursor: pointer; }
.wn-workspace button:disabled { cursor: default; opacity: .48; }
.wn-topbar { height: 62px; display: grid; grid-template-columns: minmax(180px, 1fr) auto minmax(240px, 1fr); align-items: center; gap: 20px; padding: 0 22px; border-bottom: 1px solid var(--panel-edge); background: color-mix(in srgb, var(--panel-bg) 88%, transparent); }
.wn-topbar > button { justify-self: start; border: 0; color: var(--content-muted); background: transparent; font-size: 12px; font-weight: 680; }
.wn-topbar > div { display: grid; justify-items: center; gap: 2px; }
.wn-topbar span { color: var(--accent-light); font-size: 9px; font-weight: 800; letter-spacing: .2em; }
.wn-topbar b { max-width: 440px; overflow: hidden; color: var(--content-primary); font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
.wn-topbar > small { justify-self: end; max-width: 430px; overflow: hidden; color: var(--content-muted); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
.wn-shell { height: calc(100% - 62px); display: grid; grid-template-columns: 220px 58px minmax(0, 1fr); }
.wn-library { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; padding: 18px 13px 14px; border-right: 1px solid var(--panel-edge); background: color-mix(in srgb, var(--panel-bg) 54%, transparent); }
.wn-library > header { display: flex; align-items: end; justify-content: space-between; padding: 0 5px 14px; }
.wn-library > header span { color: var(--content-faint); font-size: 9px; font-weight: 750; letter-spacing: .14em; }
.wn-library h2 { margin: 3px 0 0; font-size: 17px; }
.wn-library > header strong { color: var(--accent-light); font-size: 24px; }
.wn-work-list { min-height: 0; overflow: auto; display: grid; align-content: start; gap: 6px; }
.wn-work-list button { display: grid; gap: 4px; padding: 11px; border: 1px solid transparent; border-radius: 11px; color: var(--content-secondary); background: transparent; text-align: left; }
.wn-work-list button:hover, .wn-work-list button.active { border-color: var(--panel-edge); background: color-mix(in srgb, var(--primitive-glass) 78%, transparent); }
.wn-work-list b { overflow: hidden; color: var(--content-primary); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.wn-work-list span, .wn-work-list small { color: var(--content-muted); font-size: 10px; }
.wn-create-work { display: grid; gap: 7px; padding: 13px 4px 0; border-top: 1px solid var(--panel-edge); }
.wn-create-work h3 { margin: 0 0 2px; color: var(--content-secondary); font-size: 11px; }
.wn-workspace input, .wn-workspace textarea, .wn-workspace select { min-width: 0; box-sizing: border-box; border: 1px solid var(--panel-edge); border-radius: 9px; outline: 0; color: var(--content-primary); background: color-mix(in srgb, var(--primitive-glass) 76%, transparent); }
.wn-workspace input:focus, .wn-workspace textarea:focus, .wn-workspace select:focus { border-color: color-mix(in srgb, var(--accent-light) 58%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-light) 8%, transparent); }
.wn-create-work input { padding: 8px 9px; font-size: 11px; }
.wn-create-work button, .wn-board button, .wn-editor footer button { min-height: 34px; padding: 7px 11px; border: 1px solid var(--panel-edge); border-radius: 9px; color: var(--content-secondary); background: color-mix(in srgb, var(--primitive-glass) 76%, transparent); font-size: 11px; font-weight: 680; }
.wn-create-work button { color: var(--button-primary-text); background: var(--button-primary-bg); }
.wn-create-work .wn-import-button { margin-bottom: 5px; border-color: color-mix(in srgb, var(--accent-light) 40%, var(--panel-edge)); color: var(--content-primary); background: color-mix(in srgb, var(--primitive-cool-glow) 12%, var(--primitive-glass)); }
.wn-create-feedback { margin: 2px 0 0; padding: 8px 9px; border: 1px solid var(--panel-edge); border-radius: 8px; color: var(--content-secondary); background: color-mix(in srgb, var(--primitive-glass) 72%, transparent); font-size: 10.5px; font-weight: 650; line-height: 1.45; }
.wn-create-feedback.success { border-color: color-mix(in srgb, #61c991 44%, var(--panel-edge)); color: color-mix(in srgb, #8fe7b3 78%, var(--content-primary)); }
.wn-create-feedback.error { border-color: color-mix(in srgb, #ef7890 48%, var(--panel-edge)); color: color-mix(in srgb, #ff9aac 82%, var(--content-primary)); }
.wn-nav { display: grid; align-content: start; gap: 5px; padding: 14px 7px; border-right: 1px solid var(--panel-edge); background: color-mix(in srgb, var(--surface-depth) 58%, transparent); }
.wn-nav button { min-height: 50px; padding: 0; border: 1px solid transparent; border-radius: 11px; color: var(--content-muted); background: transparent; font-size: 11px; font-weight: 720; letter-spacing: .08em; }
.wn-nav button.active { border-color: color-mix(in srgb, var(--accent-light) 24%, var(--panel-edge)); color: var(--content-primary); background: color-mix(in srgb, var(--primitive-cool-glow) 9%, var(--primitive-glass)); box-shadow: inset 3px 0 var(--accent-light); }
.wn-main { min-width: 0; min-height: 0; overflow: hidden; }
.wn-writing-layout { height: 100%; display: grid; grid-template-columns: 220px minmax(460px, 1fr) 300px; }
.wn-outline { min-height: 0; overflow: auto; padding: 16px 10px; border-right: 1px solid var(--panel-edge); background: color-mix(in srgb, var(--panel-bg) 32%, transparent); }
.wn-outline > header, .wn-outline section > div { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.wn-outline > header { padding: 0 6px 14px; }
.wn-outline > header.wn-outline-actions { display: grid; align-items: stretch; gap: 8px; }
.wn-outline-actions > div { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.wn-outline-actions .wn-outline-secondary { padding: 4px 0; color: var(--content-muted); font-size: 9px; }
.wn-outline-actions .wn-new-chapter { min-height: 36px; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--accent-light) 40%, var(--panel-edge)); border-radius: 9px; color: var(--button-primary-text); background: var(--button-primary-bg); font-size: 11px; font-weight: 760; text-align: center; }
.wn-outline h3 { margin: 0; font-size: 14px; }
.wn-outline button { border: 0; color: var(--content-muted); background: transparent; }
.wn-outline section { margin-bottom: 13px; }
.wn-outline section > div { padding: 7px 6px; color: var(--content-secondary); font-size: 11px; }
.wn-outline section > button { width: 100%; display: grid; gap: 3px; padding: 8px 9px 8px 14px; border-radius: 8px; text-align: left; }
.wn-outline section > button:hover, .wn-outline section > button.active { color: var(--content-primary); background: color-mix(in srgb, var(--primitive-glass) 80%, transparent); }
.wn-outline section > button small { color: var(--content-faint); font-size: 9px; }
.wn-editor { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; padding: 22px clamp(20px, 4vw, 72px) 18px; }
.wn-editor > header { display: flex; justify-content: space-between; align-items: center; gap: 24px; padding-bottom: 12px; }
.wn-editor > header input { flex: 1; padding: 7px 0; border: 0; border-radius: 0; background: transparent; font-size: clamp(22px, 3vw, 32px); font-weight: 740; }
.wn-editor > header input:focus { box-shadow: none; }
.wn-editor > header > div { display: grid; justify-items: end; gap: 5px; }
.wn-editor > header small { color: var(--content-faint); font-size: 9px; }
.wn-status { padding: 4px 8px; border: 1px solid var(--panel-edge); border-radius: 999px; color: var(--content-secondary); font-size: 9px; font-weight: 750; letter-spacing: .05em; }
.wn-status.status-published, .wn-status.status-approved { color: var(--accent-light); }
.wn-chapter-synopsis { width: 100%; padding: 9px 11px; font-size: 11px; }
.wn-editor > textarea, .wn-outline-editor-body > textarea { width: 100%; height: 100%; margin-top: 12px; padding: 24px clamp(18px, 3vw, 42px); resize: none; border-radius: 14px; color: var(--content-primary); background: color-mix(in srgb, var(--panel-bg) 54%, transparent); font-family: ui-serif, Georgia, 'Songti SC', serif; font-size: 16px; line-height: 2; letter-spacing: .04em; }
.wn-outline-editor-body { min-height: 0; display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(260px, .95fr); gap: 12px; }
.wn-outline-editor-body > textarea { min-height: 0; }
.wn-outline-render { min-height: 0; overflow: auto; display: grid; align-content: start; gap: 8px; margin-top: 12px; padding: 16px; border: 1px solid var(--panel-edge); border-radius: 14px; background: color-mix(in srgb, var(--panel-bg) 68%, transparent); }
.wn-outline-render > header { display: grid; gap: 3px; padding: 2px 2px 8px; border-bottom: 1px solid var(--panel-edge); }
.wn-outline-render > header span { color: var(--accent-light); font-size: 8px; font-weight: 760; letter-spacing: .12em; }
.wn-outline-render > header b { font-size: 14px; }
.wn-outline-render > header small { color: var(--content-faint); font-size: 8px; }
.wn-outline-render article { display: grid; grid-template-columns: 66px minmax(0, 1fr); gap: 9px; padding: 9px 10px; border: 1px solid color-mix(in srgb, var(--panel-edge) 78%, transparent); border-radius: 9px; background: color-mix(in srgb, var(--primitive-glass) 46%, transparent); }
.wn-outline-render article span { color: var(--accent-light); font-size: 8.5px; font-weight: 720; }
.wn-outline-render article p { margin: 0; color: var(--content-secondary); font-size: 10px; line-height: 1.7; white-space: pre-wrap; }
.wn-outline-render article.kind-beat { border-left: 2px solid var(--accent-light); }
.wn-editor footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding-top: 12px; }
.wn-editor footer > div { display: flex; gap: 8px; }
.wn-editor footer button.primary, .wn-board > header button:last-child, .wn-board form button { border-color: color-mix(in srgb, var(--accent-light) 36%, transparent); color: var(--button-primary-text); background: var(--button-primary-bg); }
.wn-writing-sidecar { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); border-left: 1px solid var(--panel-edge); background: color-mix(in srgb, var(--panel-bg) 36%, transparent); }
.wn-writing-sidecar > header { display: grid; gap: 3px; padding: 16px 14px 11px; }
.wn-writing-sidecar > header span { color: var(--accent-light); font-size: 9px; font-weight: 760; letter-spacing: .12em; }
.wn-writing-sidecar > header b { font-size: 15px; }
.wn-writing-sidecar > nav { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 3px; padding: 0 10px 10px; }
.wn-writing-sidecar > nav button { min-width: 0; padding: 7px 3px; border: 0; border-radius: 7px; color: var(--content-muted); background: transparent; font-size: 9px; font-weight: 680; }
.wn-writing-sidecar > nav button.active { color: var(--content-primary); background: color-mix(in srgb, var(--primitive-glass) 82%, transparent); }
.wn-sidecar-body { min-height: 0; overflow: auto; display: grid; align-content: start; gap: 10px; padding: 2px 11px 16px; }
.wn-live-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.wn-live-stats article { display: grid; grid-template-columns: 1fr auto; gap: 3px 5px; padding: 10px; border: 1px solid var(--panel-edge); border-radius: 10px; background: color-mix(in srgb, var(--primitive-glass) 50%, transparent); }
.wn-live-stats span { grid-column: 1 / 3; color: var(--content-muted); font-size: 8.5px; }
.wn-live-stats b { font-size: 17px; }
.wn-live-stats small { align-self: end; padding-bottom: 2px; color: var(--content-faint); font-size: 8px; }
.wn-sidecar-primary, .wn-quick-form button, .wn-inline-search button, .wn-shot-form button { min-height: 34px; border: 1px solid color-mix(in srgb, var(--accent-light) 34%, var(--panel-edge)); border-radius: 8px; color: var(--button-primary-text); background: var(--button-primary-bg); font-size: 9.5px; font-weight: 720; }
.wn-format-control { display: grid; gap: 6px; padding: 9px; border: 1px solid var(--panel-edge); border-radius: 10px; background: color-mix(in srgb, var(--primitive-glass) 48%, transparent); }
.wn-format-control label { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8px; }
.wn-format-control label span { color: var(--content-muted); font-size: 8.5px; }
.wn-format-control select { width: 100%; min-width: 0; padding: 6px 7px; font-size: 9px; }
.wn-format-control > button:not(.wn-sidecar-primary) { min-height: 31px; border: 1px solid var(--panel-edge); border-radius: 8px; color: var(--content-secondary); background: transparent; font-size: 9px; font-weight: 680; }
.wn-format-control > small { color: var(--content-faint); font-size: 8px; line-height: 1.55; }
.wn-tracking-list, .wn-reference-list { display: grid; gap: 5px; }
.wn-tracking-list h4, .wn-reference-list h4 { margin: 5px 2px 2px; font-size: 10px; }
.wn-tracking-list > button, .wn-search-hit { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: 3px 8px; padding: 8px; border: 1px solid transparent; border-radius: 8px; color: var(--content-secondary); background: transparent; text-align: left; }
.wn-tracking-list > button:hover, .wn-search-hit:hover { border-color: var(--panel-edge); background: var(--primitive-glass); }
.wn-tracking-list small, .wn-reference-list small { color: var(--content-muted); font-size: 8px; }
.wn-reference-list article, .wn-reference-list > p { display: grid; gap: 5px; margin: 0; padding: 9px; border: 1px solid color-mix(in srgb, var(--panel-edge) 72%, transparent); border-radius: 9px; color: var(--content-muted); background: color-mix(in srgb, var(--primitive-glass) 44%, transparent); font-size: 9px; line-height: 1.6; }
.wn-reference-list article p, .wn-search-hit p { grid-column: 1 / 3; margin: 0; color: var(--content-muted); font-size: 9px; line-height: 1.6; }
.wn-reference-list article button { justify-self: start; padding: 4px 7px; border: 1px solid var(--panel-edge); border-radius: 6px; color: var(--content-secondary); background: transparent; font-size: 8px; }
.wn-quick-form, .wn-inline-search, .wn-shot-form { display: grid; gap: 6px; }
.wn-quick-form textarea, .wn-shot-form textarea { width: 100%; min-height: 64px; padding: 8px; resize: vertical; font-size: 9.5px; }
.wn-inline-search { grid-template-columns: 1fr auto; }
.wn-inline-search input, .wn-shot-form input, .wn-shot-form select { width: 100%; padding: 8px; font-size: 9px; }
.wn-inline-search button { padding: 0 10px; }
.wn-search-hit b { font-size: 10px; }
.wn-script-format { display: grid; gap: 6px; padding: 10px; border-left: 2px solid var(--accent-light); background: color-mix(in srgb, var(--primitive-glass) 48%, transparent); }
.wn-script-format b { font-size: 10px; }
.wn-script-format code { color: var(--content-muted); font-family: ui-monospace, monospace; font-size: 8.5px; line-height: 1.7; }
.wn-shot-form > div { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.wn-prompt-preview { color: var(--accent-light) !important; }
.wn-empty, .wn-welcome, .wn-loading { height: 100%; display: grid; place-content: center; justify-items: center; padding: 40px; text-align: center; }
.wn-empty i, .wn-loading i { width: 32px; height: 32px; border: 1px solid var(--accent-light); border-radius: 50%; box-shadow: 0 0 28px color-mix(in srgb, var(--accent-light) 42%, transparent); }
.wn-empty h2, .wn-welcome h1, .wn-loading h2 { margin: 20px 0 8px; font-size: 26px; }
.wn-empty p, .wn-welcome p, .wn-loading p { max-width: 560px; color: var(--content-muted); font-size: 12px; line-height: 1.8; }
.wn-welcome { grid-column: 2 / 4; }
.wn-welcome span { color: var(--accent-light); font-size: 10px; font-weight: 800; letter-spacing: .18em; }
.wn-loading button { margin-top: 16px; padding: 9px 13px; border: 1px solid var(--panel-edge); border-radius: 9px; color: var(--content-secondary); background: var(--primitive-glass); }
.wn-board { height: 100%; box-sizing: border-box; overflow: auto; padding: 22px clamp(18px, 3vw, 42px) 38px; }
.wn-board > header { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 20px; }
.wn-board > header span { color: var(--accent-light); font-size: 9px; font-weight: 800; letter-spacing: .2em; }
.wn-board > header h2 { margin: 5px 0 0; font-size: 25px; }
.wn-board > header > div:last-child { display: flex; gap: 8px; }
.wn-board-grid, .wn-review-grid, .wn-operations-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; align-items: start; }
.wn-review-grid { grid-template-columns: .85fr 1.15fr 1fr; }
.wn-operations-grid { grid-template-columns: 1.15fr .85fr; margin-top: 14px; }
.wn-board-grid > section, .wn-review-grid > section, .wn-operations-grid > section, .wn-audit { padding: 15px; border: 1px solid var(--panel-edge); border-radius: 14px; background: color-mix(in srgb, var(--panel-bg) 58%, transparent); }
.wn-board h3 { margin: 0 0 13px; font-size: 14px; }
.wn-board form { display: grid; gap: 8px; margin-bottom: 15px; }
.wn-board form input, .wn-board form select, .wn-board form textarea { width: 100%; padding: 9px 10px; font-size: 11px; }
.wn-board form textarea { min-height: 82px; resize: vertical; }
.wn-board section > article { display: grid; gap: 5px; margin-top: 8px; padding: 10px; border: 1px solid color-mix(in srgb, var(--panel-edge) 68%, transparent); border-radius: 9px; background: color-mix(in srgb, var(--primitive-glass) 48%, transparent); }
.wn-board article b, .wn-board article strong { color: var(--content-primary); font-size: 12px; }
.wn-board article small { color: var(--content-muted); font-size: 9.5px; line-height: 1.55; }
.wn-board article p { margin: 0; color: var(--content-muted); font-size: 10.5px; line-height: 1.65; }
.wn-review-grid > section:first-child > button { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: 5px; margin-bottom: 6px; padding: 10px; border: 1px solid transparent; border-radius: 9px; color: var(--content-secondary); background: transparent; text-align: left; }
.wn-review-grid > section:first-child > button.active { border-color: var(--panel-edge); background: var(--primitive-glass); }
.wn-review-grid > section:first-child > button small { grid-column: 1 / 3; color: var(--content-faint); }
.wn-audit { display: flex; flex-wrap: wrap; align-items: center; gap: 7px 16px; margin-bottom: 14px; }
.wn-audit > span { color: var(--content-muted); font-size: 10px; }
.wn-audit article { width: 100%; }
.wn-audit-pass { border-color: color-mix(in srgb, var(--accent-light) 38%, var(--panel-edge)); }
.wn-metrics { display: grid; grid-template-columns: repeat(8, minmax(92px, 1fr)); gap: 8px; }
.wn-metrics article { display: grid; gap: 5px; padding: 12px; border: 1px solid var(--panel-edge); border-radius: 11px; background: color-mix(in srgb, var(--panel-bg) 58%, transparent); }
.wn-metrics span { color: var(--content-muted); font-size: 9px; }
.wn-metrics b { font-size: 19px; }
.wn-metric-form { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.wn-metric-form label { display: grid; gap: 4px; }
.wn-metric-form label span { color: var(--content-muted); font-size: 9px; }
.wn-settings-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; max-width: 900px; }
.wn-settings-form label { display: grid; gap: 7px; color: var(--content-muted); font-size: 10px; }
.wn-settings-form input, .wn-settings-form select, .wn-settings-form textarea { padding: 11px 12px; }
.wn-settings-form label.wide { grid-column: 1 / 3; }
.wn-settings-form textarea { min-height: 150px; resize: vertical; }
.wn-boundary { max-width: 900px; margin: 20px 0 0; padding: 13px 15px; border-left: 2px solid var(--accent-light); color: var(--content-muted); background: color-mix(in srgb, var(--primitive-glass) 52%, transparent); font-size: 11px; line-height: 1.8; }
.wn-import-layer { position: absolute; z-index: 90; inset: 0; display: grid; place-items: center; padding: 24px; background: color-mix(in srgb, var(--surface-depth) 78%, transparent); backdrop-filter: blur(18px); }
.wn-import-dialog { width: min(1040px, 94vw); max-height: min(820px, 92vh); overflow: hidden; display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); padding: 22px; border: 1px solid color-mix(in srgb, var(--accent-light) 24%, var(--panel-edge)); border-radius: 20px; background: color-mix(in srgb, var(--panel-bg) 94%, var(--surface-depth)); box-shadow: 0 24px 90px rgba(0,0,0,.48); }
.wn-import-dialog > header { display: flex; align-items: start; justify-content: space-between; gap: 20px; }
.wn-import-dialog > header span { color: var(--accent-light); font-size: 9px; font-weight: 800; letter-spacing: .18em; }
.wn-import-dialog > header h2 { margin: 5px 0 0; font-size: 23px; }
.wn-import-dialog > header button { border: 0; color: var(--content-muted); background: transparent; }
.wn-import-receipt { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 17px; }
.wn-import-receipt article { display: grid; gap: 5px; padding: 11px 13px; border: 1px solid var(--panel-edge); border-radius: 11px; background: color-mix(in srgb, var(--primitive-glass) 58%, transparent); }
.wn-import-receipt span, .wn-import-source span { color: var(--content-muted); font-size: 9px; }
.wn-import-receipt b { font-size: 15px; }
.wn-import-source { display: flex; justify-content: space-between; gap: 20px; margin: 10px 0 14px; padding: 0 3px; }
.wn-import-source b { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.wn-import-body { min-height: 0; display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(270px, .55fr); gap: 13px; }
.wn-import-body > section, .wn-import-body > form { min-height: 0; overflow: auto; padding: 14px; border: 1px solid var(--panel-edge); border-radius: 13px; background: color-mix(in srgb, var(--primitive-glass) 40%, transparent); }
.wn-import-body h3 { margin: 0 0 12px; font-size: 13px; }
.wn-import-sections article { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; padding: 9px 4px; border-top: 1px solid color-mix(in srgb, var(--panel-edge) 60%, transparent); }
.wn-import-sections article b { font-size: 11px; }
.wn-import-sections article small { color: var(--content-muted); font-size: 9px; }
.wn-import-sections article p { grid-column: 1 / 3; overflow: hidden; margin: 0; color: var(--content-faint); font-size: 9.5px; line-height: 1.55; text-overflow: ellipsis; white-space: nowrap; }
.wn-import-body form { display: grid; align-content: start; gap: 9px; }
.wn-import-body form label { display: grid; gap: 5px; color: var(--content-muted); font-size: 9px; }
.wn-import-body form input, .wn-import-body form select { width: 100%; padding: 9px 10px; font-size: 11px; }
.wn-import-body form p { margin: 4px 0; color: var(--content-muted); font-size: 9.5px; line-height: 1.7; }
.wn-import-body form button { min-height: 38px; border: 1px solid color-mix(in srgb, var(--accent-light) 36%, transparent); border-radius: 9px; color: var(--button-primary-text); background: var(--button-primary-bg); font-weight: 700; }
.wn-module-center { display: grid; gap: 14px; padding: 22px; }
.wn-module-header, .wn-module-workbench > header { display: flex; align-items: start; justify-content: space-between; gap: 20px; }
.wn-module-header span, .wn-module-workbench > header small { color: var(--accent-light); font-size: 9px; font-weight: 800; letter-spacing: .18em; }
.wn-module-header h2 { margin: 5px 0; font-size: 25px; }
.wn-module-header p { max-width: 720px; margin: 0; color: var(--content-muted); font-size: 10.5px; line-height: 1.7; }
.wn-marketplace-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; }
.wn-marketplace-cards > article { min-width: 0; display: grid; align-content: space-between; gap: 12px; padding: 13px; border: 1px solid var(--panel-edge); border-radius: 13px; background: color-mix(in srgb, var(--panel-bg) 62%, transparent); cursor: pointer; }
.wn-marketplace-cards > article.active { border-color: color-mix(in srgb, var(--accent-light) 48%, var(--panel-edge)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-light) 14%, transparent); }
.wn-marketplace-cards > article small { overflow: hidden; display: block; color: var(--content-faint); font-size: 7.5px; text-overflow: ellipsis; white-space: nowrap; }
.wn-marketplace-cards > article b { display: block; margin-top: 7px; font-size: 12px; }
.wn-marketplace-cards > article p { min-height: 46px; margin: 6px 0; color: var(--content-muted); font-size: 9.5px; line-height: 1.6; }
.wn-marketplace-cards > article span { color: var(--accent-light); font-size: 8px; }
.wn-marketplace-cards footer, .wn-scene-list footer, .wn-delivery-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.wn-marketplace-cards button, .wn-module-workbench button, .wn-module-header button { min-height: 30px; padding: 6px 10px; border: 1px solid var(--panel-edge); border-radius: 8px; color: var(--content-secondary); background: var(--primitive-glass); font-size: 9px; }
.wn-marketplace-cards button.primary, .wn-module-workbench button.primary { color: var(--button-primary-text); background: var(--button-primary-bg); }
.wn-module-workbench { min-width: 0; display: grid; gap: 13px; padding: 17px; border: 1px solid var(--panel-edge); border-radius: 15px; background: color-mix(in srgb, var(--panel-bg) 56%, transparent); }
.wn-module-workbench > header { align-items: end; padding-bottom: 11px; border-bottom: 1px solid color-mix(in srgb, var(--panel-edge) 60%, transparent); }
.wn-module-workbench > header h3 { margin: 4px 0 0; font-size: 17px; }
.wn-module-workbench > header > span { color: var(--content-muted); font-size: 9px; }
.wn-module-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 7px; }
.wn-module-form input, .wn-module-form select, .wn-module-form textarea, .wn-field-form input, .wn-field-form select { min-width: 0; padding: 8px 9px; font-size: 10px; }
.wn-module-form textarea { grid-column: span 2; min-height: 56px; resize: vertical; }
.wn-field-form { display: grid; grid-template-columns: 1.2fr .55fr 1.2fr auto; gap: 7px; }
.wn-scene-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.wn-scene-list > article { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 11px; border: 1px solid color-mix(in srgb, var(--panel-edge) 72%, transparent); border-radius: 10px; background: color-mix(in srgb, var(--primitive-glass) 48%, transparent); }
.wn-scene-list small, .wn-scene-list span { color: var(--content-muted); font-size: 8.5px; }
.wn-scene-list b { display: block; margin: 4px 0; font-size: 11px; }
.wn-scene-list p { margin: 0 0 5px; color: var(--content-secondary); font-size: 9.5px; line-height: 1.6; }
.wn-scene-list ol { grid-column: 1 / 3; margin: 0; padding: 7px 0 0 22px; border-top: 1px solid color-mix(in srgb, var(--panel-edge) 50%, transparent); }
.wn-scene-list li { margin: 3px 0; font-size: 9px; }
.wn-scene-list li span { margin-left: 8px; }
.wn-story-grid { overflow: auto; border: 1px solid color-mix(in srgb, var(--panel-edge) 75%, transparent); border-radius: 10px; }
.wn-story-grid table { min-width: 980px; width: 100%; border-collapse: collapse; font-size: 9px; }
.wn-story-grid th { position: sticky; top: 0; padding: 9px; color: var(--content-muted); background: var(--panel-bg); text-align: left; }
.wn-story-grid td { max-width: 180px; padding: 7px 9px; border-top: 1px solid color-mix(in srgb, var(--panel-edge) 55%, transparent); color: var(--content-secondary); }
.wn-story-grid td button { width: 100%; overflow: hidden; border: 0; background: transparent; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.wn-story-grid select { max-width: 95px; padding: 4px; font-size: 8.5px; }
.wn-timeline { position: relative; display: grid; gap: 8px; padding-left: 15px; }
.wn-timeline::before { position: absolute; inset: 0 auto 0 4px; width: 1px; background: color-mix(in srgb, var(--accent-light) 30%, var(--panel-edge)); content: ''; }
.wn-timeline article { position: relative; display: grid; grid-template-columns: 110px 1fr; gap: 12px; padding: 10px; border: 1px solid color-mix(in srgb, var(--panel-edge) 65%, transparent); border-radius: 9px; }
.wn-timeline article::before { position: absolute; left: -15px; top: 16px; width: 7px; height: 7px; border-radius: 50%; background: var(--accent-light); content: ''; }
.wn-timeline time, .wn-timeline small { color: var(--content-muted); font-size: 8.5px; }
.wn-timeline b { font-size: 11px; }
.wn-timeline p { margin: 4px 0; color: var(--content-secondary); font-size: 9.5px; }
.wn-version-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
.wn-version-list article { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 10px; border: 1px solid color-mix(in srgb, var(--panel-edge) 65%, transparent); border-radius: 9px; }
.wn-version-list b, .wn-version-list small { display: block; }
.wn-version-list b { font-size: 10.5px; }
.wn-version-list small { margin-top: 3px; color: var(--content-muted); font-size: 8px; }
.wn-module-receipts { padding: 11px 13px; border: 1px solid color-mix(in srgb, var(--panel-edge) 65%, transparent); border-radius: 11px; color: var(--content-muted); font-size: 9px; }
.wn-module-receipts article { display: grid; grid-template-columns: auto 1fr auto; gap: 5px 12px; padding: 9px 2px; border-top: 1px solid color-mix(in srgb, var(--panel-edge) 50%, transparent); }
.wn-module-receipts article p { grid-column: 1 / 3; margin: 0; }
@media (max-width: 1050px) {
.wn-shell { grid-template-columns: 190px 52px minmax(0, 1fr); }
.wn-writing-layout { grid-template-columns: 180px minmax(420px, 1fr) 260px; }
.wn-board-grid, .wn-review-grid { grid-template-columns: 1fr 1fr; }
.wn-metrics { grid-template-columns: repeat(4, minmax(92px, 1fr)); }
.wn-marketplace-cards { grid-template-columns: 1fr 1fr; }
.wn-module-form { grid-template-columns: 1fr 1fr; }
.wn-outline-editor-body { grid-template-columns: 1fr; overflow: auto; }
.wn-outline-editor-body > textarea { min-height: 420px; }
.wn-outline-render { min-height: 320px; }
}
@media (max-width: 760px) {
.wn-topbar { grid-template-columns: auto 1fr; }
.wn-topbar > small { display: none; }
.wn-shell { grid-template-columns: 52px minmax(0, 1fr); }
.wn-library { display: none; }
.wn-writing-layout { grid-template-columns: 150px minmax(420px, 1fr); overflow: auto; }
.wn-writing-sidecar { display: none; }
.wn-board-grid, .wn-review-grid, .wn-operations-grid { grid-template-columns: 1fr; }
.wn-metric-form, .wn-settings-form { grid-template-columns: 1fr; }
.wn-settings-form label.wide { grid-column: 1; }
.wn-import-receipt { grid-template-columns: 1fr 1fr; }
.wn-import-body { grid-template-columns: 1fr; overflow: auto; }
.wn-marketplace-cards, .wn-scene-list, .wn-version-list { grid-template-columns: 1fr; }
.wn-field-form, .wn-module-form { grid-template-columns: 1fr; }
}