ZY-TAKEOVER-SNAPSHOT 20260815: 铸渊接管前现场冻结(Codex 未提交改动全部入册)· 冰朔面谕接管+可回退
This commit is contained in:
parent
ebe2ecf262
commit
ffed065841
22 changed files with 4293 additions and 567 deletions
|
|
@ -1,14 +1,18 @@
|
||||||
# HoloLake Native Desktop
|
# HoloLake Native Desktop
|
||||||
|
|
||||||
状态:`唯一原生主线候选 · 已有底层能力 · 第一阶段可见主体尚未实现`
|
状态:`唯一原生主线候选 · 个人空间、知识库与代码频道源码已实现 · 安装验收未完成`
|
||||||
|
|
||||||
这是 HoloLake 唯一未来桌面产品主线的干净源码根,采用 Tauri v2、Rust 和
|
这是 HoloLake 唯一未来桌面产品主线的干净源码根,采用 Tauri v2、Rust 和
|
||||||
React/TypeScript。桌面上的 `world.guanghu.hololake` 安装包是本源码线的本地候选验收壳,
|
React/TypeScript。桌面上的 `world.guanghu.hololake` 安装包是本源码线的本地候选验收壳,
|
||||||
不是第三条产品线,也还不是第一阶段正式产品。
|
不是第三条产品线,也还不是第一阶段正式产品。
|
||||||
|
|
||||||
当前已经建立可编译原生壳、选定的 GHS-014 个人频道首页、本机 broker/session、动态能力
|
当前可见产品已经收束为“我的光湖”:个人身份只在首次使用时建立一次,之后直接进入概览、
|
||||||
路由、GH-PNCC 只读投影,以及失败关闭的签名更新和回滚管道。这些能力保留,但都属于湖下
|
知识库、代码频道、回执与系统详情。知识库由新的 Rust 本机根持有,提供页面目录、阅读、搜索
|
||||||
基础设施;当前首页只是视觉与连接状态验收面,不代表第一阶段产品主体已经实现。
|
和文件夹导入;旧 HoloLake Era 知识数据仅以独立只读来源兼容。代码频道支持粘贴正式 HTTPS
|
||||||
|
频道地址克隆,也可登记现有本地 Git 文件夹。两者都不因此取得推送、发布或部署权限。
|
||||||
|
|
||||||
|
个人频道 SQLite 内核仍保留任务、事件和回执能力,但手工填写“任务标题/原因”不再作为默认
|
||||||
|
产品入口。当前源码与单元测试已经通过;桌面安装、跨重启真实读回和完整第一阶段仍需独立验收。
|
||||||
|
|
||||||
## 供体边界
|
## 供体边界
|
||||||
|
|
||||||
|
|
@ -40,7 +44,9 @@ React/TypeScript。桌面上的 `world.guanghu.hololake` 安装包是本源码
|
||||||
|
|
||||||
机器合同:`contracts/stage-one-platform.json`。
|
机器合同:`contracts/stage-one-platform.json`。
|
||||||
|
|
||||||
该文件目前只登记模块和边界,不表示第一阶段运行时或可见产品主体已经实现。
|
个人频道、知识库和代码频道的机器合同分别是 `contracts/personal-channel-kernel.json`、
|
||||||
|
`contracts/knowledge-workspace.json` 与 `contracts/code-channel.json`。本机身份不是平台账户
|
||||||
|
认证;本机知识与源码访问也不产生仓库、服务器、部署或发布权限。
|
||||||
|
|
||||||
## 本机开发桥
|
## 本机开发桥
|
||||||
|
|
||||||
|
|
@ -61,10 +67,10 @@ MCP 只保留为外部工具适配入口;它断开或编程 AI 重启时,活
|
||||||
唯一主线、候选壳、供体和数据保护边界见
|
唯一主线、候选壳、供体和数据保护边界见
|
||||||
`routing/hololake-stage-one-desktop-convergence.json`。当前开发顺序固定为:
|
`routing/hololake-stage-one-desktop-convergence.json`。当前开发顺序固定为:
|
||||||
|
|
||||||
1. 实现个人频道身份—任务—事件—回执最小内核;
|
1. 个人频道身份—事件—回执内核:源码与自动化测试已实现;
|
||||||
2. 迁入知识树、页面、搜索和本地持久化纵向切片;
|
2. 原生知识库与代码频道:源码与自动化测试已实现,安装验收未执行;
|
||||||
3. 实现人类审批中心与 Git 证据回读;
|
3. 实现人类审批中心与 Git 证据回读;
|
||||||
4. 投影小湖灯跨会话连续性;
|
4. 投影小湖灯跨会话连续性;
|
||||||
5. 最后完成签名安装、更新、回滚和可逆数据迁移。
|
5. 最后完成公开签名、更新、回滚和可逆数据迁移。
|
||||||
|
|
||||||
连接、动态路由、发布广播和更新服务器继续作为受门控基础设施维护,不得抢占上述产品顺序。
|
连接、动态路由、发布广播和更新服务器继续作为受门控基础设施维护,不得抢占上述产品顺序。
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
{
|
||||||
|
"schema": "hololake.knowledge-workspace-migration-provenance/v1",
|
||||||
|
"record_id": "HLP-KNOWLEDGE-MIGRATION-001",
|
||||||
|
"state": "CLEAN_ROOM_SOURCE_MIGRATION_IMPLEMENTED_LOCAL_RUNTIME_ACCEPTED",
|
||||||
|
"target": "product-source/hololake-native-desktop",
|
||||||
|
"donors": [
|
||||||
|
{
|
||||||
|
"source": "product-source/hololake-desktop",
|
||||||
|
"role": "READ_ONLY_FOLDER_IMPORT_BEHAVIOR_DONOR",
|
||||||
|
"runtime_copied": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "product-source/guanghu-knowledge-base",
|
||||||
|
"role": "READ_ONLY_PAGE_TREE_SEARCH_READING_UX_DONOR",
|
||||||
|
"runtime_copied": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "HoloLake Era.app 0.8.0",
|
||||||
|
"role": "READ_ONLY_INSTALLED_BEHAVIOR_AND_COMPATIBILITY_DONOR",
|
||||||
|
"runtime_copied": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "Singapore Outline 0.80.2",
|
||||||
|
"role": "READ_ONLY_INTERACTION_AND_INFORMATION_ARCHITECTURE_DONOR",
|
||||||
|
"runtime_copied": false,
|
||||||
|
"live_content_documents_observed": 4,
|
||||||
|
"content_migration_source": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"migration_units": [
|
||||||
|
"PAGE_TREE_CONTRACT",
|
||||||
|
"DOCUMENT_READING_CONTRACT",
|
||||||
|
"BOUNDED_SEARCH_CONTRACT",
|
||||||
|
"NATIVE_FOLDER_IMPORT_CONTRACT",
|
||||||
|
"LOCAL_GIT_COMMIT_RECEIPT",
|
||||||
|
"NESTED_OUTLINE_NAVIGATION",
|
||||||
|
"INDEPENDENT_SCROLL_REGIONS",
|
||||||
|
"GFM_AND_WIKI_LINK_RENDERING",
|
||||||
|
"EXACT_CONTENT_DEDUPLICATION",
|
||||||
|
"OPTIMISTIC_NATIVE_EDITING"
|
||||||
|
],
|
||||||
|
"excluded": [
|
||||||
|
"ELECTRON_RUNTIME",
|
||||||
|
"OUTLINE_SERVER_RUNTIME",
|
||||||
|
"TOLARIA_SURFACE",
|
||||||
|
"LEGACY_WRITE_PATH",
|
||||||
|
"IN_PLACE_DATA_MIGRATION"
|
||||||
|
],
|
||||||
|
"legacy_data_boundary": {
|
||||||
|
"mode": "READ_ONLY_COMPATIBILITY_SOURCE",
|
||||||
|
"modified": false,
|
||||||
|
"migrated": false,
|
||||||
|
"deletion_allowed": false,
|
||||||
|
"observed_markdown_documents": 279,
|
||||||
|
"observed_exact_duplicate_groups": 121,
|
||||||
|
"observed_exact_duplicate_extras": 121
|
||||||
|
},
|
||||||
|
"native_data_audit": {
|
||||||
|
"observed_markdown_documents_before_reconciliation": 130,
|
||||||
|
"cross_source_identical_documents": 121,
|
||||||
|
"union_unique_documents": 167,
|
||||||
|
"display_policy": "PREFER_NATIVE_THEN_COALESCE_EXACT_SHA256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
{
|
||||||
|
"schema": "hololake.local-runtime-acceptance/v1",
|
||||||
|
"receipt_id": "GH-HOLOLAKE-LOCAL-RUNTIME-20260815-001",
|
||||||
|
"state": "PASS_100_LOCAL_RUNTIME_ONLY",
|
||||||
|
"observed_at": "2026-08-15T09:18:00+08:00",
|
||||||
|
"installed_application": {
|
||||||
|
"path": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake 第一阶段.app",
|
||||||
|
"bundle_identifier": "world.guanghu.hololake",
|
||||||
|
"executable_sha256": "310c1b71a96b67d79435b62a553fcdba2377ab3c4ae04a19a1ffa0113ce5bcea",
|
||||||
|
"cdhash": "7a856cc7495c74ea7aa7c3b6127b73aabf6fe9fb",
|
||||||
|
"signature": "AD_HOC_HARDENED_RUNTIME_LOCAL_ONLY",
|
||||||
|
"developer_id_team": null,
|
||||||
|
"apple_notarized": false
|
||||||
|
},
|
||||||
|
"personal_space": {
|
||||||
|
"display_name_readback": "冰朔",
|
||||||
|
"identity_cardinality": 1,
|
||||||
|
"restart_readback": true,
|
||||||
|
"manual_task_form_present": false
|
||||||
|
},
|
||||||
|
"knowledge": {
|
||||||
|
"legacy_source": "HOLOLAKE_ERA_READ_ONLY",
|
||||||
|
"legacy_document_count": 279,
|
||||||
|
"native_document_count": 130,
|
||||||
|
"raw_document_count": 409,
|
||||||
|
"unique_document_count": 167,
|
||||||
|
"exact_duplicate_documents_folded": 242,
|
||||||
|
"native_git_head": "520d5c053dd22b7d73b375973ba1f31edfee82e6",
|
||||||
|
"folder_import": "PASS",
|
||||||
|
"repeated_folder_import": "ALREADY_PRESENT_NO_SECOND_TREE",
|
||||||
|
"nested_tree_open": "PASS",
|
||||||
|
"document_independent_scroll": "PASS",
|
||||||
|
"gfm_table_and_wiki_link_projection": "PASS",
|
||||||
|
"native_edit_with_optimistic_hash_and_git_receipt": "PASS",
|
||||||
|
"search_query": "连续性验收",
|
||||||
|
"search_result": "导入/hololake-runtime-acceptance-knowledge/notes/continuity.md",
|
||||||
|
"restart_readback": true,
|
||||||
|
"legacy_source_modified": false
|
||||||
|
},
|
||||||
|
"code_channel": {
|
||||||
|
"input_url": "https://guanghulab.com/code/bingshuo/guanghu-ice-heart",
|
||||||
|
"normalized_remote": "https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git",
|
||||||
|
"git_head": "a4f67a0634dd3664452bb2ea2747d13df1479f99",
|
||||||
|
"branch": "main",
|
||||||
|
"repository_clean": true,
|
||||||
|
"repository_tree_open": "PASS",
|
||||||
|
"json_machine_to_knowledge_projection": "PASS",
|
||||||
|
"credential_prompt_observed": false,
|
||||||
|
"restart_readback": true,
|
||||||
|
"push_or_deploy_attempted": false
|
||||||
|
},
|
||||||
|
"desktop_convergence": {
|
||||||
|
"active_desktop_entry_count": 1,
|
||||||
|
"active_entry": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake 第一阶段.app",
|
||||||
|
"recoverable_archive": "/Users/bingshuolingdianyuanhe/Documents/HoloLake Archive/2026-08-15-0912-pre-real-knowledge",
|
||||||
|
"deleted": false
|
||||||
|
},
|
||||||
|
"automated_gates": {
|
||||||
|
"node_tests": "50_PASS",
|
||||||
|
"rust_tests": "59_PASS",
|
||||||
|
"typescript_vite_build": "PASS",
|
||||||
|
"npm_dependency_audit": "0_VULNERABILITIES",
|
||||||
|
"cargo_clippy_deny_warnings": "PASS",
|
||||||
|
"codesign_strict_verification": "PASS"
|
||||||
|
},
|
||||||
|
"exclusions": [
|
||||||
|
"FULL_STAGE_ONE_PRODUCT_COMPLETION",
|
||||||
|
"PUBLIC_DEVELOPER_ID_SIGNATURE",
|
||||||
|
"APPLE_NOTARIZATION",
|
||||||
|
"PUBLIC_RELEASE_ACTIVATION",
|
||||||
|
"SOURCE_PUBLICATION",
|
||||||
|
"SERVER_DEPLOYMENT",
|
||||||
|
"PERSONA_BINDING_RECOVERY_COMPLETION",
|
||||||
|
"CURRENT_CODEX_NATIVE_CONNECTION_RUNTIME_ACCEPTANCE",
|
||||||
|
"GH_PNCC_NONZERO_RUNTIME_PROJECTION"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
{
|
||||||
|
"schema": "hololake.user-code-channel-contract/v1",
|
||||||
|
"record_id": "HLP-USER-CODE-CHANNEL-001",
|
||||||
|
"state": "LOCAL_INSTALLED_RUNTIME_ACCEPTED_PUBLIC_RELEASE_PENDING",
|
||||||
|
"entry_methods": ["OFFICIAL_HTTPS_CHANNEL_CLONE", "LOCAL_GIT_FOLDER_REGISTRATION"],
|
||||||
|
"clone": {
|
||||||
|
"allowed_schemes": ["https"],
|
||||||
|
"allowed_hosts": ["guanghulab.com", "guanghubingshuo.com"],
|
||||||
|
"embedded_credentials_allowed": false,
|
||||||
|
"query_or_fragment_credentials_allowed": false,
|
||||||
|
"terminal_prompt_allowed": false,
|
||||||
|
"credential_helper_allowed": false,
|
||||||
|
"app_owned_destination": true,
|
||||||
|
"failed_destination_cleanup_bounded": true
|
||||||
|
},
|
||||||
|
"registry": {
|
||||||
|
"owner": "HOLOLAKE_NATIVE_RUST_CORE",
|
||||||
|
"location": "TAURI_APP_DATA_CODE_CHANNEL_V1",
|
||||||
|
"stored_fields_include_credentials": false,
|
||||||
|
"atomic_write": true,
|
||||||
|
"restart_readback": true
|
||||||
|
},
|
||||||
|
"workbench": {
|
||||||
|
"repository_tree_browse": true,
|
||||||
|
"registered_repository_openable": true,
|
||||||
|
"path_escape_denied": true,
|
||||||
|
"symlink_traversal_denied": true,
|
||||||
|
"maximum_text_file_bytes": 2097152,
|
||||||
|
"knowledge_projection": true,
|
||||||
|
"source_projection": true,
|
||||||
|
"human_readable_formats": ["markdown", "json", "hdlp", "yaml", "toml", "ini", "source_code"]
|
||||||
|
},
|
||||||
|
"authority": {
|
||||||
|
"source_read_access": true,
|
||||||
|
"automatic_push": false,
|
||||||
|
"publication_authority": false,
|
||||||
|
"deployment_authority": false,
|
||||||
|
"server_authority": false
|
||||||
|
},
|
||||||
|
"acceptance": {
|
||||||
|
"rust_url_and_registry_tests": true,
|
||||||
|
"react_projection_integrated": true,
|
||||||
|
"repository_tree_runtime_tested": true,
|
||||||
|
"human_projection_runtime_tested": true,
|
||||||
|
"installed_runtime_acceptance": true,
|
||||||
|
"public_clone_runtime_acceptance": true,
|
||||||
|
"restart_readback": true,
|
||||||
|
"server_deployment": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
"schema": "hololake.knowledge-workspace-contract/v1",
|
||||||
|
"record_id": "HLP-KNOWLEDGE-WORKSPACE-001",
|
||||||
|
"state": "LOCAL_INSTALLED_RUNTIME_ACCEPTED_PUBLIC_RELEASE_PENDING",
|
||||||
|
"native_storage": {
|
||||||
|
"owner": "HOLOLAKE_NATIVE_RUST_CORE",
|
||||||
|
"location": "TAURI_APP_DATA_KNOWLEDGE_V1",
|
||||||
|
"engine": "LOCAL_GIT_WITH_DOCUMENT_TREE",
|
||||||
|
"webview_direct_filesystem_access": false,
|
||||||
|
"automatic_server_upload": false
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"page_tree": true,
|
||||||
|
"outline_style_nested_navigation": true,
|
||||||
|
"independent_tree_and_document_scrolling": true,
|
||||||
|
"safe_document_read": true,
|
||||||
|
"bounded_full_text_search": true,
|
||||||
|
"native_folder_picker_import": true,
|
||||||
|
"import_commit_receipt": true,
|
||||||
|
"exact_content_deduplication": true,
|
||||||
|
"optimistic_native_editing": true,
|
||||||
|
"native_edit_commit_receipt": true,
|
||||||
|
"gfm_tables_tasks_and_code": true,
|
||||||
|
"wiki_link_navigation": true,
|
||||||
|
"markdown_reading_canvas": true,
|
||||||
|
"raw_html_rendering": false
|
||||||
|
},
|
||||||
|
"folder_import": {
|
||||||
|
"maximum_files": 1000,
|
||||||
|
"maximum_file_bytes": 10485760,
|
||||||
|
"document_extensions": ["md", "markdown", "txt", "csv", "json", "yaml", "yml"],
|
||||||
|
"asset_extensions": ["png", "jpg", "jpeg", "gif", "webp", "svg"],
|
||||||
|
"symlinks_followed": false,
|
||||||
|
"git_and_dependency_directories_imported": false,
|
||||||
|
"repeated_import": "MERGE_BY_STABLE_SOURCE_NAME",
|
||||||
|
"exact_duplicate_result": "ALREADY_PRESENT",
|
||||||
|
"conflicting_file_overwrite": false,
|
||||||
|
"count_semantics": "RAW_UNIQUE_AND_DUPLICATE_COUNTS_EXPOSED_SEPARATELY"
|
||||||
|
},
|
||||||
|
"legacy_compatibility": {
|
||||||
|
"source": "HOLOLAKE_ERA_0_8_KNOWLEDGE_DATA",
|
||||||
|
"mode": "READ_ONLY_SEPARATE_ROOT",
|
||||||
|
"in_place_migration": false,
|
||||||
|
"source_modification_allowed": false,
|
||||||
|
"tolaria_surface_used": false
|
||||||
|
},
|
||||||
|
"acceptance": {
|
||||||
|
"rust_unit_tests": true,
|
||||||
|
"react_projection_integrated": true,
|
||||||
|
"installed_runtime_acceptance": true,
|
||||||
|
"folder_import_search_and_restart_readback": true,
|
||||||
|
"public_signed_runtime_acceptance": false,
|
||||||
|
"legacy_data_migrated": false,
|
||||||
|
"legacy_data_available_read_only": true,
|
||||||
|
"deduplication_runtime_tested": true,
|
||||||
|
"idempotent_import_runtime_tested": true,
|
||||||
|
"native_edit_runtime_tested": true,
|
||||||
|
"server_deployment": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"schema": "hololake.personal-channel-kernel-contract/v1",
|
||||||
|
"record_id": "HLP-PERSONAL-CHANNEL-KERNEL-001",
|
||||||
|
"state": "SOURCE_IMPLEMENTED_AND_TESTED_NOT_INSTALLED_OR_DEPLOYED",
|
||||||
|
"scope": "PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL",
|
||||||
|
"storage": {
|
||||||
|
"owner": "HOLOLAKE_NATIVE_RUST_CORE",
|
||||||
|
"engine": "EMBEDDED_SQLITE",
|
||||||
|
"location": "TAURI_APP_DATA_PERSONAL_CHANNEL_V1",
|
||||||
|
"directory_mode": "0700",
|
||||||
|
"database_mode": "0600",
|
||||||
|
"synchronous_commit": "FULL",
|
||||||
|
"webview_direct_database_access": false,
|
||||||
|
"server_upload_automatic": false
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"cardinality": "ONE_LOCAL_HUMAN_SUBJECT_PER_DATABASE",
|
||||||
|
"stable_native_ids": true,
|
||||||
|
"display_name_human_confirmed": true,
|
||||||
|
"platform_account_authentication_claimed": false,
|
||||||
|
"server_or_repository_authority_granted": false
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"one_active_task_at_a_time": true,
|
||||||
|
"purpose_required": true,
|
||||||
|
"implemented_transitions": ["CREATE_ACTIVE", "ACTIVE_TO_COMPLETED"],
|
||||||
|
"compare_expected_state_before_transition": true
|
||||||
|
},
|
||||||
|
"event_and_receipt": {
|
||||||
|
"identity_and_task_mutations_are_single_sqlite_transactions": true,
|
||||||
|
"every_mutation_has_event": true,
|
||||||
|
"every_event_has_receipt": true,
|
||||||
|
"event_chain_sha256": true,
|
||||||
|
"receipt_chain_sha256": true,
|
||||||
|
"integrity_verified_before_mutation_and_readback": true,
|
||||||
|
"tampering_fails_closed": true
|
||||||
|
},
|
||||||
|
"acceptance": {
|
||||||
|
"initialization_atomicity_test": true,
|
||||||
|
"single_active_task_test": true,
|
||||||
|
"task_completion_receipt_test": true,
|
||||||
|
"process_restart_readback_test": true,
|
||||||
|
"tamper_detection_test": true,
|
||||||
|
"react_human_projection_integrated": true,
|
||||||
|
"react_identity_onboarding_integrated": true,
|
||||||
|
"react_default_task_form_exposed": false,
|
||||||
|
"signed_installed_runtime_acceptance": false,
|
||||||
|
"local_ad_hoc_signed_installed_runtime_acceptance": true,
|
||||||
|
"data_migration": false,
|
||||||
|
"server_deployment": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"schema": "hololake.stage-one-platform-contract/v1",
|
"schema": "hololake.stage-one-platform-contract/v1",
|
||||||
"record_id": "HLP-NATIVE-STAGE-ONE-CONTRACT-001",
|
"record_id": "HLP-NATIVE-STAGE-ONE-CONTRACT-001",
|
||||||
"state": "LANGUAGE_ARCHITECTURE_CLOSED_REALITY_ENGINEERING_CONTRACT_RUNTIME_NOT_IMPLEMENTED",
|
"state": "LANGUAGE_ARCHITECTURE_CLOSED_REAL_KNOWLEDGE_AND_CODE_WORKBENCH_LOCAL_RUNTIME_ACCEPTED_STAGE_ONE_INCOMPLETE",
|
||||||
"product": "GH-AIOS",
|
"product": "GH-AIOS",
|
||||||
"formal_name": "光湖语言系统 · 通用人工智能操作平台",
|
"formal_name": "光湖语言系统 · 通用人工智能操作平台",
|
||||||
"tcs_work_boundary": {
|
"tcs_work_boundary": {
|
||||||
|
|
@ -15,7 +15,8 @@
|
||||||
"stage_gate": {
|
"stage_gate": {
|
||||||
"language_architecture": "CLOSED_AND_ARCHIVED",
|
"language_architecture": "CLOSED_AND_ARCHIVED",
|
||||||
"current_stage": "REALITY_ENGINEERING_EXECUTION",
|
"current_stage": "REALITY_ENGINEERING_EXECUTION",
|
||||||
"next_minimum_stage": "PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL"
|
"completed_source_slice": "PERSONAL_IDENTITY_KNOWLEDGE_WORKSPACE_AND_CODE_CHANNEL",
|
||||||
|
"next_minimum_stage": "HUMAN_APPROVAL_CENTER_AND_GIT_EVIDENCE_READBACK"
|
||||||
},
|
},
|
||||||
"persona_cognition_origin": {
|
"persona_cognition_origin": {
|
||||||
"human_authored_guanghu_persona_prompt_exists": false,
|
"human_authored_guanghu_persona_prompt_exists": false,
|
||||||
|
|
@ -39,12 +40,63 @@
|
||||||
"KNOWLEDGE_PROJECTION",
|
"KNOWLEDGE_PROJECTION",
|
||||||
"HUMAN_APPROVAL_CENTER",
|
"HUMAN_APPROVAL_CENTER",
|
||||||
"NATIVE_TRUST_BOUNDARY",
|
"NATIVE_TRUST_BOUNDARY",
|
||||||
"LOCAL_DEVELOPMENT_BRIDGE"
|
"LOCAL_DEVELOPMENT_BRIDGE",
|
||||||
|
"USER_CODE_CHANNEL"
|
||||||
,"PERSONAL_NODE_WORK_LAKE_AND_MOBILE_BRIDGE"
|
,"PERSONAL_NODE_WORK_LAKE_AND_MOBILE_BRIDGE"
|
||||||
],
|
],
|
||||||
"human_surface": ["PERSONAL_CHANNEL_HOME", "TASK_AND_DEVELOPMENT_LINES", "LAKE_LAMP_SHARED_BOARD", "MEMORY_AND_KNOWLEDGE", "HUMAN_APPROVAL_CENTER", "EVENTS_GIT_EVIDENCE_AND_RECEIPTS"],
|
"human_surface": ["PERSONAL_CHANNEL_HOME", "MY_HOLOLAKE_OVERVIEW", "KNOWLEDGE_WORKSPACE", "USER_CODE_CHANNELS", "LOCAL_RECEIPTS", "SYSTEM_DETAILS", "HUMAN_APPROVAL_CENTER"],
|
||||||
"universal_language": {"ai_is_language_interface": true, "vendor_adapter_matrix_required": false, "current_ai_self_adapts_to_observed_host": true, "host_self_adaptation_changes_how_not_authority": true},
|
"universal_language": {"ai_is_language_interface": true, "vendor_adapter_matrix_required": false, "current_ai_self_adapts_to_observed_host": true, "host_self_adaptation_changes_how_not_authority": true},
|
||||||
"stage_one_forbidden": ["INTERNAL_AI_CHAT", "MODEL_API_CONFIGURATION", "MODEL_SELECTION", "INTERNAL_MODEL_INFERENCE", "VENDOR_ADAPTER_MATRIX", "AI_WORKBENCH", "MODEL_AND_CONNECTIONS"],
|
"stage_one_forbidden": ["INTERNAL_AI_CHAT", "MODEL_API_CONFIGURATION", "MODEL_SELECTION", "INTERNAL_MODEL_INFERENCE", "VENDOR_ADAPTER_MATRIX", "AI_WORKBENCH", "MODEL_AND_CONNECTIONS"],
|
||||||
|
"personal_channel_kernel": {
|
||||||
|
"contract": "contracts/personal-channel-kernel.json",
|
||||||
|
"native_source_implemented": true,
|
||||||
|
"react_human_projection_integrated": true,
|
||||||
|
"local_sqlite_restart_readback_tested": true,
|
||||||
|
"identity_is_local_human_confirmation_not_platform_authentication": true,
|
||||||
|
"identity_established_once_then_default_ui_enters_my_hololake": true,
|
||||||
|
"manual_task_title_and_purpose_form_exposed_by_default": false,
|
||||||
|
"every_identity_or_task_mutation_has_event_and_receipt": true,
|
||||||
|
"event_and_receipt_hash_chains_verified_on_read": true,
|
||||||
|
"installed_runtime_acceptance": true
|
||||||
|
},
|
||||||
|
"knowledge_workspace": {
|
||||||
|
"contract": "contracts/knowledge-workspace.json",
|
||||||
|
"native_git_owned_root_implemented": true,
|
||||||
|
"page_tree_read_search_and_idempotent_folder_import_implemented": true,
|
||||||
|
"outline_style_nested_navigation_and_independent_scrolling_implemented": true,
|
||||||
|
"gfm_wiki_links_and_optimistic_native_editing_implemented": true,
|
||||||
|
"exact_content_deduplication_implemented": true,
|
||||||
|
"legacy_hololake_era_source_is_read_only": true,
|
||||||
|
"tolaria_surface_used": false,
|
||||||
|
"installed_runtime_acceptance": true
|
||||||
|
},
|
||||||
|
"user_code_channel": {
|
||||||
|
"contract": "contracts/code-channel.json",
|
||||||
|
"official_https_clone_implemented": true,
|
||||||
|
"local_git_folder_registration_implemented": true,
|
||||||
|
"repository_tree_and_file_open_implemented": true,
|
||||||
|
"machine_format_knowledge_projection_implemented": true,
|
||||||
|
"credentials_in_url_allowed": false,
|
||||||
|
"credential_prompting_allowed": false,
|
||||||
|
"push_or_deploy_authority_granted": false,
|
||||||
|
"installed_runtime_acceptance": true
|
||||||
|
},
|
||||||
|
"local_installed_runtime_acceptance": {
|
||||||
|
"receipt": "audit/local-runtime-acceptance-20260815.json",
|
||||||
|
"identity_restart_readback": true,
|
||||||
|
"legacy_knowledge_read_only_projection": true,
|
||||||
|
"folder_import_search_and_restart_readback": true,
|
||||||
|
"official_https_code_channel_clone_and_restart_readback": true,
|
||||||
|
"knowledge_unique_count": 167,
|
||||||
|
"knowledge_duplicate_count_folded": 242,
|
||||||
|
"code_repository_tree_and_json_projection": true,
|
||||||
|
"direct_local_broker_ready": true,
|
||||||
|
"current_authenticated_external_ai_connections": 0,
|
||||||
|
"gh_pncc_repository_mounts": 0,
|
||||||
|
"gh_pncc_projection_receipts": 0,
|
||||||
|
"public_developer_id_and_notarization": false,
|
||||||
|
"server_deployment": false
|
||||||
|
},
|
||||||
"reality_mutation_requires": ["VERIFIED_HUMAN_SUBJECT", "EXACT_ACTION", "EXACT_TARGET", "IMMUTABLE_PAYLOAD_DIGEST", "EXPIRY", "REPLAY_PROTECTION", "EXECUTION_RECEIPT", "READBACK_RECEIPT"],
|
"reality_mutation_requires": ["VERIFIED_HUMAN_SUBJECT", "EXACT_ACTION", "EXACT_TARGET", "IMMUTABLE_PAYLOAD_DIGEST", "EXPIRY", "REPLAY_PROTECTION", "EXECUTION_RECEIPT", "READBACK_RECEIPT"],
|
||||||
"context_risk_signals": ["MODEL_CONTEXT_SPEC", "ESTIMATED_TOKENS", "MESSAGE_VOLUME", "TURN_COUNT", "TASK_STAGE", "HOST_WARNING"],
|
"context_risk_signals": ["MODEL_CONTEXT_SPEC", "ESTIMATED_TOKENS", "MESSAGE_VOLUME", "TURN_COUNT", "TASK_STAGE", "HOST_WARNING"],
|
||||||
"exact_host_compaction_prediction_claimed": false,
|
"exact_host_compaction_prediction_claimed": false,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,24 @@
|
||||||
# HoloLake Native Desktop Architecture
|
# HoloLake Native Desktop Architecture
|
||||||
|
|
||||||
The stage-one shell is a Tauri v2 application with a Rust-owned local core and a React human projection. The five domains and their server fleet remain submerged system infrastructure rather than primary stage-one navigation.
|
The stage-one shell is a Tauri v2 application with a Rust-owned local core and a React human projection. The default product surface is **My HoloLake**: overview, knowledge, user code channels, local receipts and system details. The five domains and their server fleet remain submerged system infrastructure rather than primary stage-one navigation.
|
||||||
|
|
||||||
## Stage-one human projection
|
## Stage-one human projection
|
||||||
|
|
||||||
The home surface follows the verified GHS-014 five-lakes visual grammar while applying the current stage-one correction: it exposes one personal-channel entry, not five domain gates. The five named lake themes are token groups only and cannot change layout, copy, routing or authority. The surface reads a native status projection; only an authenticated direct connector can illuminate the live-connected state. A stored resumable session remains visibly distinct from a live connection.
|
The home surface follows the verified GHS-014 five-lakes visual grammar while applying the current stage-one correction: it is a restrained operating-system workspace, not a slogan page or a five-domain gate. The five named lake themes are token groups only and cannot change layout, copy, routing or authority. Internal transport and release details live under system details rather than dominating the first screen.
|
||||||
|
|
||||||
|
The first visible body uses a Rust-owned SQLite kernel under the Tauri app-data directory. A human-confirmed local display name creates one stable local subject and channel exactly once. The internal task/event/receipt kernel remains available for structured agents, but manual task title and purpose fields are not part of the default human surface. Event and receipt chains remain independently SHA-256-linked and fully revalidated before every read or mutation. A local identity is not platform authentication and grants no repository, node, server or deployment authority.
|
||||||
|
|
||||||
|
## Native knowledge workspace
|
||||||
|
|
||||||
|
The native core owns a separate `knowledge-v1` Git root. It projects a bounded document tree, safe text reads, local search and native folder import into a reading canvas without rendering raw HTML. Folder import ignores symlinks, Git metadata, dependency directories and unsupported files, applies file-count and byte limits, then creates a local Git commit receipt.
|
||||||
|
|
||||||
|
The installed HoloLake Era 0.8 knowledge root is detected only as a separate read-only source. The new application does not write, migrate in place or merge it with the native root. Electron, Outline runtime code and Tolaria UI are not copied into the Tauri product.
|
||||||
|
|
||||||
|
## User code channels
|
||||||
|
|
||||||
|
A human may paste a registered Guanghu HTTPS code-channel address or select an existing local Git folder. HTTPS clone accepts no credentials, queries or fragments and disables terminal prompts, askpass and credential helpers. Clones are confined to an application-owned root; local registrations do not move the source folder. The registry stores a credential-free projection of path, branch and commit.
|
||||||
|
|
||||||
|
This product channel is distinct from the under-lake PNCC persona-evidence projection below. It grants local source access only and never grants push, publication, deployment or server authority.
|
||||||
|
|
||||||
## External programming AI entry
|
## External programming AI entry
|
||||||
|
|
||||||
|
|
@ -44,10 +58,10 @@ The Tauri source in this directory is the only future HoloLake desktop mainline.
|
||||||
|
|
||||||
The current candidate already has useful under-lake organs: the signed native shell, local broker and resumable session, authenticated read-only PNCC projection, human-confirmed repository mount, deterministic routing, and fail-closed update/rollback machinery. They remain in the architecture, but their existence does not satisfy the visible stage-one body.
|
The current candidate already has useful under-lake organs: the signed native shell, local broker and resumable session, authenticated read-only PNCC projection, human-confirmed repository mount, deterministic routing, and fail-closed update/rollback machinery. They remain in the architecture, but their existence does not satisfy the visible stage-one body.
|
||||||
|
|
||||||
The implementation order is now fixed:
|
The implementation order is fixed. The first two source slices are implemented and tested but have not received installed-runtime acceptance:
|
||||||
|
|
||||||
1. personal-channel identity, task, event and receipt kernel;
|
1. personal-channel identity, event and receipt kernel — source and tests implemented;
|
||||||
2. knowledge tree, page, search and local persistence;
|
2. native knowledge workspace and user code channel — source and tests implemented;
|
||||||
3. human approval center and Git evidence readback;
|
3. human approval center and Git evidence readback;
|
||||||
4. lake-lamp cross-session continuity projection;
|
4. lake-lamp cross-session continuity projection;
|
||||||
5. signed installation, opt-in update, rollback and reversible data migration.
|
5. signed installation, opt-in update, rollback and reversible data migration.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"schema": "hololake.native-desktop-foundation/v1",
|
"schema": "hololake.native-desktop-foundation/v1",
|
||||||
"record_id": "HLP-NATIVE-DESKTOP-FOUNDATION-001",
|
"record_id": "HLP-NATIVE-DESKTOP-FOUNDATION-001",
|
||||||
"state": "TAURI_CANDIDATE_FOUNDATION_REORIENTED_TO_STAGE_ONE_VISIBLE_BODY",
|
"state": "LOCAL_INSTALLED_ACCEPTANCE_PASSED_FOR_PERSONAL_KNOWLEDGE_AND_CODE_SLICES",
|
||||||
"canonical_shell": "TAURI_V2_RUST_REACT",
|
"canonical_shell": "TAURI_V2_RUST_REACT",
|
||||||
"candidate_role": "INSTALLED_LOCAL_ACCEPTANCE_CANDIDATE_NOT_SEPARATE_PRODUCT_LINE",
|
"candidate_role": "INSTALLED_LOCAL_ACCEPTANCE_CANDIDATE_NOT_SEPARATE_PRODUCT_LINE",
|
||||||
"parallel_product_line_created": false,
|
"parallel_product_line_created": false,
|
||||||
|
|
@ -13,9 +13,31 @@
|
||||||
"selected_visual_direction": "GHS-014_FIVE_LAKES_PERSONAL_CHANNEL_STAGE_ONE",
|
"selected_visual_direction": "GHS-014_FIVE_LAKES_PERSONAL_CHANNEL_STAGE_ONE",
|
||||||
"selected_visual_direction_receipt": "audit/ghs-014-stage-one-ui-receipt.json",
|
"selected_visual_direction_receipt": "audit/ghs-014-stage-one-ui-receipt.json",
|
||||||
"home_status_is_native_projection": true,
|
"home_status_is_native_projection": true,
|
||||||
"home_surface_role": "SELECTED_VISUAL_ACCEPTANCE_SURFACE_NOT_STAGE_ONE_PRODUCT_COMPLETION",
|
"home_surface_role": "MY_HOLOLAKE_PRODUCT_WORKSPACE_NOT_STAGE_ONE_PRODUCT_COMPLETION",
|
||||||
"stage_one_visible_body_state": "NOT_IMPLEMENTED",
|
"stage_one_visible_body_state": "PERSONAL_IDENTITY_KNOWLEDGE_WORKSPACE_AND_CODE_CHANNEL_SOURCE_IMPLEMENTED_PRODUCT_INCOMPLETE",
|
||||||
"stage_one_next_slice": "PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL",
|
"stage_one_completed_source_slice": "PERSONAL_IDENTITY_KNOWLEDGE_WORKSPACE_AND_CODE_CHANNEL",
|
||||||
|
"stage_one_next_slice": "HUMAN_APPROVAL_CENTER_AND_GIT_EVIDENCE_READBACK",
|
||||||
|
"personal_channel_kernel_contract": "contracts/personal-channel-kernel.json",
|
||||||
|
"personal_channel_identity_task_event_receipt_kernel_implemented": true,
|
||||||
|
"personal_channel_private_sqlite_single_owner_implemented": true,
|
||||||
|
"personal_channel_restart_readback_and_tamper_tests_passed": true,
|
||||||
|
"personal_channel_human_projection_integrated": true,
|
||||||
|
"personal_channel_default_task_form_exposed": false,
|
||||||
|
"personal_channel_installed_runtime_acceptance": true,
|
||||||
|
"knowledge_workspace_contract": "contracts/knowledge-workspace.json",
|
||||||
|
"knowledge_workspace_native_source_implemented": true,
|
||||||
|
"knowledge_workspace_tree_read_search_and_folder_import_implemented": true,
|
||||||
|
"knowledge_workspace_legacy_hololake_era_read_only_compatibility": true,
|
||||||
|
"knowledge_workspace_installed_runtime_acceptance": true,
|
||||||
|
"code_channel_contract": "contracts/code-channel.json",
|
||||||
|
"code_channel_https_clone_and_local_git_registration_implemented": true,
|
||||||
|
"code_channel_credential_prompting_disabled": true,
|
||||||
|
"code_channel_push_or_deploy_authority_granted": false,
|
||||||
|
"code_channel_installed_runtime_acceptance": true,
|
||||||
|
"local_runtime_acceptance_receipt": "audit/local-runtime-acceptance-20260815.json",
|
||||||
|
"installed_desktop_path": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake 第一阶段.app",
|
||||||
|
"old_desktop_apps_recoverably_archived": true,
|
||||||
|
"public_developer_id_and_notarized_acceptance": false,
|
||||||
"stage_one_implementation_order": [
|
"stage_one_implementation_order": [
|
||||||
"PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL",
|
"PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL",
|
||||||
"KNOWLEDGE_TREE_PAGE_SEARCH_AND_LOCAL_PERSISTENCE",
|
"KNOWLEDGE_TREE_PAGE_SEARCH_AND_LOCAL_PERSISTENCE",
|
||||||
|
|
@ -81,7 +103,7 @@
|
||||||
"pncc_execution_limb_implemented": false,
|
"pncc_execution_limb_implemented": false,
|
||||||
"pncc_persona_lease_acquisition_implemented": false,
|
"pncc_persona_lease_acquisition_implemented": false,
|
||||||
"pncc_reality_execution_implemented": false,
|
"pncc_reality_execution_implemented": false,
|
||||||
"stage_one_product_contract_state": "TCS_UNIVERSAL_LANGUAGE_PERSONAL_CHANNEL_CONTRACT_ONLY_RUNTIME_NOT_IMPLEMENTED",
|
"stage_one_product_contract_state": "PERSONAL_KNOWLEDGE_AND_CODE_SOURCE_SLICES_IMPLEMENTED_FULL_STAGE_ONE_PRODUCT_INCOMPLETE",
|
||||||
"stage_one_internal_ai_interaction": false,
|
"stage_one_internal_ai_interaction": false,
|
||||||
"stage_one_model_api_configuration": false,
|
"stage_one_model_api_configuration": false,
|
||||||
"vendor_adapter_matrix_required": false,
|
"vendor_adapter_matrix_required": false,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
"@tauri-apps/api": "2.10.1",
|
"@tauri-apps/api": "2.10.1",
|
||||||
"@tauri-apps/plugin-process": "2.3.1",
|
"@tauri-apps/plugin-process": "2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "2.10.0",
|
"@tauri-apps/plugin-updater": "2.10.0",
|
||||||
|
"dompurify": "^3.4.13",
|
||||||
|
"marked": "^16.4.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
|
|
@ -1514,6 +1516,13 @@
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
||||||
|
|
@ -1768,6 +1777,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.4.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||||
|
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.405",
|
"version": "1.5.405",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz",
|
||||||
|
|
@ -1950,6 +1968,18 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "16.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.1.tgz",
|
||||||
|
"integrity": "sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
"@tauri-apps/api": "2.10.1",
|
"@tauri-apps/api": "2.10.1",
|
||||||
"@tauri-apps/plugin-process": "2.3.1",
|
"@tauri-apps/plugin-process": "2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "2.10.0",
|
"@tauri-apps/plugin-updater": "2.10.0",
|
||||||
|
"dompurify": "^3.4.13",
|
||||||
|
"marked": "^16.4.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const read = (relative) => fs.readFileSync(new URL(`../${relative}`, import.meta.url), 'utf8')
|
||||||
|
const knowledgeContract = JSON.parse(read('contracts/knowledge-workspace.json'))
|
||||||
|
const codeContract = JSON.parse(read('contracts/code-channel.json'))
|
||||||
|
const provenance = JSON.parse(read('audit/knowledge-workspace-migration-provenance.json'))
|
||||||
|
const knowledgeRust = read('src-tauri/src/knowledge_base.rs')
|
||||||
|
const codeRust = read('src-tauri/src/code_channel.rs')
|
||||||
|
const lib = read('src-tauri/src/lib.rs')
|
||||||
|
const ui = read('src/main.tsx')
|
||||||
|
|
||||||
|
test('knowledge workspace is HoloLake-owned and keeps the legacy source read-only', () => {
|
||||||
|
assert.equal(knowledgeContract.native_storage.owner, 'HOLOLAKE_NATIVE_RUST_CORE')
|
||||||
|
assert.equal(knowledgeContract.legacy_compatibility.mode, 'READ_ONLY_SEPARATE_ROOT')
|
||||||
|
assert.equal(knowledgeContract.legacy_compatibility.in_place_migration, false)
|
||||||
|
assert.equal(knowledgeContract.legacy_compatibility.tolaria_surface_used, false)
|
||||||
|
assert.equal(provenance.legacy_data_boundary.modified, false)
|
||||||
|
assert.match(knowledgeRust, /join\("knowledge-v1"\)/)
|
||||||
|
assert.match(knowledgeRust, /source_docs_root/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('folder import is bounded, idempotent, conflict-safe and creates a local Git receipt', () => {
|
||||||
|
assert.equal(knowledgeContract.folder_import.maximum_files, 1000)
|
||||||
|
assert.equal(knowledgeContract.folder_import.symlinks_followed, false)
|
||||||
|
assert.equal(knowledgeContract.folder_import.repeated_import, 'MERGE_BY_STABLE_SOURCE_NAME')
|
||||||
|
assert.equal(knowledgeContract.folder_import.exact_duplicate_result, 'ALREADY_PRESENT')
|
||||||
|
assert.equal(knowledgeContract.folder_import.conflicting_file_overwrite, false)
|
||||||
|
assert.equal(knowledgeContract.capabilities.import_commit_receipt, true)
|
||||||
|
assert.equal(knowledgeContract.capabilities.exact_content_deduplication, true)
|
||||||
|
assert.equal(knowledgeContract.capabilities.optimistic_native_editing, true)
|
||||||
|
assert.match(knowledgeRust, /MAX_IMPORT_FILES: usize = 1_000/)
|
||||||
|
assert.match(knowledgeRust, /kind\.is_symlink\(\)/)
|
||||||
|
assert.match(knowledgeRust, /ImportDisposition::Existing/)
|
||||||
|
assert.match(knowledgeRust, /HOLOLAKE_KNOWLEDGE_SAVE_CONFLICT/)
|
||||||
|
assert.match(knowledgeRust, /rev-parse", "HEAD/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('official code-channel clone cannot use credentials or credential prompts', () => {
|
||||||
|
assert.equal(codeContract.clone.embedded_credentials_allowed, false)
|
||||||
|
assert.equal(codeContract.clone.terminal_prompt_allowed, false)
|
||||||
|
assert.equal(codeContract.clone.credential_helper_allowed, false)
|
||||||
|
assert.equal(codeContract.authority.automatic_push, false)
|
||||||
|
assert.match(codeRust, /GIT_TERMINAL_PROMPT/)
|
||||||
|
assert.match(codeRust, /credential\.helper=/)
|
||||||
|
assert.match(codeRust, /parsed\.username\(\)\.is_empty\(\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the product surface exposes a real knowledge workbench and browsable code channel', () => {
|
||||||
|
for (const command of [
|
||||||
|
'get_knowledge_snapshot',
|
||||||
|
'read_knowledge_document',
|
||||||
|
'search_knowledge',
|
||||||
|
'save_knowledge_document',
|
||||||
|
'select_and_import_knowledge_folder',
|
||||||
|
'get_code_channel_snapshot',
|
||||||
|
'clone_code_channel',
|
||||||
|
'select_local_code_channel',
|
||||||
|
'browse_code_channel',
|
||||||
|
'read_code_channel_file',
|
||||||
|
]) {
|
||||||
|
assert.match(lib, new RegExp(command))
|
||||||
|
assert.match(ui, new RegExp(`['"]${command}['"]`))
|
||||||
|
}
|
||||||
|
assert.match(ui, /知识工作台/)
|
||||||
|
assert.match(ui, /HoloLake Era/)
|
||||||
|
assert.match(ui, /知识视图/)
|
||||||
|
assert.match(ui, /源文件/)
|
||||||
|
assert.doesNotMatch(ui, /下一件事|为什么做|开始这件事/)
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const read = (relative) => fs.readFileSync(new URL(`../${relative}`, import.meta.url), 'utf8')
|
||||||
|
const contract = JSON.parse(read('contracts/personal-channel-kernel.json'))
|
||||||
|
const foundation = JSON.parse(read('foundation.json'))
|
||||||
|
const stageOne = JSON.parse(read('contracts/stage-one-platform.json'))
|
||||||
|
const rust = read('src-tauri/src/personal_channel.rs')
|
||||||
|
const lib = read('src-tauri/src/lib.rs')
|
||||||
|
const ui = read('src/main.tsx')
|
||||||
|
|
||||||
|
test('personal channel kernel has one private SQLite owner and no server authority', () => {
|
||||||
|
assert.equal(contract.storage.owner, 'HOLOLAKE_NATIVE_RUST_CORE')
|
||||||
|
assert.equal(contract.storage.engine, 'EMBEDDED_SQLITE')
|
||||||
|
assert.equal(contract.storage.webview_direct_database_access, false)
|
||||||
|
assert.equal(contract.identity.platform_account_authentication_claimed, false)
|
||||||
|
assert.equal(contract.identity.server_or_repository_authority_granted, false)
|
||||||
|
assert.equal(contract.acceptance.server_deployment, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('identity and task mutations create one-to-one chained events and receipts', () => {
|
||||||
|
assert.equal(contract.event_and_receipt.identity_and_task_mutations_are_single_sqlite_transactions, true)
|
||||||
|
assert.equal(contract.event_and_receipt.every_mutation_has_event, true)
|
||||||
|
assert.equal(contract.event_and_receipt.every_event_has_receipt, true)
|
||||||
|
assert.equal(contract.event_and_receipt.tampering_fails_closed, true)
|
||||||
|
assert.match(rust, /CREATE TABLE IF NOT EXISTS events/)
|
||||||
|
assert.match(rust, /CREATE TABLE IF NOT EXISTS receipts/)
|
||||||
|
assert.match(rust, /verify_integrity\(&connection\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the human projection establishes identity once and keeps task mutation below the default surface', () => {
|
||||||
|
for (const command of [
|
||||||
|
'get_personal_channel_snapshot',
|
||||||
|
'initialize_personal_channel',
|
||||||
|
'create_personal_channel_task',
|
||||||
|
'transition_personal_channel_task',
|
||||||
|
]) {
|
||||||
|
assert.match(lib, new RegExp(`personal_channel::${command}`))
|
||||||
|
}
|
||||||
|
for (const command of ['get_personal_channel_snapshot', 'initialize_personal_channel']) {
|
||||||
|
assert.match(ui, new RegExp(`['"]${command}['"]`))
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(ui, /['"]create_personal_channel_task['"]/)
|
||||||
|
assert.doesNotMatch(ui, /['"]transition_personal_channel_task['"]/)
|
||||||
|
assert.doesNotMatch(ui, /下一件事|为什么做|开始这件事/)
|
||||||
|
assert.equal(stageOne.personal_channel_kernel.native_source_implemented, true)
|
||||||
|
assert.equal(stageOne.personal_channel_kernel.installed_runtime_acceptance, true)
|
||||||
|
assert.equal(foundation.personal_channel_identity_task_event_receipt_kernel_implemented, true)
|
||||||
|
assert.equal(foundation.personal_channel_installed_runtime_acceptance, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('full stage-one completion stays false after the first source slice', () => {
|
||||||
|
assert.equal(stageOne.implementation_complete, false)
|
||||||
|
assert.equal(foundation.stage_one_next_slice, 'HUMAN_APPROVAL_CENTER_AND_GIT_EVIDENCE_READBACK')
|
||||||
|
assert.match(foundation.stage_one_visible_body_state, /PRODUCT_INCOMPLETE/)
|
||||||
|
})
|
||||||
|
|
@ -8,6 +8,18 @@ version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ahash"
|
||||||
|
version = "0.8.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"version_check",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
|
|
@ -811,6 +823,18 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|
@ -1319,12 +1343,30 @@ version = "0.12.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|
@ -1351,6 +1393,7 @@ dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"fs2",
|
"fs2",
|
||||||
"ring",
|
"ring",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
|
@ -1360,6 +1403,7 @@ dependencies = [
|
||||||
"tauri-runtime",
|
"tauri-runtime",
|
||||||
"tauri-runtime-wry",
|
"tauri-runtime-wry",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1901,6 +1945,17 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
|
|
@ -3027,6 +3082,20 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
|
|
@ -4570,6 +4639,12 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ tauri-build = { version = "2.5.4", features = [] }
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
fs2 = "0.4"
|
fs2 = "0.4"
|
||||||
ring = "0.17"
|
ring = "0.17"
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
@ -27,6 +28,7 @@ tauri-runtime-wry = "=2.10.0"
|
||||||
tauri-plugin-updater = "2.10.0"
|
tauri-plugin-updater = "2.10.0"
|
||||||
tauri-plugin-dialog = "=2.7.2"
|
tauri-plugin-dialog = "=2.7.2"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
url = "2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,929 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::cmp::Reverse;
|
||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
use url::Url;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const SNAPSHOT_SCHEMA: &str = "hololake.code-channel/v1";
|
||||||
|
const REGISTRY_SCHEMA: &str = "hololake.code-channel-registry/v1";
|
||||||
|
const ALLOWED_HOSTS: &[&str] = &["guanghulab.com", "guanghubingshuo.com"];
|
||||||
|
const MAX_TREE_ENTRIES: usize = 1_000;
|
||||||
|
const MAX_CODE_FILE_BYTES: u64 = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct CloneCodeChannelInput {
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CodeChannelEntry {
|
||||||
|
pub channel_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub source_kind: String,
|
||||||
|
pub local_path: String,
|
||||||
|
pub remote_url: Option<String>,
|
||||||
|
pub git_head: String,
|
||||||
|
pub branch: String,
|
||||||
|
pub repository_clean: bool,
|
||||||
|
pub registered_at_unix_ms: u128,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CodeChannelSnapshot {
|
||||||
|
pub schema: &'static str,
|
||||||
|
pub state: &'static str,
|
||||||
|
pub storage_root: String,
|
||||||
|
pub channels: Vec<CodeChannelEntry>,
|
||||||
|
pub authority: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct BrowseCodeChannelInput {
|
||||||
|
pub channel_id: String,
|
||||||
|
pub path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CodeTreeEntry {
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: &'static str,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CodeTreeSnapshot {
|
||||||
|
pub schema: &'static str,
|
||||||
|
pub channel_id: String,
|
||||||
|
pub path: String,
|
||||||
|
pub entries: Vec<CodeTreeEntry>,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct ReadCodeChannelFileInput {
|
||||||
|
pub channel_id: String,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CodeFileProjection {
|
||||||
|
pub schema: &'static str,
|
||||||
|
pub channel_id: String,
|
||||||
|
pub path: String,
|
||||||
|
pub format: String,
|
||||||
|
pub source: String,
|
||||||
|
pub human_markdown: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
struct CodeChannelRegistry {
|
||||||
|
schema: String,
|
||||||
|
channels: Vec<CodeChannelEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ValidatedCloneUrl {
|
||||||
|
normalized: String,
|
||||||
|
owner: String,
|
||||||
|
repository: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_code_channel_snapshot(app: AppHandle) -> Result<CodeChannelSnapshot, String> {
|
||||||
|
let root = code_channel_root(&app)?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || snapshot_at(&root))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clone_code_channel(
|
||||||
|
app: AppHandle,
|
||||||
|
input: CloneCodeChannelInput,
|
||||||
|
) -> Result<CodeChannelSnapshot, String> {
|
||||||
|
let root = code_channel_root(&app)?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || clone_at(&root, input))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn select_local_code_channel(
|
||||||
|
app: AppHandle,
|
||||||
|
) -> Result<Option<CodeChannelSnapshot>, String> {
|
||||||
|
let picker = app.clone();
|
||||||
|
let selected = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
picker
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_title("选择本地 Git 代码频道")
|
||||||
|
.blocking_pick_folder()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PICKER_JOIN_FAILED: {error}"))?;
|
||||||
|
let Some(selected) = selected else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let selected = selected
|
||||||
|
.into_path()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PICKER_PATH_INVALID: {error}"))?;
|
||||||
|
let root = code_channel_root(&app)?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || register_local_at(&root, &selected).map(Some))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn browse_code_channel(
|
||||||
|
app: AppHandle,
|
||||||
|
input: BrowseCodeChannelInput,
|
||||||
|
) -> Result<CodeTreeSnapshot, String> {
|
||||||
|
let root = code_channel_root(&app)?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || browse_at(&root, input))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_code_channel_file(
|
||||||
|
app: AppHandle,
|
||||||
|
input: ReadCodeChannelFileInput,
|
||||||
|
) -> Result<CodeFileProjection, String> {
|
||||||
|
let root = code_channel_root(&app)?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || read_code_file_at(&root, input))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code_channel_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let root = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?
|
||||||
|
.join("code-channel-v1");
|
||||||
|
ensure_root(&root)?;
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_root(root: &Path) -> Result<(), String> {
|
||||||
|
fs::create_dir_all(root.join("repositories"))
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_STORAGE_UNAVAILABLE: {error}"))?;
|
||||||
|
fs::set_permissions(root, fs::Permissions::from_mode(0o700))
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PERMISSION_FAILED: {error}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_at(root: &Path) -> Result<CodeChannelSnapshot, String> {
|
||||||
|
ensure_root(root)?;
|
||||||
|
let mut registry = read_registry(root)?;
|
||||||
|
for channel in &mut registry.channels {
|
||||||
|
if let Ok(inspection) = inspect_repository(Path::new(&channel.local_path)) {
|
||||||
|
channel.git_head = inspection.git_head;
|
||||||
|
channel.branch = inspection.branch;
|
||||||
|
channel.repository_clean = inspection.repository_clean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registry
|
||||||
|
.channels
|
||||||
|
.sort_by_key(|entry| Reverse(entry.registered_at_unix_ms));
|
||||||
|
Ok(CodeChannelSnapshot {
|
||||||
|
schema: SNAPSHOT_SCHEMA,
|
||||||
|
state: "READY",
|
||||||
|
storage_root: root.to_string_lossy().into_owned(),
|
||||||
|
channels: registry.channels,
|
||||||
|
authority: "LOCAL_SOURCE_ACCESS_ONLY_NO_PUSH_OR_DEPLOY_AUTHORITY",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn browse_at(root: &Path, input: BrowseCodeChannelInput) -> Result<CodeTreeSnapshot, String> {
|
||||||
|
let (channel, repository) = registered_repository(root, &input.channel_id)?;
|
||||||
|
let relative = input.path.unwrap_or_default();
|
||||||
|
let directory = safe_repository_path(&repository, &relative)?;
|
||||||
|
if !directory.is_dir() {
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_DIRECTORY_INVALID".into());
|
||||||
|
}
|
||||||
|
let mut entries = fs::read_dir(&directory)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?;
|
||||||
|
entries.sort_by_key(|entry| {
|
||||||
|
let is_file = entry.file_type().map(|kind| kind.is_file()).unwrap_or(true);
|
||||||
|
(is_file, entry.file_name())
|
||||||
|
});
|
||||||
|
let mut projected = Vec::new();
|
||||||
|
for entry in entries {
|
||||||
|
if projected.len() >= MAX_TREE_ENTRIES {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
if name == ".git" || name == ".DS_Store" || name.starts_with("._") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let kind = entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?;
|
||||||
|
if kind.is_symlink() || (!kind.is_dir() && !kind.is_file()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entry_path = entry.path();
|
||||||
|
let relative_path = relative_posix(&repository, &entry_path)?;
|
||||||
|
let size_bytes = if kind.is_file() {
|
||||||
|
entry
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?
|
||||||
|
.len()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
projected.push(CodeTreeEntry {
|
||||||
|
path: relative_path,
|
||||||
|
name,
|
||||||
|
kind: if kind.is_dir() { "directory" } else { "file" },
|
||||||
|
size_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(CodeTreeSnapshot {
|
||||||
|
schema: "hololake.code-channel-tree/v1",
|
||||||
|
channel_id: channel.channel_id,
|
||||||
|
path: relative,
|
||||||
|
truncated: projected.len() >= MAX_TREE_ENTRIES,
|
||||||
|
entries: projected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_code_file_at(
|
||||||
|
root: &Path,
|
||||||
|
input: ReadCodeChannelFileInput,
|
||||||
|
) -> Result<CodeFileProjection, String> {
|
||||||
|
let (channel, repository) = registered_repository(root, &input.channel_id)?;
|
||||||
|
let file = safe_repository_path(&repository, &input.path)?;
|
||||||
|
let metadata = fs::metadata(&file)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_FILE_UNAVAILABLE: {error}"))?;
|
||||||
|
if !metadata.is_file() || metadata.len() > MAX_CODE_FILE_BYTES || !is_supported_text_file(&file)
|
||||||
|
{
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_FILE_UNSUPPORTED".into());
|
||||||
|
}
|
||||||
|
let source = fs::read_to_string(&file)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_FILE_INVALID_UTF8: {error}"))?;
|
||||||
|
let format = file_format(&file);
|
||||||
|
let human_markdown = human_projection(&input.path, &format, &source);
|
||||||
|
Ok(CodeFileProjection {
|
||||||
|
schema: "hololake.code-channel-file-projection/v1",
|
||||||
|
channel_id: channel.channel_id,
|
||||||
|
path: input.path,
|
||||||
|
format,
|
||||||
|
source,
|
||||||
|
human_markdown,
|
||||||
|
size_bytes: metadata.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registered_repository(
|
||||||
|
root: &Path,
|
||||||
|
channel_id: &str,
|
||||||
|
) -> Result<(CodeChannelEntry, PathBuf), String> {
|
||||||
|
let registry = read_registry(root)?;
|
||||||
|
let channel = registry
|
||||||
|
.channels
|
||||||
|
.into_iter()
|
||||||
|
.find(|entry| entry.channel_id == channel_id)
|
||||||
|
.ok_or("HOLOLAKE_CODE_CHANNEL_NOT_REGISTERED")?;
|
||||||
|
let repository = PathBuf::from(&channel.local_path)
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REPOSITORY_UNAVAILABLE: {error}"))?;
|
||||||
|
if !repository.is_dir() {
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_REPOSITORY_UNAVAILABLE".into());
|
||||||
|
}
|
||||||
|
Ok((channel, repository))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_repository_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
|
||||||
|
let relative_path = Path::new(relative);
|
||||||
|
if relative_path.is_absolute()
|
||||||
|
|| relative_path
|
||||||
|
.components()
|
||||||
|
.any(|component| !matches!(component, Component::Normal(_)))
|
||||||
|
{
|
||||||
|
if relative.is_empty() {
|
||||||
|
return Ok(root.to_path_buf());
|
||||||
|
}
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_PATH_INVALID".into());
|
||||||
|
}
|
||||||
|
if relative.is_empty() {
|
||||||
|
return Ok(root.to_path_buf());
|
||||||
|
}
|
||||||
|
let candidate = root.join(relative_path);
|
||||||
|
let metadata = fs::symlink_metadata(&candidate)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PATH_UNAVAILABLE: {error}"))?;
|
||||||
|
if metadata.file_type().is_symlink() {
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_SYMLINK_DENIED".into());
|
||||||
|
}
|
||||||
|
let canonical = candidate
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PATH_UNAVAILABLE: {error}"))?;
|
||||||
|
if !canonical.starts_with(root) {
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_PATH_ESCAPE".into());
|
||||||
|
}
|
||||||
|
Ok(canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_posix(root: &Path, path: &Path) -> Result<String, String> {
|
||||||
|
Ok(path
|
||||||
|
.strip_prefix(root)
|
||||||
|
.map_err(|_| "HOLOLAKE_CODE_CHANNEL_PATH_INVALID".to_string())?
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_supported_text_file(path: &Path) -> bool {
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
if ["README", "LICENSE", "Dockerfile", "Makefile", "Procfile"].contains(&name) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
matches!(
|
||||||
|
path.extension()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str(),
|
||||||
|
"md" | "markdown"
|
||||||
|
| "txt"
|
||||||
|
| "json"
|
||||||
|
| "jsonl"
|
||||||
|
| "yaml"
|
||||||
|
| "yml"
|
||||||
|
| "toml"
|
||||||
|
| "hdlp"
|
||||||
|
| "rs"
|
||||||
|
| "ts"
|
||||||
|
| "tsx"
|
||||||
|
| "js"
|
||||||
|
| "jsx"
|
||||||
|
| "mjs"
|
||||||
|
| "cjs"
|
||||||
|
| "py"
|
||||||
|
| "sh"
|
||||||
|
| "zsh"
|
||||||
|
| "bash"
|
||||||
|
| "css"
|
||||||
|
| "scss"
|
||||||
|
| "html"
|
||||||
|
| "xml"
|
||||||
|
| "sql"
|
||||||
|
| "graphql"
|
||||||
|
| "go"
|
||||||
|
| "java"
|
||||||
|
| "kt"
|
||||||
|
| "swift"
|
||||||
|
| "c"
|
||||||
|
| "h"
|
||||||
|
| "cpp"
|
||||||
|
| "hpp"
|
||||||
|
| "proto"
|
||||||
|
| "ini"
|
||||||
|
| "conf"
|
||||||
|
| "env"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_format(path: &Path) -> String {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("text")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn human_projection(path: &str, format: &str, source: &str) -> String {
|
||||||
|
if matches!(format, "md" | "markdown") {
|
||||||
|
return source.to_string();
|
||||||
|
}
|
||||||
|
let title = Path::new(path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("代码文件");
|
||||||
|
if format == "json" {
|
||||||
|
if let Ok(value) = serde_json::from_str::<serde_json::Value>(source) {
|
||||||
|
return format!(
|
||||||
|
"# {title}\n\n> 来自代码频道的 JSON 结构,已转换为可读层级。\n\n{}",
|
||||||
|
json_as_markdown(&value, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches!(format, "hdlp" | "yaml" | "yml" | "toml" | "ini" | "conf") {
|
||||||
|
let rows = source
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
trimmed
|
||||||
|
.split_once(':')
|
||||||
|
.or_else(|| trimmed.split_once('='))
|
||||||
|
.map(|(key, value)| format!("| {} | {} |", key.trim(), value.trim()))
|
||||||
|
})
|
||||||
|
.take(120)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !rows.is_empty() {
|
||||||
|
return format!(
|
||||||
|
"# {title}\n\n> 来自代码频道的结构化文件。原始机器字段已转成阅读表格。\n\n| 字段 | 内容 |\n| --- | --- |\n{}",
|
||||||
|
rows.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"# {title}\n\n> 来自代码频道的 `{format}` 文件。下方保留原始内容,便于核验。\n\n```{format}\n{source}\n```"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_as_markdown(value: &serde_json::Value, level: usize) -> String {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Object(map) => map
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| match value {
|
||||||
|
serde_json::Value::Object(_) | serde_json::Value::Array(_) => format!(
|
||||||
|
"{} {key}\n\n{}",
|
||||||
|
"#".repeat(level.min(5)),
|
||||||
|
json_as_markdown(value, level + 1)
|
||||||
|
),
|
||||||
|
_ => format!("- **{key}**:{}", scalar_json(value)),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n"),
|
||||||
|
serde_json::Value::Array(values) => values
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, value)| match value {
|
||||||
|
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
|
||||||
|
format!("{}. {}", index + 1, json_as_markdown(value, level + 1))
|
||||||
|
}
|
||||||
|
_ => format!("- {}", scalar_json(value)),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n"),
|
||||||
|
_ => scalar_json(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scalar_json(value: &serde_json::Value) -> String {
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.unwrap_or_else(|| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_at(root: &Path, input: CloneCodeChannelInput) -> Result<CodeChannelSnapshot, String> {
|
||||||
|
ensure_root(root)?;
|
||||||
|
let validated = validate_clone_url(&input.url)?;
|
||||||
|
let destination = root.join("repositories").join(format!(
|
||||||
|
"{}--{}",
|
||||||
|
safe_segment(&validated.owner),
|
||||||
|
safe_segment(&validated.repository)
|
||||||
|
));
|
||||||
|
if destination.exists() {
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_DESTINATION_EXISTS".into());
|
||||||
|
}
|
||||||
|
let output = Command::new("/usr/bin/git")
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.env("GIT_ASKPASS", "/usr/bin/false")
|
||||||
|
.env("SSH_ASKPASS", "/usr/bin/false")
|
||||||
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
.args([
|
||||||
|
"-c",
|
||||||
|
"credential.helper=",
|
||||||
|
"-c",
|
||||||
|
"core.askPass=",
|
||||||
|
"clone",
|
||||||
|
"--origin",
|
||||||
|
"origin",
|
||||||
|
"--no-tags",
|
||||||
|
])
|
||||||
|
.arg(&validated.normalized)
|
||||||
|
.arg(&destination)
|
||||||
|
.output()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_CLONE_FAILED: {error}"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
if destination.starts_with(root.join("repositories")) {
|
||||||
|
let _ = fs::remove_dir_all(&destination);
|
||||||
|
}
|
||||||
|
return Err(format!(
|
||||||
|
"HOLOLAKE_CODE_CHANNEL_CLONE_FAILED: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let entry = entry_for_repository(
|
||||||
|
&destination,
|
||||||
|
"CLONED_HTTPS",
|
||||||
|
Some(validated.normalized),
|
||||||
|
Some(format!("{}/{}", validated.owner, validated.repository)),
|
||||||
|
)?;
|
||||||
|
upsert_entry(root, entry)?;
|
||||||
|
snapshot_at(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_local_at(root: &Path, selected: &Path) -> Result<CodeChannelSnapshot, String> {
|
||||||
|
ensure_root(root)?;
|
||||||
|
let canonical = selected
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_LOCAL_UNAVAILABLE: {error}"))?;
|
||||||
|
if !canonical.is_dir() {
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_LOCAL_INVALID".into());
|
||||||
|
}
|
||||||
|
let remote = git_optional(&canonical, &["remote", "get-url", "origin"])
|
||||||
|
.and_then(|value| redact_remote_url(value.trim()));
|
||||||
|
let entry = entry_for_repository(&canonical, "LOCAL_GIT", remote, None)?;
|
||||||
|
upsert_entry(root, entry)?;
|
||||||
|
snapshot_at(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RepositoryInspection {
|
||||||
|
git_head: String,
|
||||||
|
branch: String,
|
||||||
|
repository_clean: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_repository(path: &Path) -> Result<RepositoryInspection, String> {
|
||||||
|
let inside = git(path, &["rev-parse", "--is-inside-work-tree"], "INSPECT")?;
|
||||||
|
if inside.trim() != "true" {
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_NOT_GIT".into());
|
||||||
|
}
|
||||||
|
let git_head = git(path, &["rev-parse", "HEAD"], "READ_HEAD")?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
let branch = git_optional(path, &["symbolic-ref", "--short", "HEAD"])
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| "DETACHED".into());
|
||||||
|
let repository_clean = git(path, &["status", "--porcelain"], "READ_STATUS")?
|
||||||
|
.trim()
|
||||||
|
.is_empty();
|
||||||
|
Ok(RepositoryInspection {
|
||||||
|
git_head,
|
||||||
|
branch,
|
||||||
|
repository_clean,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_for_repository(
|
||||||
|
path: &Path,
|
||||||
|
source_kind: &str,
|
||||||
|
remote_url: Option<String>,
|
||||||
|
explicit_name: Option<String>,
|
||||||
|
) -> Result<CodeChannelEntry, String> {
|
||||||
|
let inspection = inspect_repository(path)?;
|
||||||
|
let name = explicit_name.unwrap_or_else(|| {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("代码频道")
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
Ok(CodeChannelEntry {
|
||||||
|
channel_id: format!("channel-{}", Uuid::new_v4()),
|
||||||
|
name,
|
||||||
|
source_kind: source_kind.into(),
|
||||||
|
local_path: path.to_string_lossy().into_owned(),
|
||||||
|
remote_url,
|
||||||
|
git_head: inspection.git_head,
|
||||||
|
branch: inspection.branch,
|
||||||
|
repository_clean: inspection.repository_clean,
|
||||||
|
registered_at_unix_ms: now_unix_ms()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_clone_url(raw: &str) -> Result<ValidatedCloneUrl, String> {
|
||||||
|
let raw = raw.trim();
|
||||||
|
if raw.len() > 2_048 {
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into());
|
||||||
|
}
|
||||||
|
let mut parsed = Url::parse(raw).map_err(|_| "HOLOLAKE_CODE_CHANNEL_URL_INVALID")?;
|
||||||
|
if parsed.scheme() != "https"
|
||||||
|
|| !parsed.username().is_empty()
|
||||||
|
|| parsed.password().is_some()
|
||||||
|
|| parsed.query().is_some()
|
||||||
|
|| parsed.fragment().is_some()
|
||||||
|
|| parsed.port().is_some()
|
||||||
|
{
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into());
|
||||||
|
}
|
||||||
|
let host = parsed
|
||||||
|
.host_str()
|
||||||
|
.ok_or("HOLOLAKE_CODE_CHANNEL_URL_INVALID")?
|
||||||
|
.to_lowercase();
|
||||||
|
if !ALLOWED_HOSTS.contains(&host.as_str()) {
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_HOST_NOT_TRUSTED".into());
|
||||||
|
}
|
||||||
|
let segments = parsed
|
||||||
|
.path_segments()
|
||||||
|
.ok_or("HOLOLAKE_CODE_CHANNEL_URL_INVALID")?
|
||||||
|
.filter(|segment| !segment.is_empty())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if segments.len() != 3 || segments[0] != "code" {
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into());
|
||||||
|
}
|
||||||
|
let owner = segments[1].to_string();
|
||||||
|
let repository = segments[2]
|
||||||
|
.strip_suffix(".git")
|
||||||
|
.unwrap_or(segments[2])
|
||||||
|
.to_string();
|
||||||
|
if !valid_git_segment(&owner) || !valid_git_segment(&repository) {
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into());
|
||||||
|
}
|
||||||
|
parsed.set_path(&format!("/code/{owner}/{repository}.git"));
|
||||||
|
Ok(ValidatedCloneUrl {
|
||||||
|
normalized: parsed.to_string(),
|
||||||
|
owner,
|
||||||
|
repository,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_git_segment(value: &str) -> bool {
|
||||||
|
!value.is_empty()
|
||||||
|
&& value.len() <= 100
|
||||||
|
&& value != "."
|
||||||
|
&& value != ".."
|
||||||
|
&& value
|
||||||
|
.chars()
|
||||||
|
.all(|character| character.is_ascii_alphanumeric() || "-_.".contains(character))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redact_remote_url(raw: &str) -> Option<String> {
|
||||||
|
let mut parsed = Url::parse(raw).ok()?;
|
||||||
|
if parsed.scheme() != "https" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
parsed.set_username("").ok()?;
|
||||||
|
parsed.set_password(None).ok()?;
|
||||||
|
parsed.set_query(None);
|
||||||
|
parsed.set_fragment(None);
|
||||||
|
Some(parsed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_segment(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.map(|character| {
|
||||||
|
if character.is_ascii_alphanumeric() || "-_.".contains(character) {
|
||||||
|
character
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_entry(root: &Path, entry: CodeChannelEntry) -> Result<(), String> {
|
||||||
|
let mut registry = read_registry(root)?;
|
||||||
|
registry
|
||||||
|
.channels
|
||||||
|
.retain(|existing| existing.local_path != entry.local_path);
|
||||||
|
registry.channels.push(entry);
|
||||||
|
write_registry(root, ®istry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_registry(root: &Path) -> Result<CodeChannelRegistry, String> {
|
||||||
|
let path = root.join("channels.json");
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(CodeChannelRegistry {
|
||||||
|
schema: REGISTRY_SCHEMA.into(),
|
||||||
|
channels: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let bytes = fs::read(&path)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_UNAVAILABLE: {error}"))?;
|
||||||
|
let registry: CodeChannelRegistry = serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_INVALID: {error}"))?;
|
||||||
|
if registry.schema != REGISTRY_SCHEMA {
|
||||||
|
return Err("HOLOLAKE_CODE_CHANNEL_REGISTRY_SCHEMA_INVALID".into());
|
||||||
|
}
|
||||||
|
Ok(registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_registry(root: &Path, registry: &CodeChannelRegistry) -> Result<(), String> {
|
||||||
|
let bytes = serde_json::to_vec_pretty(registry)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_INVALID: {error}"))?;
|
||||||
|
let temporary = root.join(format!("channels.json.tmp-{}", Uuid::new_v4()));
|
||||||
|
let final_path = root.join("channels.json");
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&temporary)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?;
|
||||||
|
file.write_all(&bytes)
|
||||||
|
.and_then(|_| file.sync_all())
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?;
|
||||||
|
fs::rename(&temporary, &final_path)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git(root: &Path, args: &[&str], operation: &str) -> Result<String, String> {
|
||||||
|
let output = Command::new("/usr/bin/git")
|
||||||
|
.current_dir(root)
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_GIT_{operation}_FAILED: {error}"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"HOLOLAKE_CODE_CHANNEL_GIT_{operation}_FAILED: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
String::from_utf8(output.stdout)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_GIT_{operation}_INVALID_UTF8: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_optional(root: &Path, args: &[&str]) -> Option<String> {
|
||||||
|
let output = Command::new("/usr/bin/git")
|
||||||
|
.current_dir(root)
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
output
|
||||||
|
.status
|
||||||
|
.success()
|
||||||
|
.then(|| String::from_utf8_lossy(&output.stdout).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix_ms() -> Result<u128, String> {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis())
|
||||||
|
.map_err(|error| format!("HOLOLAKE_CLOCK_INVALID: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clone_url_is_normalized_to_official_https_git_endpoint() {
|
||||||
|
let parsed =
|
||||||
|
validate_clone_url("https://guanghulab.com/code/bingshuo/guanghu-ice-heart").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
parsed.normalized,
|
||||||
|
"https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git"
|
||||||
|
);
|
||||||
|
assert_eq!(parsed.owner, "bingshuo");
|
||||||
|
assert_eq!(parsed.repository, "guanghu-ice-heart");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clone_url_rejects_credentials_queries_and_unknown_hosts() {
|
||||||
|
assert!(
|
||||||
|
validate_clone_url("https://user:secret@guanghulab.com/code/bingshuo/repo").is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validate_clone_url("https://guanghulab.com/code/bingshuo/repo?token=secret").is_err()
|
||||||
|
);
|
||||||
|
assert!(validate_clone_url("https://example.com/code/bingshuo/repo").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_redaction_removes_credentials_and_fragments() {
|
||||||
|
assert_eq!(
|
||||||
|
redact_remote_url("https://user:secret@example.com/a/b.git?x=1#readme"),
|
||||||
|
Some("https://example.com/a/b.git".into())
|
||||||
|
);
|
||||||
|
assert_eq!(redact_remote_url("git@example.com:a/b.git"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_repository_registration_is_persistent_and_deduplicated() {
|
||||||
|
let root = tempdir().unwrap();
|
||||||
|
let repository = tempdir().unwrap();
|
||||||
|
ensure_root(root.path()).unwrap();
|
||||||
|
git(
|
||||||
|
repository.path(),
|
||||||
|
&["init", "--initial-branch=main"],
|
||||||
|
"TEST_INIT",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
git(
|
||||||
|
repository.path(),
|
||||||
|
&["config", "user.name", "Test"],
|
||||||
|
"TEST_CONFIG",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
git(
|
||||||
|
repository.path(),
|
||||||
|
&["config", "user.email", "test@example.invalid"],
|
||||||
|
"TEST_CONFIG",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(repository.path().join("README.md"), "# test\n").unwrap();
|
||||||
|
git(repository.path(), &["add", "README.md"], "TEST_ADD").unwrap();
|
||||||
|
git(
|
||||||
|
repository.path(),
|
||||||
|
&["commit", "-m", "initial"],
|
||||||
|
"TEST_COMMIT",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
register_local_at(root.path(), repository.path()).unwrap();
|
||||||
|
register_local_at(root.path(), repository.path()).unwrap();
|
||||||
|
let snapshot = snapshot_at(root.path()).unwrap();
|
||||||
|
assert_eq!(snapshot.channels.len(), 1);
|
||||||
|
assert_eq!(snapshot.channels[0].branch, "main");
|
||||||
|
assert!(snapshot.channels[0].repository_clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registered_repository_can_be_browsed_and_projected_as_knowledge() {
|
||||||
|
let root = tempdir().unwrap();
|
||||||
|
let repository = tempdir().unwrap();
|
||||||
|
ensure_root(root.path()).unwrap();
|
||||||
|
git(
|
||||||
|
repository.path(),
|
||||||
|
&["init", "--initial-branch=main"],
|
||||||
|
"TEST_INIT",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
git(
|
||||||
|
repository.path(),
|
||||||
|
&["config", "user.name", "Test"],
|
||||||
|
"TEST_CONFIG",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
git(
|
||||||
|
repository.path(),
|
||||||
|
&["config", "user.email", "test@example.invalid"],
|
||||||
|
"TEST_CONFIG",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::create_dir(repository.path().join("config")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
repository.path().join("config/system.json"),
|
||||||
|
r#"{"name":"光湖","state":"ready","routes":["native","mcp"]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
git(repository.path(), &["add", "."], "TEST_ADD").unwrap();
|
||||||
|
git(
|
||||||
|
repository.path(),
|
||||||
|
&["commit", "-m", "initial"],
|
||||||
|
"TEST_COMMIT",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let snapshot = register_local_at(root.path(), repository.path()).unwrap();
|
||||||
|
let channel_id = snapshot.channels[0].channel_id.clone();
|
||||||
|
let tree = browse_at(
|
||||||
|
root.path(),
|
||||||
|
BrowseCodeChannelInput {
|
||||||
|
channel_id: channel_id.clone(),
|
||||||
|
path: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(tree
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.name == "config" && entry.kind == "directory"));
|
||||||
|
|
||||||
|
let projection = read_code_file_at(
|
||||||
|
root.path(),
|
||||||
|
ReadCodeChannelFileInput {
|
||||||
|
channel_id,
|
||||||
|
path: "config/system.json".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(projection.format, "json");
|
||||||
|
assert!(projection
|
||||||
|
.human_markdown
|
||||||
|
.contains("来自代码频道的 JSON 结构"));
|
||||||
|
assert!(projection.human_markdown.contains("**state**:ready"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code_tree_denies_parent_escape() {
|
||||||
|
let repository = tempdir().unwrap();
|
||||||
|
assert!(safe_repository_path(repository.path(), "../secret").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,11 @@
|
||||||
|
mod code_channel;
|
||||||
mod direct_local_broker;
|
mod direct_local_broker;
|
||||||
mod direct_local_session;
|
mod direct_local_session;
|
||||||
mod dynamic_capability_routing;
|
mod dynamic_capability_routing;
|
||||||
mod home_status;
|
mod home_status;
|
||||||
|
mod knowledge_base;
|
||||||
mod local_development_bridge;
|
mod local_development_bridge;
|
||||||
|
mod personal_channel;
|
||||||
mod pncc_receipt_projection;
|
mod pncc_receipt_projection;
|
||||||
mod pncc_remote_git;
|
mod pncc_remote_git;
|
||||||
mod pncc_repository_binding;
|
mod pncc_repository_binding;
|
||||||
|
|
@ -33,6 +36,20 @@ pub fn run() {
|
||||||
local_development_bridge::acquire_development_write_lane,
|
local_development_bridge::acquire_development_write_lane,
|
||||||
local_development_bridge::inspect_development_write_lane,
|
local_development_bridge::inspect_development_write_lane,
|
||||||
local_development_bridge::release_development_write_lane,
|
local_development_bridge::release_development_write_lane,
|
||||||
|
personal_channel::get_personal_channel_snapshot,
|
||||||
|
personal_channel::initialize_personal_channel,
|
||||||
|
personal_channel::create_personal_channel_task,
|
||||||
|
personal_channel::transition_personal_channel_task,
|
||||||
|
knowledge_base::get_knowledge_snapshot,
|
||||||
|
knowledge_base::read_knowledge_document,
|
||||||
|
knowledge_base::search_knowledge,
|
||||||
|
knowledge_base::save_knowledge_document,
|
||||||
|
knowledge_base::select_and_import_knowledge_folder,
|
||||||
|
code_channel::get_code_channel_snapshot,
|
||||||
|
code_channel::clone_code_channel,
|
||||||
|
code_channel::select_local_code_channel,
|
||||||
|
code_channel::browse_code_channel,
|
||||||
|
code_channel::read_code_channel_file,
|
||||||
pncc_repository_binding::inspect_mounted_pncc_repository,
|
pncc_repository_binding::inspect_mounted_pncc_repository,
|
||||||
pncc_repository_binding::select_pncc_repository_candidate,
|
pncc_repository_binding::select_pncc_repository_candidate,
|
||||||
pncc_repository_binding::confirm_pncc_repository_mount,
|
pncc_repository_binding::confirm_pncc_repository_mount,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,853 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
use ring::digest::{digest, SHA256};
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const KERNEL_SCHEMA: &str = "hololake.personal-channel-kernel/v1";
|
||||||
|
const DATABASE_SCHEMA_VERSION: i64 = 1;
|
||||||
|
const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InitializePersonalChannelInput {
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreatePersonalChannelTaskInput {
|
||||||
|
pub title: String,
|
||||||
|
pub purpose: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TransitionPersonalChannelTaskInput {
|
||||||
|
pub task_id: String,
|
||||||
|
pub expected_status: String,
|
||||||
|
pub next_status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PersonalChannelIdentity {
|
||||||
|
pub human_subject_id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub channel_id: String,
|
||||||
|
pub created_at_unix_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PersonalChannelTask {
|
||||||
|
pub task_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub purpose: String,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at_unix_ms: i64,
|
||||||
|
pub updated_at_unix_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PersonalChannelEventProjection {
|
||||||
|
pub sequence: i64,
|
||||||
|
pub event_id: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub task_id: Option<String>,
|
||||||
|
pub summary: String,
|
||||||
|
pub occurred_at_unix_ms: i64,
|
||||||
|
pub event_hash: String,
|
||||||
|
pub receipt_id: String,
|
||||||
|
pub receipt_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PersonalChannelIntegrity {
|
||||||
|
pub state: &'static str,
|
||||||
|
pub schema_version: i64,
|
||||||
|
pub event_count: i64,
|
||||||
|
pub receipt_count: i64,
|
||||||
|
pub last_event_hash: String,
|
||||||
|
pub last_receipt_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PersonalChannelSnapshot {
|
||||||
|
pub schema: &'static str,
|
||||||
|
pub state: &'static str,
|
||||||
|
pub identity: Option<PersonalChannelIdentity>,
|
||||||
|
pub current_task: Option<PersonalChannelTask>,
|
||||||
|
pub recent_events: Vec<PersonalChannelEventProjection>,
|
||||||
|
pub integrity: PersonalChannelIntegrity,
|
||||||
|
pub storage: &'static str,
|
||||||
|
pub authority: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct EventHashPayload<'a> {
|
||||||
|
schema: &'static str,
|
||||||
|
sequence: i64,
|
||||||
|
event_id: &'a str,
|
||||||
|
human_subject_id: &'a str,
|
||||||
|
kind: &'a str,
|
||||||
|
task_id: Option<&'a str>,
|
||||||
|
summary: &'a str,
|
||||||
|
occurred_at_unix_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_personal_channel_snapshot(
|
||||||
|
app: AppHandle,
|
||||||
|
) -> Result<PersonalChannelSnapshot, String> {
|
||||||
|
let database = personal_channel_database(&app)?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || snapshot_at(&database))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn initialize_personal_channel(
|
||||||
|
app: AppHandle,
|
||||||
|
input: InitializePersonalChannelInput,
|
||||||
|
) -> Result<PersonalChannelSnapshot, String> {
|
||||||
|
let database = personal_channel_database(&app)?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || initialize_at(&database, input))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_personal_channel_task(
|
||||||
|
app: AppHandle,
|
||||||
|
input: CreatePersonalChannelTaskInput,
|
||||||
|
) -> Result<PersonalChannelSnapshot, String> {
|
||||||
|
let database = personal_channel_database(&app)?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || create_task_at(&database, input))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn transition_personal_channel_task(
|
||||||
|
app: AppHandle,
|
||||||
|
input: TransitionPersonalChannelTaskInput,
|
||||||
|
) -> Result<PersonalChannelSnapshot, String> {
|
||||||
|
let database = personal_channel_database(&app)?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || transition_task_at(&database, input))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn personal_channel_database(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let app_data = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?;
|
||||||
|
let root = app_data.join("personal-channel-v1");
|
||||||
|
create_private_directory(&root)?;
|
||||||
|
Ok(root.join("personal-channel.sqlite3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_private_directory(path: &Path) -> Result<(), String> {
|
||||||
|
fs::create_dir_all(path)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_UNAVAILABLE: {error}"))?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| {
|
||||||
|
format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_PERMISSION_FAILED: {error}")
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_database(path: &Path) -> Result<Connection, String> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
create_private_directory(parent)?;
|
||||||
|
}
|
||||||
|
let connection = Connection::open(path)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNAVAILABLE: {error}"))?;
|
||||||
|
connection
|
||||||
|
.busy_timeout(Duration::from_secs(5))
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNAVAILABLE: {error}"))?;
|
||||||
|
connection
|
||||||
|
.execute_batch(
|
||||||
|
"PRAGMA foreign_keys = ON;
|
||||||
|
PRAGMA journal_mode = DELETE;
|
||||||
|
PRAGMA synchronous = FULL;
|
||||||
|
PRAGMA trusted_schema = OFF;
|
||||||
|
CREATE TABLE IF NOT EXISTS kernel_meta (
|
||||||
|
key TEXT PRIMARY KEY NOT NULL,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO kernel_meta(key, value) VALUES ('schema_version', '1');
|
||||||
|
CREATE TABLE IF NOT EXISTS identities (
|
||||||
|
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
|
||||||
|
human_subject_id TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
channel_id TEXT NOT NULL UNIQUE,
|
||||||
|
created_at_unix_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
task_id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
human_subject_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
purpose TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'COMPLETED')),
|
||||||
|
created_at_unix_ms INTEGER NOT NULL,
|
||||||
|
updated_at_unix_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS one_active_personal_task
|
||||||
|
ON tasks(status) WHERE status = 'ACTIVE';
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
sequence INTEGER PRIMARY KEY NOT NULL,
|
||||||
|
event_id TEXT NOT NULL UNIQUE,
|
||||||
|
human_subject_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
task_id TEXT,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
occurred_at_unix_ms INTEGER NOT NULL,
|
||||||
|
previous_event_hash TEXT NOT NULL,
|
||||||
|
payload_sha256 TEXT NOT NULL,
|
||||||
|
event_hash TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS receipts (
|
||||||
|
sequence INTEGER PRIMARY KEY NOT NULL,
|
||||||
|
receipt_id TEXT NOT NULL UNIQUE,
|
||||||
|
event_sequence INTEGER NOT NULL UNIQUE REFERENCES events(sequence),
|
||||||
|
event_hash TEXT NOT NULL,
|
||||||
|
payload_sha256 TEXT NOT NULL,
|
||||||
|
previous_receipt_hash TEXT NOT NULL,
|
||||||
|
receipt_hash TEXT NOT NULL UNIQUE,
|
||||||
|
issued_at_unix_ms INTEGER NOT NULL
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID: {error}"))?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|error| {
|
||||||
|
format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_PERMISSION_FAILED: {error}")
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let version: String = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT value FROM kernel_meta WHERE key = 'schema_version'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID: {error}"))?;
|
||||||
|
if version != DATABASE_SCHEMA_VERSION.to_string() {
|
||||||
|
return Err("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_UNSUPPORTED".into());
|
||||||
|
}
|
||||||
|
Ok(connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initialize_at(
|
||||||
|
database: &Path,
|
||||||
|
input: InitializePersonalChannelInput,
|
||||||
|
) -> Result<PersonalChannelSnapshot, String> {
|
||||||
|
let display_name = validated_text(&input.display_name, 80, "DISPLAY_NAME")?;
|
||||||
|
let mut connection = open_database(database)?;
|
||||||
|
verify_integrity(&connection)?;
|
||||||
|
let transaction = connection
|
||||||
|
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||||
|
.map_err(database_write_error)?;
|
||||||
|
let already_initialized: bool = transaction
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM identities WHERE singleton = 1)",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(database_read_error)?;
|
||||||
|
if already_initialized {
|
||||||
|
return Err("HOLOLAKE_PERSONAL_CHANNEL_ALREADY_INITIALIZED".into());
|
||||||
|
}
|
||||||
|
let human_subject_id = format!("human-local-{}", Uuid::new_v4());
|
||||||
|
let channel_id = format!("channel-local-{}", Uuid::new_v4());
|
||||||
|
let created_at = now_unix_ms()?;
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO identities(singleton, human_subject_id, display_name, channel_id, created_at_unix_ms)
|
||||||
|
VALUES(1, ?1, ?2, ?3, ?4)",
|
||||||
|
params![human_subject_id, display_name, channel_id, created_at],
|
||||||
|
)
|
||||||
|
.map_err(database_write_error)?;
|
||||||
|
append_event(
|
||||||
|
&transaction,
|
||||||
|
&human_subject_id,
|
||||||
|
"CHANNEL_INITIALIZED",
|
||||||
|
None,
|
||||||
|
&format!("{display_name} 建立了个人频道"),
|
||||||
|
created_at,
|
||||||
|
)?;
|
||||||
|
transaction.commit().map_err(database_write_error)?;
|
||||||
|
snapshot_at(database)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_task_at(
|
||||||
|
database: &Path,
|
||||||
|
input: CreatePersonalChannelTaskInput,
|
||||||
|
) -> Result<PersonalChannelSnapshot, String> {
|
||||||
|
let title = validated_text(&input.title, 160, "TASK_TITLE")?;
|
||||||
|
let purpose = validated_text(&input.purpose, 1_000, "TASK_PURPOSE")?;
|
||||||
|
let mut connection = open_database(database)?;
|
||||||
|
verify_integrity(&connection)?;
|
||||||
|
let transaction = connection
|
||||||
|
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||||
|
.map_err(database_write_error)?;
|
||||||
|
let human_subject_id = require_identity_id(&transaction)?;
|
||||||
|
let active_exists: bool = transaction
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM tasks WHERE status = 'ACTIVE')",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(database_read_error)?;
|
||||||
|
if active_exists {
|
||||||
|
return Err("HOLOLAKE_PERSONAL_CHANNEL_ACTIVE_TASK_EXISTS".into());
|
||||||
|
}
|
||||||
|
let task_id = format!("task-local-{}", Uuid::new_v4());
|
||||||
|
let observed_at = now_unix_ms()?;
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO tasks(task_id, human_subject_id, title, purpose, status, created_at_unix_ms, updated_at_unix_ms)
|
||||||
|
VALUES(?1, ?2, ?3, ?4, 'ACTIVE', ?5, ?5)",
|
||||||
|
params![task_id, human_subject_id, title, purpose, observed_at],
|
||||||
|
)
|
||||||
|
.map_err(database_write_error)?;
|
||||||
|
append_event(
|
||||||
|
&transaction,
|
||||||
|
&human_subject_id,
|
||||||
|
"TASK_STARTED",
|
||||||
|
Some(&task_id),
|
||||||
|
&format!("开始:{title}"),
|
||||||
|
observed_at,
|
||||||
|
)?;
|
||||||
|
transaction.commit().map_err(database_write_error)?;
|
||||||
|
snapshot_at(database)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transition_task_at(
|
||||||
|
database: &Path,
|
||||||
|
input: TransitionPersonalChannelTaskInput,
|
||||||
|
) -> Result<PersonalChannelSnapshot, String> {
|
||||||
|
validate_identifier(&input.task_id, "TASK")?;
|
||||||
|
if input.expected_status != "ACTIVE" || input.next_status != "COMPLETED" {
|
||||||
|
return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_TRANSITION_INVALID".into());
|
||||||
|
}
|
||||||
|
let mut connection = open_database(database)?;
|
||||||
|
verify_integrity(&connection)?;
|
||||||
|
let transaction = connection
|
||||||
|
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||||
|
.map_err(database_write_error)?;
|
||||||
|
let human_subject_id = require_identity_id(&transaction)?;
|
||||||
|
let task = transaction
|
||||||
|
.query_row(
|
||||||
|
"SELECT title, status FROM tasks WHERE task_id = ?1",
|
||||||
|
params![input.task_id],
|
||||||
|
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(database_read_error)?
|
||||||
|
.ok_or("HOLOLAKE_PERSONAL_CHANNEL_TASK_NOT_FOUND")?;
|
||||||
|
if task.1 != input.expected_status {
|
||||||
|
return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_STATE_CONFLICT".into());
|
||||||
|
}
|
||||||
|
let observed_at = now_unix_ms()?;
|
||||||
|
let changed = transaction
|
||||||
|
.execute(
|
||||||
|
"UPDATE tasks SET status = 'COMPLETED', updated_at_unix_ms = ?1
|
||||||
|
WHERE task_id = ?2 AND status = 'ACTIVE'",
|
||||||
|
params![observed_at, input.task_id],
|
||||||
|
)
|
||||||
|
.map_err(database_write_error)?;
|
||||||
|
if changed != 1 {
|
||||||
|
return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_STATE_CONFLICT".into());
|
||||||
|
}
|
||||||
|
append_event(
|
||||||
|
&transaction,
|
||||||
|
&human_subject_id,
|
||||||
|
"TASK_COMPLETED",
|
||||||
|
Some(&input.task_id),
|
||||||
|
&format!("完成:{}", task.0),
|
||||||
|
observed_at,
|
||||||
|
)?;
|
||||||
|
transaction.commit().map_err(database_write_error)?;
|
||||||
|
snapshot_at(database)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_identity_id(transaction: &Transaction<'_>) -> Result<String, String> {
|
||||||
|
transaction
|
||||||
|
.query_row(
|
||||||
|
"SELECT human_subject_id FROM identities WHERE singleton = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(database_read_error)?
|
||||||
|
.ok_or_else(|| "HOLOLAKE_PERSONAL_CHANNEL_NOT_INITIALIZED".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_event(
|
||||||
|
transaction: &Transaction<'_>,
|
||||||
|
human_subject_id: &str,
|
||||||
|
kind: &str,
|
||||||
|
task_id: Option<&str>,
|
||||||
|
summary: &str,
|
||||||
|
occurred_at_unix_ms: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let (last_sequence, previous_event_hash, previous_receipt_hash) = transaction
|
||||||
|
.query_row(
|
||||||
|
"SELECT e.sequence, e.event_hash, r.receipt_hash
|
||||||
|
FROM events e JOIN receipts r ON r.event_sequence = e.sequence
|
||||||
|
ORDER BY e.sequence DESC LIMIT 1",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(database_read_error)?
|
||||||
|
.unwrap_or((0, ZERO_HASH.into(), ZERO_HASH.into()));
|
||||||
|
let sequence = last_sequence + 1;
|
||||||
|
let event_id = format!("event-local-{}", Uuid::new_v4());
|
||||||
|
let payload = EventHashPayload {
|
||||||
|
schema: KERNEL_SCHEMA,
|
||||||
|
sequence,
|
||||||
|
event_id: &event_id,
|
||||||
|
human_subject_id,
|
||||||
|
kind,
|
||||||
|
task_id,
|
||||||
|
summary,
|
||||||
|
occurred_at_unix_ms,
|
||||||
|
};
|
||||||
|
let payload_bytes = serde_json::to_vec(&payload)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_EVENT_INVALID: {error}"))?;
|
||||||
|
let payload_sha256 = sha256_hex(&payload_bytes);
|
||||||
|
let event_hash =
|
||||||
|
sha256_hex(format!("event-chain/v1\n{previous_event_hash}\n{payload_sha256}").as_bytes());
|
||||||
|
let receipt_seed = sha256_hex(format!("receipt-id/v1\n{event_hash}").as_bytes());
|
||||||
|
let receipt_id = format!("HLR-{}", &receipt_seed[..24]);
|
||||||
|
let receipt_hash = sha256_hex(
|
||||||
|
format!(
|
||||||
|
"receipt-chain/v1\n{previous_receipt_hash}\n{receipt_id}\n{event_hash}\n{payload_sha256}"
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO events(sequence, event_id, human_subject_id, kind, task_id, summary,
|
||||||
|
occurred_at_unix_ms, previous_event_hash, payload_sha256, event_hash)
|
||||||
|
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
|
params![
|
||||||
|
sequence,
|
||||||
|
event_id,
|
||||||
|
human_subject_id,
|
||||||
|
kind,
|
||||||
|
task_id,
|
||||||
|
summary,
|
||||||
|
occurred_at_unix_ms,
|
||||||
|
previous_event_hash,
|
||||||
|
payload_sha256,
|
||||||
|
event_hash
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(database_write_error)?;
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO receipts(sequence, receipt_id, event_sequence, event_hash, payload_sha256,
|
||||||
|
previous_receipt_hash, receipt_hash, issued_at_unix_ms)
|
||||||
|
VALUES(?1, ?2, ?1, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
params![
|
||||||
|
sequence,
|
||||||
|
receipt_id,
|
||||||
|
event_hash,
|
||||||
|
payload_sha256,
|
||||||
|
previous_receipt_hash,
|
||||||
|
receipt_hash,
|
||||||
|
occurred_at_unix_ms
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(database_write_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_at(database: &Path) -> Result<PersonalChannelSnapshot, String> {
|
||||||
|
let connection = open_database(database)?;
|
||||||
|
let integrity = verify_integrity(&connection)?;
|
||||||
|
let identity = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT human_subject_id, display_name, channel_id, created_at_unix_ms
|
||||||
|
FROM identities WHERE singleton = 1",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok(PersonalChannelIdentity {
|
||||||
|
human_subject_id: row.get(0)?,
|
||||||
|
display_name: row.get(1)?,
|
||||||
|
channel_id: row.get(2)?,
|
||||||
|
created_at_unix_ms: row.get(3)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(database_read_error)?;
|
||||||
|
let current_task = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT task_id, title, purpose, status, created_at_unix_ms, updated_at_unix_ms
|
||||||
|
FROM tasks WHERE status = 'ACTIVE' LIMIT 1",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok(PersonalChannelTask {
|
||||||
|
task_id: row.get(0)?,
|
||||||
|
title: row.get(1)?,
|
||||||
|
purpose: row.get(2)?,
|
||||||
|
status: row.get(3)?,
|
||||||
|
created_at_unix_ms: row.get(4)?,
|
||||||
|
updated_at_unix_ms: row.get(5)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(database_read_error)?;
|
||||||
|
let mut statement = connection
|
||||||
|
.prepare(
|
||||||
|
"SELECT e.sequence, e.event_id, e.kind, e.task_id, e.summary, e.occurred_at_unix_ms,
|
||||||
|
e.event_hash, r.receipt_id, r.receipt_hash
|
||||||
|
FROM events e JOIN receipts r ON r.event_sequence = e.sequence
|
||||||
|
ORDER BY e.sequence DESC LIMIT 12",
|
||||||
|
)
|
||||||
|
.map_err(database_read_error)?;
|
||||||
|
let recent_events = statement
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(PersonalChannelEventProjection {
|
||||||
|
sequence: row.get(0)?,
|
||||||
|
event_id: row.get(1)?,
|
||||||
|
kind: row.get(2)?,
|
||||||
|
task_id: row.get(3)?,
|
||||||
|
summary: row.get(4)?,
|
||||||
|
occurred_at_unix_ms: row.get(5)?,
|
||||||
|
event_hash: row.get(6)?,
|
||||||
|
receipt_id: row.get(7)?,
|
||||||
|
receipt_hash: row.get(8)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(database_read_error)?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(database_read_error)?;
|
||||||
|
Ok(PersonalChannelSnapshot {
|
||||||
|
schema: KERNEL_SCHEMA,
|
||||||
|
state: if identity.is_some() {
|
||||||
|
"READY"
|
||||||
|
} else {
|
||||||
|
"UNINITIALIZED"
|
||||||
|
},
|
||||||
|
identity,
|
||||||
|
current_task,
|
||||||
|
recent_events,
|
||||||
|
integrity,
|
||||||
|
storage: "LOCAL_PRIVATE_SQLITE_SINGLE_HOLOLAKE_OWNER",
|
||||||
|
authority: "LOCAL_HUMAN_CONFIRMED_IDENTITY_NOT_SERVER_AUTHORITY",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_integrity(connection: &Connection) -> Result<PersonalChannelIntegrity, String> {
|
||||||
|
let schema_version: i64 = connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT value FROM kernel_meta WHERE key = 'schema_version'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.map_err(database_read_error)?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID".to_string())?;
|
||||||
|
if schema_version != DATABASE_SCHEMA_VERSION {
|
||||||
|
return Err("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_UNSUPPORTED".into());
|
||||||
|
}
|
||||||
|
let receipt_count: i64 = connection
|
||||||
|
.query_row("SELECT COUNT(*) FROM receipts", [], |row| row.get(0))
|
||||||
|
.map_err(database_read_error)?;
|
||||||
|
let mut statement = connection
|
||||||
|
.prepare(
|
||||||
|
"SELECT e.sequence, e.event_id, e.human_subject_id, e.kind, e.task_id, e.summary,
|
||||||
|
e.occurred_at_unix_ms, e.previous_event_hash, e.payload_sha256, e.event_hash,
|
||||||
|
r.receipt_id, r.event_hash, r.payload_sha256, r.previous_receipt_hash, r.receipt_hash
|
||||||
|
FROM events e LEFT JOIN receipts r ON r.event_sequence = e.sequence
|
||||||
|
ORDER BY e.sequence ASC",
|
||||||
|
)
|
||||||
|
.map_err(database_read_error)?;
|
||||||
|
let mut rows = statement.query([]).map_err(database_read_error)?;
|
||||||
|
let mut expected_sequence = 1_i64;
|
||||||
|
let mut previous_event_hash = ZERO_HASH.to_string();
|
||||||
|
let mut previous_receipt_hash = ZERO_HASH.to_string();
|
||||||
|
let mut event_count = 0_i64;
|
||||||
|
while let Some(row) = rows.next().map_err(database_read_error)? {
|
||||||
|
let sequence: i64 = row.get(0).map_err(database_read_error)?;
|
||||||
|
let event_id: String = row.get(1).map_err(database_read_error)?;
|
||||||
|
let human_subject_id: String = row.get(2).map_err(database_read_error)?;
|
||||||
|
let kind: String = row.get(3).map_err(database_read_error)?;
|
||||||
|
let task_id: Option<String> = row.get(4).map_err(database_read_error)?;
|
||||||
|
let summary: String = row.get(5).map_err(database_read_error)?;
|
||||||
|
let occurred_at: i64 = row.get(6).map_err(database_read_error)?;
|
||||||
|
let stored_previous_event: String = row.get(7).map_err(database_read_error)?;
|
||||||
|
let stored_payload: String = row.get(8).map_err(database_read_error)?;
|
||||||
|
let stored_event_hash: String = row.get(9).map_err(database_read_error)?;
|
||||||
|
let receipt_id: Option<String> = row.get(10).map_err(database_read_error)?;
|
||||||
|
let receipt_event_hash: Option<String> = row.get(11).map_err(database_read_error)?;
|
||||||
|
let receipt_payload: Option<String> = row.get(12).map_err(database_read_error)?;
|
||||||
|
let stored_previous_receipt: Option<String> = row.get(13).map_err(database_read_error)?;
|
||||||
|
let stored_receipt_hash: Option<String> = row.get(14).map_err(database_read_error)?;
|
||||||
|
if sequence != expected_sequence || stored_previous_event != previous_event_hash {
|
||||||
|
return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into());
|
||||||
|
}
|
||||||
|
let payload = EventHashPayload {
|
||||||
|
schema: KERNEL_SCHEMA,
|
||||||
|
sequence,
|
||||||
|
event_id: &event_id,
|
||||||
|
human_subject_id: &human_subject_id,
|
||||||
|
kind: &kind,
|
||||||
|
task_id: task_id.as_deref(),
|
||||||
|
summary: &summary,
|
||||||
|
occurred_at_unix_ms: occurred_at,
|
||||||
|
};
|
||||||
|
let payload_sha256 = sha256_hex(
|
||||||
|
&serde_json::to_vec(&payload)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_EVENT_INVALID: {error}"))?,
|
||||||
|
);
|
||||||
|
let event_hash = sha256_hex(
|
||||||
|
format!("event-chain/v1\n{previous_event_hash}\n{payload_sha256}").as_bytes(),
|
||||||
|
);
|
||||||
|
let receipt_id = receipt_id.ok_or("HOLOLAKE_PERSONAL_CHANNEL_RECEIPT_MISSING")?;
|
||||||
|
let expected_receipt_id = format!(
|
||||||
|
"HLR-{}",
|
||||||
|
&sha256_hex(format!("receipt-id/v1\n{event_hash}").as_bytes())[..24]
|
||||||
|
);
|
||||||
|
let receipt_hash = sha256_hex(
|
||||||
|
format!(
|
||||||
|
"receipt-chain/v1\n{previous_receipt_hash}\n{receipt_id}\n{event_hash}\n{payload_sha256}"
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
if stored_payload != payload_sha256
|
||||||
|
|| stored_event_hash != event_hash
|
||||||
|
|| receipt_id != expected_receipt_id
|
||||||
|
|| receipt_event_hash.as_deref() != Some(event_hash.as_str())
|
||||||
|
|| receipt_payload.as_deref() != Some(payload_sha256.as_str())
|
||||||
|
|| stored_previous_receipt.as_deref() != Some(previous_receipt_hash.as_str())
|
||||||
|
|| stored_receipt_hash.as_deref() != Some(receipt_hash.as_str())
|
||||||
|
{
|
||||||
|
return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into());
|
||||||
|
}
|
||||||
|
previous_event_hash = event_hash;
|
||||||
|
previous_receipt_hash = receipt_hash;
|
||||||
|
expected_sequence += 1;
|
||||||
|
event_count += 1;
|
||||||
|
}
|
||||||
|
if receipt_count != event_count {
|
||||||
|
return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into());
|
||||||
|
}
|
||||||
|
let identity_count: i64 = connection
|
||||||
|
.query_row("SELECT COUNT(*) FROM identities", [], |row| row.get(0))
|
||||||
|
.map_err(database_read_error)?;
|
||||||
|
if identity_count > 1 || (identity_count == 1 && event_count == 0) {
|
||||||
|
return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into());
|
||||||
|
}
|
||||||
|
Ok(PersonalChannelIntegrity {
|
||||||
|
state: "PASS_100",
|
||||||
|
schema_version,
|
||||||
|
event_count,
|
||||||
|
receipt_count,
|
||||||
|
last_event_hash: previous_event_hash,
|
||||||
|
last_receipt_hash: previous_receipt_hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validated_text(value: &str, maximum_chars: usize, kind: &str) -> Result<String, String> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
let count = trimmed.chars().count();
|
||||||
|
if count == 0
|
||||||
|
|| count > maximum_chars
|
||||||
|
|| trimmed.chars().any(|character| character.is_control())
|
||||||
|
{
|
||||||
|
return Err(format!("HOLOLAKE_PERSONAL_CHANNEL_{kind}_INVALID"));
|
||||||
|
}
|
||||||
|
Ok(trimmed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_identifier(value: &str, kind: &str) -> Result<(), String> {
|
||||||
|
if value.is_empty()
|
||||||
|
|| value.len() > 128
|
||||||
|
|| !value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
|
||||||
|
{
|
||||||
|
return Err(format!("HOLOLAKE_PERSONAL_CHANNEL_{kind}_ID_INVALID"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix_ms() -> Result<i64, String> {
|
||||||
|
let millis = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_SYSTEM_CLOCK_INVALID: {error}"))?
|
||||||
|
.as_millis();
|
||||||
|
i64::try_from(millis).map_err(|_| "HOLOLAKE_SYSTEM_CLOCK_INVALID".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(value: &[u8]) -> String {
|
||||||
|
digest(&SHA256, value)
|
||||||
|
.as_ref()
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn database_read_error(error: rusqlite::Error) -> String {
|
||||||
|
format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNREADABLE: {error}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn database_write_error(error: rusqlite::Error) -> String {
|
||||||
|
format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_WRITE_FAILED: {error}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn database(temp: &TempDir) -> PathBuf {
|
||||||
|
temp.path().join("personal-channel.sqlite3")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initialize(database: &Path) -> PersonalChannelSnapshot {
|
||||||
|
initialize_at(
|
||||||
|
database,
|
||||||
|
InitializePersonalChannelInput {
|
||||||
|
display_name: "冰朔".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initialization_creates_identity_event_and_receipt_atomically() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let snapshot = initialize(&database(&temp));
|
||||||
|
assert_eq!(snapshot.state, "READY");
|
||||||
|
assert_eq!(snapshot.identity.unwrap().display_name, "冰朔");
|
||||||
|
assert_eq!(snapshot.integrity.event_count, 1);
|
||||||
|
assert_eq!(snapshot.integrity.receipt_count, 1);
|
||||||
|
assert_eq!(snapshot.recent_events[0].kind, "CHANNEL_INITIALIZED");
|
||||||
|
assert!(snapshot.recent_events[0].receipt_id.starts_with("HLR-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restart_reads_the_same_identity_task_event_and_receipt_chain() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let database = database(&temp);
|
||||||
|
initialize(&database);
|
||||||
|
let created = create_task_at(
|
||||||
|
&database,
|
||||||
|
CreatePersonalChannelTaskInput {
|
||||||
|
title: "完成第一阶段闭环".into(),
|
||||||
|
purpose: "让身份、任务、事件与回执在重启后仍然可见".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let before_hash = created.integrity.last_receipt_hash.clone();
|
||||||
|
drop(created);
|
||||||
|
let after_restart = snapshot_at(&database).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
after_restart.current_task.unwrap().title,
|
||||||
|
"完成第一阶段闭环"
|
||||||
|
);
|
||||||
|
assert_eq!(after_restart.integrity.event_count, 2);
|
||||||
|
assert_eq!(after_restart.integrity.last_receipt_hash, before_hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_active_task_is_enforced_and_completion_is_receipted() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let database = database(&temp);
|
||||||
|
initialize(&database);
|
||||||
|
let created = create_task_at(
|
||||||
|
&database,
|
||||||
|
CreatePersonalChannelTaskInput {
|
||||||
|
title: "当前任务".into(),
|
||||||
|
purpose: "验证单一当前焦点".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
create_task_at(
|
||||||
|
&database,
|
||||||
|
CreatePersonalChannelTaskInput {
|
||||||
|
title: "冲突任务".into(),
|
||||||
|
purpose: "不应被创建".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
"HOLOLAKE_PERSONAL_CHANNEL_ACTIVE_TASK_EXISTS"
|
||||||
|
);
|
||||||
|
let completed = transition_task_at(
|
||||||
|
&database,
|
||||||
|
TransitionPersonalChannelTaskInput {
|
||||||
|
task_id: created.current_task.unwrap().task_id,
|
||||||
|
expected_status: "ACTIVE".into(),
|
||||||
|
next_status: "COMPLETED".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(completed.current_task.is_none());
|
||||||
|
assert_eq!(completed.recent_events[0].kind, "TASK_COMPLETED");
|
||||||
|
assert_eq!(completed.integrity.event_count, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changed_event_bytes_fail_closed_on_readback() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let database = database(&temp);
|
||||||
|
initialize(&database);
|
||||||
|
let connection = open_database(&database).unwrap();
|
||||||
|
connection
|
||||||
|
.execute(
|
||||||
|
"UPDATE events SET summary = '被篡改' WHERE sequence = 1",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
snapshot_at(&database).unwrap_err(),
|
||||||
|
"HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_identity_is_singleton_and_not_recreated() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let database = database(&temp);
|
||||||
|
initialize(&database);
|
||||||
|
assert_eq!(
|
||||||
|
initialize_at(
|
||||||
|
&database,
|
||||||
|
InitializePersonalChannelInput {
|
||||||
|
display_name: "另一个人".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_err(),
|
||||||
|
"HOLOLAKE_PERSONAL_CHANNEL_ALREADY_INITIALIZED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,9 @@
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/icon.png"
|
||||||
|
],
|
||||||
"createUpdaterArtifacts": false,
|
"createUpdaterArtifacts": false,
|
||||||
"category": "Productivity"
|
"category": "Productivity"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import { StrictMode, useCallback, useEffect, useMemo, useState } from 'react'
|
import { StrictMode, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import { marked } from 'marked'
|
||||||
import './design-tokens.css'
|
import './design-tokens.css'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear'
|
type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear'
|
||||||
type Panel = 'channel' | 'receipts' | 'updates' | 'settings' | null
|
type ViewId = 'overview' | 'knowledge' | 'code' | 'receipts' | 'system'
|
||||||
|
type KnowledgeSource = 'native' | 'legacy'
|
||||||
|
|
||||||
interface HomeStatus {
|
interface HomeStatus {
|
||||||
schema: string
|
|
||||||
directLocalBrokerState: string
|
directLocalBrokerState: string
|
||||||
directConnectionCount: number
|
directConnectionCount: number
|
||||||
resumableSessionCount: number
|
resumableSessionCount: number
|
||||||
|
|
@ -16,114 +18,73 @@ interface HomeStatus {
|
||||||
pnccReceiptCount: number
|
pnccReceiptCount: number
|
||||||
updateState: string
|
updateState: string
|
||||||
releaseRecoveryState: string
|
releaseRecoveryState: string
|
||||||
automaticUpstreamUpdates: boolean
|
|
||||||
mcpRole: string
|
mcpRole: string
|
||||||
}
|
}
|
||||||
|
interface PersonalChannelIdentity { humanSubjectId: string; displayName: string; channelId: string; createdAtUnixMs: number }
|
||||||
interface DiscoveryTicketReceipt {
|
interface PersonalChannelEvent { sequence: number; eventId: string; kind: string; summary: string; occurredAtUnixMs: number; receiptId: string; receiptHash: string }
|
||||||
schema: string
|
interface PersonalChannelSnapshot { state: string; identity?: PersonalChannelIdentity; recentEvents: PersonalChannelEvent[]; integrity: { state: string; eventCount: number; receiptCount: number } }
|
||||||
|
interface KnowledgeDocumentSummary {
|
||||||
|
source: KnowledgeSource
|
||||||
|
path: string
|
||||||
|
title: string
|
||||||
|
updatedAtUnixMs: number
|
||||||
|
sizeBytes: number
|
||||||
|
contentSha256: string
|
||||||
|
duplicateCount: number
|
||||||
|
}
|
||||||
|
interface KnowledgeSnapshot {
|
||||||
state: string
|
state: string
|
||||||
accountKey: string
|
nativeRoot: string
|
||||||
laneId: string
|
legacyAvailable: boolean
|
||||||
clientInstanceId: string
|
legacyRoot?: string
|
||||||
discoveryTicket: string
|
documents: KnowledgeDocumentSummary[]
|
||||||
issuedAtUnixMs: number
|
rawDocumentCount: number
|
||||||
receiptId: string
|
uniqueDocumentCount: number
|
||||||
|
duplicateDocumentCount: number
|
||||||
|
truncated: boolean
|
||||||
}
|
}
|
||||||
|
interface KnowledgeDocument {
|
||||||
interface ReceiptEvent {
|
source: KnowledgeSource
|
||||||
sequence: number
|
path: string
|
||||||
kind: string
|
title: string
|
||||||
observedAtUnixMs: number
|
body: string
|
||||||
eventHash: string
|
updatedAtUnixMs: number
|
||||||
|
contentSha256: string
|
||||||
|
writable: boolean
|
||||||
}
|
}
|
||||||
|
interface KnowledgeSearchResult { source: KnowledgeSource; path: string; title: string; snippet: string }
|
||||||
interface ReceiptProjection {
|
interface KnowledgeImportResult {
|
||||||
events: ReceiptEvent[]
|
|
||||||
returnedEventCount: number
|
|
||||||
lastSequence: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RepositoryInspection {
|
|
||||||
personaId: string
|
|
||||||
humanResponsibilitySubject: string
|
|
||||||
repositoryPath: string
|
|
||||||
gitHead: string
|
|
||||||
repositoryClean: boolean
|
|
||||||
currentCheckpoint: { relativePath: string; sha256: string }
|
|
||||||
modelInferenceStarted: boolean
|
|
||||||
realityExecutionAllowed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RepositoryCandidate {
|
|
||||||
schema: string
|
|
||||||
state: string
|
state: string
|
||||||
candidateId: string
|
sourceName: string
|
||||||
confirmationToken: string
|
importedDocuments: number
|
||||||
expiresAtUnixMs: number
|
importedAssets: number
|
||||||
inspection: RepositoryInspection
|
existingDocuments: number
|
||||||
}
|
conflicts: number
|
||||||
|
skipped: number
|
||||||
interface ReleaseCandidate {
|
firstDocument?: string
|
||||||
candidateId: string
|
gitCommit: string
|
||||||
confirmationToken: string
|
snapshot: KnowledgeSnapshot
|
||||||
releaseId: string
|
|
||||||
currentVersion: string
|
|
||||||
version: string
|
|
||||||
notes: string
|
|
||||||
features: string[]
|
|
||||||
fixes: string[]
|
|
||||||
minimumVersion: string
|
|
||||||
dataMigrationRequired: boolean
|
|
||||||
compatibilityNotes?: string
|
|
||||||
restartMessage?: string
|
|
||||||
rollbackSupported: boolean
|
|
||||||
previousVersion?: string
|
|
||||||
expiresAtUnixMs: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReleaseCheckReceipt {
|
|
||||||
state: string
|
|
||||||
candidate?: ReleaseCandidate
|
|
||||||
}
|
}
|
||||||
|
interface KnowledgeSaveResult { state: string; gitCommit: string; document: KnowledgeDocument }
|
||||||
|
interface CodeChannelEntry { channelId: string; name: string; sourceKind: string; localPath: string; remoteUrl?: string; gitHead: string; branch: string; repositoryClean: boolean; registeredAtUnixMs: number }
|
||||||
|
interface CodeChannelSnapshot { state: string; channels: CodeChannelEntry[]; authority: string }
|
||||||
|
interface CodeTreeEntry { path: string; name: string; kind: 'directory' | 'file'; sizeBytes: number }
|
||||||
|
interface CodeTreeSnapshot { channelId: string; path: string; entries: CodeTreeEntry[]; truncated: boolean }
|
||||||
|
interface CodeFileProjection { channelId: string; path: string; format: string; source: string; humanMarkdown: string; sizeBytes: number }
|
||||||
|
interface ReceiptEvent { sequence: number; kind: string; observedAtUnixMs: number; eventHash: string }
|
||||||
|
interface ReceiptProjection { events: ReceiptEvent[] }
|
||||||
|
interface DiscoveryTicketReceipt { laneId: string; clientInstanceId: string; discoveryTicket: string }
|
||||||
|
interface ReleaseCandidate { candidateId: string; confirmationToken: string; currentVersion: string; version: string; notes: string; features: string[]; fixes: string[]; dataMigrationRequired: boolean }
|
||||||
|
interface ReleaseCheckReceipt { candidate?: ReleaseCandidate }
|
||||||
|
|
||||||
|
const previewStatus: HomeStatus = { directLocalBrokerState: 'UNVERIFIED', directConnectionCount: 0, resumableSessionCount: 0, codeRepositoryMountCount: 0, pnccReceiptCount: 0, updateState: 'UNPROVISIONED_FAIL_CLOSED', releaseRecoveryState: 'NONE', mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY' }
|
||||||
|
const previewPersonal: PersonalChannelSnapshot = { state: 'UNAVAILABLE', recentEvents: [], integrity: { state: 'UNKNOWN', eventCount: 0, receiptCount: 0 } }
|
||||||
|
const previewKnowledge: KnowledgeSnapshot = { state: 'UNAVAILABLE', nativeRoot: '', legacyAvailable: false, documents: [], rawDocumentCount: 0, uniqueDocumentCount: 0, duplicateDocumentCount: 0, truncated: false }
|
||||||
|
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: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' },
|
||||||
{ id: 'dawn', name: '晨湖曦光' },
|
|
||||||
{ id: 'nebula', name: '星云紫夜' },
|
|
||||||
{ id: 'candle', name: '烛畔暖湖' },
|
|
||||||
{ id: 'clear', name: '清浅澄湖' },
|
|
||||||
]
|
]
|
||||||
|
const viewLabels: Record<ViewId, string> = { overview: '总览', knowledge: '知识工作台', code: '代码频道', receipts: '回执', system: '系统详情' }
|
||||||
const recoveryPreviewStates = [
|
|
||||||
'NONE',
|
|
||||||
'AWAITING_HUMAN_HEALTH_CONFIRMATION',
|
|
||||||
'ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT',
|
|
||||||
'INSTALL_FAILED_BACKUP_RETAINED',
|
|
||||||
'INSTALL_INTERRUPTED_BACKUP_RETAINED',
|
|
||||||
'INSTALL_REPLACED_AWAITING_MANUAL_RESTART',
|
|
||||||
'ROLLBACK_REPLACED_AWAITING_MANUAL_RESTART',
|
|
||||||
'RECOVERY_STATE_UNREADABLE_ACTION_REQUIRED',
|
|
||||||
]
|
|
||||||
const requestedRecoveryPreview = import.meta.env.DEV
|
|
||||||
? new URLSearchParams(window.location.search).get('releaseRecoveryState')
|
|
||||||
: null
|
|
||||||
const recoveryPreviewState = requestedRecoveryPreview && recoveryPreviewStates.includes(requestedRecoveryPreview)
|
|
||||||
? requestedRecoveryPreview
|
|
||||||
: 'NONE'
|
|
||||||
|
|
||||||
const previewStatus: HomeStatus = {
|
|
||||||
schema: 'hololake.home-status/preview',
|
|
||||||
directLocalBrokerState: 'PREVIEW',
|
|
||||||
directConnectionCount: 0,
|
|
||||||
resumableSessionCount: 0,
|
|
||||||
codeRepositoryMountCount: 0,
|
|
||||||
pnccReceiptCount: 0,
|
|
||||||
updateState: 'UNPROVISIONED_FAIL_CLOSED',
|
|
||||||
releaseRecoveryState: recoveryPreviewState,
|
|
||||||
automaticUpstreamUpdates: false,
|
|
||||||
mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrCreateLocalId(key: string, prefix: string) {
|
function getOrCreateLocalId(key: string, prefix: string) {
|
||||||
const existing = window.localStorage.getItem(key)
|
const existing = window.localStorage.getItem(key)
|
||||||
|
|
@ -133,193 +94,332 @@ function getOrCreateLocalId(key: string, prefix: string) {
|
||||||
return generated
|
return generated
|
||||||
}
|
}
|
||||||
|
|
||||||
function Icon({ name }: { name: 'settings' | 'arrow' | 'receipt' | 'close' | 'copy' }) {
|
function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' | 'system') {
|
||||||
const paths = {
|
const value = String(error)
|
||||||
settings: <><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-1.9 1.9-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.04 1.56V20h-2.7v-.08a1.7 1.7 0 0 0-1.05-1.56 1.7 1.7 0 0 0-1.88.34l-.06.06-1.9-1.9.06-.06A1.7 1.7 0 0 0 7.72 15a1.7 1.7 0 0 0-1.56-1.04H6v-2.7h.08A1.7 1.7 0 0 0 7.64 10a1.7 1.7 0 0 0-.34-1.88l-.06-.06 1.9-1.9.06.06a1.7 1.7 0 0 0 1.88.34A1.7 1.7 0 0 0 12.12 5V5h2.7v.08a1.7 1.7 0 0 0 1.04 1.56 1.7 1.7 0 0 0 1.88-.34l.06-.06 1.9 1.9-.06.06a1.7 1.7 0 0 0-.34 1.88 1.7 1.7 0 0 0 1.56 1.04H21v2.7h-.08A1.7 1.7 0 0 0 19.4 15Z"/></>,
|
if (value.includes('HOST_NOT_TRUSTED')) return '该地址不属于已登记的光湖代码频道。'
|
||||||
arrow: <><path d="M5 12h13"/><path d="m14 7 5 5-5 5"/></>,
|
if (value.includes('URL_INVALID')) return '频道地址无效,请粘贴完整的 HTTPS 克隆地址。'
|
||||||
receipt: <><path d="M7 3h10v18l-2.5-1.5L12 21l-2.5-1.5L7 21Z"/><path d="M10 8h4M10 12h4"/></>,
|
if (value.includes('DESTINATION_EXISTS')) return '该代码频道已经存在,无需再次克隆。'
|
||||||
close: <><path d="m7 7 10 10M17 7 7 17"/></>,
|
if (value.includes('CLONE_FAILED')) return '代码频道克隆失败,请核对地址及读取权限。'
|
||||||
|
if (value.includes('NO_SUPPORTED_FILES')) return '文件夹中没有可导入的知识文件。'
|
||||||
|
if (value.includes('SAVE_CONFLICT')) return '页面已在别处更新,请重新打开后再编辑。'
|
||||||
|
if (value.includes('FILE_UNSUPPORTED')) return '该文件不是当前可阅读的文本格式。'
|
||||||
|
if (context === 'identity') return '个人空间未能建立。'
|
||||||
|
if (context === 'knowledge') return '知识操作未完成,原文件未被覆盖。'
|
||||||
|
if (context === 'code') return '代码频道操作未完成。'
|
||||||
|
return '系统操作未完成。'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Icon({ name }: { name: ViewId | 'search' | 'import' | 'folder' | 'copy' | 'arrow' | 'chevron' | 'file' | 'edit' | 'save' | 'back' }) {
|
||||||
|
const paths: Record<string, React.ReactNode> = {
|
||||||
|
overview: <><rect x="4" y="4" width="6" height="6" rx="1.5"/><rect x="14" y="4" width="6" height="6" rx="1.5"/><rect x="4" y="14" width="6" height="6" rx="1.5"/><rect x="14" y="14" width="6" height="6" rx="1.5"/></>,
|
||||||
|
knowledge: <><path d="M5 4.5h10a3 3 0 0 1 3 3V20H8a3 3 0 0 1-3-3Z"/><path d="M8 4.5v12.8A2.7 2.7 0 0 1 10.7 20"/></>,
|
||||||
|
code: <><path d="m8.5 8-4 4 4 4M15.5 8l4 4-4 4M13.5 5l-3 14"/></>,
|
||||||
|
receipts: <><path d="M7 3.5h10v17l-2.5-1.5L12 20.5 9.5 19 7 20.5Z"/><path d="M10 8h4M10 12h4"/></>,
|
||||||
|
system: <><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.4-2.4 1a7 7 0 0 0-2-1.2L14.2 3h-4.1l-.3 2.7a7 7 0 0 0-2 1.2l-2.5-1-2 3.4 2.1 1.5a7 7 0 0 0 0 2.4l-2 1.5 2 3.4 2.4-1a7 7 0 0 0 2 1.2l.3 2.7h4.1l.3-2.7a7 7 0 0 0 2-1.2l2.5 1 2-3.4-2.1-1.5A7 7 0 0 0 19 12Z"/></>,
|
||||||
|
search: <><circle cx="11" cy="11" r="6"/><path d="m16 16 4 4"/></>,
|
||||||
|
import: <><path d="M12 4v11M8 11l4 4 4-4"/><path d="M5 19h14"/></>,
|
||||||
|
folder: <path d="M3.5 7.5h6l2-2h9v13h-17Z"/>,
|
||||||
|
file: <><path d="M6 3.5h8l4 4v13H6Z"/><path d="M14 3.5v4h4"/></>,
|
||||||
copy: <><rect x="8" y="8" width="10" height="11" rx="2"/><path d="M15 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2"/></>,
|
copy: <><rect x="8" y="8" width="10" height="11" rx="2"/><path d="M15 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2"/></>,
|
||||||
|
arrow: <><path d="M5 12h13M14 7l5 5-5 5"/></>,
|
||||||
|
chevron: <path d="m9 6 6 6-6 6"/>,
|
||||||
|
edit: <><path d="m4 20 4.5-1 10-10-3.5-3.5-10 10Z"/><path d="m13.5 6.5 3.5 3.5"/></>,
|
||||||
|
save: <><path d="M5 4h12l2 2v14H5Z"/><path d="M8 4v6h8V4M8 20v-6h8v6"/></>,
|
||||||
|
back: <path d="m15 6-6 6 6 6"/>,
|
||||||
}
|
}
|
||||||
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>
|
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitFrontmatter(body: string) {
|
||||||
|
const normalized = body.replace(/\r\n/g, '\n')
|
||||||
|
if (!normalized.startsWith('---\n')) return { content: normalized, metadata: {} as Record<string, string> }
|
||||||
|
const closing = normalized.indexOf('\n---\n', 4)
|
||||||
|
if (closing < 0) return { content: normalized, metadata: {} as Record<string, string> }
|
||||||
|
const metadata: Record<string, string> = {}
|
||||||
|
for (const line of normalized.slice(4, closing).split('\n')) {
|
||||||
|
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
|
||||||
|
if (match) metadata[match[1]] = match[2].replace(/^['"]|['"]$/g, '')
|
||||||
|
}
|
||||||
|
return { content: normalized.slice(closing + 5), metadata }
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownHtml(body: string) {
|
||||||
|
const withWikiLinks = body.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_whole, target: string, label?: string) =>
|
||||||
|
`<button class="wiki-link" type="button" data-wiki="${escapeHtml(target.trim())}">${escapeHtml((label || target).trim())}</button>`)
|
||||||
|
const html = marked.parse(withWikiLinks, { async: false, gfm: true, breaks: false }) as string
|
||||||
|
return DOMPurify.sanitize(html, { ADD_ATTR: ['data-wiki'], ADD_TAGS: ['button'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentOutline(body: string) {
|
||||||
|
return splitFrontmatter(body).content.split('\n').flatMap((line, index) => {
|
||||||
|
const match = /^(#{1,4})\s+(.+)$/.exec(line.trim())
|
||||||
|
return match ? [{ id: `outline-${index}`, level: match[1].length, title: match[2].replace(/[*_`]/g, '') }] : []
|
||||||
|
}).slice(0, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownDocument({ body, onWiki }: { body: string; onWiki?: (target: string) => void }) {
|
||||||
|
const parsed = useMemo(() => splitFrontmatter(body), [body])
|
||||||
|
const html = useMemo(() => markdownHtml(parsed.content), [parsed.content])
|
||||||
|
return <article className="markdown-document" onClick={(event) => {
|
||||||
|
const element = (event.target as HTMLElement).closest<HTMLElement>('[data-wiki]')
|
||||||
|
if (element?.dataset.wiki && onWiki) onWiki(element.dataset.wiki)
|
||||||
|
}} dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeNode { name: string; key: string; folders: Map<string, TreeNode>; documents: KnowledgeDocumentSummary[] }
|
||||||
|
function knowledgeTree(documents: KnowledgeDocumentSummary[]) {
|
||||||
|
const root: TreeNode = { name: '知识', key: '', folders: new Map(), documents: [] }
|
||||||
|
for (const document of documents) {
|
||||||
|
const segments = document.path.split('/').filter(Boolean)
|
||||||
|
const file = segments.pop()
|
||||||
|
let cursor = root
|
||||||
|
for (const segment of segments) {
|
||||||
|
const key = cursor.key ? `${cursor.key}/${segment}` : segment
|
||||||
|
if (!cursor.folders.has(segment)) cursor.folders.set(segment, { name: segment, key, folders: new Map(), documents: [] })
|
||||||
|
cursor = cursor.folders.get(segment)!
|
||||||
|
}
|
||||||
|
cursor.documents.push({ ...document, title: document.title || file || '未命名页面' })
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
function countTree(node: TreeNode): number {
|
||||||
|
return node.documents.length + [...node.folders.values()].reduce((sum, child) => sum + countTree(child), 0)
|
||||||
|
}
|
||||||
|
function KnowledgeTree({ node, depth, expanded, active, onToggle, onOpen }: { node: TreeNode; depth: number; expanded: Set<string>; active?: string; onToggle: (key: string) => void; onOpen: (item: KnowledgeDocumentSummary) => void }) {
|
||||||
|
const folders = [...node.folders.values()].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
||||||
|
const documents = [...node.documents].sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'))
|
||||||
|
return <>
|
||||||
|
{folders.map((folder) => {
|
||||||
|
const open = depth === 0 || expanded.has(folder.key)
|
||||||
|
return <div key={folder.key} className="tree-branch">
|
||||||
|
<button className="tree-folder" style={{ paddingLeft: 12 + depth * 14 }} type="button" onClick={() => onToggle(folder.key)}>
|
||||||
|
<span className={open ? 'tree-chevron open' : 'tree-chevron'}><Icon name="chevron"/></span><Icon name="folder"/><span>{folder.name}</span><em>{countTree(folder)}</em>
|
||||||
|
</button>
|
||||||
|
{open && <KnowledgeTree node={folder} depth={depth + 1} expanded={expanded} active={active} onToggle={onToggle} onOpen={onOpen}/>}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
{documents.map((document) => <button key={`${document.source}:${document.path}`} className={active === `${document.source}:${document.path}` ? 'tree-document active' : 'tree-document'} style={{ paddingLeft: 31 + depth * 14 }} type="button" onClick={() => onOpen(document)}>
|
||||||
|
<Icon name="file"/><span>{document.title}</span>{document.duplicateCount > 0 && <em title="折叠的相同内容">+{document.duplicateCount}</em>}
|
||||||
|
</button>)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
function HoloLakeApp() {
|
function HoloLakeApp() {
|
||||||
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 [view, setView] = useState<ViewId>('knowledge')
|
||||||
const [status, setStatus] = useState<HomeStatus>(previewStatus)
|
const [status, setStatus] = useState<HomeStatus>(previewStatus)
|
||||||
const [panel, setPanel] = useState<Panel>(null)
|
const [personal, setPersonal] = useState<PersonalChannelSnapshot>(previewPersonal)
|
||||||
const [ticket, setTicket] = useState<DiscoveryTicketReceipt | null>(null)
|
const [knowledge, setKnowledge] = useState<KnowledgeSnapshot>(previewKnowledge)
|
||||||
|
const [codeChannels, setCodeChannels] = useState<CodeChannelSnapshot>(previewCode)
|
||||||
|
const [activeDocument, setActiveDocument] = useState<KnowledgeDocument | null>(null)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState<KnowledgeSearchResult[] | null>(null)
|
||||||
|
const [knowledgeBusy, setKnowledgeBusy] = useState(false)
|
||||||
|
const [knowledgeMessage, setKnowledgeMessage] = useState('')
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(['导入']))
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
const [lastKnowledgeReceipt, setLastKnowledgeReceipt] = useState('')
|
||||||
|
const [cloneUrl, setCloneUrl] = useState('')
|
||||||
|
const [codeBusy, setCodeBusy] = useState(false)
|
||||||
|
const [codeMessage, setCodeMessage] = useState('')
|
||||||
|
const [activeChannel, setActiveChannel] = useState<CodeChannelEntry | null>(null)
|
||||||
|
const [codeTree, setCodeTree] = useState<CodeTreeSnapshot | null>(null)
|
||||||
|
const [activeCodeFile, setActiveCodeFile] = useState<CodeFileProjection | null>(null)
|
||||||
|
const [codeMode, setCodeMode] = useState<'human' | 'source'>('human')
|
||||||
|
const [displayName, setDisplayName] = useState('')
|
||||||
|
const [identityBusy, setIdentityBusy] = useState(false)
|
||||||
|
const [identityMessage, setIdentityMessage] = useState('')
|
||||||
const [receipts, setReceipts] = useState<ReceiptEvent[]>([])
|
const [receipts, setReceipts] = useState<ReceiptEvent[]>([])
|
||||||
const [repositoryCandidate, setRepositoryCandidate] = useState<RepositoryCandidate | null>(null)
|
const [ticket, setTicket] = useState<DiscoveryTicketReceipt | null>(null)
|
||||||
const [repositoryBusy, setRepositoryBusy] = useState(false)
|
const [systemMessage, setSystemMessage] = useState('')
|
||||||
const [repositoryMessage, setRepositoryMessage] = useState('')
|
|
||||||
const [releaseCandidate, setReleaseCandidate] = useState<ReleaseCandidate | null>(null)
|
const [releaseCandidate, setReleaseCandidate] = useState<ReleaseCandidate | null>(null)
|
||||||
const [releaseBusy, setReleaseBusy] = useState(false)
|
const [systemBusy, setSystemBusy] = useState(false)
|
||||||
const [releaseMessage, setReleaseMessage] = useState('')
|
|
||||||
const [message, setMessage] = useState('')
|
|
||||||
|
|
||||||
const refreshStatus = useCallback(async () => {
|
const refreshCore = useCallback(async () => {
|
||||||
try {
|
const [homeResult, personalResult, knowledgeResult, codeResult] = await Promise.allSettled([
|
||||||
setStatus(await invoke<HomeStatus>('get_hololake_home_status'))
|
invoke<HomeStatus>('get_hololake_home_status'),
|
||||||
} catch {
|
invoke<PersonalChannelSnapshot>('get_personal_channel_snapshot'),
|
||||||
setStatus(previewStatus)
|
invoke<KnowledgeSnapshot>('get_knowledge_snapshot'),
|
||||||
}
|
invoke<CodeChannelSnapshot>('get_code_channel_snapshot'),
|
||||||
|
])
|
||||||
|
if (homeResult.status === 'fulfilled') setStatus(homeResult.value)
|
||||||
|
if (personalResult.status === 'fulfilled') setPersonal(personalResult.value)
|
||||||
|
if (knowledgeResult.status === 'fulfilled') setKnowledge(knowledgeResult.value)
|
||||||
|
if (codeResult.status === 'fulfilled') setCodeChannels(codeResult.value)
|
||||||
|
}, [])
|
||||||
|
const loadReceipts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const projection = await invoke<ReceiptProjection>('query_pncc_receipt_projection', { input: { afterSequence: 0, limit: 25 } })
|
||||||
|
setReceipts(projection.events)
|
||||||
|
} catch { setReceipts([]) }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshStatus()
|
void refreshCore()
|
||||||
const timer = window.setInterval(() => void refreshStatus(), 3000)
|
const timer = window.setInterval(() => void refreshCore(), 3500)
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [refreshStatus])
|
}, [refreshCore])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
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(() => { if (view === 'receipts') void loadReceipts() }, [loadReceipts, view])
|
||||||
|
|
||||||
const connectionState = useMemo(() => {
|
const visibleDocuments = useMemo(() => {
|
||||||
if (status.directConnectionCount > 0) return { label: '已连接', detail: `${status.directConnectionCount} 个直连通道在线`, tone: 'ready' }
|
if (!searchResults) return knowledge.documents
|
||||||
if (status.resumableSessionCount > 0) return { label: '可续接', detail: `${status.resumableSessionCount} 个会话等待续接`, tone: 'waiting' }
|
return searchResults.map((result) => knowledge.documents.find((item) => item.source === result.source && item.path === result.path)).filter(Boolean) as KnowledgeDocumentSummary[]
|
||||||
return { label: '等待连接', detail: '本机直连核心已就绪', tone: 'quiet' }
|
}, [knowledge.documents, searchResults])
|
||||||
}, [status])
|
const tree = useMemo(() => knowledgeTree(visibleDocuments), [visibleDocuments])
|
||||||
|
const activeSummary = activeDocument
|
||||||
|
? knowledge.documents.find((item) => item.source === activeDocument.source && item.path === activeDocument.path)
|
||||||
|
: undefined
|
||||||
|
const parsedDocument = useMemo(() => activeDocument ? splitFrontmatter(activeDocument.body) : null, [activeDocument])
|
||||||
|
const outline = useMemo(() => activeDocument ? documentOutline(activeDocument.body) : [], [activeDocument])
|
||||||
|
const connectionOnline = status.directConnectionCount > 0
|
||||||
|
const connectionLabel = connectionOnline
|
||||||
|
? '本地原生连接 · 客户端在线'
|
||||||
|
: status.resumableSessionCount > 0
|
||||||
|
? '原生会话可续接 · 当前离线'
|
||||||
|
: '原生接口已驻留 · 尚无客户端'
|
||||||
|
|
||||||
const releaseRecoveryIdle = useMemo(() => [
|
const openDocument = async (source: KnowledgeSource, path: string) => {
|
||||||
'NONE',
|
setKnowledgeBusy(true)
|
||||||
'HEALTH_CONFIRMED_BACKUP_REMOVED',
|
setKnowledgeMessage('')
|
||||||
'ROLLBACK_CONFIRMED_BACKUP_REMOVED',
|
setEditing(false)
|
||||||
'INSTALL_FAILURE_ACKNOWLEDGED_BACKUP_REMOVED',
|
|
||||||
].includes(status.releaseRecoveryState), [status.releaseRecoveryState])
|
|
||||||
|
|
||||||
const releaseRecoveryNeedsAttention = !releaseRecoveryIdle
|
|
||||||
|
|
||||||
const openReceipts = async () => {
|
|
||||||
setPanel('receipts')
|
|
||||||
await loadReceipts()
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadReceipts = async () => {
|
|
||||||
try {
|
try {
|
||||||
const projection = await invoke<ReceiptProjection>('query_pncc_receipt_projection', { input: { afterSequence: 0, limit: 25 } })
|
const document = await invoke<KnowledgeDocument>('read_knowledge_document', { input: { source, path } })
|
||||||
setReceipts(projection.events)
|
setActiveDocument(document)
|
||||||
} catch {
|
setDraft(document.body)
|
||||||
setReceipts([])
|
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
|
||||||
}
|
finally { setKnowledgeBusy(false) }
|
||||||
}
|
}
|
||||||
|
const openWiki = (target: string) => {
|
||||||
const chooseRepository = async () => {
|
const normalized = target.replace(/\.md$/i, '').toLowerCase()
|
||||||
setRepositoryBusy(true)
|
const match = knowledge.documents.find((item) =>
|
||||||
setRepositoryMessage('')
|
item.title.toLowerCase() === normalized || item.path.replace(/\.md$/i, '').toLowerCase().endsWith(normalized))
|
||||||
try {
|
if (match) void openDocument(match.source, match.path)
|
||||||
const candidate = await invoke<RepositoryCandidate | null>('select_pncc_repository_candidate')
|
else setKnowledgeMessage(`未找到链接页面:${target}`)
|
||||||
setRepositoryCandidate(candidate)
|
|
||||||
if (!candidate) setRepositoryMessage('已取消选择,没有写入任何绑定。')
|
|
||||||
} catch (error) {
|
|
||||||
setRepositoryCandidate(null)
|
|
||||||
setRepositoryMessage(`这个文件夹不能作为代码通道:${String(error)}`)
|
|
||||||
} finally {
|
|
||||||
setRepositoryBusy(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const runSearch = async (event: React.FormEvent) => {
|
||||||
const confirmRepository = async () => {
|
event.preventDefault()
|
||||||
if (!repositoryCandidate) return
|
if (!searchQuery.trim()) { setSearchResults(null); return }
|
||||||
setRepositoryBusy(true)
|
setKnowledgeBusy(true)
|
||||||
setRepositoryMessage('')
|
|
||||||
try {
|
try {
|
||||||
await invoke('confirm_pncc_repository_mount', {
|
setSearchResults(await invoke<KnowledgeSearchResult[]>('search_knowledge', { input: { query: searchQuery } }))
|
||||||
input: {
|
setActiveDocument(null)
|
||||||
candidateId: repositoryCandidate.candidateId,
|
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
|
||||||
confirmationToken: repositoryCandidate.confirmationToken,
|
finally { setKnowledgeBusy(false) }
|
||||||
},
|
}
|
||||||
|
const importKnowledge = async () => {
|
||||||
|
setKnowledgeBusy(true)
|
||||||
|
setKnowledgeMessage('')
|
||||||
|
try {
|
||||||
|
const result = await invoke<KnowledgeImportResult | null>('select_and_import_knowledge_folder')
|
||||||
|
if (!result) return
|
||||||
|
setKnowledge(result.snapshot)
|
||||||
|
setSearchResults(null)
|
||||||
|
setSearchQuery('')
|
||||||
|
setLastKnowledgeReceipt(result.gitCommit)
|
||||||
|
setKnowledgeMessage(`导入完成:新增 ${result.importedDocuments},已存在 ${result.existingDocuments},冲突 ${result.conflicts}。`)
|
||||||
|
if (result.firstDocument) await openDocument('native', result.firstDocument)
|
||||||
|
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
|
||||||
|
finally { setKnowledgeBusy(false) }
|
||||||
|
}
|
||||||
|
const saveDocument = async () => {
|
||||||
|
if (!activeDocument?.writable) return
|
||||||
|
setKnowledgeBusy(true)
|
||||||
|
try {
|
||||||
|
const result = await invoke<KnowledgeSaveResult>('save_knowledge_document', {
|
||||||
|
input: { path: activeDocument.path, body: draft, expectedContentSha256: activeDocument.contentSha256 },
|
||||||
})
|
})
|
||||||
setRepositoryCandidate(null)
|
setActiveDocument(result.document)
|
||||||
setRepositoryMessage('代码仓库已按当前固定提交绑定为只读通道。')
|
setDraft(result.document.body)
|
||||||
await Promise.all([refreshStatus(), loadReceipts()])
|
setEditing(false)
|
||||||
} catch (error) {
|
setLastKnowledgeReceipt(result.gitCommit)
|
||||||
setRepositoryMessage(`绑定没有写入:${String(error)}`)
|
setKnowledgeMessage(result.state === 'UNCHANGED' ? '内容没有变化。' : '页面已保存,并写入本机 Git 回执。')
|
||||||
} finally {
|
await refreshCore()
|
||||||
setRepositoryBusy(false)
|
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
|
||||||
}
|
finally { setKnowledgeBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkUpdate = async () => {
|
const cloneCodeChannel = async (event: React.FormEvent) => {
|
||||||
setReleaseBusy(true)
|
event.preventDefault()
|
||||||
setReleaseMessage('')
|
if (!cloneUrl.trim()) return
|
||||||
|
setCodeBusy(true)
|
||||||
|
setCodeMessage('')
|
||||||
try {
|
try {
|
||||||
const receipt = await invoke<ReleaseCheckReceipt>('check_hololake_update')
|
const snapshot = await invoke<CodeChannelSnapshot>('clone_code_channel', { input: { url: cloneUrl } })
|
||||||
setReleaseCandidate(receipt.candidate || null)
|
setCodeChannels(snapshot)
|
||||||
if (!receipt.candidate) setReleaseMessage('当前已经是最新版本。')
|
setCloneUrl('')
|
||||||
} catch (error) {
|
setCodeMessage('代码频道已克隆并登记。')
|
||||||
const reason = String(error)
|
} catch (error) { setCodeMessage(humanError(error, 'code')) }
|
||||||
setReleaseCandidate(null)
|
finally { setCodeBusy(false) }
|
||||||
setReleaseMessage(reason.includes('UNPROVISIONED_NO_NETWORK_REQUEST')
|
|
||||||
? '京东主控的正式发布地址和更新公钥还没有登记。HoloLake 保持离线,不会向任何上游检查更新。'
|
|
||||||
: `更新检查没有完成:${reason}`)
|
|
||||||
} finally {
|
|
||||||
setReleaseBusy(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const selectLocalCodeChannel = async () => {
|
||||||
const confirmUpdateInstall = async () => {
|
setCodeBusy(true)
|
||||||
if (!releaseCandidate) return
|
setCodeMessage('')
|
||||||
setReleaseBusy(true)
|
|
||||||
setReleaseMessage('正在重新核对广播并验证安装包;完成后由你手动重启 HoloLake。')
|
|
||||||
try {
|
try {
|
||||||
await invoke('confirm_hololake_update_install', {
|
const snapshot = await invoke<CodeChannelSnapshot | null>('select_local_code_channel')
|
||||||
input: {
|
if (snapshot) {
|
||||||
candidateId: releaseCandidate.candidateId,
|
setCodeChannels(snapshot)
|
||||||
confirmationToken: releaseCandidate.confirmationToken,
|
setCodeMessage('本地代码频道已登记,原文件未移动。')
|
||||||
},
|
|
||||||
})
|
|
||||||
setReleaseCandidate(null)
|
|
||||||
setReleaseMessage('签名安装包已验证并安装。请在准备好后手动重启 HoloLake。')
|
|
||||||
await refreshStatus()
|
|
||||||
} catch (error) {
|
|
||||||
setReleaseMessage(`更新没有安装:${String(error)}`)
|
|
||||||
} finally {
|
|
||||||
setReleaseBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveReleaseRecovery = async (action: 'keep' | 'rollback') => {
|
|
||||||
setReleaseBusy(true)
|
|
||||||
setReleaseMessage(action === 'rollback' ? '正在复核上一版签名并恢复;完成后仍由你手动重启。' : '正在确认当前版本健康并清理上一版副本。')
|
|
||||||
try {
|
|
||||||
if (action === 'rollback') {
|
|
||||||
await invoke('rollback_hololake_update')
|
|
||||||
setReleaseMessage('上一版已恢复到应用位置。请手动重启 HoloLake,启动后再确认恢复正常。')
|
|
||||||
} else {
|
|
||||||
await invoke('confirm_hololake_release_health')
|
|
||||||
setReleaseMessage('当前运行版本已由你确认,临时回退副本已经清理。')
|
|
||||||
}
|
}
|
||||||
await refreshStatus()
|
} catch (error) { setCodeMessage(humanError(error, 'code')) }
|
||||||
} catch (error) {
|
finally { setCodeBusy(false) }
|
||||||
setReleaseMessage(`没有改变当前应用:${String(error)}`)
|
|
||||||
} finally {
|
|
||||||
setReleaseBusy(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const browseChannel = async (channel: CodeChannelEntry, path = '') => {
|
||||||
const issueInvitation = async () => {
|
setCodeBusy(true)
|
||||||
setMessage('')
|
setActiveChannel(channel)
|
||||||
|
setActiveCodeFile(null)
|
||||||
try {
|
try {
|
||||||
const receipt = await invoke<DiscoveryTicketReceipt>('issue_direct_local_discovery_ticket', {
|
setCodeTree(await invoke<CodeTreeSnapshot>('browse_code_channel', { input: { channelId: channel.channelId, path } }))
|
||||||
|
} catch (error) { setCodeMessage(humanError(error, 'code')) }
|
||||||
|
finally { setCodeBusy(false) }
|
||||||
|
}
|
||||||
|
const openCodeEntry = async (entry: CodeTreeEntry) => {
|
||||||
|
if (!activeChannel) return
|
||||||
|
if (entry.kind === 'directory') { await browseChannel(activeChannel, entry.path); return }
|
||||||
|
setCodeBusy(true)
|
||||||
|
setCodeMode('human')
|
||||||
|
try {
|
||||||
|
setActiveCodeFile(await invoke<CodeFileProjection>('read_code_channel_file', {
|
||||||
|
input: { channelId: activeChannel.channelId, path: entry.path },
|
||||||
|
}))
|
||||||
|
} catch (error) { setCodeMessage(humanError(error, 'code')) }
|
||||||
|
finally { setCodeBusy(false) }
|
||||||
|
}
|
||||||
|
const codeParent = () => codeTree?.path.split('/').filter(Boolean).slice(0, -1).join('/') || ''
|
||||||
|
|
||||||
|
const initializeIdentity = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setIdentityBusy(true)
|
||||||
|
try {
|
||||||
|
setPersonal(await invoke<PersonalChannelSnapshot>('initialize_personal_channel', { input: { displayName } }))
|
||||||
|
setDisplayName('')
|
||||||
|
} catch (error) { setIdentityMessage(humanError(error, 'identity')) }
|
||||||
|
finally { setIdentityBusy(false) }
|
||||||
|
}
|
||||||
|
const issueInvitation = async () => {
|
||||||
|
setSystemBusy(true)
|
||||||
|
setSystemMessage('')
|
||||||
|
try {
|
||||||
|
setTicket(await invoke<DiscoveryTicketReceipt>('issue_direct_local_discovery_ticket', {
|
||||||
input: {
|
input: {
|
||||||
accountId: getOrCreateLocalId('hololake-local-account', 'human-local'),
|
accountId: getOrCreateLocalId('hololake-local-account', 'human-local'),
|
||||||
laneId: 'personal-channel',
|
laneId: 'personal-channel',
|
||||||
clientInstanceId: getOrCreateLocalId('hololake-external-ai-client', 'external-ai'),
|
clientInstanceId: getOrCreateLocalId('hololake-external-ai-client', 'external-ai'),
|
||||||
},
|
},
|
||||||
})
|
}))
|
||||||
setTicket(receipt)
|
setSystemMessage('一次性发现凭据已生成。连接成功后,状态条会显示真实在线数。')
|
||||||
setMessage('连接邀请已生成。它只在这台电脑上有效。')
|
} catch (error) { setSystemMessage(humanError(error, 'system')) }
|
||||||
} catch (error) {
|
finally { setSystemBusy(false) }
|
||||||
setMessage(`暂时无法生成连接邀请:${String(error)}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const invitationText = ticket ? JSON.stringify({
|
const invitationText = ticket ? JSON.stringify({
|
||||||
schema: 'hololake.direct-local-invitation/v1',
|
schema: 'hololake.direct-local-invitation/v1',
|
||||||
transport: 'INSTALLED_HOLOLAKE_EXECUTABLE_WITH_CONNECTOR_FLAG',
|
|
||||||
connectorArgument: '--connector',
|
connectorArgument: '--connector',
|
||||||
openSession: {
|
openSession: {
|
||||||
operation: 'OPEN_SESSION',
|
operation: 'OPEN_SESSION',
|
||||||
|
|
@ -330,160 +430,170 @@ function HoloLakeApp() {
|
||||||
discoveryTicket: ticket.discoveryTicket,
|
discoveryTicket: ticket.discoveryTicket,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY',
|
|
||||||
}, null, 2) : ''
|
}, null, 2) : ''
|
||||||
|
|
||||||
const copyInvitation = async () => {
|
const copyInvitation = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(invitationText)
|
await navigator.clipboard.writeText(invitationText)
|
||||||
setMessage('已复制。把它交给桌面上的编程 AI 即可。')
|
setSystemMessage('连接凭据已复制。')
|
||||||
} catch {
|
} catch { setSystemMessage('复制失败,请手动选择凭据。') }
|
||||||
setMessage('复制没有成功,请选中下方邀请内容后复制。')
|
}
|
||||||
}
|
const checkUpdate = async () => {
|
||||||
|
setSystemBusy(true)
|
||||||
|
try {
|
||||||
|
const result = await invoke<ReleaseCheckReceipt>('check_hololake_update')
|
||||||
|
setReleaseCandidate(result.candidate || null)
|
||||||
|
setSystemMessage(result.candidate ? '发现已签名更新。' : '当前没有可安装更新。')
|
||||||
|
} catch (error) { setSystemMessage(humanError(error, 'system')) }
|
||||||
|
finally { setSystemBusy(false) }
|
||||||
|
}
|
||||||
|
const installUpdate = async () => {
|
||||||
|
if (!releaseCandidate) return
|
||||||
|
setSystemBusy(true)
|
||||||
|
try {
|
||||||
|
await invoke('confirm_hololake_update_install', {
|
||||||
|
input: { candidateId: releaseCandidate.candidateId, confirmationToken: releaseCandidate.confirmationToken },
|
||||||
|
})
|
||||||
|
setSystemMessage('更新安装已启动。')
|
||||||
|
} catch (error) { setSystemMessage(humanError(error, 'system')) }
|
||||||
|
finally { setSystemBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const renderOverview = () => (
|
||||||
<div className="world-shell">
|
<section className="content-page overview-page">
|
||||||
<div className="scene-image" aria-hidden="true" />
|
<header className="page-title">
|
||||||
<div className="nebula nebula-one" aria-hidden="true" />
|
<div><span className="kicker">PERSONAL HOLOLAKE</span><h1>个人工作空间</h1><p>知识、代码与本机协作的统一入口</p></div>
|
||||||
<div className="nebula nebula-two" aria-hidden="true" />
|
<button className="evidence-pill" type="button" onClick={() => setView('system')}><i className={connectionOnline ? 'online' : ''}/>{connectionLabel}</button>
|
||||||
<div className="mist" aria-hidden="true" />
|
|
||||||
<div className="stars" aria-hidden="true">{Array.from({ length: 18 }, (_, index) => <i key={index} style={{ '--star-index': index } as React.CSSProperties} />)}</div>
|
|
||||||
<div className="glimmers" aria-hidden="true">{Array.from({ length: 11 }, (_, index) => <i key={index} style={{ '--glimmer-index': index } as React.CSSProperties} />)}</div>
|
|
||||||
|
|
||||||
<header className="world-header">
|
|
||||||
<div className="world-mark">
|
|
||||||
<span className="world-name">光湖</span>
|
|
||||||
<span className="world-route">个人频道</span>
|
|
||||||
</div>
|
|
||||||
<button className="icon-button" type="button" aria-label="打开设置" onClick={() => setPanel('settings')}><Icon name="settings" /></button>
|
|
||||||
</header>
|
</header>
|
||||||
|
<div className="overview-grid">
|
||||||
<main className="home-content">
|
<button className="metric-panel" type="button" onClick={() => setView('knowledge')}><span>唯一知识页</span><strong>{knowledge.uniqueDocumentCount}</strong><small>{knowledge.duplicateDocumentCount} 份相同内容已折叠</small></button>
|
||||||
<section className="hero" aria-labelledby="home-title">
|
<button className="metric-panel" type="button" onClick={() => setView('code')}><span>代码频道</span><strong>{codeChannels.channels.length}</strong><small>可浏览、可转译、本机只读接入</small></button>
|
||||||
<p className="route-kicker">HOLOLAKE · PERSONAL CHANNEL</p>
|
<button className="metric-panel" type="button" onClick={() => setView('receipts')}><span>可核验回执</span><strong>{personal.integrity.receiptCount + status.pnccReceiptCount}</strong><small>身份、知识与代码证据</small></button>
|
||||||
<h1 id="home-title">语言进入这里,<br />成为可见、可续的现实。</h1>
|
</div>
|
||||||
<p className="hero-copy">外部编程 AI 从本机进入。HoloLake 保存路线、边界与回执;五个域安静地运行在湖底。</p>
|
<div className="overview-columns">
|
||||||
<div className="hero-actions">
|
<section className="plain-panel recent-panel">
|
||||||
<button className="primary-action" type="button" onClick={() => setPanel('channel')}>
|
<header><div><h2>最近知识</h2><p>从真实页面继续阅读</p></div><button type="button" onClick={() => setView('knowledge')}>打开工作台</button></header>
|
||||||
{status.directConnectionCount > 0 ? '已连接 · 查看通道' : '连接编程 AI'}
|
{knowledge.documents.slice().sort((a, b) => b.updatedAtUnixMs - a.updatedAtUnixMs).slice(0, 7).map((item) =>
|
||||||
<Icon name="arrow" />
|
<button className="recent-row" key={`${item.source}:${item.path}`} type="button" onClick={() => { setView('knowledge'); void openDocument(item.source, item.path) }}>
|
||||||
</button>
|
<Icon name="file"/><span><b>{item.title}</b><small>{item.path}</small></span><time>{new Date(item.updatedAtUnixMs).toLocaleDateString('zh-CN')}</time>
|
||||||
<button className="secondary-action" type="button" onClick={() => void openReceipts()}><Icon name="receipt" />查看回执</button>
|
</button>)}
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
<section className="plain-panel system-proof">
|
||||||
<section className="status-cluster" aria-label="光湖运行状态">
|
<header><div><h2>运行证据</h2><p>状态来自当前进程,不使用演示值</p></div></header>
|
||||||
<article className="status-item">
|
<dl><div><dt>Unix 本地代理</dt><dd>{status.directLocalBrokerState}</dd></div><div><dt>在线客户端</dt><dd>{status.directConnectionCount}</dd></div><div><dt>可续接会话</dt><dd>{status.resumableSessionCount}</dd></div><div><dt>PNCC 投影回执</dt><dd>{status.pnccReceiptCount}</dd></div><div><dt>MCP</dt><dd>备用 / 恢复</dd></div></dl>
|
||||||
<span className={`status-light ${connectionState.tone}`} aria-hidden="true" />
|
<button className="secondary-button" type="button" onClick={() => setView('system')}>查看验证入口</button>
|
||||||
<div><h2>本机直连通道</h2><p>{connectionState.detail}</p></div>
|
|
||||||
</article>
|
|
||||||
<article className="status-item">
|
|
||||||
<span className={`status-light ${status.codeRepositoryMountCount > 0 ? 'ready' : 'quiet'}`} aria-hidden="true" />
|
|
||||||
<div><h2>代码通道仓库</h2><p>{status.codeRepositoryMountCount > 0 ? `已验证 · ${status.codeRepositoryMountCount} 个仓库` : '等待人类确认绑定'}</p></div>
|
|
||||||
</article>
|
|
||||||
<button className="status-item status-button" type="button" onClick={() => setPanel('updates')}>
|
|
||||||
<span className={`status-light ${releaseRecoveryNeedsAttention ? 'waiting' : status.updateState.startsWith('READY') ? 'ready' : 'quiet'}`} aria-hidden="true" />
|
|
||||||
<div><h2>更新广播</h2><p>{releaseRecoveryNeedsAttention ? '更新结果等待你的确认' : status.updateState.startsWith('READY') ? '仅接收光湖签名广播' : '信任根未配置 · 已关闭联网'}</p></div>
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</div>
|
||||||
|
</section>
|
||||||
<footer className="world-footer">
|
|
||||||
<p><b>GH-AIOS</b><span>光湖人工智能操作系统</span></p>
|
|
||||||
<p className="trust-note"><i />你的工作留在本机 · 连接与更新均需确认</p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
{panel && <div className="panel-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setPanel(null)}>
|
|
||||||
<section className="detail-panel" role="dialog" aria-modal="true" aria-labelledby="panel-title">
|
|
||||||
<button className="panel-close" type="button" aria-label="关闭" onClick={() => setPanel(null)}><Icon name="close" /></button>
|
|
||||||
{panel === 'channel' && <>
|
|
||||||
<p className="panel-kicker">个人频道</p>
|
|
||||||
<h2 id="panel-title">让编程 AI 从本机进入</h2>
|
|
||||||
<p className="panel-intro">MCP 只负责发现和恢复。进入后,编程 AI 使用 HoloLake 的本机直连通道;电脑不关,驻留核心就一直在。</p>
|
|
||||||
<div className="connection-summary"><span className={`status-light ${connectionState.tone}`} /><div><b>{connectionState.label}</b><span>{connectionState.detail}</span></div></div>
|
|
||||||
{status.directConnectionCount > 0 ? <div className="session-guidance">
|
|
||||||
<b>通道已建立</b>
|
|
||||||
<span>编程 AI 正在使用本机直连通道。一次性邀请已经失效,不会再次显示。</span>
|
|
||||||
</div> : status.resumableSessionCount > 0 ? <div className="session-guidance">
|
|
||||||
<b>等待原会话续接</b>
|
|
||||||
<span>请让原编程 AI 使用它保存的续接凭据重新进入。HoloLake 不会重新展示已经消费的邀请。</span>
|
|
||||||
</div> : ticket ? <>
|
|
||||||
<button className="primary-action panel-action" type="button" onClick={() => void copyInvitation()}><Icon name="copy" />复制给编程 AI</button>
|
|
||||||
<pre className="invitation-preview">{invitationText}</pre>
|
|
||||||
</> : <button className="primary-action panel-action" type="button" onClick={() => void issueInvitation()}>生成一次性连接邀请<Icon name="arrow" /></button>}
|
|
||||||
{message && <p className="panel-message" aria-live="polite">{message}</p>}
|
|
||||||
<p className="boundary-note">连接邀请不赋予服务器权限。写入、发布与更新仍分别确认并留下回执。</p>
|
|
||||||
</>}
|
|
||||||
{panel === 'receipts' && <>
|
|
||||||
<p className="panel-kicker">可核验的发生</p>
|
|
||||||
<h2 id="panel-title">代码通道回执</h2>
|
|
||||||
<p className="panel-intro">这里是只读投影,不是权限来源。没有回执,也不代表服务器离线。</p>
|
|
||||||
{repositoryCandidate ? <div className="candidate-card">
|
|
||||||
<div className="candidate-heading"><span className="status-light waiting" /><div><b>等待你确认</b><span>HoloLake 已只读检查这个 Git 仓库</span></div></div>
|
|
||||||
<dl className="candidate-facts">
|
|
||||||
<div><dt>人格</dt><dd>{repositoryCandidate.inspection.personaId}</dd></div>
|
|
||||||
<div><dt>责任人</dt><dd>{repositoryCandidate.inspection.humanResponsibilitySubject}</dd></div>
|
|
||||||
<div><dt>固定提交</dt><dd>{repositoryCandidate.inspection.gitHead.slice(0, 12)}</dd></div>
|
|
||||||
<div><dt>工作区</dt><dd>{repositoryCandidate.inspection.repositoryClean ? '干净' : '有未提交变化 · 不读取'}</dd></div>
|
|
||||||
<div><dt>检查点</dt><dd>{repositoryCandidate.inspection.currentCheckpoint.relativePath}</dd></div>
|
|
||||||
</dl>
|
|
||||||
<p>确认后只绑定这一份已提交证据;不会启动模型、不会取得人格租约、不会执行现实动作。</p>
|
|
||||||
<div className="candidate-actions"><button className="primary-action" type="button" disabled={repositoryBusy} onClick={() => void confirmRepository()}>确认只读绑定<Icon name="arrow" /></button><button className="text-action" type="button" disabled={repositoryBusy} onClick={() => setRepositoryCandidate(null)}>重新选择</button></div>
|
|
||||||
</div> : <button className="repository-picker" type="button" disabled={repositoryBusy} onClick={() => void chooseRepository()}><span><b>{status.codeRepositoryMountCount > 0 ? '再绑定一个代码仓库' : '绑定代码通道仓库'}</b><small>从 macOS 原生文件夹选择器进入</small></span><Icon name="arrow" /></button>}
|
|
||||||
{repositoryMessage && <p className="panel-message" aria-live="polite">{repositoryMessage}</p>}
|
|
||||||
{receipts.length ? <ol className="receipt-list">{receipts.map((receipt) => <li key={receipt.sequence}><span>{receipt.sequence.toString().padStart(2, '0')}</span><div><b>{receipt.kind === 'REPOSITORY_BINDING_REVALIDATED' ? '仓库绑定已复核' : '远端对象读取已验证'}</b><small>{new Date(Number(receipt.observedAtUnixMs)).toLocaleString('zh-CN')} · {receipt.eventHash.slice(0, 12)}</small></div></li>)}</ol> : <div className="empty-state compact"><i /><b>湖面还没有新回执</b><span>完成代码仓库绑定或读取后,这里会出现可核验记录。</span></div>}
|
|
||||||
</>}
|
|
||||||
{panel === 'updates' && <>
|
|
||||||
<p className="panel-kicker">光湖签名广播</p>
|
|
||||||
<h2 id="panel-title">更新由你决定何时进入</h2>
|
|
||||||
<p className="panel-intro">HoloLake 不接收开源上游自动推送。只有登记到本机的光湖主控地址和公钥可以参与更新,并且检查、安装、重启是三件分开的事。</p>
|
|
||||||
{status.releaseRecoveryState === 'AWAITING_HUMAN_HEALTH_CONFIRMATION' ? <div className="recovery-card">
|
|
||||||
<div className="candidate-heading"><span className="status-light waiting" /><div><b>新版本已启动,等待你的判断</b><span>上一版仍作为已验签副本保留</span></div></div>
|
|
||||||
<p>如果当前湖面、连接和代码通道都正常,确认保留当前版本;如果不正常,恢复上一版。两个动作都不会自动重启。</p>
|
|
||||||
<div className="recovery-actions"><button className="primary-action" type="button" disabled={releaseBusy} onClick={() => void resolveReleaseRecovery('keep')}>当前版本正常</button><button className="secondary-action" type="button" disabled={releaseBusy} onClick={() => void resolveReleaseRecovery('rollback')}>恢复上一版</button></div>
|
|
||||||
</div> : status.releaseRecoveryState === 'ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT' ? <div className="recovery-card">
|
|
||||||
<div className="candidate-heading"><span className="status-light waiting" /><div><b>上一版已经恢复</b><span>请确认湖面与通道运行正常</span></div></div>
|
|
||||||
<p>确认后,HoloLake 会清理这次更新留下的临时回退副本,并重新开放下一次更新检查。</p>
|
|
||||||
<button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void resolveReleaseRecovery('keep')}>确认恢复正常<Icon name="arrow" /></button>
|
|
||||||
</div> : ['INSTALL_FAILED_BACKUP_RETAINED', 'INSTALL_INTERRUPTED_BACKUP_RETAINED'].includes(status.releaseRecoveryState) ? <div className="recovery-card">
|
|
||||||
<div className="candidate-heading"><span className="status-light waiting" /><div><b>更新没有替换当前版本</b><span>上一版保护副本仍保留</span></div></div>
|
|
||||||
<p>当前 HoloLake 仍在原版本上运行。确认运行正常后,清理保护副本并重新开放更新。</p>
|
|
||||||
<button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void resolveReleaseRecovery('keep')}>确认当前版本正常<Icon name="arrow" /></button>
|
|
||||||
</div> : ['INSTALL_REPLACED_AWAITING_MANUAL_RESTART', 'ROLLBACK_REPLACED_AWAITING_MANUAL_RESTART', 'LAST_KNOWN_GOOD_VERIFIED_INSTALLING'].includes(status.releaseRecoveryState) ? <div className="recovery-card">
|
|
||||||
<div className="candidate-heading"><span className="status-light waiting" /><div><b>应用已替换,等待手动重启</b><span>HoloLake 不会替你关闭或重启</span></div></div>
|
|
||||||
<p>关闭并重新打开 HoloLake 后,这里会要求你确认新版本健康,或确认上一版已经恢复。</p>
|
|
||||||
</div> : !releaseRecoveryIdle ? <div className="recovery-card">
|
|
||||||
<div className="candidate-heading"><span className="status-light waiting" /><div><b>更新保护状态需要维护</b><span>下一次更新已经锁住,现有湖面仍可使用</span></div></div>
|
|
||||||
<p>HoloLake 没有删除或猜测恢复记录,也不会联网继续更新。由光湖维护人员核对本机回执后再恢复更新通道。</p>
|
|
||||||
</div> : releaseCandidate ? <div className="release-card">
|
|
||||||
<div className="release-version"><span>新版本</span><b>{releaseCandidate.version}</b><small>当前 {releaseCandidate.currentVersion}</small></div>
|
|
||||||
<p>{releaseCandidate.notes}</p>
|
|
||||||
<ul>{releaseCandidate.features.map((feature) => <li key={feature}>{feature}</li>)}{releaseCandidate.fixes.map((fix) => <li key={fix}>{fix}</li>)}</ul>
|
|
||||||
<dl className="candidate-facts">
|
|
||||||
<div><dt>数据迁移</dt><dd>{releaseCandidate.dataMigrationRequired ? '需要' : '不需要'}</dd></div>
|
|
||||||
<div><dt>最低版本</dt><dd>{releaseCandidate.minimumVersion}</dd></div>
|
|
||||||
<div><dt>重启</dt><dd>安装后由你手动完成</dd></div>
|
|
||||||
<div><dt>回滚声明</dt><dd>{releaseCandidate.rollbackSupported ? `支持 · ${releaseCandidate.previousVersion || '上一版本'}` : '不支持'}</dd></div>
|
|
||||||
</dl>
|
|
||||||
<p className="release-warning">安装前会再次读取同一广播,验证下载域名、Tauri 签名、文件大小和 SHA-256;内容变化就停止并要求重新确认。</p>
|
|
||||||
<button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void confirmUpdateInstall()}>确认下载并安装<Icon name="arrow" /></button>
|
|
||||||
</div> : <div className="update-state-card"><span className={`status-light ${status.updateState.startsWith('READY') ? 'ready' : 'quiet'}`} /><div><b>{status.updateState.startsWith('READY') ? '主控信任根已登记' : '更新网络保持关闭'}</b><span>{status.updateState.startsWith('READY') ? '可以由你手动检查签名广播' : '没有正式地址和公钥时,不发起网络请求'}</span></div></div>}
|
|
||||||
{!releaseCandidate && releaseRecoveryIdle && <button className="primary-action panel-action" type="button" disabled={releaseBusy} onClick={() => void checkUpdate()}>{releaseBusy ? '正在核对…' : '检查光湖更新'}<Icon name="arrow" /></button>}
|
|
||||||
{releaseMessage && <p className="panel-message" aria-live="polite">{releaseMessage}</p>}
|
|
||||||
<p className="boundary-note">持久回退底座已进入本机容器。公共更新仍需京东主控正式地址、公钥、签名发布流水线与苹果公证全部到位后才会启用。</p>
|
|
||||||
</>}
|
|
||||||
{panel === 'settings' && <>
|
|
||||||
<p className="panel-kicker">湖面设置</p>
|
|
||||||
<h2 id="panel-title">选择你看见光湖的方式</h2>
|
|
||||||
<p className="panel-intro">只改变光与色。布局、文案、连接边界和底层路由保持不变。</p>
|
|
||||||
<div className="theme-list">{themes.map((choice) => <button key={choice.id} type="button" className={theme === choice.id ? 'selected' : ''} onClick={() => setTheme(choice.id)}><i className={`theme-swatch ${choice.id}`} /><span><b>{choice.name}</b><small>{choice.id === 'night' ? '当前定版基色' : '五色湖定版主题'}</small></span><em>{theme === choice.id ? '已选择' : ''}</em></button>)}</div>
|
|
||||||
<p className="boundary-note">上游自动更新保持关闭。正式更新只接受 HoloLake 自有信任根的签名广播,并由你确认。</p>
|
|
||||||
</>}
|
|
||||||
</section>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const renderKnowledge = () => (
|
||||||
|
<section className="full-workbench knowledge-page">
|
||||||
|
<aside className="knowledge-browser">
|
||||||
|
<header><div><span className="kicker">KNOWLEDGE</span><h1>知识工作台</h1></div><button className="icon-button" title="导入文件夹" type="button" disabled={knowledgeBusy} onClick={() => void importKnowledge()}><Icon name="import"/></button></header>
|
||||||
|
<form className="search-box" onSubmit={(event) => void runSearch(event)}>
|
||||||
|
<Icon name="search"/><input aria-label="检索知识" value={searchQuery} placeholder="检索标题与正文" onChange={(event) => setSearchQuery(event.target.value)}/>
|
||||||
|
{searchResults && <button type="button" onClick={() => { setSearchResults(null); setSearchQuery('') }}>清除</button>}
|
||||||
|
</form>
|
||||||
|
<div className="knowledge-counts"><span>{knowledge.uniqueDocumentCount} 篇唯一文档</span><span>{knowledge.duplicateDocumentCount} 份重复已折叠</span></div>
|
||||||
|
<div className="knowledge-tree" aria-busy={knowledgeBusy}>
|
||||||
|
{visibleDocuments.length
|
||||||
|
? <KnowledgeTree node={tree} depth={0} expanded={expanded} active={activeDocument ? `${activeDocument.source}:${activeDocument.path}` : undefined}
|
||||||
|
onToggle={(key) => setExpanded((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next })}
|
||||||
|
onOpen={(item) => void openDocument(item.source, item.path)}/>
|
||||||
|
: <div className="empty-state">没有匹配的知识页</div>}
|
||||||
|
</div>
|
||||||
|
<footer>{knowledgeMessage || (lastKnowledgeReceipt ? `Git ${lastKnowledgeReceipt.slice(0, 10)}` : '导入会自动检查相同内容')}</footer>
|
||||||
|
</aside>
|
||||||
|
<main className="document-workspace">
|
||||||
|
{activeDocument ? <>
|
||||||
|
<header className="document-toolbar">
|
||||||
|
<div className="breadcrumbs"><span>{activeDocument.source === 'legacy' ? 'HoloLake Era · 只读源' : '我的知识库'}</span><b>/</b><span>{activeDocument.path}</span></div>
|
||||||
|
<div>{editing
|
||||||
|
? <><button className="toolbar-button" type="button" onClick={() => { setEditing(false); setDraft(activeDocument.body) }}>取消</button><button className="toolbar-button primary" type="button" disabled={knowledgeBusy} onClick={() => void saveDocument()}><Icon name="save"/>保存</button></>
|
||||||
|
: activeDocument.writable && <button className="toolbar-button" type="button" onClick={() => { setDraft(activeDocument.body); setEditing(true) }}><Icon name="edit"/>编辑</button>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="document-scroll">
|
||||||
|
{editing
|
||||||
|
? <textarea className="document-editor" aria-label="Markdown 编辑器" value={draft} onChange={(event) => setDraft(event.target.value)}/>
|
||||||
|
: <><header className="reader-heading"><h1>{activeDocument.title}</h1><div>{Object.entries(parsedDocument?.metadata || {}).slice(0, 5).map(([key, value]) => <span key={key}>{key} · {value}</span>)}</div></header><MarkdownDocument body={activeDocument.body} onWiki={openWiki}/></>}
|
||||||
|
</div>
|
||||||
|
</> : <div className="workbench-empty"><span>光</span><h2>选择一篇知识页</h2><p>目录、正文与版本证据会在同一个工作台中展开。</p><button className="primary-button" type="button" onClick={() => void importKnowledge()}>导入本地文件夹</button></div>}
|
||||||
|
</main>
|
||||||
|
<aside className="document-inspector">
|
||||||
|
<div className="inspector-tabs"><span className="active">大纲</span><span>来源</span><span>回执</span></div>
|
||||||
|
{activeDocument ? <div className="inspector-scroll">
|
||||||
|
<section><h2>页面大纲</h2>{outline.length ? <nav className="outline-list">{outline.map((item) => <span key={item.id} style={{ paddingLeft: (item.level - 1) * 11 }}>{item.title}</span>)}</nav> : <p>当前页面没有标题层级。</p>}</section>
|
||||||
|
<section><h2>来源</h2><dl><div><dt>知识根</dt><dd>{activeDocument.source === 'native' ? 'HoloLake 本机 Git' : 'HoloLake Era 只读供体'}</dd></div><div><dt>路径</dt><dd>{activeDocument.path}</dd></div><div><dt>内容指纹</dt><dd>{activeDocument.contentSha256.slice(0, 16)}</dd></div><div><dt>相同内容</dt><dd>{activeSummary?.duplicateCount || 0} 份已折叠</dd></div></dl></section>
|
||||||
|
<section><h2>页面状态</h2><p>{activeDocument.writable ? '可编辑;保存时写入本机 Git。' : '供体原件只读;不会被修改。'}</p>{lastKnowledgeReceipt && <code>{lastKnowledgeReceipt}</code>}</section>
|
||||||
|
</div> : <div className="empty-state">选择页面后显示大纲与证据</div>}
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderCode = () => (
|
||||||
|
<section className="full-workbench code-workbench">
|
||||||
|
<aside className="code-channels">
|
||||||
|
<header><div><span className="kicker">CODE CHANNEL</span><h1>代码频道</h1></div></header>
|
||||||
|
<form className="clone-form" onSubmit={(event) => void cloneCodeChannel(event)}>
|
||||||
|
<label htmlFor="clone-url">克隆频道地址</label><div><input id="clone-url" type="url" value={cloneUrl} placeholder="https://guanghulab.com/code/…" onChange={(event) => setCloneUrl(event.target.value)}/><button disabled={!cloneUrl.trim() || codeBusy}>克隆</button></div>
|
||||||
|
</form>
|
||||||
|
<button className="local-folder-button" type="button" onClick={() => void selectLocalCodeChannel()}><Icon name="folder"/>导入本机 Git 文件夹</button>
|
||||||
|
<div className="channel-selector">{codeChannels.channels.map((channel) => <button className={activeChannel?.channelId === channel.channelId ? 'active' : ''} key={channel.channelId} type="button" onClick={() => void browseChannel(channel)}><Icon name="code"/><span><b>{channel.name}</b><small>{channel.branch} · {channel.gitHead.slice(0, 8)}</small></span></button>)}</div>
|
||||||
|
<footer>{codeMessage || '仅提供本机读取,不授予推送、发布或部署权限。'}</footer>
|
||||||
|
</aside>
|
||||||
|
<aside className="repository-tree">
|
||||||
|
<header><button className="icon-button" disabled={!codeTree?.path} type="button" onClick={() => activeChannel && void browseChannel(activeChannel, codeParent())}><Icon name="back"/></button><div><b>{activeChannel?.name || '尚未选择频道'}</b><small>/{codeTree?.path || ''}</small></div></header>
|
||||||
|
<div className="repository-entries">{codeTree?.entries.map((entry) => <button key={entry.path} type="button" onClick={() => void openCodeEntry(entry)}><Icon name={entry.kind === 'directory' ? 'folder' : 'file'}/><span>{entry.name}</span>{entry.kind === 'directory' && <Icon name="chevron"/>}</button>) || <div className="empty-state">选择频道后浏览仓库</div>}</div>
|
||||||
|
</aside>
|
||||||
|
<main className="code-reader">
|
||||||
|
{activeCodeFile ? <>
|
||||||
|
<header><div><span>{activeCodeFile.path}</span><small>{activeCodeFile.format.toUpperCase()} · {activeCodeFile.sizeBytes.toLocaleString()} bytes</small></div><div className="mode-switch"><button className={codeMode === 'human' ? 'active' : ''} type="button" onClick={() => setCodeMode('human')}>知识视图</button><button className={codeMode === 'source' ? 'active' : ''} type="button" onClick={() => setCodeMode('source')}>源文件</button></div></header>
|
||||||
|
<div className="code-reader-scroll">{codeMode === 'human' ? <MarkdownDocument body={activeCodeFile.humanMarkdown}/> : <pre className="source-code"><code>{activeCodeFile.source}</code></pre>}</div>
|
||||||
|
</> : <div className="workbench-empty"><span></></span><h2>浏览真实仓库内容</h2><p>支持 Markdown、JSON、HDLP、YAML、TOML 与常见代码格式;机器文件可切换为知识视图。</p></div>}
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderReceipts = () => (
|
||||||
|
<section className="content-page">
|
||||||
|
<header className="page-title"><div><span className="kicker">LOCAL RECEIPTS</span><h1>回执</h1><p>本机身份、知识与代码读取的可核验记录</p></div></header>
|
||||||
|
<div className="receipt-grid">
|
||||||
|
<section className="plain-panel"><h2>个人空间</h2>{personal.recentEvents.map((event) => <article className="receipt-row" key={event.eventId}><span>{String(event.sequence).padStart(2, '0')}</span><div><b>{event.summary}</b><small>{new Date(event.occurredAtUnixMs).toLocaleString('zh-CN')} · {event.receiptHash.slice(0, 12)}</small></div></article>)}</section>
|
||||||
|
<section className="plain-panel"><h2>PNCC 读取投影</h2>{receipts.length ? receipts.map((receipt) => <article className="receipt-row" key={receipt.sequence}><span>{String(receipt.sequence).padStart(2, '0')}</span><div><b>{receipt.kind}</b><small>{new Date(Number(receipt.observedAtUnixMs)).toLocaleString('zh-CN')} · {receipt.eventHash.slice(0, 12)}</small></div></article>) : <div className="empty-state">尚无 PNCC 运行投影回执</div>}</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderSystem = () => (
|
||||||
|
<section className="content-page">
|
||||||
|
<header className="page-title"><div><span className="kicker">SYSTEM EVIDENCE</span><h1>系统详情</h1><p>这里显示可回读的运行事实,不显示推测状态</p></div></header>
|
||||||
|
<div className="system-grid">
|
||||||
|
<section className="plain-panel connection-panel">
|
||||||
|
<header><div><h2>人格体本地原生连接</h2><p>Unix Stream 长连接为主,MCP 仅作发现与恢复</p></div><span className={connectionOnline ? 'status-chip online' : 'status-chip'}>{connectionOnline ? `${status.directConnectionCount} 个客户端在线` : '当前无客户端'}</span></header>
|
||||||
|
<dl className="evidence-list"><div><dt>代理进程</dt><dd>{status.directLocalBrokerState}</dd></div><div><dt>当前连接</dt><dd>{status.directConnectionCount}</dd></div><div><dt>可续接会话</dt><dd>{status.resumableSessionCount}</dd></div><div><dt>传输</dt><dd>UNIX_STREAM_JSON_LINES</dd></div><div><dt>MCP 角色</dt><dd>备用 / 恢复 / 兼容</dd></div></dl>
|
||||||
|
{ticket ? <><button className="secondary-button" type="button" onClick={() => void copyInvitation()}><Icon name="copy"/>复制一次性连接凭据</button><pre className="invitation-data">{invitationText}</pre></> : <button className="primary-button" type="button" disabled={systemBusy} onClick={() => void issueInvitation()}>生成本机连接凭据</button>}
|
||||||
|
</section>
|
||||||
|
<section className="plain-panel"><header><div><h2>GH-PNCC 投影</h2><p>仓库挂载和读取回执来自同一运行事件流</p></div></header><dl className="evidence-list"><div><dt>已挂载仓库</dt><dd>{status.codeRepositoryMountCount}</dd></div><div><dt>投影回执</dt><dd>{status.pnccReceiptCount}</dd></div><div><dt>当前结论</dt><dd>{status.codeRepositoryMountCount > 0 && status.pnccReceiptCount > 0 ? '已有运行证据' : '架构已接入,尚无完整运行投影'}</dd></div></dl></section>
|
||||||
|
<section className="plain-panel"><header><div><h2>显示主题</h2><p>五湖主题只改变视觉令牌,不改变功能结构</p></div></header><div className="theme-options">{themes.map((choice) => <button className={theme === choice.id ? 'active' : ''} key={choice.id} type="button" onClick={() => setTheme(choice.id)}><i className={choice.id}/><span>{choice.name}</span></button>)}</div></section>
|
||||||
|
<section className="plain-panel"><header><div><h2>软件更新</h2><p>只接受登记到本机的签名发布源</p></div></header>{releaseCandidate ? <div className="release-summary"><b>HoloLake {releaseCandidate.version}</b><p>{releaseCandidate.notes}</p><button className="primary-button" onClick={() => void installUpdate()}>验证并安装</button></div> : <button className="secondary-button" disabled={systemBusy} onClick={() => void checkUpdate()}>检查签名更新</button>}</section>
|
||||||
|
</div>
|
||||||
|
{systemMessage && <p className="global-message">{systemMessage}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
|
||||||
|
return <div className="app-shell">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="brand"><span className="brand-mark">光</span><div><b>HoloLake</b><small>GH-AIOS</small></div></div>
|
||||||
|
<nav>{(Object.keys(viewLabels) as ViewId[]).map((item) => <button className={view === item ? 'active' : ''} key={item} type="button" onClick={() => setView(item)}><Icon name={item}/><span>{viewLabels[item]}</span>{item === 'knowledge' && <em>{knowledge.uniqueDocumentCount}</em>}</button>)}</nav>
|
||||||
|
<div className="sidebar-foot"><span className="identity-dot"/><div><b>{personal.identity?.displayName || '本机空间'}</b><small>{personal.identity ? '个人频道已建立' : '等待首次设置'}</small></div></div>
|
||||||
|
</aside>
|
||||||
|
<section className="app-main">
|
||||||
|
<header className="connection-strip"><button type="button" onClick={() => setView('system')}><i className={connectionOnline ? 'online' : ''}/><span>{connectionLabel}</span></button><span className="mcp-backup">MCP 备用</span><button type="button" onClick={() => setView('system')}>连接验证 <Icon name="arrow"/></button></header>
|
||||||
|
<main className="workspace">{view === 'overview' ? renderOverview() : view === 'knowledge' ? renderKnowledge() : view === 'code' ? renderCode() : view === 'receipts' ? renderReceipts() : renderSystem()}</main>
|
||||||
|
</section>
|
||||||
|
{personal.state !== 'UNAVAILABLE' && !personal.identity && <div className="onboarding-backdrop"><section className="onboarding-card" role="dialog" aria-modal="true"><span className="onboarding-mark">光湖</span><span className="kicker">FIRST LOCAL ENTRY</span><h1>建立个人空间</h1><p>仅需一次。知识、频道与本机记录都归属于这台电脑。</p><form onSubmit={(event) => void initializeIdentity(event)}><label htmlFor="display-name">称呼</label><input id="display-name" autoFocus maxLength={80} value={displayName} placeholder="你的名字" onChange={(event) => setDisplayName(event.target.value)}/><button className="primary-button" disabled={identityBusy || !displayName.trim()}>进入 HoloLake</button></form>{identityMessage && <p>{identityMessage}</p>}</section></div>}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(<StrictMode><HoloLakeApp /></StrictMode>)
|
createRoot(document.getElementById('root')!).render(<StrictMode><HoloLakeApp/></StrictMode>)
|
||||||
|
|
|
||||||
|
|
@ -1,180 +1,295 @@
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #root { width: 100%; height: 100%; margin: 0; }
|
||||||
:root {
|
button, input, textarea { font: inherit; }
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei UI", sans-serif;
|
button { color: inherit; }
|
||||||
color: var(--content-primary);
|
body {
|
||||||
background: var(--surface-depth);
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: geometricPrecision;
|
|
||||||
}
|
|
||||||
|
|
||||||
body { margin: 0; min-width: 320px; min-height: 100vh; overflow: hidden; }
|
|
||||||
button { font: inherit; }
|
|
||||||
button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px; }
|
|
||||||
|
|
||||||
.world-shell {
|
|
||||||
position: relative;
|
|
||||||
min-height: 100vh;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
isolation: isolate;
|
color: var(--content-secondary);
|
||||||
background: linear-gradient(180deg, var(--surface-sky) 0%, var(--surface-horizon) 40%, var(--surface-lake) 65%, var(--surface-depth) 100%);
|
background: var(--surface-depth);
|
||||||
transition: background 450ms ease, color 300ms ease;
|
font-family: Inter, "SF Pro Text", -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 430;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
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; }
|
||||||
|
::-webkit-scrollbar { width: 11px; height: 11px; }
|
||||||
|
::-webkit-scrollbar-track { background: rgba(0, 0, 0, .08); }
|
||||||
|
::-webkit-scrollbar-thumb { min-height: 38px; border: 3px solid transparent; border-radius: 999px; background: color-mix(in srgb, var(--content-muted) 54%, transparent); background-clip: padding-box; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--content-secondary) 68%, transparent); background-clip: padding-box; }
|
||||||
|
|
||||||
.scene-image {
|
.app-shell {
|
||||||
position: absolute;
|
width: 100%; height: 100%;
|
||||||
z-index: -4;
|
display: grid; grid-template-columns: 226px minmax(0, 1fr);
|
||||||
inset: 25% 0 0;
|
overflow: hidden;
|
||||||
background-image: linear-gradient(180deg, transparent 0%, var(--surface-depth) 100%), url('./assets/hololake-night-lake.png');
|
background:
|
||||||
background-size: cover;
|
radial-gradient(circle at 82% -12%, var(--primitive-nebula-a), transparent 31%),
|
||||||
background-position: center 42%;
|
linear-gradient(145deg, var(--surface-horizon), var(--surface-depth) 76%);
|
||||||
opacity: var(--primitive-scene-opacity);
|
|
||||||
mix-blend-mode: screen;
|
|
||||||
transition: opacity 450ms ease;
|
|
||||||
}
|
}
|
||||||
|
.sidebar {
|
||||||
.nebula { position: absolute; z-index: -3; border-radius: 50%; filter: blur(42px); pointer-events: none; }
|
min-height: 0; display: flex; flex-direction: column;
|
||||||
.nebula-one { width: 58vw; height: 38vw; left: -12vw; top: -18vw; background: radial-gradient(circle, var(--primitive-nebula-a), transparent 68%); }
|
padding: 28px 16px 20px;
|
||||||
.nebula-two { width: 48vw; height: 32vw; right: -12vw; top: 4vh; background: radial-gradient(circle, var(--primitive-nebula-b), transparent 70%); }
|
border-right: 1px solid var(--panel-edge);
|
||||||
.mist { position: absolute; z-index: -2; left: 0; right: 0; top: 38%; height: 36%; background: radial-gradient(ellipse at 50% 52%, var(--primitive-mist-a), var(--primitive-mist-b) 45%, transparent 72%); filter: blur(24px); pointer-events: none; }
|
background: color-mix(in srgb, var(--surface-depth) 92%, transparent);
|
||||||
|
|
||||||
.stars i, .glimmers i { position: absolute; display: block; border-radius: 50%; pointer-events: none; }
|
|
||||||
.stars i { width: calc(1px + (var(--star-index) % 3) * 1px); height: calc(1px + (var(--star-index) % 3) * 1px); left: calc(4% + (var(--star-index) * 5.2%)); top: calc(5% + (var(--star-index) % 5) * 5.4%); background: var(--accent-light); opacity: calc(.16 + (var(--star-index) % 4) * .09); box-shadow: 0 0 8px var(--primitive-warm-glow); animation: breathe calc(4s + (var(--star-index) % 5) * .7s) ease-in-out infinite; }
|
|
||||||
.glimmers i { width: calc(2px + (var(--glimmer-index) % 3) * 2px); height: 2px; left: calc(9% + (var(--glimmer-index) * 8.1%)); top: calc(52% + (var(--glimmer-index) % 4) * 6.4%); background: var(--accent-light); box-shadow: 0 0 12px var(--primitive-cool-glow); opacity: .18; animation: glimmer calc(5s + (var(--glimmer-index) % 4) * 1.2s) ease-in-out infinite; }
|
|
||||||
@keyframes breathe { 50% { opacity: .75; transform: scale(1.5); } }
|
|
||||||
@keyframes glimmer { 50% { opacity: .7; transform: translateX(16px) scaleX(2.4); } }
|
|
||||||
|
|
||||||
.world-header { height: 78px; display: flex; align-items: center; justify-content: space-between; padding: 0 clamp(28px, 5vw, 70px); }
|
|
||||||
.world-mark { display: flex; align-items: baseline; gap: 17px; }
|
|
||||||
.world-name { font-size: 21px; font-weight: 610; letter-spacing: .18em; }
|
|
||||||
.world-route { color: var(--content-muted); font-size: 12px; letter-spacing: .16em; }
|
|
||||||
.icon-button, .panel-close { border: 0; color: var(--content-muted); background: transparent; display: grid; place-items: center; cursor: pointer; border-radius: 50%; transition: color 180ms ease, background 180ms ease; }
|
|
||||||
.icon-button { width: 42px; height: 42px; }
|
|
||||||
.icon-button:hover, .panel-close:hover { color: var(--accent-light); background: var(--primitive-glass-hover); }
|
|
||||||
.icon-button svg, .panel-close svg, .hero-actions svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.45; stroke-linecap: round; stroke-linejoin: round; }
|
|
||||||
|
|
||||||
.home-content { min-height: calc(100vh - 156px); display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(285px, .45fr); align-items: center; gap: clamp(48px, 7vw, 112px); padding: 0 clamp(54px, 8vw, 126px) 28px; }
|
|
||||||
.hero { max-width: 680px; transform: translateY(-2vh); }
|
|
||||||
.route-kicker, .panel-kicker { margin: 0 0 21px; color: var(--accent-light); font-size: 11px; font-weight: 630; letter-spacing: .22em; }
|
|
||||||
.hero h1 { margin: 0; max-width: 700px; color: var(--content-primary); font-size: clamp(40px, 4.15vw, 58px); font-weight: 430; line-height: 1.22; letter-spacing: -.035em; text-wrap: balance; }
|
|
||||||
.hero-copy { max-width: 540px; margin: 27px 0 0; color: var(--content-muted); font-size: 14px; line-height: 1.9; letter-spacing: .035em; }
|
|
||||||
.hero-actions { display: flex; align-items: center; gap: 14px; margin-top: 37px; }
|
|
||||||
.primary-action, .secondary-action { min-height: 46px; border-radius: 999px; padding: 0 22px; display: inline-flex; align-items: center; justify-content: center; gap: 12px; cursor: pointer; transition: transform 180ms ease, background 180ms ease, box-shadow 180ms ease; }
|
|
||||||
.primary-action:disabled, .secondary-action:disabled, .repository-picker:disabled, .text-action:disabled { opacity: .55; cursor: wait; transform: none; }
|
|
||||||
.primary-action { border: 0; color: var(--button-primary-text); background: var(--button-primary-bg); box-shadow: var(--button-primary-shadow); font-weight: 620; white-space: nowrap; }
|
|
||||||
.secondary-action { color: var(--button-secondary-text); border: 1px solid var(--button-secondary-edge); background: var(--button-secondary-bg); backdrop-filter: blur(18px); }
|
|
||||||
.primary-action:hover, .secondary-action:hover { transform: translateY(-2px); }
|
|
||||||
.secondary-action:hover { background: var(--primitive-glass-hover); }
|
|
||||||
|
|
||||||
.status-cluster { display: grid; gap: 30px; width: min(100%, 330px); justify-self: end; }
|
|
||||||
.status-item { display: grid; grid-template-columns: 10px 1fr; align-items: start; gap: 15px; padding: 5px 0; }
|
|
||||||
.status-button { width: 100%; border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; }
|
|
||||||
.status-button:hover h2 { color: var(--accent-light); }
|
|
||||||
.status-item h2 { margin: 0 0 8px; color: var(--content-secondary); font-size: 13px; font-weight: 590; letter-spacing: .06em; }
|
|
||||||
.status-item p { margin: 0; color: var(--content-muted); opacity: .72; font-size: 11.5px; line-height: 1.5; letter-spacing: .04em; }
|
|
||||||
.status-light { width: 6px; height: 6px; margin-top: 5px; flex: 0 0 auto; border-radius: 50%; background: var(--state-quiet); box-shadow: 0 0 14px currentColor; }
|
|
||||||
.status-light.ready { color: var(--state-ready); background: currentColor; }
|
|
||||||
.status-light.waiting { color: var(--state-waiting); background: currentColor; }
|
|
||||||
.status-light.quiet { color: var(--state-quiet); background: currentColor; }
|
|
||||||
|
|
||||||
.world-footer { height: 78px; display: flex; align-items: center; justify-content: space-between; padding: 0 clamp(28px, 5vw, 70px); color: var(--content-faint); font-size: 10.5px; letter-spacing: .09em; }
|
|
||||||
.world-footer p { margin: 0; display: flex; align-items: center; gap: 13px; }
|
|
||||||
.world-footer b { color: var(--accent-light); font-size: 11.5px; letter-spacing: .14em; }
|
|
||||||
.trust-note i { width: 4px; height: 4px; border-radius: 50%; background: var(--accent-light); opacity: .65; }
|
|
||||||
|
|
||||||
.panel-backdrop { position: fixed; z-index: 20; inset: 0; display: flex; align-items: stretch; justify-content: flex-end; background: rgba(2, 4, 9, .28); backdrop-filter: blur(5px); }
|
|
||||||
.detail-panel { position: relative; width: min(500px, 92vw); height: 100%; overflow-y: auto; padding: 80px 54px 50px; color: var(--content-primary); background: var(--panel-bg); box-shadow: -26px 0 80px var(--primitive-shadow); backdrop-filter: blur(38px) saturate(115%); animation: panel-in 240ms ease-out; }
|
|
||||||
@keyframes panel-in { from { transform: translateX(24px); opacity: 0; } }
|
|
||||||
.panel-close { position: absolute; right: 34px; top: 30px; width: 38px; height: 38px; }
|
|
||||||
.detail-panel h2 { margin: 0; max-width: 380px; font-size: 31px; font-weight: 480; line-height: 1.28; letter-spacing: -.025em; }
|
|
||||||
.panel-intro { margin: 19px 0 30px; color: var(--content-muted); font-size: 13px; line-height: 1.85; }
|
|
||||||
.connection-summary { display: flex; align-items: flex-start; gap: 15px; margin: 32px 0; padding: 20px 22px; border-radius: 20px; background: var(--primitive-glass); box-shadow: inset 0 1px var(--primitive-glass-top); }
|
|
||||||
.connection-summary div { display: grid; gap: 6px; }
|
|
||||||
.connection-summary b { color: var(--content-secondary); font-size: 13px; }
|
|
||||||
.connection-summary span { color: var(--content-faint); font-size: 11.5px; }
|
|
||||||
.session-guidance { display: grid; gap: 8px; margin: 0 0 20px; padding: 18px 20px; border: 1px solid var(--primitive-line); border-radius: 18px; background: rgba(255, 255, 255, .035); }
|
|
||||||
.session-guidance b { color: var(--content-secondary); font-size: 13px; }
|
|
||||||
.session-guidance span { color: var(--content-muted); font-size: 12px; line-height: 1.7; }
|
|
||||||
.panel-action { min-height: 46px; }
|
|
||||||
.panel-message { color: var(--content-muted); font-size: 11.5px; line-height: 1.6; }
|
|
||||||
.invitation-preview { max-height: 180px; overflow: auto; margin: 18px 0; padding: 17px; border-radius: 16px; color: var(--content-faint); background: rgba(0, 0, 0, .13); font: 10px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-all; }
|
|
||||||
.boundary-note { margin-top: 34px; color: var(--content-faint); font-size: 10.5px; line-height: 1.75; }
|
|
||||||
|
|
||||||
.receipt-list { list-style: none; display: grid; gap: 14px; margin: 30px 0 0; padding: 0; }
|
|
||||||
.receipt-list li { display: grid; grid-template-columns: 34px 1fr; gap: 14px; align-items: start; padding: 16px 18px; border-radius: 17px; background: var(--primitive-glass); }
|
|
||||||
.receipt-list li > span { color: var(--accent-light); font: 11px ui-monospace, monospace; }
|
|
||||||
.receipt-list li div { display: grid; gap: 7px; }
|
|
||||||
.receipt-list b { color: var(--content-secondary); font-size: 12px; font-weight: 580; }
|
|
||||||
.receipt-list small { color: var(--content-faint); font-size: 10px; }
|
|
||||||
.empty-state { min-height: 260px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
|
|
||||||
.empty-state i { width: 7px; height: 7px; margin-bottom: 20px; border-radius: 50%; background: var(--accent-light); box-shadow: 0 0 32px 12px var(--primitive-warm-glow); }
|
|
||||||
.empty-state b { color: var(--content-secondary); font-size: 13px; font-weight: 570; }
|
|
||||||
.empty-state span { max-width: 260px; margin-top: 10px; color: var(--content-faint); font-size: 11px; line-height: 1.7; }
|
|
||||||
.empty-state.compact { min-height: 170px; }
|
|
||||||
|
|
||||||
.repository-picker { width: 100%; min-height: 74px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 13px 19px; border: 0; border-radius: 19px; color: var(--content-secondary); background: var(--primitive-glass); box-shadow: inset 0 1px var(--primitive-glass-top); text-align: left; cursor: pointer; }
|
|
||||||
.repository-picker:hover { background: var(--primitive-glass-hover); }
|
|
||||||
.repository-picker span { display: grid; gap: 6px; }
|
|
||||||
.repository-picker b { font-size: 12.5px; font-weight: 590; }
|
|
||||||
.repository-picker small { color: var(--content-faint); font-size: 10px; }
|
|
||||||
.repository-picker svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.45; stroke-linecap: round; stroke-linejoin: round; }
|
|
||||||
.candidate-card { margin-top: 26px; padding: 21px; border-radius: 21px; background: var(--primitive-glass); box-shadow: inset 0 1px var(--primitive-glass-top); }
|
|
||||||
.candidate-heading { display: flex; align-items: flex-start; gap: 14px; }
|
|
||||||
.candidate-heading div { display: grid; gap: 5px; }
|
|
||||||
.candidate-heading b { color: var(--content-secondary); font-size: 12.5px; }
|
|
||||||
.candidate-heading span { color: var(--content-faint); font-size: 10.5px; }
|
|
||||||
.candidate-facts { display: grid; gap: 10px; margin: 20px 0; }
|
|
||||||
.candidate-facts div { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 12px; }
|
|
||||||
.candidate-facts dt { color: var(--content-faint); font-size: 10px; }
|
|
||||||
.candidate-facts dd { margin: 0; overflow: hidden; color: var(--content-secondary); font: 10.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.candidate-card > p { color: var(--content-faint); font-size: 10.5px; line-height: 1.7; }
|
|
||||||
.candidate-actions { display: flex; align-items: center; gap: 16px; margin-top: 18px; }
|
|
||||||
.candidate-actions .primary-action { min-height: 42px; padding-inline: 18px; font-size: 12px; }
|
|
||||||
.text-action { border: 0; color: var(--content-muted); background: transparent; font-size: 11px; cursor: pointer; }
|
|
||||||
.update-state-card { display: flex; align-items: flex-start; gap: 15px; margin: 30px 0; padding: 20px 22px; border-radius: 20px; background: var(--primitive-glass); box-shadow: inset 0 1px var(--primitive-glass-top); }
|
|
||||||
.update-state-card div { display: grid; gap: 6px; }
|
|
||||||
.update-state-card b { color: var(--content-secondary); font-size: 12.5px; }
|
|
||||||
.update-state-card span { color: var(--content-faint); font-size: 10.5px; line-height: 1.6; }
|
|
||||||
.release-card { margin-top: 27px; }
|
|
||||||
.release-version { display: grid; grid-template-columns: 1fr auto; align-items: baseline; gap: 6px 16px; }
|
|
||||||
.release-version span { color: var(--content-faint); font-size: 10px; letter-spacing: .12em; }
|
|
||||||
.release-version b { grid-row: 1 / 3; grid-column: 2; color: var(--accent-light); font-size: 30px; font-weight: 480; }
|
|
||||||
.release-version small { color: var(--content-muted); font-size: 11px; }
|
|
||||||
.release-card > p { color: var(--content-muted); font-size: 11.5px; line-height: 1.7; }
|
|
||||||
.release-card ul { margin: 18px 0; padding-left: 18px; color: var(--content-secondary); font-size: 11px; line-height: 1.8; }
|
|
||||||
.release-card .release-warning { color: var(--content-faint); font-size: 10px; }
|
|
||||||
.recovery-card { display: grid; gap: 17px; margin-top: 27px; padding: 21px; border: 1px solid var(--primitive-line); border-radius: 21px; background: var(--primitive-glass); box-shadow: inset 0 1px var(--primitive-glass-top); }
|
|
||||||
.recovery-card > p { margin: 0; color: var(--content-muted); font-size: 11.5px; line-height: 1.7; }
|
|
||||||
.recovery-actions { display: flex; flex-wrap: wrap; gap: 11px; }
|
|
||||||
|
|
||||||
.theme-list { display: grid; gap: 9px; margin-top: 30px; }
|
|
||||||
.theme-list button { width: 100%; min-height: 66px; display: grid; grid-template-columns: 40px 1fr auto; align-items: center; gap: 14px; padding: 10px 14px; border: 0; border-radius: 17px; color: var(--content-secondary); background: transparent; text-align: left; cursor: pointer; }
|
|
||||||
.theme-list button:hover, .theme-list button.selected { background: var(--primitive-glass-hover); }
|
|
||||||
.theme-list span { display: grid; gap: 5px; }
|
|
||||||
.theme-list b { font-size: 12.5px; font-weight: 590; white-space: nowrap; }
|
|
||||||
.theme-list small, .theme-list em { color: var(--content-faint); font-size: 10px; font-style: normal; }
|
|
||||||
.theme-list button.selected em { color: var(--accent-light); }
|
|
||||||
.theme-swatch { width: 34px; height: 34px; border-radius: 50%; box-shadow: inset 0 0 0 1px rgba(255,255,255,.18), 0 6px 18px var(--primitive-shadow); }
|
|
||||||
.theme-swatch.night { background: linear-gradient(145deg, #0a1020, #04070d 70%); }
|
|
||||||
.theme-swatch.dawn { background: linear-gradient(145deg, #f5f7fc, #d3e0ef 70%); }
|
|
||||||
.theme-swatch.nebula { background: linear-gradient(145deg, #281b47, #080510 70%); }
|
|
||||||
.theme-swatch.candle { background: linear-gradient(145deg, #4a2d13, #0a0703 70%); }
|
|
||||||
.theme-swatch.clear { background: linear-gradient(145deg, #f1f5f8, #b9ccda 70%); }
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.home-content { grid-template-columns: 1fr; align-content: center; gap: 50px; padding-inline: 62px; }
|
|
||||||
.hero { transform: none; }
|
|
||||||
.status-cluster { width: 100%; grid-template-columns: repeat(3, 1fr); justify-self: stretch; gap: 26px; }
|
|
||||||
}
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 13px; min-height: 46px; padding: 0 10px; }
|
||||||
@media (max-height: 640px) {
|
.brand-mark {
|
||||||
.world-header, .world-footer { height: 62px; }
|
width: 40px; height: 40px; display: grid; place-items: center;
|
||||||
.home-content { min-height: calc(100vh - 124px); }
|
border: 1px solid color-mix(in srgb, var(--accent-light) 34%, transparent);
|
||||||
.hero h1 { font-size: clamp(36px, 5vw, 56px); }
|
border-radius: 13px; color: var(--accent-light); background: color-mix(in srgb, var(--accent-light) 7%, transparent);
|
||||||
.hero-copy { margin-top: 18px; }
|
font-family: "Songti SC", serif; font-size: 19px;
|
||||||
.hero-actions { margin-top: 25px; }
|
|
||||||
}
|
}
|
||||||
|
.brand b { display: block; color: var(--content-primary); font-size: 18px; font-weight: 650; letter-spacing: .01em; }
|
||||||
|
.brand small { display: block; margin-top: 4px; color: var(--content-muted); font-size: 11px; font-weight: 520; letter-spacing: .22em; }
|
||||||
|
.sidebar nav { display: grid; gap: 7px; margin-top: 54px; }
|
||||||
|
.sidebar nav button {
|
||||||
|
min-height: 50px; display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 13px;
|
||||||
|
padding: 0 14px; border: 1px solid transparent; border-radius: 12px;
|
||||||
|
color: var(--content-muted); background: transparent; text-align: left; cursor: pointer;
|
||||||
|
}
|
||||||
|
.sidebar nav button span { font-size: 15px; font-weight: 520; }
|
||||||
|
.sidebar nav button em { min-width: 28px; padding: 3px 7px; border-radius: 8px; background: var(--primitive-glass); font-size: 12px; font-style: normal; text-align: center; }
|
||||||
|
.sidebar nav button:hover { color: var(--content-secondary); background: var(--primitive-glass); }
|
||||||
|
.sidebar nav button.active { border-color: var(--panel-edge); color: var(--content-primary); background: var(--primitive-glass-hover); }
|
||||||
|
.sidebar-foot {
|
||||||
|
min-width: 0; display: grid; grid-template-columns: 10px minmax(0, 1fr); align-items: center; gap: 12px;
|
||||||
|
margin-top: auto; padding: 18px 11px 0; border-top: 1px solid var(--panel-edge);
|
||||||
|
}
|
||||||
|
.identity-dot, .connection-strip i, .evidence-pill i { width: 8px; height: 8px; border-radius: 50%; background: var(--state-ready); box-shadow: 0 0 16px color-mix(in srgb, var(--state-ready) 48%, transparent); }
|
||||||
|
.sidebar-foot b { display: block; overflow: hidden; color: var(--content-secondary); font-size: 14px; font-weight: 620; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.sidebar-foot small { display: block; margin-top: 3px; color: var(--content-muted); font-size: 12px; }
|
||||||
|
|
||||||
|
.app-main { min-width: 0; min-height: 0; display: grid; grid-template-rows: 45px minmax(0, 1fr); }
|
||||||
|
.connection-strip {
|
||||||
|
position: relative; z-index: 4; display: flex; align-items: center; justify-content: center; gap: 10px;
|
||||||
|
padding: 0 22px; border-bottom: 1px solid var(--panel-edge);
|
||||||
|
background: color-mix(in srgb, var(--surface-depth) 87%, transparent);
|
||||||
|
}
|
||||||
|
.connection-strip button { display: inline-flex; align-items: center; gap: 8px; border: 0; color: var(--content-muted); background: transparent; font-size: 13px; font-weight: 520; cursor: pointer; }
|
||||||
|
.connection-strip button:last-child { position: absolute; right: 22px; }
|
||||||
|
.connection-strip button:last-child svg { width: 16px; height: 16px; }
|
||||||
|
.connection-strip i { background: var(--content-faint); box-shadow: none; }
|
||||||
|
.connection-strip i.online { background: var(--state-ready); box-shadow: 0 0 12px color-mix(in srgb, var(--state-ready) 52%, transparent); }
|
||||||
|
.mcp-backup { padding: 4px 8px; border: 1px solid var(--panel-edge); border-radius: 7px; color: var(--content-muted); font-size: 11px; font-weight: 560; }
|
||||||
|
.workspace { min-width: 0; min-height: 0; overflow: hidden; }
|
||||||
|
.content-page { height: 100%; overflow: auto; padding: 48px clamp(28px, 4vw, 64px) 64px; }
|
||||||
|
.page-title { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 34px; }
|
||||||
|
.page-title h1 { margin: 8px 0 0; color: var(--content-primary); font-size: clamp(34px, 4vw, 48px); font-weight: 620; letter-spacing: -.035em; line-height: 1.08; }
|
||||||
|
.page-title p { margin: 10px 0 0; color: var(--content-muted); font-size: 15px; }
|
||||||
|
.kicker { color: var(--accent-light); font-size: 11px; font-weight: 720; letter-spacing: .22em; }
|
||||||
|
.evidence-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 9px; min-height: 39px; padding: 0 14px;
|
||||||
|
border: 1px solid var(--panel-edge); border-radius: 999px; color: var(--content-secondary); background: var(--primitive-glass); font-size: 13px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.evidence-pill i { background: var(--content-faint); box-shadow: none; }
|
||||||
|
.evidence-pill i.online { background: var(--state-ready); }
|
||||||
|
.plain-panel, .metric-panel {
|
||||||
|
border: 1px solid var(--panel-edge); border-radius: 16px;
|
||||||
|
background: color-mix(in srgb, var(--panel-bg) 91%, transparent);
|
||||||
|
box-shadow: 0 18px 42px color-mix(in srgb, var(--primitive-shadow) 34%, transparent);
|
||||||
|
}
|
||||||
|
.primary-button, .secondary-button, .toolbar-button {
|
||||||
|
min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
padding: 0 17px; border-radius: 10px; font-size: 14px; font-weight: 650; cursor: pointer;
|
||||||
|
}
|
||||||
|
.primary-button, .toolbar-button.primary { border: 1px solid transparent; color: var(--button-primary-text); background: var(--button-primary-bg); box-shadow: var(--button-primary-shadow); }
|
||||||
|
.secondary-button, .toolbar-button { border: 1px solid var(--button-secondary-edge); color: var(--button-secondary-text); background: var(--button-secondary-bg); }
|
||||||
|
.primary-button:disabled, .secondary-button:disabled { opacity: .45; cursor: default; }
|
||||||
|
|
||||||
|
.overview-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
|
||||||
|
.metric-panel { min-height: 155px; display: flex; flex-direction: column; align-items: flex-start; padding: 25px; color: inherit; text-align: left; cursor: pointer; }
|
||||||
|
.metric-panel:hover { border-color: color-mix(in srgb, var(--accent-light) 35%, var(--panel-edge)); background: var(--primitive-glass-hover); }
|
||||||
|
.metric-panel span { color: var(--content-muted); font-size: 14px; font-weight: 590; }
|
||||||
|
.metric-panel strong { margin-top: 10px; color: var(--content-primary); font-size: 38px; font-weight: 570; }
|
||||||
|
.metric-panel small { margin-top: auto; color: var(--content-muted); font-size: 13px; }
|
||||||
|
.overview-columns { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(280px, .7fr); gap: 18px; margin-top: 18px; }
|
||||||
|
.plain-panel { padding: 24px; }
|
||||||
|
.plain-panel > header, .recent-panel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
|
||||||
|
.plain-panel h2 { margin: 0; color: var(--content-primary); font-size: 18px; font-weight: 650; }
|
||||||
|
.plain-panel header p { margin: 6px 0 0; color: var(--content-muted); font-size: 13px; }
|
||||||
|
.recent-panel > header > button { border: 0; color: var(--content-muted); background: transparent; font-size: 13px; cursor: pointer; }
|
||||||
|
.recent-row {
|
||||||
|
width: 100%; min-height: 58px; display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 12px;
|
||||||
|
padding: 9px 6px; border: 0; border-bottom: 1px solid var(--panel-edge); color: inherit; background: transparent; text-align: left; cursor: pointer;
|
||||||
|
}
|
||||||
|
.recent-row:hover { background: var(--primitive-glass); }
|
||||||
|
.recent-row svg { color: var(--accent-light); }
|
||||||
|
.recent-row span { min-width: 0; }
|
||||||
|
.recent-row b, .recent-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.recent-row b { color: var(--content-secondary); font-size: 14.5px; font-weight: 590; }
|
||||||
|
.recent-row small, .recent-row time { margin-top: 4px; color: var(--content-muted); font-size: 12px; }
|
||||||
|
.system-proof dl, .evidence-list { display: grid; gap: 0; margin: 20px 0; }
|
||||||
|
.system-proof dl div, .evidence-list div, .document-inspector dl div { display: flex; justify-content: space-between; gap: 20px; padding: 11px 0; border-bottom: 1px solid var(--panel-edge); }
|
||||||
|
.system-proof dt, .evidence-list dt, .document-inspector dt { color: var(--content-muted); }
|
||||||
|
.system-proof dd, .evidence-list dd, .document-inspector dd { margin: 0; color: var(--content-secondary); font-weight: 580; text-align: right; }
|
||||||
|
|
||||||
|
.full-workbench { height: 100%; min-width: 0; min-height: 0; display: grid; background: color-mix(in srgb, var(--surface-depth) 70%, transparent); }
|
||||||
|
.knowledge-page { grid-template-columns: 310px minmax(430px, 1fr) 278px; }
|
||||||
|
.knowledge-browser, .document-inspector, .code-channels, .repository-tree {
|
||||||
|
min-width: 0; min-height: 0; display: flex; flex-direction: column;
|
||||||
|
border-right: 1px solid var(--panel-edge);
|
||||||
|
background: color-mix(in srgb, var(--surface-depth) 82%, transparent);
|
||||||
|
}
|
||||||
|
.knowledge-browser > header, .code-channels > header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 24px 18px 14px;
|
||||||
|
}
|
||||||
|
.knowledge-browser h1, .code-channels h1 { margin: 5px 0 0; color: var(--content-primary); font-size: 23px; font-weight: 650; letter-spacing: -.02em; }
|
||||||
|
.icon-button { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--panel-edge); border-radius: 9px; color: var(--content-muted); background: var(--primitive-glass); cursor: pointer; }
|
||||||
|
.icon-button:disabled { opacity: .3; cursor: default; }
|
||||||
|
.search-box { min-height: 42px; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; align-items: center; gap: 9px; margin: 0 14px; padding: 0 12px; border: 1px solid var(--panel-edge); border-radius: 10px; background: color-mix(in srgb, var(--surface-depth) 62%, transparent); }
|
||||||
|
.search-box svg { color: var(--content-muted); }
|
||||||
|
.search-box input { min-width: 0; border: 0; outline: 0; color: var(--content-secondary); background: transparent; font-size: 14px; }
|
||||||
|
.search-box button { border: 0; color: var(--content-muted); background: transparent; font-size: 12px; cursor: pointer; }
|
||||||
|
.knowledge-counts { display: flex; justify-content: space-between; gap: 8px; padding: 13px 16px 10px; color: var(--content-muted); font-size: 11.5px; font-weight: 540; }
|
||||||
|
.knowledge-tree { min-height: 0; overflow: auto; padding: 2px 8px 18px; }
|
||||||
|
.tree-folder, .tree-document {
|
||||||
|
width: 100%; min-height: 38px; display: grid; align-items: center; gap: 7px;
|
||||||
|
border: 0; border-radius: 8px; color: var(--content-muted); background: transparent; text-align: left; cursor: pointer;
|
||||||
|
}
|
||||||
|
.tree-folder { grid-template-columns: 14px 18px minmax(0, 1fr) auto; font-size: 14px; font-weight: 580; }
|
||||||
|
.tree-document { grid-template-columns: 18px minmax(0, 1fr) auto; font-size: 13.5px; font-weight: 480; }
|
||||||
|
.tree-folder > svg, .tree-document > svg { width: 17px; height: 17px; color: var(--accent-light); }
|
||||||
|
.tree-folder span, .tree-document span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.tree-folder em, .tree-document em { padding-right: 8px; color: var(--content-muted); font-size: 11px; font-style: normal; }
|
||||||
|
.tree-folder:hover, .tree-document:hover, .tree-document.active { color: var(--content-primary); background: var(--primitive-glass-hover); }
|
||||||
|
.tree-document.active { box-shadow: inset 2px 0 var(--accent-light); }
|
||||||
|
.tree-chevron { display: grid; place-items: center; transition: transform .14s ease; }
|
||||||
|
.tree-chevron svg { width: 14px; height: 14px; }
|
||||||
|
.tree-chevron.open { transform: rotate(90deg); }
|
||||||
|
.knowledge-browser > footer, .code-channels > footer { min-height: 44px; padding: 12px 16px; border-top: 1px solid var(--panel-edge); color: var(--content-muted); font-size: 12px; line-height: 1.5; }
|
||||||
|
.document-workspace, .code-reader { min-width: 0; min-height: 0; display: grid; grid-template-rows: 49px minmax(0, 1fr); }
|
||||||
|
.document-toolbar, .code-reader > header {
|
||||||
|
min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 18px;
|
||||||
|
padding: 0 20px; border-bottom: 1px solid var(--panel-edge); background: color-mix(in srgb, var(--surface-depth) 66%, transparent);
|
||||||
|
}
|
||||||
|
.breadcrumbs { min-width: 0; display: flex; align-items: center; gap: 8px; color: var(--content-muted); font-size: 12px; }
|
||||||
|
.breadcrumbs span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.document-toolbar > div:last-child { display: flex; gap: 8px; }
|
||||||
|
.toolbar-button { min-height: 33px; padding: 0 12px; font-size: 12.5px; }
|
||||||
|
.toolbar-button svg { width: 16px; height: 16px; }
|
||||||
|
.document-scroll, .code-reader-scroll { min-width: 0; min-height: 0; overflow: auto; scroll-behavior: smooth; }
|
||||||
|
.reader-heading { max-width: 870px; margin: 0 auto; padding: 48px 54px 13px; }
|
||||||
|
.reader-heading h1 { margin: 0; color: var(--content-primary); font-size: clamp(32px, 3vw, 42px); font-weight: 680; letter-spacing: -.035em; line-height: 1.18; }
|
||||||
|
.reader-heading div { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 20px; }
|
||||||
|
.reader-heading span { padding: 5px 8px; border-radius: 6px; color: var(--content-muted); background: var(--primitive-glass); font-size: 12px; }
|
||||||
|
.markdown-document { max-width: 870px; margin: 0 auto; padding: 20px 54px 100px; color: var(--content-secondary); font-size: 17px; font-weight: 430; line-height: 1.78; overflow-wrap: anywhere; }
|
||||||
|
.markdown-document h1, .markdown-document h2, .markdown-document h3, .markdown-document h4 { color: var(--content-primary); font-weight: 680; line-height: 1.34; letter-spacing: -.02em; }
|
||||||
|
.markdown-document h1 { margin: 48px 0 20px; font-size: 34px; }
|
||||||
|
.markdown-document h2 { margin: 42px 0 17px; font-size: 27px; }
|
||||||
|
.markdown-document h3 { margin: 34px 0 14px; font-size: 22px; }
|
||||||
|
.markdown-document h4 { margin: 28px 0 12px; font-size: 18px; }
|
||||||
|
.markdown-document p { margin: 0 0 20px; }
|
||||||
|
.markdown-document ul, .markdown-document ol { margin: 0 0 22px; padding-left: 27px; }
|
||||||
|
.markdown-document li { margin: 6px 0; }
|
||||||
|
.markdown-document a { color: color-mix(in srgb, var(--accent-light) 78%, var(--content-primary)); text-decoration-thickness: 1px; text-underline-offset: 3px; }
|
||||||
|
.markdown-document blockquote { margin: 25px 0; padding: 14px 19px; border-left: 3px solid var(--accent-light); color: var(--content-muted); background: var(--primitive-glass); }
|
||||||
|
.markdown-document pre, .source-code { overflow: auto; padding: 19px; border: 1px solid var(--panel-edge); border-radius: 10px; color: var(--content-secondary); background: rgba(0, 0, 0, .25); font: 13.5px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 2; }
|
||||||
|
.markdown-document code:not(pre code) { padding: 2px 5px; border-radius: 5px; background: var(--primitive-glass-hover); font: .88em ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
.markdown-document table { width: 100%; display: table; margin: 25px 0; border-collapse: collapse; font-size: 14px; line-height: 1.5; }
|
||||||
|
.markdown-document th, .markdown-document td { padding: 10px 12px; border: 1px solid var(--panel-edge); text-align: left; vertical-align: top; }
|
||||||
|
.markdown-document th { color: var(--content-primary); background: var(--primitive-glass-hover); font-weight: 650; }
|
||||||
|
.markdown-document img { max-width: 100%; border-radius: 10px; }
|
||||||
|
.markdown-document input[type='checkbox'] { width: 16px; height: 16px; margin: 0 7px 0 0; accent-color: var(--accent-light); }
|
||||||
|
.wiki-link { display: inline-flex; padding: 2px 7px; border: 1px solid color-mix(in srgb, var(--accent-light) 28%, transparent); border-radius: 6px; color: var(--accent-light); background: color-mix(in srgb, var(--accent-light) 7%, transparent); font-size: .94em; cursor: pointer; }
|
||||||
|
.document-editor { width: calc(100% - 70px); max-width: 930px; height: calc(100% - 58px); display: block; margin: 28px auto; padding: 28px; resize: none; border: 1px solid var(--panel-edge); border-radius: 12px; outline: 0; color: var(--content-secondary); background: color-mix(in srgb, var(--surface-depth) 58%, transparent); font: 15px/1.72 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
.document-inspector { border-right: 0; border-left: 1px solid var(--panel-edge); }
|
||||||
|
.inspector-tabs { height: 49px; display: flex; align-items: center; gap: 18px; padding: 0 17px; border-bottom: 1px solid var(--panel-edge); color: var(--content-muted); font-size: 12.5px; }
|
||||||
|
.inspector-tabs span { height: 49px; display: flex; align-items: center; }
|
||||||
|
.inspector-tabs .active { border-bottom: 2px solid var(--accent-light); color: var(--content-primary); }
|
||||||
|
.inspector-scroll { min-height: 0; overflow: auto; padding: 4px 18px 34px; }
|
||||||
|
.inspector-scroll section { padding: 20px 0; border-bottom: 1px solid var(--panel-edge); }
|
||||||
|
.inspector-scroll h2 { margin: 0 0 14px; color: var(--content-primary); font-size: 14px; font-weight: 650; }
|
||||||
|
.inspector-scroll p, .inspector-scroll dt, .inspector-scroll dd { font-size: 12.5px; line-height: 1.55; }
|
||||||
|
.inspector-scroll dl { margin: 0; }
|
||||||
|
.inspector-scroll dl div { display: block; }
|
||||||
|
.inspector-scroll dd { margin-top: 4px; text-align: left; overflow-wrap: anywhere; }
|
||||||
|
.inspector-scroll code { display: block; overflow-wrap: anywhere; color: var(--content-muted); font-size: 11px; }
|
||||||
|
.outline-list { display: grid; gap: 10px; }
|
||||||
|
.outline-list span { overflow: hidden; color: var(--content-muted); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.workbench-empty, .empty-state { display: grid; place-content: center; justify-items: center; color: var(--content-muted); text-align: center; }
|
||||||
|
.workbench-empty { min-height: 0; padding: 40px; }
|
||||||
|
.workbench-empty > span { color: var(--accent-light); font-family: "Songti SC", serif; font-size: 36px; }
|
||||||
|
.workbench-empty h2 { margin: 18px 0 7px; color: var(--content-primary); font-size: 22px; }
|
||||||
|
.workbench-empty p { max-width: 500px; margin: 0 0 22px; font-size: 14px; line-height: 1.6; }
|
||||||
|
.empty-state { min-height: 160px; padding: 24px; font-size: 13px; }
|
||||||
|
|
||||||
|
.code-workbench { grid-template-columns: 280px 290px minmax(0, 1fr); }
|
||||||
|
.clone-form { padding: 9px 14px 13px; }
|
||||||
|
.clone-form label { display: block; margin-bottom: 8px; color: var(--content-muted); font-size: 12px; }
|
||||||
|
.clone-form > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
|
||||||
|
.clone-form input { min-width: 0; height: 39px; padding: 0 10px; border: 1px solid var(--panel-edge); border-radius: 9px 0 0 9px; outline: 0; color: var(--content-secondary); background: rgba(0, 0, 0, .12); font-size: 12px; }
|
||||||
|
.clone-form button { border: 0; border-radius: 0 9px 9px 0; color: var(--button-primary-text); background: var(--button-primary-bg); font-size: 12px; font-weight: 650; }
|
||||||
|
.local-folder-button { min-height: 39px; display: flex; align-items: center; gap: 9px; margin: 0 14px 12px; padding: 0 12px; border: 1px solid var(--panel-edge); border-radius: 9px; color: var(--content-secondary); background: var(--primitive-glass); font-size: 13px; cursor: pointer; }
|
||||||
|
.channel-selector { min-height: 0; display: grid; align-content: start; gap: 5px; overflow: auto; padding: 2px 8px 16px; }
|
||||||
|
.channel-selector button { min-height: 58px; display: grid; grid-template-columns: 26px minmax(0, 1fr); align-items: center; gap: 10px; padding: 9px 11px; border: 0; border-radius: 9px; color: var(--content-muted); background: transparent; text-align: left; cursor: pointer; }
|
||||||
|
.channel-selector button:hover, .channel-selector button.active { color: var(--content-primary); background: var(--primitive-glass-hover); }
|
||||||
|
.channel-selector span, .channel-selector b, .channel-selector small { min-width: 0; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.channel-selector b { font-size: 13.5px; font-weight: 610; }
|
||||||
|
.channel-selector small { margin-top: 4px; color: var(--content-muted); font-size: 11px; }
|
||||||
|
.repository-tree > header { min-height: 59px; display: grid; grid-template-columns: 36px minmax(0, 1fr); align-items: center; gap: 10px; padding: 9px 12px; border-bottom: 1px solid var(--panel-edge); }
|
||||||
|
.repository-tree > header b, .repository-tree > header small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.repository-tree > header b { color: var(--content-primary); font-size: 13.5px; }
|
||||||
|
.repository-tree > header small { margin-top: 4px; color: var(--content-muted); font-size: 11px; }
|
||||||
|
.repository-entries { min-height: 0; overflow: auto; padding: 7px; }
|
||||||
|
.repository-entries > button { width: 100%; min-height: 39px; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; align-items: center; gap: 9px; padding: 6px 9px; border: 0; border-radius: 7px; color: var(--content-muted); background: transparent; text-align: left; cursor: pointer; }
|
||||||
|
.repository-entries > button:hover { color: var(--content-primary); background: var(--primitive-glass-hover); }
|
||||||
|
.repository-entries > button svg { width: 17px; height: 17px; color: var(--accent-light); }
|
||||||
|
.repository-entries > button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
|
||||||
|
.code-reader > header { min-height: 59px; }
|
||||||
|
.code-reader > header > div:first-child { min-width: 0; }
|
||||||
|
.code-reader > header span, .code-reader > header small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.code-reader > header span { color: var(--content-primary); font-size: 13.5px; font-weight: 590; }
|
||||||
|
.code-reader > header small { margin-top: 3px; color: var(--content-muted); font-size: 11px; }
|
||||||
|
.mode-switch { display: flex; padding: 3px; border: 1px solid var(--panel-edge); border-radius: 8px; }
|
||||||
|
.mode-switch button { min-height: 29px; padding: 0 10px; border: 0; border-radius: 6px; color: var(--content-muted); background: transparent; font-size: 11.5px; cursor: pointer; }
|
||||||
|
.mode-switch button.active { color: var(--content-primary); background: var(--primitive-glass-hover); }
|
||||||
|
.source-code { min-height: calc(100% - 44px); margin: 22px; white-space: pre; }
|
||||||
|
|
||||||
|
.receipt-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||||
|
.receipt-row { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 13px; padding: 16px 0; border-bottom: 1px solid var(--panel-edge); }
|
||||||
|
.receipt-row > span { color: var(--accent-light); font: 12px ui-monospace, monospace; }
|
||||||
|
.receipt-row b, .receipt-row small { display: block; }
|
||||||
|
.receipt-row b { color: var(--content-secondary); font-size: 14px; font-weight: 590; }
|
||||||
|
.receipt-row small { margin-top: 6px; color: var(--content-muted); font-size: 12px; }
|
||||||
|
.system-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||||
|
.connection-panel { grid-row: span 2; }
|
||||||
|
.status-chip { padding: 6px 9px; border: 1px solid var(--panel-edge); border-radius: 999px; color: var(--content-muted); background: var(--primitive-glass); font-size: 12px; }
|
||||||
|
.status-chip.online { color: var(--state-ready); }
|
||||||
|
.invitation-data { max-height: 170px; overflow: auto; margin-top: 14px; padding: 13px; border: 1px solid var(--panel-edge); border-radius: 8px; color: var(--content-muted); background: rgba(0, 0, 0, .18); font: 11px/1.55 ui-monospace, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.theme-options { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 7px; margin-top: 18px; }
|
||||||
|
.theme-options button { min-height: 72px; display: grid; place-items: center; gap: 6px; padding: 7px 3px; border: 1px solid transparent; border-radius: 9px; color: var(--content-muted); background: transparent; font-size: 10px; cursor: pointer; }
|
||||||
|
.theme-options button.active { border-color: var(--panel-edge); color: var(--content-primary); background: var(--primitive-glass-hover); }
|
||||||
|
.theme-options i { width: 25px; height: 25px; border: 1px solid var(--panel-edge); border-radius: 50%; background: radial-gradient(circle at 65% 30%, var(--accent-light), var(--surface-horizon) 28%, var(--surface-depth) 70%); }
|
||||||
|
.global-message { position: fixed; right: 24px; bottom: 20px; max-width: 420px; margin: 0; padding: 11px 15px; border: 1px solid var(--panel-edge); border-radius: 9px; color: var(--content-secondary); background: var(--panel-bg); box-shadow: 0 12px 32px var(--primitive-shadow); font-size: 13px; }
|
||||||
|
|
||||||
|
.onboarding-backdrop { position: fixed; z-index: 20; inset: 0; display: grid; place-items: center; padding: 28px; background: color-mix(in srgb, var(--surface-depth) 86%, transparent); backdrop-filter: blur(18px); }
|
||||||
|
.onboarding-card { width: min(460px, 100%); padding: 40px; border: 1px solid var(--panel-edge); border-radius: 20px; background: var(--panel-bg); box-shadow: 0 28px 90px var(--primitive-shadow); }
|
||||||
|
.onboarding-mark { width: 54px; height: 54px; display: grid; place-items: center; margin-bottom: 24px; border: 1px solid color-mix(in srgb, var(--accent-light) 38%, transparent); border-radius: 16px; color: var(--accent-light); font-family: "Songti SC", serif; font-size: 18px; }
|
||||||
|
.onboarding-card h1 { margin: 9px 0 11px; color: var(--content-primary); font-size: 34px; font-weight: 650; }
|
||||||
|
.onboarding-card p { color: var(--content-muted); font-size: 14px; line-height: 1.65; }
|
||||||
|
.onboarding-card form { display: grid; gap: 10px; margin-top: 25px; }
|
||||||
|
.onboarding-card label { color: var(--content-muted); font-size: 13px; }
|
||||||
|
.onboarding-card input { height: 46px; padding: 0 13px; border: 1px solid var(--panel-edge); border-radius: 10px; outline: 0; color: var(--content-secondary); background: rgba(0, 0, 0, .13); font-size: 15px; }
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.app-shell { grid-template-columns: 190px minmax(0, 1fr); }
|
||||||
|
.sidebar { padding-left: 10px; padding-right: 10px; }
|
||||||
|
.knowledge-page { grid-template-columns: 270px minmax(400px, 1fr) 230px; }
|
||||||
|
.code-workbench { grid-template-columns: 245px 245px minmax(0, 1fr); }
|
||||||
|
.reader-heading, .markdown-document { padding-left: 38px; padding-right: 38px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.app-shell { grid-template-columns: 74px minmax(0, 1fr); }
|
||||||
|
.brand > div, .sidebar nav button span, .sidebar nav button em, .sidebar-foot div { display: none; }
|
||||||
|
.brand { justify-content: center; padding: 0; }
|
||||||
|
.sidebar nav button { grid-template-columns: 1fr; justify-items: center; padding: 0; }
|
||||||
|
.sidebar-foot { grid-template-columns: 1fr; justify-items: center; }
|
||||||
|
.knowledge-page { grid-template-columns: 250px minmax(390px, 1fr); }
|
||||||
|
.document-inspector { display: none; }
|
||||||
|
.code-workbench { grid-template-columns: 220px 230px minmax(0, 1fr); }
|
||||||
|
.overview-grid, .system-grid { grid-template-columns: 1fr; }
|
||||||
|
.overview-columns { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue