From ba3836f00fee9bf8454049916974270e34810360 Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 24 Apr 2026 22:31:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=EF=BC=9A=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=20oclaw=20=E4=BB=93=E5=BA=93=E9=A6=96=E6=8F=90?= =?UTF-8?q?=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 oclaw/ 下重新初始化 Git 仓库 - 补齐子仓库 .gitignore,避免提交本地运行态数据(_local、node_modules、logs 等) - 提交当前工程代码与配置 Made-with: Cursor --- .github/workflows/ci.yml | 24 + .gitignore | 74 + .gitmodules | 3 + README.md | 15 + __init__.py | 6 + _hooks_selftest_workspace/AGENTS.md | 1 + .../memory/2026-04-22-1310.md | 8 + .../memory/2026-04-22-1321.md | 8 + admin/__init__.py | 4 + admin/chat_api.py | 1532 ++++ admin/mcp_e2e_probe.py | 184 + admin/models_api.py | 561 ++ admin/routes.py | 3069 +++++++ admin/skills_api.py | 604 ++ admin/static/app.js | 7578 +++++++++++++++++ admin/static/chat.html | 455 + admin/static/chat.js | 3404 ++++++++ admin/static/default-user-avatar.svg | 344 + admin/static/index.html | 59 + admin/static/oliver.svg | 333 + admin/static/styles.css | 315 + admin/static/theme-deepseek.css | 775 ++ agent/workspace-coding/AGENTS.md | 23 + agent/workspace-coding/IDENTITY.md | 18 + agent/workspace-coding/SOUL.md | 17 + agent/workspace-coding/USER.md | 14 + agent/workspace-coding/memory/README.md | 7 + agent/workspace-main/AGENTS.md | 22 + agent/workspace-main/IDENTITY.md | 18 + agent/workspace-main/SOUL.md | 18 + agent/workspace-main/USER.md | 16 + agent/workspace-main/memory/README.md | 7 + agent/workspace-social/AGENTS.md | 23 + agent/workspace-social/IDENTITY.md | 17 + agent/workspace-social/SOUL.md | 16 + agent/workspace-social/USER.md | 14 + agent/workspace-social/memory/README.md | 7 + agents/__init__.py | 40 + agents/agent_scope.py | 167 + agents/factory.py | 450 + agents/network_ops_agent.py | 45 + agents/specialist_agent.py | 516 ++ agents/specialists.py | 123 + agents/subagent_registry.py | 37 + app_server/__init__.py | 4 + application/__init__.py | 2 + application/gateway/__init__.py | 6 + application/gateway/inbound_service.py | 457 + application/gateway/inbound_usecase.py | 13 + channels/__init__.py | 4 + channels/base.py | 46 + channels/wecom/__init__.py | 6 + channels/wecom/longconn_runner.py | 737 ++ channels/wecom/normalize.py | 127 + channels/wecom/wecom_bridge.py | 49 + chat/__init__.py | 1 + chat/agent.py | 278 + chat/agent_errors.py | 69 + chat/agent_messages.py | 494 ++ chat/tool_runtime.py | 630 ++ chat/turn_types.py | 16 + data/eval/assistant_gateway.jsonl | 4 + data/eval/assistant_mvp.jsonl | 4 + data/eval/mvp_tasks.jsonl | 6 + data/mcp_local.env.example | 53 + data/mcp_registry.seed.json | 38 + desktop/README.md | 49 + desktop/assets/oclaw.ico | Bin 0 -> 372526 bytes desktop/main.js | 503 ++ desktop/package-lock.json | 5066 +++++++++++ desktop/package.json | 58 + desktop/preload.js | 6 + desktop/tools/generate-icon.cjs | 38 + docs/DEPLOY_CHECKLIST.md | 92 + docs/DESKTOP_PACKAGING.md | 70 + docs/ENVIRONMENT_VARIABLES.md | 405 + docs/ENVIRONMENT_VARIABLES_CHANGELOG.md | 181 + docs/GATEWAY_IMAGE_GENERATE.md | 59 + docs/GATEWAY_TELEGRAM_NORMALIZATION.md | 50 + docs/LLM_TRANSPORTS.md | 79 + docs/MCP_LOCAL_SERVER.md | 528 ++ docs/OCLAW_COMPAT_REMOVAL_CHECKLIST.md | 35 + docs/OCLAW_MIGRATION_GUIDE.md | 70 + docs/PROMPT_STYLE_GUIDE.md | 27 + docs/RUNBOOK.md | 448 + docs/memory-system/Archives/.gitkeep | 1 + docs/memory-system/Archives/README.md | 11 + docs/memory-system/Areas/.gitkeep | 1 + docs/memory-system/Areas/README.md | 11 + docs/memory-system/Inbox/.gitkeep | 1 + docs/memory-system/Inbox/README.md | 11 + docs/memory-system/PARA.md | 22 + docs/memory-system/Projects/.gitkeep | 1 + docs/memory-system/Projects/README.md | 11 + docs/memory-system/README.md | 90 + docs/memory-system/Resources/.gitkeep | 1 + docs/memory-system/Resources/README.md | 11 + docs/memory-system/runs/daily-2026-04-21.md | 19 + docs/memory-system/runs/weekly-2026-W17.md | 24 + docs/memory-system/templates/atomic-note.md | 35 + docs/memory-system/templates/daily-routine.md | 34 + .../memory-system/templates/srs-conversion.md | 64 + docs/memory-system/templates/weekly-review.md | 35 + docs/memory-system/weekly-review-schedule.md | 29 + docs/oclaw-skill-troubleshooting.md | 17 + docs/oclaw-trace-taxonomy.md | 15 + docs/openclaw-skill-troubleshooting.md | 119 + docs/openclaw-trace-taxonomy.md | 71 + evals/__init__.py | 0 evals/assistant_runner.py | 105 + evals/runner.py | 125 + extensions/README.md | 13 + extensions/__init__.py | 11 + extensions/anthropic/__init__.py | 13 + extensions/anthropic/api.py | 150 + extensions/anthropic/index.py | 27 + extensions/api/README.md | 22 + extensions/api/__init__.py | 10 + extensions/api/plugin_api.py | 6 + extensions/deepseek/__init__.py | 17 + extensions/deepseek/api.py | 21 + extensions/deepseek/index.py | 29 + extensions/image-generation-core/__init__.py | 15 + extensions/image-generation-core/api.py | 40 + extensions/image-generation-core/index.py | 23 + .../image-generation-core/runtime_api.py | 8 + extensions/kimi-coding/__init__.py | 19 + extensions/kimi-coding/api.py | 14 + extensions/kimi-coding/index.py | 23 + extensions/memory-core/__init__.py | 17 + extensions/memory-core/api.py | 405 + extensions/memory-core/index.py | 24 + extensions/memory-lancedb/__init__.py | 17 + extensions/memory-lancedb/api.py | 6 + extensions/memory-lancedb/index.py | 55 + extensions/memory-wiki/__init__.py | 10 + extensions/memory-wiki/api.py | 285 + extensions/memory-wiki/index.py | 28 + extensions/openai/__init__.py | 37 + extensions/openai/api.py | 69 + extensions/openai/index.py | 28 + extensions/plugin_api.py | 33 + extensions/qwen/__init__.py | 27 + extensions/qwen/api.py | 32 + extensions/qwen/index.py | 23 + extensions/telegram/__init__.py | 31 + extensions/telegram/api.py | 168 + extensions/telegram/index.py | 21 + extensions/webhooks/README.md | 30 + extensions/webhooks/__init__.py | 23 + extensions/webhooks/api.py | 17 + extensions/webhooks/config.py | 68 + extensions/webhooks/http.py | 338 + extensions/webhooks/index.py | 26 + extensions/webhooks/runtime_api.py | 35 + extensions/whatsapp/README.md | 36 + extensions/whatsapp/__init__.py | 18 + extensions/whatsapp/api.py | 49 + gateway/README.md | 18 + gateway/__init__.py | 13 + gateway/python_extensions_loader.py | 265 + gateway/server_methods/README.md | 49 + gateway/server_methods/__init__.py | 70 + gateway/server_methods/agent.py | 153 + gateway/server_methods/agents.py | 250 + .../server_methods/attachment_normalize.py | 41 + gateway/server_methods/base_hash.py | 14 + gateway/server_methods/channels.py | 106 + gateway/server_methods/chat.py | 152 + gateway/server_methods/commands.py | 62 + gateway/server_methods/config.py | 169 + gateway/server_methods/connect.py | 20 + gateway/server_methods/cron.py | 216 + gateway/server_methods/devices.py | 281 + gateway/server_methods/dispatcher.py | 6 + gateway/server_methods/doctor.py | 86 + gateway/server_methods/exec_approvals.py | 192 + gateway/server_methods/health.py | 60 + gateway/server_methods/image.py | 155 + gateway/server_methods/logs.py | 63 + gateway/server_methods/models.py | 61 + gateway/server_methods/models_auth_status.py | 128 + gateway/server_methods/nodes.py | 340 + gateway/server_methods/nodes_pending.py | 158 + gateway/server_methods/push.py | 86 + gateway/server_methods/record_shared.py | 15 + gateway/server_methods/restart_request.py | 53 + gateway/server_methods/send.py | 266 + gateway/server_methods/sessions.py | 315 + gateway/server_methods/shared_types.py | 47 + gateway/server_methods/skills.py | 273 + gateway/server_methods/system.py | 243 + gateway/server_methods/talk.py | 227 + .../server_methods/telegram_send_normalize.py | 41 + gateway/server_methods/tools_catalog.py | 117 + gateway/server_methods/tools_effective.py | 92 + gateway/server_methods/tts.py | 183 + gateway/server_methods/types.py | 2 + gateway/server_methods/update.py | 148 + gateway/server_methods/usage.py | 268 + gateway/server_methods/validation.py | 40 + gateway/server_methods/voicewake.py | 88 + gateway/server_methods/web.py | 120 + gateway/server_methods/wizard.py | 170 + gateway/server_plugins.py | 135 + gateway/server_startup_plugins.py | 88 + hooks/README.md | 30 + hooks/__init__.py | 27 + hooks/_selftest.py | 95 + hooks/bundled/boot-md/HOOK.md | 13 + hooks/bundled/boot-md/handler.py | 77 + hooks/bundled/bootstrap-extra-files/HOOK.md | 30 + .../bundled/bootstrap-extra-files/handler.py | 146 + hooks/bundled/command-logger/HOOK.md | 13 + hooks/bundled/command-logger/handler.py | 35 + hooks/bundled/session-memory/HOOK.md | 17 + hooks/bundled/session-memory/handler.py | 165 + hooks/bundled/wiki-auto-inject/HOOK.md | 13 + hooks/bundled/wiki-auto-inject/handler.py | 345 + hooks/frontmatter.py | 75 + hooks/internal_hooks.py | 97 + hooks/loader.py | 104 + hooks/policy.py | 125 + hooks/workspace.py | 140 + indexing/workspace_indexer.py | 101 + infrastructure/__init__.py | 2 + infrastructure/agent_context/__init__.py | 6 + infrastructure/agent_context/loader.py | 51 + interfaces/__init__.py | 2 + interfaces/gateway/__init__.py | 2 + interfaces/gateway/context_builder.py | 92 + interfaces/gateway/dispatcher.py | 36 + interfaces/gateway/http_adapter.py | 50 + interfaces/http/__init__.py | 6 + interfaces/http/fastapi_app.py | 200 + interfaces/http/weixin_ilink_api.py | 95 + interfaces/ws/__init__.py | 7 + interfaces/ws/auth_and_hello.py | 70 + interfaces/ws/common.py | 81 + interfaces/ws/entrypoint.py | 14 + interfaces/ws/events.py | 58 + interfaces/ws/runtime.py | 6 + interfaces/ws/runtime_dispatch.py | 13 + interfaces/ws/runtime_helpers.py | 137 + interfaces/ws/runtime_impl.py | 190 + interfaces/ws/runtime_loop.py | 43 + interfaces/ws/schema_impl.py | 79 + interfaces/ws/server_methods_bridge.py | 192 + interfaces/ws/turn_runner.py | 328 + interfaces/ws/ws_schema.py | 6 + oclaw.json | 57 + openclaw_protocol_schemas/agent.json | 29 + openclaw_protocol_schemas/agent_wait.json | 13 + openclaw_protocol_schemas/chat.json | 45 + openclaw_protocol_schemas/connect.json | 61 + openclaw_protocol_schemas/frames.json | 70 + openclaw_protocol_schemas/hello_ok.json | 86 + openclaw_protocol_schemas/sessions.json | 38 + openclaw_runtime/agent_core_attempt.py | 189 + openclaw_runtime/agent_core_run.py | 357 + openclaw_runtime/command_parser.py | 46 + openclaw_runtime/direct_loop.py | 617 ++ openclaw_runtime/gateway.py | 677 ++ openclaw_runtime/hooks_runtime.py | 192 + openclaw_runtime/memory_stage.py | 206 + openclaw_runtime/project_context_prompt.py | 189 + openclaw_runtime/relay_pointer.py | 228 + openclaw_runtime/router.py | 134 + openclaw_runtime/skill_executor.py | 135 + openclaw_runtime/skill_installer.py | 401 + openclaw_runtime/skill_role_binding.py | 129 + openclaw_runtime/skills.py | 382 + openclaw_runtime/skills_prompt.py | 167 + openclaw_runtime/system_prompt.py | 56 + openclaw_runtime/types.py | 246 + openclaw_runtime/worker.py | 236 + ops/__init__.py | 5 + ops/__main__.py | 7 + ops/main.py | 63 + ops/mcp_env.py | 97 + ops/mcp_registry_export.py | 109 + ops/memory.py | 162 + ops/providers/__init__.py | 4 + ops/providers/base.py | 14 + ops/providers/registry.py | 9 + ops/providers/wecom.py | 211 + ops/runtime.py | 293 + ops/stack.py | 91 + orchestration/__init__.py | 15 + orchestration/evaluation.py | 62 + orchestration/inventory.py | 20 + orchestration/memory.py | 275 + orchestration/policy.py | 72 + orchestration/protocol.py | 119 + orchestration/security.py | 54 + orchestration/session_mapping.py | 41 + orchestration/tool_adapter.py | 34 + orchestration/trace.py | 56 + orchestration/vector_store.py | 275 + platform/__init__.py | 0 platform/config/__init__.py | 0 platform/config/passwords.py | 21 + platform/config/paths.py | 194 + platform/embeddings/embedding_client.py | 77 + platform/files/__init__.py | 0 platform/files/attachment_assets.py | 333 + platform/files/file_attachments.py | 479 ++ platform/files/session_export.py | 66 + platform/files/tabular_attachment_store.py | 659 ++ platform/integrations/cc_mini_vendor.py | 85 + platform/integrations/wecom_client.py | 64 + platform/llm/OPENCLAW_MIT_LICENSE.txt | 27 + platform/llm/__init__.py | 1 + platform/llm/chat_models.py | 69 + platform/llm/image_message_client.py | 685 ++ platform/llm/replay_policy.py | 91 + platform/llm/tool_call_id.py | 160 + platform/llm/tool_schema.py | 103 + platform/llm/tool_wire_policy.py | 779 ++ platform/llm/transports/__init__.py | 1 + platform/llm/transports/anthropic_messages.py | 204 + platform/llm/transports/base.py | 63 + platform/llm/transports/google_gemini_sse.py | 265 + .../llm/transports/openai_chat_completions.py | 309 + platform/llm/transports/openai_responses.py | 236 + platform/llm/transports/simple.py | 144 + platform/persistence/__init__.py | 0 platform/persistence/sqlite_store.py | 5438 ++++++++++++ prompts/README.md | 22 + prompts/__init__.py | 3 + prompts/fallback/ollama_failure.en.md | 9 + prompts/fallback/ollama_failure.zh.md | 9 + .../fallback/openai_missing_key_user.en.md | 11 + .../fallback/openai_missing_key_user.zh.md | 11 + prompts/fallback/openai_transport_error.en.md | 9 + prompts/fallback/openai_transport_error.zh.md | 9 + .../fallback/openai_transport_oversized.en.md | 9 + .../fallback/openai_transport_oversized.zh.md | 9 + prompts/fallback/openclaw_runtime_error.en.md | 7 + prompts/fallback/openclaw_runtime_error.zh.md | 7 + prompts/fallback/task_queued.en.md | 7 + prompts/fallback/task_queued.zh.md | 7 + prompts/frontmatter.py | 92 + prompts/image/default_edit_prompt.zh.md | 7 + prompts/loader.py | 97 + prompts/runtime/default_system.en.md | 27 + prompts/runtime/default_system.zh.md | 22 + prompts/tools/image_attachment_meta.md | 8 + prompts/tools/text_attachment_wrap.md | 9 + prompts/tools/tool_result_unpaired.md | 8 + prompts_openclaw/MAPPING.md | 27 + prompts_openclaw/manager/decision.md | 36 + prompts_openclaw/roles/manager/system.md | 21 + .../roles/specialists/generalist/system.md | 25 + .../roles/specialists/image/system.md | 22 + .../specialists/memory_curator/system.md | 11 + .../roles/specialists/ops/system.md | 22 + prompts_openclaw/router/decide_route.md | 25 + .../runtime/memory_context_block.md | 10 + .../runtime/project_context_block.md | 7 + .../runtime/system_with_memory.md | 9 + .../runtime/system_with_skills.md | 6 + prompts_openclaw/shared/README.md | 8 + pytest.ini | 5 + requirements.txt | 19 + scripts/bootstrap_venv.ps1 | 51 + scripts/create_bind_code.py | 37 + scripts/install_extra_mcp_batch.py | 71 + scripts/install_mcp_context7.py | 145 + scripts/install_mcp_sqlite_brave_calendar.py | 153 + scripts/offline_eval.py | 46 + scripts/seed_mcp_registry.py | 96 + scripts/set_wecom_auto_bind.py | 55 + scripts/set_wecom_config.py | 35 + scripts/start_all.ps1 | 49 + scripts/start_desktop.ps1 | 49 + scripts/start_gateway.ps1 | 72 + scripts/start_ops.ps1 | 70 + scripts/start_ops.sh | 81 + scripts/start_web.ps1 | 10 + scripts/start_wiki_worker.ps1 | 37 + scripts/status_all.ps1 | 140 + scripts/status_ops.ps1 | 21 + scripts/status_ops.sh | 19 + scripts/status_wiki_worker.ps1 | 23 + scripts/stop_all.ps1 | 48 + scripts/stop_desktop.ps1 | 45 + scripts/stop_gateway.ps1 | 62 + scripts/stop_ops.ps1 | 33 + scripts/stop_ops.sh | 13 + scripts/stop_web.ps1 | 8 + scripts/stop_wiki_worker.ps1 | 31 + scripts/switch_wecom_bot.py | 48 + scripts/unbind_wecom_bot.py | 47 + scripts/wecom_smoke_test.py | 60 + scripts/wecom_status.py | 78 + scripts/weixin_install.ps1 | 41 + scripts/weixin_login.ps1 | 32 + scripts/weixin_start.ps1 | 39 + scripts/weixin_status.ps1 | 38 + scripts/weixin_stop.ps1 | 41 + scripts/wiki_auto_smoke_test.py | 100 + scripts/ws_probe.py | 91 + shared/__init__.py | 2 + skills/README.md | 16 + skills/coding/SKILL.md | 31 + skills/main/SKILL.md | 27 + skills/self-improvement/README.md | 14 + skills/self-improvement/SKILL.md | 644 ++ skills/self-improvement/_meta.json | 6 + skills/self-improvement/assets/ERRORS.md | 5 + .../assets/FEATURE_REQUESTS.md | 5 + skills/self-improvement/assets/LEARNINGS.md | 45 + .../self-improvement/assets/SKILL-TEMPLATE.md | 177 + .../self-improvement/hooks/openclaw/HOOK.md | 23 + .../hooks/openclaw/handler.js | 112 + .../hooks/openclaw/handler.ts | 111 + .../self-improvement/references/examples.md | 374 + .../references/hooks-setup.md | 225 + .../references/openclaw-integration.md | 254 + skills/self-improvement/scripts/activator.sh | 20 + .../scripts/error-detector.sh | 55 + .../self-improvement/scripts/extract-skill.sh | 221 + skills/social/SKILL.md | 31 + skills/weather/SKILL.md | 63 + skills/weather/_meta.json | 6 + skills/weather/scripts/run.py | 54 + tests/conftest.py | 36 + tests/test_admin_auth_rbac.py | 150 + tests/test_admin_chat_stream_async_task.py | 354 + tests/test_admin_chat_wiki_events_api.py | 118 + .../test_admin_internal_tools_preview_api.py | 62 + tests/test_admin_internal_tools_reload_api.py | 56 + ...t_admin_llm_preview_planner_consistency.py | 83 + tests/test_admin_llm_tools_preview_api.py | 74 + .../test_admin_mcp_tool_wire_role_mode_api.py | 137 + tests/test_admin_openclaw_runs_api.py | 93 + tests/test_admin_openclaw_tasks_api.py | 106 + tests/test_admin_session_monitor_api.py | 157 + tests/test_admin_skills_api.py | 174 + ..._admin_tool_policy_openclaw_retry_codes.py | 137 + ..._admin_tools_exposure_trace_setting_api.py | 62 + tests/test_admin_tools_self_check_api.py | 67 + tests/test_anthropic_transport.py | 27 + tests/test_backup_retention.py | 38 + tests/test_catalog_expert_role_loading.py | 42 + tests/test_catalog_mcp_integration.py | 42 + tests/test_cc_mini_vendor.py | 26 + tests/test_chat_profile_api.py | 97 + tests/test_chat_session_full_smoke.py | 91 + tests/test_chat_session_isolation.py | 130 + tests/test_clawhub_client.py | 17 + tests/test_fastapi_inbound.py | 21 + tests/test_file_attachments.py | 398 + tests/test_frontmatter_yaml.py | 29 + tests/test_gateway_image_generation_chain.py | 48 + tests/test_gateway_server_methods.py | 741 ++ tests/test_gateway_telegram_send_normalize.py | 55 + tests/test_gemini_sse_transport.py | 30 + ...test_get_messages_preserve_tool_pairing.py | 52 + tests/test_mcp_adapter.py | 176 + tests/test_mcp_admin_api.py | 357 + tests/test_mcp_e2e_probe.py | 63 + tests/test_mcp_filesystem_argv.py | 91 + tests/test_mcp_installer.py | 53 + tests/test_mcp_market.py | 42 + tests/test_mcp_registry.py | 34 + tests/test_mcp_runtime.py | 131 + tests/test_mcp_skill_adapter.py | 30 + tests/test_mcp_store.py | 73 + tests/test_mcp_workspace_filesystem_e2e.py | 239 + tests/test_memory_vector.py | 76 + tests/test_memory_wiki_plugin.py | 89 + tests/test_oclaw_gateway_optimizations.py | 125 + tests/test_openai_responses_transport.py | 36 + tests/test_openai_tools_shrink.py | 32 + tests/test_openclaw_acp_pointer_relay.py | 57 + tests/test_openclaw_agent_core.py | 176 + tests/test_openclaw_agent_core_trace.py | 119 + tests/test_openclaw_command_parser.py | 28 + tests/test_openclaw_dynamic_expert.py | 38 + tests/test_openclaw_extensions_webhooks_py.py | 211 + tests/test_openclaw_extensions_whatsapp_py.py | 65 + tests/test_openclaw_gateway_trace.py | 429 + tests/test_openclaw_generalist_executor.py | 28 + tests/test_openclaw_hooks_runtime.py | 34 + tests/test_openclaw_memory_stage.py | 46 + tests/test_openclaw_project_context_prompt.py | 121 + tests/test_openclaw_relay_pointer_protocol.py | 66 + tests/test_openclaw_relay_pointer_security.py | 53 + tests/test_openclaw_role_prompt_paths.py | 12 + tests/test_openclaw_router.py | 129 + tests/test_openclaw_skill_manifest.py | 58 + ...t_openclaw_startup_workspace_resolution.py | 32 + tests/test_openclaw_system_prompt.py | 62 + tests/test_openclaw_task_queue.py | 30 + tests/test_openclaw_tool_result_guard.py | 43 + tests/test_ops_cli.py | 71 + tests/test_paths_db_migration.py | 66 + tests/test_prompt_ci_guard.py | 53 + tests/test_prompt_hardcode_guard.py | 29 + tests/test_prompt_loader.py | 43 + tests/test_prompt_runtime_paths.py | 27 + tests/test_replay_tool_call_id.py | 50 + tests/test_runtime_streaming_contract.py | 82 + tests/test_secret_migration.py | 72 + tests/test_skill_executor_trace.py | 49 + tests/test_skill_installer.py | 125 + tests/test_skill_role_binding_catalog.py | 82 + .../test_skill_runtime_metadata_and_tools.py | 79 + ...skill_runtime_restricted_fs_permissions.py | 36 + tests/test_skills_prompt.py | 20 + tests/test_tool_batch_partition.py | 39 + tests/test_tool_llm_truncation.py | 33 + tests/test_tool_loop_guard.py | 125 + tests/test_tool_pairing_messages.py | 167 + tests/test_tool_wire_policy.py | 213 + tests/test_tool_wire_policy_role_scoped.py | 60 + tests/test_wecom_longconn_outbound_drain.py | 88 + tests/test_wiki_auto_inject_hook.py | 240 + tests/test_wiki_worker_pipeline.py | 130 + tests/test_workspace_path_guard.py | 300 + tests/test_ws_gateway.py | 320 + tools/__init__.py | 17 + tools/base.py | 80 + tools/catalog.py | 308 + tools/expert_registry.py | 207 + tools/experts/__init__.py | 2 + tools/experts/generalist/generalist_tools.py | 29 + tools/experts/generalist/geo_http.py | 66 + tools/experts/generalist/geo_info.py | 78 + tools/experts/generalist/image_edit.py | 129 + tools/experts/generalist/system_info.py | 33 + tools/experts/generalist/tabular_query.py | 150 + tools/experts/generalist/weather.py | 150 + tools/experts/generalist/web_search.py | 136 + tools/experts/memory_curator/__init__.py | 15 + .../memory_curator/wiki_curator_tools.py | 156 + tools/experts/network_ops/config_diff.py | 40 + tools/experts/network_ops/device_status.py | 78 + tools/experts/network_ops/get_path.py | 99 + tools/experts/network_ops/log_analysis.py | 53 + .../network_ops/network_probe_tools.py | 259 + tools/experts/network_ops/ops_tools.py | 77 + tools/experts/network_ops/query_route.py | 51 + tools/experts/network_ops/tabular_query.py | 10 + tools/experts/productivity/__init__.py | 4 + tools/experts/productivity/kb_tools.py | 96 + tools/experts/productivity/todo_tools.py | 146 + tools/experts/workspace/__init__.py | 4 + tools/experts/workspace/fs_tools.py | 148 + tools/experts/workspace/git_tools.py | 154 + tools/experts/workspace/patch_tools.py | 53 + tools/experts/workspace/search_tools.py | 92 + tools/experts/workspace/shell_tools.py | 91 + tools/experts/workspace/workspace_base.py | 261 + tools/exposure_plan.py | 232 + tools/mcp/__init__.py | 16 + tools/mcp/adapter.py | 165 + tools/mcp/filesystem_argv.py | 230 + tools/mcp/installer.py | 184 + tools/mcp/manifest.py | 20 + tools/mcp/market.py | 153 + tools/mcp/registry.py | 42 + tools/mcp/runtime.py | 288 + tools/plugin_loader.py | 134 + tools/public/__init__.py | 2 + tools/public/system_time_tool.py | 32 + tools/public_registry.py | 140 + tools/skills/clawhub_client.py | 138 + tools/skills_runtime/__init__.py | 2 + .../skills_runtime/materialize_skill_tools.py | 54 + tools/skills_runtime/subprocess_exec.py | 262 + tools/system_time_tool.py | 7 + tools/tool_validation.py | 37 + ui/__init__.py | 0 ui/streamlit/__init__.py | 0 wiki_worker/__init__.py | 2 + wiki_worker/main.py | 333 + 579 files changed, 83112 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 README.md create mode 100644 __init__.py create mode 100644 _hooks_selftest_workspace/AGENTS.md create mode 100644 _hooks_selftest_workspace/memory/2026-04-22-1310.md create mode 100644 _hooks_selftest_workspace/memory/2026-04-22-1321.md create mode 100644 admin/__init__.py create mode 100644 admin/chat_api.py create mode 100644 admin/mcp_e2e_probe.py create mode 100644 admin/models_api.py create mode 100644 admin/routes.py create mode 100644 admin/skills_api.py create mode 100644 admin/static/app.js create mode 100644 admin/static/chat.html create mode 100644 admin/static/chat.js create mode 100644 admin/static/default-user-avatar.svg create mode 100644 admin/static/index.html create mode 100644 admin/static/oliver.svg create mode 100644 admin/static/styles.css create mode 100644 admin/static/theme-deepseek.css create mode 100644 agent/workspace-coding/AGENTS.md create mode 100644 agent/workspace-coding/IDENTITY.md create mode 100644 agent/workspace-coding/SOUL.md create mode 100644 agent/workspace-coding/USER.md create mode 100644 agent/workspace-coding/memory/README.md create mode 100644 agent/workspace-main/AGENTS.md create mode 100644 agent/workspace-main/IDENTITY.md create mode 100644 agent/workspace-main/SOUL.md create mode 100644 agent/workspace-main/USER.md create mode 100644 agent/workspace-main/memory/README.md create mode 100644 agent/workspace-social/AGENTS.md create mode 100644 agent/workspace-social/IDENTITY.md create mode 100644 agent/workspace-social/SOUL.md create mode 100644 agent/workspace-social/USER.md create mode 100644 agent/workspace-social/memory/README.md create mode 100644 agents/__init__.py create mode 100644 agents/agent_scope.py create mode 100644 agents/factory.py create mode 100644 agents/network_ops_agent.py create mode 100644 agents/specialist_agent.py create mode 100644 agents/specialists.py create mode 100644 agents/subagent_registry.py create mode 100644 app_server/__init__.py create mode 100644 application/__init__.py create mode 100644 application/gateway/__init__.py create mode 100644 application/gateway/inbound_service.py create mode 100644 application/gateway/inbound_usecase.py create mode 100644 channels/__init__.py create mode 100644 channels/base.py create mode 100644 channels/wecom/__init__.py create mode 100644 channels/wecom/longconn_runner.py create mode 100644 channels/wecom/normalize.py create mode 100644 channels/wecom/wecom_bridge.py create mode 100644 chat/__init__.py create mode 100644 chat/agent.py create mode 100644 chat/agent_errors.py create mode 100644 chat/agent_messages.py create mode 100644 chat/tool_runtime.py create mode 100644 chat/turn_types.py create mode 100644 data/eval/assistant_gateway.jsonl create mode 100644 data/eval/assistant_mvp.jsonl create mode 100644 data/eval/mvp_tasks.jsonl create mode 100644 data/mcp_local.env.example create mode 100644 data/mcp_registry.seed.json create mode 100644 desktop/README.md create mode 100644 desktop/assets/oclaw.ico create mode 100644 desktop/main.js create mode 100644 desktop/package-lock.json create mode 100644 desktop/package.json create mode 100644 desktop/preload.js create mode 100644 desktop/tools/generate-icon.cjs create mode 100644 docs/DEPLOY_CHECKLIST.md create mode 100644 docs/DESKTOP_PACKAGING.md create mode 100644 docs/ENVIRONMENT_VARIABLES.md create mode 100644 docs/ENVIRONMENT_VARIABLES_CHANGELOG.md create mode 100644 docs/GATEWAY_IMAGE_GENERATE.md create mode 100644 docs/GATEWAY_TELEGRAM_NORMALIZATION.md create mode 100644 docs/LLM_TRANSPORTS.md create mode 100644 docs/MCP_LOCAL_SERVER.md create mode 100644 docs/OCLAW_COMPAT_REMOVAL_CHECKLIST.md create mode 100644 docs/OCLAW_MIGRATION_GUIDE.md create mode 100644 docs/PROMPT_STYLE_GUIDE.md create mode 100644 docs/RUNBOOK.md create mode 100644 docs/memory-system/Archives/.gitkeep create mode 100644 docs/memory-system/Archives/README.md create mode 100644 docs/memory-system/Areas/.gitkeep create mode 100644 docs/memory-system/Areas/README.md create mode 100644 docs/memory-system/Inbox/.gitkeep create mode 100644 docs/memory-system/Inbox/README.md create mode 100644 docs/memory-system/PARA.md create mode 100644 docs/memory-system/Projects/.gitkeep create mode 100644 docs/memory-system/Projects/README.md create mode 100644 docs/memory-system/README.md create mode 100644 docs/memory-system/Resources/.gitkeep create mode 100644 docs/memory-system/Resources/README.md create mode 100644 docs/memory-system/runs/daily-2026-04-21.md create mode 100644 docs/memory-system/runs/weekly-2026-W17.md create mode 100644 docs/memory-system/templates/atomic-note.md create mode 100644 docs/memory-system/templates/daily-routine.md create mode 100644 docs/memory-system/templates/srs-conversion.md create mode 100644 docs/memory-system/templates/weekly-review.md create mode 100644 docs/memory-system/weekly-review-schedule.md create mode 100644 docs/oclaw-skill-troubleshooting.md create mode 100644 docs/oclaw-trace-taxonomy.md create mode 100644 docs/openclaw-skill-troubleshooting.md create mode 100644 docs/openclaw-trace-taxonomy.md create mode 100644 evals/__init__.py create mode 100644 evals/assistant_runner.py create mode 100644 evals/runner.py create mode 100644 extensions/README.md create mode 100644 extensions/__init__.py create mode 100644 extensions/anthropic/__init__.py create mode 100644 extensions/anthropic/api.py create mode 100644 extensions/anthropic/index.py create mode 100644 extensions/api/README.md create mode 100644 extensions/api/__init__.py create mode 100644 extensions/api/plugin_api.py create mode 100644 extensions/deepseek/__init__.py create mode 100644 extensions/deepseek/api.py create mode 100644 extensions/deepseek/index.py create mode 100644 extensions/image-generation-core/__init__.py create mode 100644 extensions/image-generation-core/api.py create mode 100644 extensions/image-generation-core/index.py create mode 100644 extensions/image-generation-core/runtime_api.py create mode 100644 extensions/kimi-coding/__init__.py create mode 100644 extensions/kimi-coding/api.py create mode 100644 extensions/kimi-coding/index.py create mode 100644 extensions/memory-core/__init__.py create mode 100644 extensions/memory-core/api.py create mode 100644 extensions/memory-core/index.py create mode 100644 extensions/memory-lancedb/__init__.py create mode 100644 extensions/memory-lancedb/api.py create mode 100644 extensions/memory-lancedb/index.py create mode 100644 extensions/memory-wiki/__init__.py create mode 100644 extensions/memory-wiki/api.py create mode 100644 extensions/memory-wiki/index.py create mode 100644 extensions/openai/__init__.py create mode 100644 extensions/openai/api.py create mode 100644 extensions/openai/index.py create mode 100644 extensions/plugin_api.py create mode 100644 extensions/qwen/__init__.py create mode 100644 extensions/qwen/api.py create mode 100644 extensions/qwen/index.py create mode 100644 extensions/telegram/__init__.py create mode 100644 extensions/telegram/api.py create mode 100644 extensions/telegram/index.py create mode 100644 extensions/webhooks/README.md create mode 100644 extensions/webhooks/__init__.py create mode 100644 extensions/webhooks/api.py create mode 100644 extensions/webhooks/config.py create mode 100644 extensions/webhooks/http.py create mode 100644 extensions/webhooks/index.py create mode 100644 extensions/webhooks/runtime_api.py create mode 100644 extensions/whatsapp/README.md create mode 100644 extensions/whatsapp/__init__.py create mode 100644 extensions/whatsapp/api.py create mode 100644 gateway/README.md create mode 100644 gateway/__init__.py create mode 100644 gateway/python_extensions_loader.py create mode 100644 gateway/server_methods/README.md create mode 100644 gateway/server_methods/__init__.py create mode 100644 gateway/server_methods/agent.py create mode 100644 gateway/server_methods/agents.py create mode 100644 gateway/server_methods/attachment_normalize.py create mode 100644 gateway/server_methods/base_hash.py create mode 100644 gateway/server_methods/channels.py create mode 100644 gateway/server_methods/chat.py create mode 100644 gateway/server_methods/commands.py create mode 100644 gateway/server_methods/config.py create mode 100644 gateway/server_methods/connect.py create mode 100644 gateway/server_methods/cron.py create mode 100644 gateway/server_methods/devices.py create mode 100644 gateway/server_methods/dispatcher.py create mode 100644 gateway/server_methods/doctor.py create mode 100644 gateway/server_methods/exec_approvals.py create mode 100644 gateway/server_methods/health.py create mode 100644 gateway/server_methods/image.py create mode 100644 gateway/server_methods/logs.py create mode 100644 gateway/server_methods/models.py create mode 100644 gateway/server_methods/models_auth_status.py create mode 100644 gateway/server_methods/nodes.py create mode 100644 gateway/server_methods/nodes_pending.py create mode 100644 gateway/server_methods/push.py create mode 100644 gateway/server_methods/record_shared.py create mode 100644 gateway/server_methods/restart_request.py create mode 100644 gateway/server_methods/send.py create mode 100644 gateway/server_methods/sessions.py create mode 100644 gateway/server_methods/shared_types.py create mode 100644 gateway/server_methods/skills.py create mode 100644 gateway/server_methods/system.py create mode 100644 gateway/server_methods/talk.py create mode 100644 gateway/server_methods/telegram_send_normalize.py create mode 100644 gateway/server_methods/tools_catalog.py create mode 100644 gateway/server_methods/tools_effective.py create mode 100644 gateway/server_methods/tts.py create mode 100644 gateway/server_methods/types.py create mode 100644 gateway/server_methods/update.py create mode 100644 gateway/server_methods/usage.py create mode 100644 gateway/server_methods/validation.py create mode 100644 gateway/server_methods/voicewake.py create mode 100644 gateway/server_methods/web.py create mode 100644 gateway/server_methods/wizard.py create mode 100644 gateway/server_plugins.py create mode 100644 gateway/server_startup_plugins.py create mode 100644 hooks/README.md create mode 100644 hooks/__init__.py create mode 100644 hooks/_selftest.py create mode 100644 hooks/bundled/boot-md/HOOK.md create mode 100644 hooks/bundled/boot-md/handler.py create mode 100644 hooks/bundled/bootstrap-extra-files/HOOK.md create mode 100644 hooks/bundled/bootstrap-extra-files/handler.py create mode 100644 hooks/bundled/command-logger/HOOK.md create mode 100644 hooks/bundled/command-logger/handler.py create mode 100644 hooks/bundled/session-memory/HOOK.md create mode 100644 hooks/bundled/session-memory/handler.py create mode 100644 hooks/bundled/wiki-auto-inject/HOOK.md create mode 100644 hooks/bundled/wiki-auto-inject/handler.py create mode 100644 hooks/frontmatter.py create mode 100644 hooks/internal_hooks.py create mode 100644 hooks/loader.py create mode 100644 hooks/policy.py create mode 100644 hooks/workspace.py create mode 100644 indexing/workspace_indexer.py create mode 100644 infrastructure/__init__.py create mode 100644 infrastructure/agent_context/__init__.py create mode 100644 infrastructure/agent_context/loader.py create mode 100644 interfaces/__init__.py create mode 100644 interfaces/gateway/__init__.py create mode 100644 interfaces/gateway/context_builder.py create mode 100644 interfaces/gateway/dispatcher.py create mode 100644 interfaces/gateway/http_adapter.py create mode 100644 interfaces/http/__init__.py create mode 100644 interfaces/http/fastapi_app.py create mode 100644 interfaces/http/weixin_ilink_api.py create mode 100644 interfaces/ws/__init__.py create mode 100644 interfaces/ws/auth_and_hello.py create mode 100644 interfaces/ws/common.py create mode 100644 interfaces/ws/entrypoint.py create mode 100644 interfaces/ws/events.py create mode 100644 interfaces/ws/runtime.py create mode 100644 interfaces/ws/runtime_dispatch.py create mode 100644 interfaces/ws/runtime_helpers.py create mode 100644 interfaces/ws/runtime_impl.py create mode 100644 interfaces/ws/runtime_loop.py create mode 100644 interfaces/ws/schema_impl.py create mode 100644 interfaces/ws/server_methods_bridge.py create mode 100644 interfaces/ws/turn_runner.py create mode 100644 interfaces/ws/ws_schema.py create mode 100644 oclaw.json create mode 100644 openclaw_protocol_schemas/agent.json create mode 100644 openclaw_protocol_schemas/agent_wait.json create mode 100644 openclaw_protocol_schemas/chat.json create mode 100644 openclaw_protocol_schemas/connect.json create mode 100644 openclaw_protocol_schemas/frames.json create mode 100644 openclaw_protocol_schemas/hello_ok.json create mode 100644 openclaw_protocol_schemas/sessions.json create mode 100644 openclaw_runtime/agent_core_attempt.py create mode 100644 openclaw_runtime/agent_core_run.py create mode 100644 openclaw_runtime/command_parser.py create mode 100644 openclaw_runtime/direct_loop.py create mode 100644 openclaw_runtime/gateway.py create mode 100644 openclaw_runtime/hooks_runtime.py create mode 100644 openclaw_runtime/memory_stage.py create mode 100644 openclaw_runtime/project_context_prompt.py create mode 100644 openclaw_runtime/relay_pointer.py create mode 100644 openclaw_runtime/router.py create mode 100644 openclaw_runtime/skill_executor.py create mode 100644 openclaw_runtime/skill_installer.py create mode 100644 openclaw_runtime/skill_role_binding.py create mode 100644 openclaw_runtime/skills.py create mode 100644 openclaw_runtime/skills_prompt.py create mode 100644 openclaw_runtime/system_prompt.py create mode 100644 openclaw_runtime/types.py create mode 100644 openclaw_runtime/worker.py create mode 100644 ops/__init__.py create mode 100644 ops/__main__.py create mode 100644 ops/main.py create mode 100644 ops/mcp_env.py create mode 100644 ops/mcp_registry_export.py create mode 100644 ops/memory.py create mode 100644 ops/providers/__init__.py create mode 100644 ops/providers/base.py create mode 100644 ops/providers/registry.py create mode 100644 ops/providers/wecom.py create mode 100644 ops/runtime.py create mode 100644 ops/stack.py create mode 100644 orchestration/__init__.py create mode 100644 orchestration/evaluation.py create mode 100644 orchestration/inventory.py create mode 100644 orchestration/memory.py create mode 100644 orchestration/policy.py create mode 100644 orchestration/protocol.py create mode 100644 orchestration/security.py create mode 100644 orchestration/session_mapping.py create mode 100644 orchestration/tool_adapter.py create mode 100644 orchestration/trace.py create mode 100644 orchestration/vector_store.py create mode 100644 platform/__init__.py create mode 100644 platform/config/__init__.py create mode 100644 platform/config/passwords.py create mode 100644 platform/config/paths.py create mode 100644 platform/embeddings/embedding_client.py create mode 100644 platform/files/__init__.py create mode 100644 platform/files/attachment_assets.py create mode 100644 platform/files/file_attachments.py create mode 100644 platform/files/session_export.py create mode 100644 platform/files/tabular_attachment_store.py create mode 100644 platform/integrations/cc_mini_vendor.py create mode 100644 platform/integrations/wecom_client.py create mode 100644 platform/llm/OPENCLAW_MIT_LICENSE.txt create mode 100644 platform/llm/__init__.py create mode 100644 platform/llm/chat_models.py create mode 100644 platform/llm/image_message_client.py create mode 100644 platform/llm/replay_policy.py create mode 100644 platform/llm/tool_call_id.py create mode 100644 platform/llm/tool_schema.py create mode 100644 platform/llm/tool_wire_policy.py create mode 100644 platform/llm/transports/__init__.py create mode 100644 platform/llm/transports/anthropic_messages.py create mode 100644 platform/llm/transports/base.py create mode 100644 platform/llm/transports/google_gemini_sse.py create mode 100644 platform/llm/transports/openai_chat_completions.py create mode 100644 platform/llm/transports/openai_responses.py create mode 100644 platform/llm/transports/simple.py create mode 100644 platform/persistence/__init__.py create mode 100644 platform/persistence/sqlite_store.py create mode 100644 prompts/README.md create mode 100644 prompts/__init__.py create mode 100644 prompts/fallback/ollama_failure.en.md create mode 100644 prompts/fallback/ollama_failure.zh.md create mode 100644 prompts/fallback/openai_missing_key_user.en.md create mode 100644 prompts/fallback/openai_missing_key_user.zh.md create mode 100644 prompts/fallback/openai_transport_error.en.md create mode 100644 prompts/fallback/openai_transport_error.zh.md create mode 100644 prompts/fallback/openai_transport_oversized.en.md create mode 100644 prompts/fallback/openai_transport_oversized.zh.md create mode 100644 prompts/fallback/openclaw_runtime_error.en.md create mode 100644 prompts/fallback/openclaw_runtime_error.zh.md create mode 100644 prompts/fallback/task_queued.en.md create mode 100644 prompts/fallback/task_queued.zh.md create mode 100644 prompts/frontmatter.py create mode 100644 prompts/image/default_edit_prompt.zh.md create mode 100644 prompts/loader.py create mode 100644 prompts/runtime/default_system.en.md create mode 100644 prompts/runtime/default_system.zh.md create mode 100644 prompts/tools/image_attachment_meta.md create mode 100644 prompts/tools/text_attachment_wrap.md create mode 100644 prompts/tools/tool_result_unpaired.md create mode 100644 prompts_openclaw/MAPPING.md create mode 100644 prompts_openclaw/manager/decision.md create mode 100644 prompts_openclaw/roles/manager/system.md create mode 100644 prompts_openclaw/roles/specialists/generalist/system.md create mode 100644 prompts_openclaw/roles/specialists/image/system.md create mode 100644 prompts_openclaw/roles/specialists/memory_curator/system.md create mode 100644 prompts_openclaw/roles/specialists/ops/system.md create mode 100644 prompts_openclaw/router/decide_route.md create mode 100644 prompts_openclaw/runtime/memory_context_block.md create mode 100644 prompts_openclaw/runtime/project_context_block.md create mode 100644 prompts_openclaw/runtime/system_with_memory.md create mode 100644 prompts_openclaw/runtime/system_with_skills.md create mode 100644 prompts_openclaw/shared/README.md create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 scripts/bootstrap_venv.ps1 create mode 100644 scripts/create_bind_code.py create mode 100644 scripts/install_extra_mcp_batch.py create mode 100644 scripts/install_mcp_context7.py create mode 100644 scripts/install_mcp_sqlite_brave_calendar.py create mode 100644 scripts/offline_eval.py create mode 100644 scripts/seed_mcp_registry.py create mode 100644 scripts/set_wecom_auto_bind.py create mode 100644 scripts/set_wecom_config.py create mode 100644 scripts/start_all.ps1 create mode 100644 scripts/start_desktop.ps1 create mode 100644 scripts/start_gateway.ps1 create mode 100644 scripts/start_ops.ps1 create mode 100644 scripts/start_ops.sh create mode 100644 scripts/start_web.ps1 create mode 100644 scripts/start_wiki_worker.ps1 create mode 100644 scripts/status_all.ps1 create mode 100644 scripts/status_ops.ps1 create mode 100644 scripts/status_ops.sh create mode 100644 scripts/status_wiki_worker.ps1 create mode 100644 scripts/stop_all.ps1 create mode 100644 scripts/stop_desktop.ps1 create mode 100644 scripts/stop_gateway.ps1 create mode 100644 scripts/stop_ops.ps1 create mode 100644 scripts/stop_ops.sh create mode 100644 scripts/stop_web.ps1 create mode 100644 scripts/stop_wiki_worker.ps1 create mode 100644 scripts/switch_wecom_bot.py create mode 100644 scripts/unbind_wecom_bot.py create mode 100644 scripts/wecom_smoke_test.py create mode 100644 scripts/wecom_status.py create mode 100644 scripts/weixin_install.ps1 create mode 100644 scripts/weixin_login.ps1 create mode 100644 scripts/weixin_start.ps1 create mode 100644 scripts/weixin_status.ps1 create mode 100644 scripts/weixin_stop.ps1 create mode 100644 scripts/wiki_auto_smoke_test.py create mode 100644 scripts/ws_probe.py create mode 100644 shared/__init__.py create mode 100644 skills/README.md create mode 100644 skills/coding/SKILL.md create mode 100644 skills/main/SKILL.md create mode 100644 skills/self-improvement/README.md create mode 100644 skills/self-improvement/SKILL.md create mode 100644 skills/self-improvement/_meta.json create mode 100644 skills/self-improvement/assets/ERRORS.md create mode 100644 skills/self-improvement/assets/FEATURE_REQUESTS.md create mode 100644 skills/self-improvement/assets/LEARNINGS.md create mode 100644 skills/self-improvement/assets/SKILL-TEMPLATE.md create mode 100644 skills/self-improvement/hooks/openclaw/HOOK.md create mode 100644 skills/self-improvement/hooks/openclaw/handler.js create mode 100644 skills/self-improvement/hooks/openclaw/handler.ts create mode 100644 skills/self-improvement/references/examples.md create mode 100644 skills/self-improvement/references/hooks-setup.md create mode 100644 skills/self-improvement/references/openclaw-integration.md create mode 100644 skills/self-improvement/scripts/activator.sh create mode 100644 skills/self-improvement/scripts/error-detector.sh create mode 100644 skills/self-improvement/scripts/extract-skill.sh create mode 100644 skills/social/SKILL.md create mode 100644 skills/weather/SKILL.md create mode 100644 skills/weather/_meta.json create mode 100644 skills/weather/scripts/run.py create mode 100644 tests/conftest.py create mode 100644 tests/test_admin_auth_rbac.py create mode 100644 tests/test_admin_chat_stream_async_task.py create mode 100644 tests/test_admin_chat_wiki_events_api.py create mode 100644 tests/test_admin_internal_tools_preview_api.py create mode 100644 tests/test_admin_internal_tools_reload_api.py create mode 100644 tests/test_admin_llm_preview_planner_consistency.py create mode 100644 tests/test_admin_llm_tools_preview_api.py create mode 100644 tests/test_admin_mcp_tool_wire_role_mode_api.py create mode 100644 tests/test_admin_openclaw_runs_api.py create mode 100644 tests/test_admin_openclaw_tasks_api.py create mode 100644 tests/test_admin_session_monitor_api.py create mode 100644 tests/test_admin_skills_api.py create mode 100644 tests/test_admin_tool_policy_openclaw_retry_codes.py create mode 100644 tests/test_admin_tools_exposure_trace_setting_api.py create mode 100644 tests/test_admin_tools_self_check_api.py create mode 100644 tests/test_anthropic_transport.py create mode 100644 tests/test_backup_retention.py create mode 100644 tests/test_catalog_expert_role_loading.py create mode 100644 tests/test_catalog_mcp_integration.py create mode 100644 tests/test_cc_mini_vendor.py create mode 100644 tests/test_chat_profile_api.py create mode 100644 tests/test_chat_session_full_smoke.py create mode 100644 tests/test_chat_session_isolation.py create mode 100644 tests/test_clawhub_client.py create mode 100644 tests/test_fastapi_inbound.py create mode 100644 tests/test_file_attachments.py create mode 100644 tests/test_frontmatter_yaml.py create mode 100644 tests/test_gateway_image_generation_chain.py create mode 100644 tests/test_gateway_server_methods.py create mode 100644 tests/test_gateway_telegram_send_normalize.py create mode 100644 tests/test_gemini_sse_transport.py create mode 100644 tests/test_get_messages_preserve_tool_pairing.py create mode 100644 tests/test_mcp_adapter.py create mode 100644 tests/test_mcp_admin_api.py create mode 100644 tests/test_mcp_e2e_probe.py create mode 100644 tests/test_mcp_filesystem_argv.py create mode 100644 tests/test_mcp_installer.py create mode 100644 tests/test_mcp_market.py create mode 100644 tests/test_mcp_registry.py create mode 100644 tests/test_mcp_runtime.py create mode 100644 tests/test_mcp_skill_adapter.py create mode 100644 tests/test_mcp_store.py create mode 100644 tests/test_mcp_workspace_filesystem_e2e.py create mode 100644 tests/test_memory_vector.py create mode 100644 tests/test_memory_wiki_plugin.py create mode 100644 tests/test_oclaw_gateway_optimizations.py create mode 100644 tests/test_openai_responses_transport.py create mode 100644 tests/test_openai_tools_shrink.py create mode 100644 tests/test_openclaw_acp_pointer_relay.py create mode 100644 tests/test_openclaw_agent_core.py create mode 100644 tests/test_openclaw_agent_core_trace.py create mode 100644 tests/test_openclaw_command_parser.py create mode 100644 tests/test_openclaw_dynamic_expert.py create mode 100644 tests/test_openclaw_extensions_webhooks_py.py create mode 100644 tests/test_openclaw_extensions_whatsapp_py.py create mode 100644 tests/test_openclaw_gateway_trace.py create mode 100644 tests/test_openclaw_generalist_executor.py create mode 100644 tests/test_openclaw_hooks_runtime.py create mode 100644 tests/test_openclaw_memory_stage.py create mode 100644 tests/test_openclaw_project_context_prompt.py create mode 100644 tests/test_openclaw_relay_pointer_protocol.py create mode 100644 tests/test_openclaw_relay_pointer_security.py create mode 100644 tests/test_openclaw_role_prompt_paths.py create mode 100644 tests/test_openclaw_router.py create mode 100644 tests/test_openclaw_skill_manifest.py create mode 100644 tests/test_openclaw_startup_workspace_resolution.py create mode 100644 tests/test_openclaw_system_prompt.py create mode 100644 tests/test_openclaw_task_queue.py create mode 100644 tests/test_openclaw_tool_result_guard.py create mode 100644 tests/test_ops_cli.py create mode 100644 tests/test_paths_db_migration.py create mode 100644 tests/test_prompt_ci_guard.py create mode 100644 tests/test_prompt_hardcode_guard.py create mode 100644 tests/test_prompt_loader.py create mode 100644 tests/test_prompt_runtime_paths.py create mode 100644 tests/test_replay_tool_call_id.py create mode 100644 tests/test_runtime_streaming_contract.py create mode 100644 tests/test_secret_migration.py create mode 100644 tests/test_skill_executor_trace.py create mode 100644 tests/test_skill_installer.py create mode 100644 tests/test_skill_role_binding_catalog.py create mode 100644 tests/test_skill_runtime_metadata_and_tools.py create mode 100644 tests/test_skill_runtime_restricted_fs_permissions.py create mode 100644 tests/test_skills_prompt.py create mode 100644 tests/test_tool_batch_partition.py create mode 100644 tests/test_tool_llm_truncation.py create mode 100644 tests/test_tool_loop_guard.py create mode 100644 tests/test_tool_pairing_messages.py create mode 100644 tests/test_tool_wire_policy.py create mode 100644 tests/test_tool_wire_policy_role_scoped.py create mode 100644 tests/test_wecom_longconn_outbound_drain.py create mode 100644 tests/test_wiki_auto_inject_hook.py create mode 100644 tests/test_wiki_worker_pipeline.py create mode 100644 tests/test_workspace_path_guard.py create mode 100644 tests/test_ws_gateway.py create mode 100644 tools/__init__.py create mode 100644 tools/base.py create mode 100644 tools/catalog.py create mode 100644 tools/expert_registry.py create mode 100644 tools/experts/__init__.py create mode 100644 tools/experts/generalist/generalist_tools.py create mode 100644 tools/experts/generalist/geo_http.py create mode 100644 tools/experts/generalist/geo_info.py create mode 100644 tools/experts/generalist/image_edit.py create mode 100644 tools/experts/generalist/system_info.py create mode 100644 tools/experts/generalist/tabular_query.py create mode 100644 tools/experts/generalist/weather.py create mode 100644 tools/experts/generalist/web_search.py create mode 100644 tools/experts/memory_curator/__init__.py create mode 100644 tools/experts/memory_curator/wiki_curator_tools.py create mode 100644 tools/experts/network_ops/config_diff.py create mode 100644 tools/experts/network_ops/device_status.py create mode 100644 tools/experts/network_ops/get_path.py create mode 100644 tools/experts/network_ops/log_analysis.py create mode 100644 tools/experts/network_ops/network_probe_tools.py create mode 100644 tools/experts/network_ops/ops_tools.py create mode 100644 tools/experts/network_ops/query_route.py create mode 100644 tools/experts/network_ops/tabular_query.py create mode 100644 tools/experts/productivity/__init__.py create mode 100644 tools/experts/productivity/kb_tools.py create mode 100644 tools/experts/productivity/todo_tools.py create mode 100644 tools/experts/workspace/__init__.py create mode 100644 tools/experts/workspace/fs_tools.py create mode 100644 tools/experts/workspace/git_tools.py create mode 100644 tools/experts/workspace/patch_tools.py create mode 100644 tools/experts/workspace/search_tools.py create mode 100644 tools/experts/workspace/shell_tools.py create mode 100644 tools/experts/workspace/workspace_base.py create mode 100644 tools/exposure_plan.py create mode 100644 tools/mcp/__init__.py create mode 100644 tools/mcp/adapter.py create mode 100644 tools/mcp/filesystem_argv.py create mode 100644 tools/mcp/installer.py create mode 100644 tools/mcp/manifest.py create mode 100644 tools/mcp/market.py create mode 100644 tools/mcp/registry.py create mode 100644 tools/mcp/runtime.py create mode 100644 tools/plugin_loader.py create mode 100644 tools/public/__init__.py create mode 100644 tools/public/system_time_tool.py create mode 100644 tools/public_registry.py create mode 100644 tools/skills/clawhub_client.py create mode 100644 tools/skills_runtime/__init__.py create mode 100644 tools/skills_runtime/materialize_skill_tools.py create mode 100644 tools/skills_runtime/subprocess_exec.py create mode 100644 tools/system_time_tool.py create mode 100644 tools/tool_validation.py create mode 100644 ui/__init__.py create mode 100644 ui/streamlit/__init__.py create mode 100644 wiki_worker/__init__.py create mode 100644 wiki_worker/main.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..7f88569d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: ci + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: python -m pip install -r requirements.txt ruff mypy + - name: Lint changed architecture modules + run: ruff check oclaw/interfaces oclaw/extensions oclaw/tests/test_oclaw_gateway_optimizations.py + - name: Type check critical gateway modules + run: mypy --ignore-missing-imports oclaw/interfaces/gateway/context_builder.py oclaw/interfaces/ws/server_methods_bridge.py oclaw/interfaces/ws/turn_runner.py + - name: Run tests + run: python -m pytest -q oclaw/tests + - name: Offline eval sanity + run: python oclaw/scripts/offline_eval.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a904ae2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +.venv/ +build/ +dist/ +*.spec +__pycache__/ +*.pyc +.pytest_cache/ +oclaw/scripts/.run/ +oclaw/.venv/ +.venv/ +.mypy_cache/ +data/*.sqlite +data/*.sqlite-journal +data/*.sqlite-shm +data/*.sqlite-wal +data/attachments/ +oclaw/data/attachments/ +oclaw/data/*.sqlite +oclaw/data/*.sqlite-journal +oclaw/data/*.sqlite-shm +oclaw/data/*.sqlite-wal +oclaw/data/logs/ +oclaw/data/locks/ +oclaw/data/ops_runtime_state.json +oclaw/data/mcp_local.env +oclaw/data/google_oauth_client.json +oclaw/data/_pre_merge_sqlite_*/ +data/logs/ +data/locks/ +data/ops_runtime_state.json +data/mcp_local.env +data/google_oauth_client.json +data/_pre_merge_sqlite_*/ +# oclaw 子仓库运行态目录(勿提交) +_local/ +_local/*.env +_local/*.json +_local/*.txt +data/channel_sidecar/ +data/**/node_modules/ +data/**/*.log +data/**/*.err.log +platform/data/ +scripts/.run/ +desktop/node_modules/ +desktop/dist/ +desktop/runtime-data/ +# 本地密钥(随仓库目录迁移时记得复制此文件;勿提交) +src/_local/google_oauth_client.json +src/_local/mcp_local.env +# MCP admin export / 安装后自动备份(可重装 JSON,本机 path 与列表可能不同) +src/_local/mcp_registry_migrated.json +src/platform/data/*.sqlite +src/platform/data/*.sqlite-journal +src/platform/data/*.sqlite-shm +src/platform/data/*.sqlite-wal +src/platform/data/logs/ +src/platform/data/locks/ +src/platform/data/ops_runtime_state.json +# 历史 Vite 前端已移除;若本地仍有残留目录可忽略或手动删除 +web/ + +# 根目录临时调试日志(勿提交) +debug-*.log +.playwright-mcp/ + +# Python 安装/扩展缓存(非本应用源码) +Python/_cache/ +oclaw/desktop/node_modules/ +oclaw/desktop/dist/ +oclaw/desktop/runtime-data/ +oclaw/desktop/assets/oclaw.ico +vendor/openclaw-main.zip +vendor/openclaw/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..ab7b7b29 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/cc-mini"] + path = vendor/cc-mini + url = https://github.com/e10nMa2k/cc-mini.git diff --git a/README.md b/README.md new file mode 100644 index 00000000..a2a958eb --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# OpenClaw Architecture Root + +This directory is the new architecture root for ongoing refactors. + +## Layers +- `interfaces/`: transport adapters (HTTP/WS/gateway handlers) +- `application/`: use-cases and orchestration services +- `domain/`: business rules and domain primitives +- `infrastructure/`: integrations and runtime adapters +- `shared/`: shared utilities/types + +## Migration Rule +New business logic should be implemented in `oclaw/`. +`oclaw/` modules remain as compatibility bridges during migration. + diff --git a/__init__.py b/__init__.py new file mode 100644 index 00000000..72794d96 --- /dev/null +++ b/__init__.py @@ -0,0 +1,6 @@ +"""OpenClaw v2 package root. + +All new architecture-first code should live under `oclaw/`. +Legacy `src/` modules may import from this package as a compatibility bridge. +""" + diff --git a/_hooks_selftest_workspace/AGENTS.md b/_hooks_selftest_workspace/AGENTS.md new file mode 100644 index 00000000..9afd7581 --- /dev/null +++ b/_hooks_selftest_workspace/AGENTS.md @@ -0,0 +1 @@ +# AGENTS diff --git a/_hooks_selftest_workspace/memory/2026-04-22-1310.md b/_hooks_selftest_workspace/memory/2026-04-22-1310.md new file mode 100644 index 00000000..d8c1ba42 --- /dev/null +++ b/_hooks_selftest_workspace/memory/2026-04-22-1310.md @@ -0,0 +1,8 @@ +# Session: 2026-04-22 13:10:55 UTC + +- **Session Key**: agent:main:main +- **Action**: reset + +## Conversation Summary + +- (no messages found) diff --git a/_hooks_selftest_workspace/memory/2026-04-22-1321.md b/_hooks_selftest_workspace/memory/2026-04-22-1321.md new file mode 100644 index 00000000..d48e560b --- /dev/null +++ b/_hooks_selftest_workspace/memory/2026-04-22-1321.md @@ -0,0 +1,8 @@ +# Session: 2026-04-22 13:21:10 UTC + +- **Session Key**: agent:main:main +- **Action**: reset + +## Conversation Summary + +- (no messages found) diff --git a/admin/__init__.py b/admin/__init__.py new file mode 100644 index 00000000..a04cd9d0 --- /dev/null +++ b/admin/__init__.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +__all__ = [] + diff --git a/admin/chat_api.py b/admin/chat_api.py new file mode 100644 index 00000000..e83206bf --- /dev/null +++ b/admin/chat_api.py @@ -0,0 +1,1532 @@ +"""Admin-mounted chat REST API (non-Streamlit). Uses same bearer auth as other /admin/api routes.""" + +from __future__ import annotations + +import base64 +import json +import queue +import threading +import os +from collections.abc import Callable, Iterator +from typing import Any +from pathlib import Path + +from fastapi import APIRouter, Body, File, Header, HTTPException, Query, UploadFile +from fastapi.responses import Response, StreamingResponse + +from oclaw.ops.mcp_env import apply_gateway_mcp_env_to_os +from oclaw.agents.factory import build_gateway_executor +from oclaw.chat.agent import GenerationInterrupted +from oclaw.platform.config.paths import db_path +from oclaw.platform.files.attachment_assets import AttachmentAssetStore +from oclaw.platform.files.file_attachments import ( + DEFAULT_TABULAR_CELL_CHARS, + DEFAULT_TABULAR_COLUMNS, + DEFAULT_MAX_EXCEL_SHEETS, + DEFAULT_TABULAR_ROWS_READ, + clear_attachment_limits_cache, + process_file_data, +) +from oclaw.platform.files.session_export import export_session_json, export_session_markdown +from oclaw.platform.persistence.sqlite_store import SqliteStore +from oclaw.openclaw_runtime.gateway import OpenClawGateway +from oclaw.openclaw_runtime.types import StandardMessage, normalize_interaction_mode, normalize_requested_specialist + + +def _oclaw_config_path() -> Path: + raw = str(os.getenv("AIA_OCLAW_CONFIG_PATH") or "").strip() + if raw: + p = Path(raw) + return p if p.is_absolute() else p.resolve() + # repo_root/src/admin/chat_api.py -> repo_root + return Path(__file__).resolve().parents[2] / "oclaw" / "oclaw.json" + + +def _wiki_root_from_config() -> Path | None: + cfg_path = _oclaw_config_path() + if not cfg_path.exists(): + return None + try: + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) + except Exception: + return None + plugins = cfg.get("plugins") if isinstance(cfg, dict) else {} + entries = plugins.get("entries") if isinstance(plugins, dict) else {} + wiki_entry = entries.get("memory-wiki") if isinstance(entries, dict) else {} + root_cfg = str(wiki_entry.get("wiki_root") or "").strip() + if not root_cfg: + return None + root = Path(root_cfg) + if not root.is_absolute(): + root = (Path(__file__).resolve().parents[2] / root).resolve() + return root + +_CHAT_MSG_LIMIT = 256 +_SESSION_TITLE_MAX_LEN = 120 +_AVATAR_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 +_AVATAR_MIMES = frozenset({"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"}) + +_CHAT_STOP_EVENTS: dict[str, threading.Event] = {} +_CHAT_STOP_LOCK = threading.Lock() +_DISPATCH_REASON_LABELS: dict[str, dict[str, str]] = { + "manager_no_specialist_fallback": { + "zh": "无可用专家,回退通用", + "en": "No specialist; fallback to generalist", + }, + "dynamic_agent_build_failed": { + "zh": "动态专家构建失败", + "en": "Dynamic agent build failed", + }, + "manager_factory_failed": { + "zh": "专家执行器创建失败", + "en": "Specialist executor factory failed", + }, + "manager_route_missing": { + "zh": "总控路由缺失", + "en": "Manager route missing", + }, + "manager_select_failed": { + "zh": "总控决策失败", + "en": "Manager selection failed", + }, + "manager_model_missing": { + "zh": "总控模型不可用", + "en": "Manager model missing", + }, + "dynamic_agent_selected": { + "zh": "动态专家命中", + "en": "Dynamic agent selected", + }, +} +_DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON" +_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON" +_CHAT_SPECIALIST_IDS: tuple[str, ...] = ("generalist", "ops", "image", "memory_curator") +DEFAULT_TABULAR_SQL_TIMEOUT_MS = 8_000 + + +def _safe_rel_avatar_name(name: str) -> str: + raw = (name or "").replace("\\", "/").split("/")[-1].strip() + return raw or "avatar.png" + + +def _api_lang(store: SqliteStore) -> str: + v = str(store.get_setting("ui_lang") or "zh").strip().lower() + return v if v in ("zh", "en") else "zh" + + +def _init_gateway_executor( + store: SqliteStore, + ctx: dict[str, Any], + *, + lang: str, + specialist: str | None, + policy_session_id: str | None, +) -> Any: + uid = str(ctx.get("user_id") or "").strip() + uname = str(ctx.get("username") or "").strip() + tid = str(ctx.get("tenant_id") or "").strip() + return build_gateway_executor( + store, + lang=lang, + specialist=specialist, + profile_id=None, + openai_api_key=None, + llm_mode=None, + model=None, + base_url=None, + viewer_user_id=uid or None, + viewer_username=uname or None, + viewer_tenant_id=tid or None, + policy_session_id=policy_session_id, + path_policy_tenant_id=tid or None, + path_policy_user_id=uid or None, + ) + + +def _normalize_attachments_for_api(raw: Any) -> list[dict[str, Any]] | None: + """DB 存 JSON 字符串;部分历史数据为单对象。统一成 list 便于前端直接使用。""" + if raw is None: + return None + if isinstance(raw, (bytes, bytearray)): + try: + raw = raw.decode("utf-8") + except Exception: + return None + if isinstance(raw, list): + return [x for x in raw if isinstance(x, dict)] + if isinstance(raw, dict): + return [raw] + if not isinstance(raw, str): + return None + s = raw.strip() + if not s or s == "null": + return None + try: + v = json.loads(s) + except Exception: + return None + if isinstance(v, list): + return [x for x in v if isinstance(x, dict)] + if isinstance(v, dict): + return [v] + return None + + +def _serialize_message(m: Any) -> dict[str, Any]: + return { + "id": int(getattr(m, "id", 0) or 0), + "session_id": str(getattr(m, "session_id", "") or ""), + "turn_uuid": str(getattr(m, "turn_uuid", "") or ""), + "event_type": str(getattr(m, "event_type", "") or ""), + "event_payload": _safe_json_object(getattr(m, "event_payload", None)), + "role": str(getattr(m, "role", "") or ""), + "content": str(getattr(m, "content", "") or ""), + "tool_calls": getattr(m, "tool_calls", None), + "attachments": _normalize_attachments_for_api(getattr(m, "attachments", None)), + "timestamp": str(getattr(m, "timestamp", "") or ""), + } + + +def _register_stop(session_id: str) -> threading.Event: + with _CHAT_STOP_LOCK: + ev = threading.Event() + _CHAT_STOP_EVENTS[str(session_id)] = ev + return ev + + +def _clear_stop(session_id: str) -> None: + with _CHAT_STOP_LOCK: + _CHAT_STOP_EVENTS.pop(str(session_id), None) + + +def _decode_base64_payload(s: str) -> bytes | None: + """Decode base64 from browser (may lack padding; URL-safe variants).""" + raw = (s or "").strip() + if not raw: + return None + if raw.startswith("data:") and "," in raw: + raw = raw.split(",", 1)[1].strip() + raw = raw.replace("-", "+").replace("_", "/") + pad = (-len(raw)) % 4 + if pad: + raw += "=" * pad + try: + return base64.b64decode(raw, validate=False) + except Exception: + try: + return base64.standard_b64decode(raw) + except Exception: + return None + + +def _parse_attachments_payload(raw: Any) -> list[dict[str, Any]] | None: + if not raw: + return None + if not isinstance(raw, list): + return None + out: list[dict[str, Any]] = [] + for item in raw: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "file").strip() or "file" + b64 = item.get("data_base64") if "data_base64" in item else item.get("data") + if not isinstance(b64, str) or not b64.strip(): + continue + data = _decode_base64_payload(b64) + if not data: + continue + got = process_file_data(name, data) + if got: + out.extend(got) + return out if out else None + + +def _chat_username(ctx: dict[str, Any]) -> str: + return str(ctx.get("username") or "").strip().lower() + + +def _is_administrator_chat_viewer(ctx: dict[str, Any]) -> bool: + """``administrator`` 账户:在单会话读写/导出等接口上可按租户打开任意会话(供审计与 Session Monitor)。 + + ``GET /chat/sessions`` **不再**使用租户全量列表,避免与普通用户会话混在同一侧边栏; + 看全租户会话请用审计页、``GET /admin/api/chat/admin/sessions`` 等专用接口。 + """ + return _chat_username(ctx) == "administrator" + + +def _require_administrator_chat_viewer(ctx: dict[str, Any]) -> None: + if not _is_administrator_chat_viewer(ctx): + raise HTTPException(status_code=403, detail="administrator_only") + + +def _resolve_chat_session(store: SqliteStore, ctx: dict[str, Any], session_id: str): + tenant_id = str(ctx.get("tenant_id") or "") + user_id = str(ctx.get("user_id") or "") + if _is_administrator_chat_viewer(ctx): + return store.get_session_in_tenant(session_id=str(session_id or "").strip(), tenant_id=tenant_id) + return store.get_session_for_user(session_id=session_id, tenant_id=tenant_id, user_id=user_id) + + +def _effective_user_text(*, text: str, attachments: list[dict[str, Any]] | None, store: SqliteStore) -> str: + """Streamlit 等价:仅有附件时也要落库一条用户消息,否则模型侧无输入。""" + t = (text or "").strip() + if t: + return t + if attachments: + return "(已上传附件)" if _api_lang(store) == "zh" else "(attachment uploaded)" + return "" + + +def _safe_json_object(raw: Any) -> dict[str, Any]: + if isinstance(raw, dict): + return raw + if isinstance(raw, str): + s = raw.strip() + if not s: + return {} + try: + obj = json.loads(s) + except Exception: + return {} + return obj if isinstance(obj, dict) else {} + return {} + + +def _normalize_chat_mode(payload: dict[str, Any] | None) -> tuple[str, str]: + body = payload or {} + # Backward compatibility: + # - chat_mode=composite|specialist + # - interaction_mode=comprehensive|expert + raw_interaction = str(body.get("interaction_mode") or "").strip() + if not raw_interaction: + legacy_mode = str(body.get("chat_mode") or "").strip().lower() + if legacy_mode == "specialist": + raw_interaction = "expert" + elif legacy_mode == "composite": + raw_interaction = "comprehensive" + mode = normalize_interaction_mode(raw_interaction) + specialist = normalize_requested_specialist(body.get("specialist")) + return mode, specialist + + +def _normalize_memory_mode(payload: dict[str, Any] | None) -> str: + body = payload or {} + raw = str(body.get("memory_mode") or "").strip().lower() + return raw if raw in {"default", "store_only"} else "default" + + +def _specialist_flags_with_overrides(store: SqliteStore) -> dict[str, bool]: + flags: dict[str, bool] = {sid: True for sid in _CHAT_SPECIALIST_IDS} + raw = str(store.get_setting(_SPECIALIST_FLAGS_SETTING_KEY) or "").strip() + if not raw: + return flags + try: + obj = json.loads(raw) + except Exception: + return flags + if not isinstance(obj, dict): + return flags + for sid in _CHAT_SPECIALIST_IDS: + if sid in obj: + flags[sid] = bool(obj.get(sid)) + # keep generalist always on for safety + flags["generalist"] = True + return flags + + +def _apply_specialist_flags(store: SqliteStore, specialist: str) -> str: + sid = str(specialist or "generalist").strip().lower() or "generalist" + flags = _specialist_flags_with_overrides(store) + if not flags.get(sid, True): + return "generalist" + return sid + + +def _dispatch_reason_label(reason: str, *, lang: str) -> str: + key = str(reason or "").strip() + if not key: + return "-" + row = _DISPATCH_REASON_LABELS.get(key) + if not isinstance(row, dict): + return key + l = "en" if str(lang or "").startswith("en") else "zh" + return str(row.get(l) or row.get("en") or key) + + +def _dispatch_reason_labels_with_overrides(store: SqliteStore) -> dict[str, dict[str, str]]: + out: dict[str, dict[str, str]] = dict(_DISPATCH_REASON_LABELS) + raw = str(store.get_setting(_DISPATCH_REASON_LABELS_SETTING_KEY) or "").strip() + if not raw: + return out + try: + obj = json.loads(raw) + except Exception: + return out + if not isinstance(obj, dict): + return out + for rk, rv in obj.items(): + key = str(rk or "").strip() + if not key or not isinstance(rv, dict): + continue + zh = str(rv.get("zh") or "").strip() + en = str(rv.get("en") or "").strip() + if not zh and not en: + continue + base = out.get(key, {}) + out[key] = { + "zh": zh or str(base.get("zh") or ""), + "en": en or str(base.get("en") or ""), + } + return out + + +def _dispatch_reason_label_from_map(reason: str, *, lang: str, labels: dict[str, dict[str, str]]) -> str: + key = str(reason or "").strip() + if not key: + return "-" + row = labels.get(key) + if not isinstance(row, dict): + return key + l = "en" if str(lang or "").startswith("en") else "zh" + return str(row.get(l) or row.get("en") or key) + + +def _deep_merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = dict(base or {}) + for k, v in (patch or {}).items(): + if isinstance(v, dict) and isinstance(out.get(k), dict): + out[k] = _deep_merge_dict(dict(out.get(k) or {}), v) + else: + out[k] = v + return out + + +def _safe_int(raw: Any, default: int, *, min_value: int = 1, max_value: int = 2_000_000) -> int: + try: + value = int(raw) + except Exception: + return default + if value < min_value: + return default + return min(value, max_value) + + +def _safe_timeout_ms(raw: Any, default: int = DEFAULT_TABULAR_SQL_TIMEOUT_MS) -> int: + return _safe_int(raw, default, min_value=100, max_value=120_000) + + +def _tabular_limits_from_oclaw_config() -> dict[str, int]: + cfg_path = _oclaw_config_path() + obj: dict[str, Any] = {} + try: + if cfg_path.exists() and cfg_path.is_file(): + loaded = json.loads(cfg_path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + obj = loaded + except Exception: + obj = {} + tabular = ( + (((obj.get("plugins") or {}).get("entries") or {}).get("memory-wiki") or {}) + .get("auto", {}) + .get("attachments", {}) + .get("tabular", {}) + ) + if not isinstance(tabular, dict): + tabular = {} + return { + "max_rows_read": _safe_int(tabular.get("max_rows_read"), DEFAULT_TABULAR_ROWS_READ), + "max_columns": _safe_int(tabular.get("max_columns"), DEFAULT_TABULAR_COLUMNS), + "max_cell_chars": _safe_int(tabular.get("max_cell_chars"), DEFAULT_TABULAR_CELL_CHARS), + "max_excel_sheets": _safe_int(tabular.get("max_excel_sheets"), DEFAULT_MAX_EXCEL_SHEETS, max_value=500), + "large_table_preview_rows": _safe_int(tabular.get("large_table_preview_rows"), 20, max_value=500), + "tool_mode_enabled": bool(tabular.get("tool_mode_enabled", True)), + "tool_mode_min_rows": _safe_int(tabular.get("tool_mode_min_rows"), 20_000), + "tool_mode_max_bytes": _safe_int(tabular.get("tool_mode_max_bytes"), 30 * 1024 * 1024), + "sql_timeout_ms": _safe_timeout_ms(tabular.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS), + } + + +def _set_tabular_limits_into_oclaw_config(limits: dict[str, int]) -> dict[str, int]: + cfg_path = _oclaw_config_path() + cfg_obj: dict[str, Any] = {} + try: + if cfg_path.exists() and cfg_path.is_file(): + loaded = json.loads(cfg_path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + cfg_obj = loaded + except Exception: + cfg_obj = {} + patch = { + "plugins": { + "entries": { + "memory-wiki": { + "auto": { + "attachments": { + "tabular": { + "max_rows_read": int(limits.get("max_rows_read") or DEFAULT_TABULAR_ROWS_READ), + "max_columns": int(limits.get("max_columns") or DEFAULT_TABULAR_COLUMNS), + "max_cell_chars": int(limits.get("max_cell_chars") or DEFAULT_TABULAR_CELL_CHARS), + "max_excel_sheets": int(limits.get("max_excel_sheets") or DEFAULT_MAX_EXCEL_SHEETS), + "large_table_preview_rows": int(limits.get("large_table_preview_rows") or 20), + "tool_mode_enabled": bool(limits.get("tool_mode_enabled", True)), + "tool_mode_min_rows": int(limits.get("tool_mode_min_rows") or 20_000), + "tool_mode_max_bytes": int(limits.get("tool_mode_max_bytes") or (30 * 1024 * 1024)), + "sql_timeout_ms": int( + _safe_timeout_ms(limits.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS) + ), + } + } + } + } + } + } + } + merged = _deep_merge_dict(cfg_obj, patch) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text(json.dumps(merged, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + clear_attachment_limits_cache() + return _tabular_limits_from_oclaw_config() + + +def _chat_session_mode_setting_key(*, tenant_id: str, user_id: str, session_id: str, field: str) -> str: + return f"chat.session.mode.{tenant_id}.{user_id}.{session_id}.{field}" + + +def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]]) -> None: + chat = APIRouter(prefix="/admin/api/chat", tags=["chat"]) + + @chat.get("/sessions") + def api_chat_sessions( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "") + user_id = str(ctx.get("user_id") or "") + # 含 administrator:侧边栏只列「当前登录用户」名下会话,避免租户内他人会话出现在 /chat。 + # 全租户会话列表见审计、GET /admin/api/chat/admin/sessions 等。 + meta = store.get_sessions_list_meta_for_user(tenant_id=tenant_id, user_id=user_id) + rows = store.list_sessions_for_user( + tenant_id=tenant_id, user_id=user_id, limit=limit, offset=offset + ) + return { + "ok": True, + "total": int(meta.session_count or 0), + "sessions": [ + { + "id": s.id, + "title": s.title, + "created_at": s.created_at, + "last_message_at": s.last_message_at, + } + for s in rows + ], + } + + @chat.post("/sessions") + def api_chat_create_session( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "") + user_id = str(ctx.get("user_id") or "") + title = str(payload.get("title") or "").strip() or ("新会话" if _api_lang(store) == "zh" else "New Chat") + s = store.create_session_for_user(title=title, tenant_id=tenant_id, user_id=user_id) + return { + "ok": True, + "session": { + "id": s.id, + "title": s.title, + "created_at": s.created_at, + "last_message_at": s.last_message_at, + }, + } + + @chat.patch("/sessions/{session_id}") + def api_chat_rename_session( + session_id: str, + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "") + user_id = str(ctx.get("user_id") or "") + sess = _resolve_chat_session(store, ctx, session_id) + if not sess: + raise HTTPException(status_code=404, detail="session_not_found") + title = str(payload.get("title") or "").strip()[:_SESSION_TITLE_MAX_LEN] or ( + "新会话" if _api_lang(store) == "zh" else "New Chat" + ) + store.rename_session(session_id, title) + s = _resolve_chat_session(store, ctx, session_id) + return { + "ok": True, + "session": { + "id": s.id, + "title": s.title, + "created_at": s.created_at, + "last_message_at": s.last_message_at, + }, + } + + @chat.delete("/sessions/{session_id}") + def api_chat_delete_session( + session_id: str, + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "") + user_id = str(ctx.get("user_id") or "") + sess = _resolve_chat_session(store, ctx, session_id) + if not sess: + raise HTTPException(status_code=404, detail="session_not_found") + if _is_administrator_chat_viewer(ctx): + store.delete_session_in_tenant(session_id=session_id, tenant_id=tenant_id) + else: + store.delete_session_for_user(session_id=session_id, tenant_id=tenant_id, user_id=user_id) + remaining = store.list_sessions_for_user( + tenant_id=tenant_id, user_id=user_id, limit=1, offset=0 + ) + next_id = str(remaining[0].id) if remaining else "" + if not next_id: + lang = _api_lang(store) + ns = store.create_session_for_user( + title=("新会话" if lang == "zh" else "New Chat"), tenant_id=tenant_id, user_id=user_id + ) + next_id = str(ns.id) + return {"ok": True, "next_session_id": next_id} + + @chat.post("/sessions/{session_id}/fork") + def api_chat_fork_session( + session_id: str, + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "") + user_id = str(ctx.get("user_id") or "") + sess = _resolve_chat_session(store, ctx, session_id) + if not sess: + raise HTTPException(status_code=404, detail="session_not_found") + last_id = store.get_last_message_id(session_id) + if last_id is None: + raise HTTPException(status_code=400, detail="empty_session") + base_title = (sess.title or "")[:80] or session_id[:8] + dup_title = str(payload.get("title") or "").strip() or ( + (f"{base_title} (copy)" if _api_lang(store) == "en" else f"{base_title} (副本)") + )[:_SESSION_TITLE_MAX_LEN] + try: + ns = store.fork_session(session_id, last_id, dup_title) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + store.ensure_ui_session_owner(session_id=str(ns.id), tenant_id=tenant_id, user_id=user_id) + return { + "ok": True, + "session": { + "id": ns.id, + "title": ns.title, + "created_at": ns.created_at, + "last_message_at": ns.last_message_at, + }, + } + + @chat.get("/sessions/{session_id}/export") + def api_chat_export_session( + session_id: str, + format: str = Query(default="md", description="md or json"), + authorization: str | None = Header(default=None), + ) -> Response: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "") + user_id = str(ctx.get("user_id") or "") + sess = _resolve_chat_session(store, ctx, session_id) + if not sess: + raise HTTPException(status_code=404, detail="session_not_found") + fmt = (format or "md").strip().lower() + if fmt in ("json", "application/json"): + body = export_session_json(store, session_id) + return Response( + content=body.encode("utf-8"), + media_type="application/json; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="chat-{session_id[:8]}.json"', + }, + ) + body = export_session_markdown(store, session_id) + return Response( + content=body.encode("utf-8"), + media_type="text/markdown; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="chat-{session_id[:8]}.md"'}, + ) + + @chat.get("/sessions/{session_id}/messages") + def api_chat_messages( + session_id: str, + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "") + user_id = str(ctx.get("user_id") or "") + sess = _resolve_chat_session(store, ctx, session_id) + if not sess: + raise HTTPException(status_code=404, detail="session_not_found") + meta = store.get_session_messages_meta(session_id) + msgs = store.get_messages(session_id=session_id, limit=_CHAT_MSG_LIMIT) + return { + "ok": True, + "message_count": int(meta.message_count or 0), + "messages": [_serialize_message(m) for m in msgs], + } + + @chat.get("/sessions/{session_id}/wiki-events") + def api_chat_wiki_events( + session_id: str, + after: str | None = Query(default=None, description="Return events with finished_at > after (ISO)."), + limit: int = Query(default=20, ge=1, le=200), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_administrator_chat_viewer(ctx) + tenant_id = str(ctx.get("tenant_id") or "") + user_id = str(ctx.get("user_id") or "") + sess = _resolve_chat_session(store, ctx, session_id) + if not sess: + raise HTTPException(status_code=404, detail="session_not_found") + after_iso = str(after or "").strip() + rows = store.openclaw_task_list(tenant_id=tenant_id, session_id=str(session_id), limit=max(50, int(limit) * 5)) + events: list[dict[str, Any]] = [] + for t in rows: + if str(t.task_type or "") != "wiki_capture": + continue + if str(t.status or "") not in {"done", "failed"}: + continue + fin = str(t.finished_at or "").strip() + if not fin: + continue + if after_iso and fin <= after_iso: + continue + try: + result_obj = json.loads(str(t.result or "{}")) + except Exception: + result_obj = {} + events.append( + { + "task_id": str(t.id), + "status": str(t.status), + "finished_at": fin, + "ok": bool(isinstance(result_obj, dict) and result_obj.get("ok", False)), + "result": result_obj if isinstance(result_obj, dict) else {}, + } + ) + events.sort(key=lambda e: str(e.get("finished_at") or "")) + return { + "ok": True, + "session_id": str(session_id), + "viewer_user_id": str(user_id), + "events": events[-max(1, min(int(limit), 200)) :], + } + + @chat.get("/sessions/{session_id}/wiki-file") + def api_chat_wiki_file( + session_id: str, + path: str = Query(..., description="Relative wiki path, e.g. inbox/merged-turns.md"), + max_chars: int = Query(default=80_000, ge=1_000, le=200_000), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_administrator_chat_viewer(ctx) + tenant_id = str(ctx.get("tenant_id") or "") + _ = str(ctx.get("user_id") or "") + sess = _resolve_chat_session(store, ctx, session_id) + if not sess: + raise HTTPException(status_code=404, detail="session_not_found") + wiki_root = _wiki_root_from_config() + if wiki_root is None: + raise HTTPException(status_code=404, detail="wiki_root_missing") + rel = str(path or "").replace("\\", "/").strip().lstrip("/") + if not rel or ".." in rel.split("/"): + raise HTTPException(status_code=400, detail="invalid_path") + fp = (wiki_root / rel).resolve() + try: + fp.relative_to(wiki_root.resolve()) + except Exception: + raise HTTPException(status_code=400, detail="path_outside_wiki_root") from None + if not fp.exists() or not fp.is_file(): + raise HTTPException(status_code=404, detail="file_not_found") + try: + content = fp.read_text(encoding="utf-8") + except Exception: + raise HTTPException(status_code=500, detail="read_failed") from None + return { + "ok": True, + "tenant_id": str(tenant_id), + "session_id": str(session_id), + "path": rel, + "truncated": len(content) > int(max_chars), + "content": content[: int(max_chars)], + } + + @chat.get("/sessions/{session_id}/mode") + def api_chat_session_mode_get( + session_id: str, + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "") + user_id = str(ctx.get("user_id") or "") + sess = _resolve_chat_session(store, ctx, session_id) + if not sess: + raise HTTPException(status_code=404, detail="session_not_found") + mode_key = _chat_session_mode_setting_key( + tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode" + ) + specialist_key = _chat_session_mode_setting_key( + tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist" + ) + memory_mode_key = _chat_session_mode_setting_key( + tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode" + ) + interaction_mode = normalize_interaction_mode(store.get_setting(mode_key)) + specialist = normalize_requested_specialist(store.get_setting(specialist_key)) + specialist = _apply_specialist_flags(store, specialist) + memory_mode = _normalize_memory_mode({"memory_mode": store.get_setting(memory_mode_key)}) + return {"ok": True, "interaction_mode": interaction_mode, "specialist": specialist, "memory_mode": memory_mode} + + @chat.post("/sessions/{session_id}/mode") + def api_chat_session_mode_set( + session_id: str, + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "") + user_id = str(ctx.get("user_id") or "") + sess = _resolve_chat_session(store, ctx, session_id) + if not sess: + raise HTTPException(status_code=404, detail="session_not_found") + interaction_mode = normalize_interaction_mode(payload.get("interaction_mode")) + specialist = normalize_requested_specialist(payload.get("specialist")) + specialist = _apply_specialist_flags(store, specialist) + mode_key = _chat_session_mode_setting_key( + tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode" + ) + specialist_key = _chat_session_mode_setting_key( + tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist" + ) + memory_mode_key = _chat_session_mode_setting_key( + tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode" + ) + memory_mode = _normalize_memory_mode(payload) + store.set_setting(mode_key, interaction_mode) + store.set_setting(specialist_key, specialist) + store.set_setting(memory_mode_key, memory_mode) + return {"ok": True, "interaction_mode": interaction_mode, "specialist": specialist, "memory_mode": memory_mode} + + @chat.get("/admin/user-stats") + def api_chat_admin_user_stats( + q: str | None = Query(default=None), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_administrator_chat_viewer(ctx) + tenant_id = str(ctx.get("tenant_id") or "") + total, users, totals = store.list_admin_user_stats( + tenant_id=tenant_id, + q=q, + active_window_minutes=30, + limit=limit, + offset=offset, + ) + return {"ok": True, "total": int(total), "totals": totals, "users": users} + + @chat.get("/admin/sessions") + def api_chat_admin_sessions( + user_id: str | None = Query(default=None), + q: str | None = Query(default=None), + active_only: bool = Query(default=False), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_administrator_chat_viewer(ctx) + tenant_id = str(ctx.get("tenant_id") or "") + total, rows = store.list_admin_sessions( + tenant_id=tenant_id, + user_id=user_id, + q=q, + active_only=bool(active_only), + active_window_minutes=30, + limit=limit, + offset=offset, + ) + return {"ok": True, "total": int(total), "sessions": rows} + + @chat.get("/admin/dynamic-expert-stats") + def api_chat_admin_dynamic_expert_stats( + limit: int = Query(default=200, ge=20, le=1000), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + lang = _api_lang(store) + labels = _dispatch_reason_labels_with_overrides(store) + _require_administrator_chat_viewer(ctx) + tenant_id = str(ctx.get("tenant_id") or "") + rows = store.openclaw_task_list(tenant_id=tenant_id, limit=limit) + total = len(rows) + dynamic_used = 0 + fallback_generalist = 0 + reasons: dict[str, int] = {} + specialist_counts: dict[str, int] = {} + for t in rows: + payload = _safe_json_object(t.payload) + if str(payload.get("interaction_mode") or "") != "comprehensive": + continue + reason = str(payload.get("dispatch_reason") or "").strip() or "unknown" + reasons[reason] = int(reasons.get(reason, 0)) + 1 + sel = str(payload.get("manager_selected_specialist") or "generalist").strip() or "generalist" + specialist_counts[sel] = int(specialist_counts.get(sel, 0)) + 1 + if bool(payload.get("dynamic_agent_used")): + dynamic_used += 1 + if reason in {"manager_no_specialist_fallback", "dynamic_agent_build_failed"}: + fallback_generalist += 1 + return { + "ok": True, + "window_task_count": int(total), + "dynamic_used_count": int(dynamic_used), + "fallback_generalist_count": int(fallback_generalist), + "dynamic_used_rate": (float(dynamic_used) / float(total)) if total > 0 else 0.0, + "dispatch_reasons": reasons, + "dispatch_reason_labels": {k: _dispatch_reason_label_from_map(k, lang=lang, labels=labels) for k in reasons.keys()}, + "selected_specialists": specialist_counts, + } + + @chat.get("/settings/ui-lang") + def api_chat_get_ui_lang( + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + _ = resolve_auth(store, authorization) + return {"ok": True, "lang": _api_lang(store)} + + @chat.post("/settings/ui-lang") + def api_chat_set_ui_lang( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + _ = resolve_auth(store, authorization) + lang = str(payload.get("lang") or "").strip().lower() + if lang not in ("zh", "en"): + raise HTTPException(status_code=400, detail="invalid_lang") + store.set_setting("ui_lang", lang) + return {"ok": True, "lang": lang} + + @chat.get("/settings/dispatch-reason-labels") + def api_chat_get_dispatch_reason_labels( + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_administrator_chat_viewer(ctx) + raw = str(store.get_setting(_DISPATCH_REASON_LABELS_SETTING_KEY) or "").strip() + overrides: dict[str, Any] = {} + if raw: + try: + obj = json.loads(raw) + if isinstance(obj, dict): + overrides = obj + except Exception: + overrides = {} + return { + "ok": True, + "setting_key": _DISPATCH_REASON_LABELS_SETTING_KEY, + "overrides": overrides, + "effective": _dispatch_reason_labels_with_overrides(store), + } + + @chat.post("/settings/dispatch-reason-labels") + def api_chat_set_dispatch_reason_labels( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + body = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_administrator_chat_viewer(ctx) + overrides = body.get("overrides") + if overrides is None: + store.set_setting(_DISPATCH_REASON_LABELS_SETTING_KEY, "") + return {"ok": True, "cleared": True, "effective": _dispatch_reason_labels_with_overrides(store)} + if not isinstance(overrides, dict): + raise HTTPException(status_code=400, detail="invalid_overrides") + clean: dict[str, dict[str, str]] = {} + for rk, rv in overrides.items(): + reason = str(rk or "").strip() + if not reason or not isinstance(rv, dict): + continue + zh = str(rv.get("zh") or "").strip() + en = str(rv.get("en") or "").strip() + if not zh and not en: + continue + clean[reason] = {"zh": zh, "en": en} + store.set_setting(_DISPATCH_REASON_LABELS_SETTING_KEY, json.dumps(clean, ensure_ascii=False)) + return {"ok": True, "saved": clean, "effective": _dispatch_reason_labels_with_overrides(store)} + + @chat.get("/settings/specialist-flags") + def api_chat_get_specialist_flags( + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_administrator_chat_viewer(ctx) + flags = _specialist_flags_with_overrides(store) + available = [sid for sid in _CHAT_SPECIALIST_IDS if bool(flags.get(sid, True))] + return { + "ok": True, + "setting_key": _SPECIALIST_FLAGS_SETTING_KEY, + "flags": flags, + "available_specialists": available, + } + + @chat.post("/settings/specialist-flags") + def api_chat_set_specialist_flags( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + body = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_administrator_chat_viewer(ctx) + raw_flags = body.get("flags") + if raw_flags is None: + store.set_setting(_SPECIALIST_FLAGS_SETTING_KEY, "") + flags = _specialist_flags_with_overrides(store) + return {"ok": True, "cleared": True, "flags": flags} + if not isinstance(raw_flags, dict): + raise HTTPException(status_code=400, detail="invalid_flags") + clean: dict[str, bool] = {} + for sid in _CHAT_SPECIALIST_IDS: + if sid in raw_flags: + clean[sid] = bool(raw_flags.get(sid)) + clean["generalist"] = True + store.set_setting(_SPECIALIST_FLAGS_SETTING_KEY, json.dumps(clean, ensure_ascii=False)) + flags = _specialist_flags_with_overrides(store) + return {"ok": True, "saved": clean, "flags": flags} + + @chat.get("/settings/attachment-limits") + def api_chat_get_attachment_limits( + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_administrator_chat_viewer(ctx) + limits = _tabular_limits_from_oclaw_config() + return {"ok": True, "limits": limits} + + @chat.post("/settings/attachment-limits") + def api_chat_set_attachment_limits( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + body = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_administrator_chat_viewer(ctx) + raw = body.get("limits") + if raw is None: + limits = { + "max_rows_read": DEFAULT_TABULAR_ROWS_READ, + "max_columns": DEFAULT_TABULAR_COLUMNS, + "max_cell_chars": DEFAULT_TABULAR_CELL_CHARS, + "max_excel_sheets": DEFAULT_MAX_EXCEL_SHEETS, + "large_table_preview_rows": 20, + "tool_mode_enabled": True, + "tool_mode_min_rows": 20_000, + "tool_mode_max_bytes": 30 * 1024 * 1024, + "sql_timeout_ms": DEFAULT_TABULAR_SQL_TIMEOUT_MS, + } + saved = _set_tabular_limits_into_oclaw_config(limits) + return {"ok": True, "cleared": True, "limits": saved} + if not isinstance(raw, dict): + raise HTTPException(status_code=400, detail="invalid_limits") + next_limits = { + "max_rows_read": _safe_int(raw.get("max_rows_read"), DEFAULT_TABULAR_ROWS_READ), + "max_columns": _safe_int(raw.get("max_columns"), DEFAULT_TABULAR_COLUMNS), + "max_cell_chars": _safe_int(raw.get("max_cell_chars"), DEFAULT_TABULAR_CELL_CHARS), + "max_excel_sheets": _safe_int(raw.get("max_excel_sheets"), DEFAULT_MAX_EXCEL_SHEETS, max_value=500), + "large_table_preview_rows": _safe_int(raw.get("large_table_preview_rows"), 20, max_value=500), + "tool_mode_enabled": bool(raw.get("tool_mode_enabled", True)), + "tool_mode_min_rows": _safe_int(raw.get("tool_mode_min_rows"), 20_000), + "tool_mode_max_bytes": _safe_int(raw.get("tool_mode_max_bytes"), 30 * 1024 * 1024), + "sql_timeout_ms": _safe_timeout_ms(raw.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS), + } + saved = _set_tabular_limits_into_oclaw_config(next_limits) + return {"ok": True, "limits": saved} + + @chat.get("/profile") + def api_chat_profile_get( + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "").strip() + user_id = str(ctx.get("user_id") or "").strip() + if not tenant_id or not user_id: + raise HTTPException(status_code=401, detail="auth_required") + row = store.get_user_by_id(tenant_id=tenant_id, user_id=user_id) + if not row: + raise HTTPException(status_code=404, detail="user_not_found") + aid = str(row.get("avatar_attachment_id") or "").strip() + avatar_url = f"/admin/api/chat/attachments/{aid}" if aid else "" + return { + "ok": True, + "profile": { + "id": str(row.get("id") or ""), + "tenant_id": str(row.get("tenant_id") or ""), + "username": str(row.get("username") or ""), + "display_name": str(row.get("display_name") or ""), + "role": str(row.get("role") or ""), + "is_active": bool(row.get("is_active")), + "created_at": str(row.get("created_at") or ""), + "avatar_attachment_id": aid or None, + "avatar_url": avatar_url or None, + }, + } + + @chat.patch("/profile") + def api_chat_profile_patch( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "").strip() + user_id = str(ctx.get("user_id") or "").strip() + if not tenant_id or not user_id: + raise HTTPException(status_code=401, detail="auth_required") + if "display_name" in payload: + dn = str(payload.get("display_name") or "").strip()[:120] or str(ctx.get("username") or "User").strip() or "User" + store.update_user_account(tenant_id=tenant_id, user_id=user_id, display_name=dn) + row = store.get_user_by_id(tenant_id=tenant_id, user_id=user_id) or {} + aid = str(row.get("avatar_attachment_id") or "").strip() + return { + "ok": True, + "profile": { + "id": str(row.get("id") or ""), + "tenant_id": str(row.get("tenant_id") or ""), + "username": str(row.get("username") or ""), + "display_name": str(row.get("display_name") or ""), + "role": str(row.get("role") or ""), + "is_active": bool(row.get("is_active")), + "created_at": str(row.get("created_at") or ""), + "avatar_attachment_id": aid or None, + "avatar_url": (f"/admin/api/chat/attachments/{aid}" if aid else None), + }, + } + + @chat.post("/profile/avatar") + async def api_chat_profile_avatar_upload( + authorization: str | None = Header(default=None), + file: UploadFile = File(...), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "").strip() + user_id = str(ctx.get("user_id") or "").strip() + if not tenant_id or not user_id: + raise HTTPException(status_code=401, detail="auth_required") + raw = await file.read() + if not raw: + raise HTTPException(status_code=400, detail="empty_file") + if len(raw) > _AVATAR_UPLOAD_MAX_BYTES: + raise HTTPException(status_code=413, detail="avatar_too_large") + mime = (str(file.content_type or "").split(";", 1)[0]).strip().lower() + if mime == "image/jpg": + mime = "image/jpeg" + if mime not in _AVATAR_MIMES: + raise HTTPException(status_code=415, detail="avatar_invalid_mime") + fname = _safe_rel_avatar_name(str(file.filename or "avatar")) + ast = AttachmentAssetStore() + meta = ast.save_bytes(raw, filename=fname, mime=mime) + aid = str(meta.attachment_id or "").strip() + if not aid: + raise HTTPException(status_code=500, detail="avatar_save_failed") + store.update_user_account(tenant_id=tenant_id, user_id=user_id, avatar_attachment_id=aid) + return { + "ok": True, + "avatar_attachment_id": aid, + "avatar_url": f"/admin/api/chat/attachments/{aid}", + } + + @chat.delete("/profile/avatar") + def api_chat_profile_avatar_delete( + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "").strip() + user_id = str(ctx.get("user_id") or "").strip() + if not tenant_id or not user_id: + raise HTTPException(status_code=401, detail="auth_required") + store.update_user_account(tenant_id=tenant_id, user_id=user_id, avatar_attachment_id="") + return {"ok": True, "avatar_attachment_id": None, "avatar_url": None} + + @chat.post("/sessions/{session_id}/stop") + def api_chat_stop_turn( + session_id: str, + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + sess = _resolve_chat_session(store, ctx, session_id) + if not sess: + raise HTTPException(status_code=404, detail="session_not_found") + sid = str(session_id) + with _CHAT_STOP_LOCK: + ev = _CHAT_STOP_EVENTS.get(sid) + if ev: + ev.set() + return {"ok": True} + + @chat.get("/attachments/{attachment_id}") + def api_chat_attachment_bytes( + attachment_id: str, + authorization: str | None = Header(default=None), + ) -> Response: + store = SqliteStore(db_path()) + _ = resolve_auth(store, authorization) + aid = str(attachment_id or "").strip() + if not aid: + raise HTTPException(status_code=400, detail="attachment_id_required") + ast = AttachmentAssetStore() + try: + blob, meta = ast.load_bytes(aid) + except Exception: + raise HTTPException(status_code=404, detail="attachment_not_found") from None + mime = (meta.mime if meta else None) or "application/octet-stream" + return Response(content=blob, media_type=mime) + + @chat.post("/sessions/{session_id}/messages") + def api_chat_send( + session_id: str, + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "") + user_id = str(ctx.get("user_id") or "") + role = str(ctx.get("role") or "member") + sess = _resolve_chat_session(store, ctx, session_id) + if not sess: + raise HTTPException(status_code=404, detail="session_not_found") + text_raw = str(payload.get("text") or "").strip() + attachments = _parse_attachments_payload(payload.get("attachments")) + interaction_mode, selected_specialist = _normalize_chat_mode(payload) + memory_mode = _normalize_memory_mode(payload) + if "interaction_mode" not in payload and "chat_mode" not in payload: + mode_key = _chat_session_mode_setting_key( + tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode" + ) + interaction_mode = normalize_interaction_mode(store.get_setting(mode_key)) + if "specialist" not in payload: + specialist_key = _chat_session_mode_setting_key( + tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist" + ) + selected_specialist = normalize_requested_specialist(store.get_setting(specialist_key)) + if "memory_mode" not in payload: + memory_mode_key = _chat_session_mode_setting_key( + tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode" + ) + memory_mode = _normalize_memory_mode({"memory_mode": store.get_setting(memory_mode_key)}) + selected_specialist = _apply_specialist_flags(store, selected_specialist) + if not text_raw and not attachments: + raise HTTPException(status_code=400, detail="text_or_attachments_required") + text = _effective_user_text(text=text_raw, attachments=attachments, store=store) + if not _is_administrator_chat_viewer(ctx) and tenant_id and user_id: + store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id) + lang = _api_lang(store) + apply_gateway_mcp_env_to_os() + manager_agent = _init_gateway_executor( + store, + ctx, + lang=lang, + specialist="generalist", + policy_session_id=str(session_id), + ) + specialist_factory = lambda sid: _init_gateway_executor( + store, + ctx, + lang=lang, + specialist=sid, + policy_session_id=str(session_id), + ) + try: + gw = OpenClawGateway(store=store) + msg = StandardMessage( + session_id=str(session_id), + tenant_id=tenant_id, + user_id=user_id, + role=role, + channel="admin_chat", + text=str(text or ""), + attachments=list(attachments or []), + metadata={ + "tenant_id": tenant_id, + "user_id": user_id, + "role": role, + "channel": "admin_chat", + "interaction_mode": interaction_mode, + "selected_specialist": selected_specialist, + "memory_mode": memory_mode, + }, + ) + gw_result = gw.handle_turn( + msg=msg, + lang=lang, + executor=manager_agent, + specialist_executor_factory=specialist_factory, + ) + reply = gw_result.reply_text + except GenerationInterrupted: + msg = "已中断回答。" if lang == "zh" else "Response stopped." + store.add_message(session_id=session_id, role="assistant", content=msg) + return {"ok": True, "reply": msg, "interrupted": True} + return { + "ok": True, + "reply": str(reply or ""), + "mode": str(getattr(gw_result, "mode", "sync_direct") or "sync_direct"), + "task_id": str(getattr(gw_result, "task_id", "") or ""), + "interaction_mode": str(getattr(gw_result, "interaction_mode", interaction_mode) or interaction_mode), + "selected_specialist": str(getattr(gw_result, "selected_specialist", selected_specialist) or selected_specialist), + } + + @chat.post("/sessions/{session_id}/messages/stream") + def api_chat_send_stream( + session_id: str, + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> StreamingResponse: + """Server-Sent Events: token deltas + progress + tool_ui, then done (assistant persisted by run_turn).""" + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + tenant_id = str(ctx.get("tenant_id") or "") + user_id = str(ctx.get("user_id") or "") + role = str(ctx.get("role") or "member") + sess = _resolve_chat_session(store, ctx, session_id) + if not sess: + raise HTTPException(status_code=404, detail="session_not_found") + text_raw = str(payload.get("text") or "").strip() + attachments = _parse_attachments_payload(payload.get("attachments")) + interaction_mode, selected_specialist = _normalize_chat_mode(payload) + memory_mode = _normalize_memory_mode(payload) + if "interaction_mode" not in payload and "chat_mode" not in payload: + mode_key = _chat_session_mode_setting_key( + tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode" + ) + interaction_mode = normalize_interaction_mode(store.get_setting(mode_key)) + if "specialist" not in payload: + specialist_key = _chat_session_mode_setting_key( + tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist" + ) + selected_specialist = normalize_requested_specialist(store.get_setting(specialist_key)) + if "memory_mode" not in payload: + memory_mode_key = _chat_session_mode_setting_key( + tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode" + ) + memory_mode = _normalize_memory_mode({"memory_mode": store.get_setting(memory_mode_key)}) + selected_specialist = _apply_specialist_flags(store, selected_specialist) + if not text_raw and not attachments: + raise HTTPException(status_code=400, detail="text_or_attachments_required") + text = _effective_user_text(text=text_raw, attachments=attachments, store=store) + if not _is_administrator_chat_viewer(ctx) and tenant_id and user_id: + store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id) + lang = _api_lang(store) + apply_gateway_mcp_env_to_os() + manager_agent = _init_gateway_executor( + store, + ctx, + lang=lang, + specialist="generalist", + policy_session_id=str(session_id), + ) + specialist_factory = lambda sid: _init_gateway_executor( + store, + ctx, + lang=lang, + specialist=sid, + policy_session_id=str(session_id), + ) + meta = { + "tenant_id": tenant_id, + "user_id": user_id, + "role": role, + "channel": "admin_chat", + "interaction_mode": interaction_mode, + "selected_specialist": selected_specialist, + "memory_mode": memory_mode, + } + + _DONE = object() + max_q = str(store.get_setting("AIA_SSE_QUEUE_MAXSIZE") or "").strip() + if not max_q: + max_q = str(os.getenv("AIA_SSE_QUEUE_MAXSIZE") or "").strip() + q_maxsize = 2000 + if max_q.isdigit(): + q_maxsize = max(200, min(int(max_q), 50_000)) + q: queue.Queue[tuple[str, Any] | object] = queue.Queue(maxsize=q_maxsize) + sid = str(session_id) + stop_ev = _register_stop(sid) + dropped = {"count": 0} + + def _emit(kind: str, data: Any) -> None: + item = (kind, data) + try: + q.put_nowait(item) + return + except Exception: + pass + # Backpressure: if queue is full, drop low-priority events. + if kind in ("token", "progress", "tool_ui"): + dropped["count"] += 1 + return + # Always try to deliver terminal events. + q.put(item) + + def _worker() -> None: + try: + + def should_stop() -> bool: + return stop_ev.is_set() + + def on_tool_ui(event: str, pl: dict[str, Any]) -> None: + ev_name = str(event or "") + kind = "skill_ui" if ev_name.startswith("skill_") else "tool_ui" + _emit(kind, {"event": ev_name, "payload": pl}) + + gw = OpenClawGateway(store=store) + msg = StandardMessage( + session_id=str(session_id), + tenant_id=tenant_id, + user_id=user_id, + role=role, + channel="admin_chat", + text=str(text or ""), + attachments=list(attachments or []), + metadata=dict(meta), + ) + gw_result = gw.handle_turn( + msg=msg, + lang=lang, + executor=manager_agent, + specialist_executor_factory=specialist_factory, + on_token=lambda s: _emit("token", s), + on_progress=lambda s: _emit("progress", s), + on_tool_ui=on_tool_ui, + should_stop=should_stop, + ) + reply = gw_result.reply_text + _emit( + "done", + { + "reply": str(reply or ""), + "mode": str(getattr(gw_result, "mode", "sync_direct") or "sync_direct"), + "task_id": str(getattr(gw_result, "task_id", "") or ""), + "interaction_mode": str( + getattr(gw_result, "interaction_mode", interaction_mode) or interaction_mode + ), + "selected_specialist": str( + getattr(gw_result, "selected_specialist", selected_specialist) or selected_specialist + ), + }, + ) + except GenerationInterrupted: + msg = "已中断回答。" if lang == "zh" else "Response stopped." + try: + store.add_message(session_id=session_id, role="assistant", content=msg) + except Exception: + pass + _emit("done", {"reply": msg, "interrupted": True}) + except Exception as e: + _emit("error", str(e)) + finally: + _clear_stop(sid) + q.put(_DONE) + + threading.Thread(target=_worker, name="chat_stream", daemon=True).start() + + def event_iter() -> Iterator[str]: + while True: + item = q.get() + if item is _DONE: + break + kind, data = item # type: ignore[misc] + if kind == "token": + line = json.dumps({"type": "token", "delta": data}, ensure_ascii=False) + elif kind == "progress": + line = json.dumps({"type": "progress", "message": data}, ensure_ascii=False) + elif kind == "tool_ui": + line = json.dumps({"type": "tool_ui", **(data if isinstance(data, dict) else {})}, ensure_ascii=False) + elif kind == "skill_ui": + line = json.dumps({"type": "skill_ui", **(data if isinstance(data, dict) else {})}, ensure_ascii=False) + elif kind == "done": + line = json.dumps( + { + "type": "done", + "reply": (data or {}).get("reply", ""), + "interrupted": bool((data or {}).get("interrupted")), + "mode": str((data or {}).get("mode") or "sync_direct"), + "task_id": str((data or {}).get("task_id") or ""), + "interaction_mode": str((data or {}).get("interaction_mode") or interaction_mode), + "selected_specialist": str((data or {}).get("selected_specialist") or selected_specialist), + "dropped_events": int(dropped["count"] or 0), + }, + ensure_ascii=False, + ) + elif kind == "error": + line = json.dumps({"type": "error", "message": str(data)}, ensure_ascii=False) + else: + continue + yield f"data: {line}\n\n" + + return StreamingResponse( + event_iter(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + router.include_router(chat) + + +__all__ = ["include_chat_routes"] diff --git a/admin/mcp_e2e_probe.py b/admin/mcp_e2e_probe.py new file mode 100644 index 00000000..f2342001 --- /dev/null +++ b/admin/mcp_e2e_probe.py @@ -0,0 +1,184 @@ +"""Build dynamic MCP E2E probe plans from the live ToolRegistry (one tool use per MCP server_id).""" +from __future__ import annotations + +from typing import Any + +from oclaw.tools.base import ToolRegistry, ToolSpec +from oclaw.tools.tool_validation import validate_tool_arguments + + +def parse_mcp_bound_tool_name(full_name: str) -> tuple[str, str] | None: + """Parse ``mcp__{server_id}__{mcp_tool}`` into (server_id, mcp_tool_name).""" + if not full_name.startswith("mcp__"): + return None + rest = full_name[len("mcp__") :] + idx = rest.find("__") + if idx < 0: + return None + server_id = rest[:idx].strip() + tool = rest[idx + 2 :].strip() + if not server_id or not tool: + return None + return server_id, tool + + +def _json_schema_required_keys(parameters: dict[str, Any]) -> list[str]: + if not isinstance(parameters, dict) or parameters.get("type") != "object": + return [] + req = parameters.get("required") + if not isinstance(req, list): + return [] + return [str(x).strip() for x in req if str(x).strip()] + + +def _fill_required_from_properties( + parameters: dict[str, Any], + *, + workspace_root: str, +) -> dict[str, Any] | None: + props = parameters.get("properties") if isinstance(parameters.get("properties"), dict) else {} + req = _json_schema_required_keys(parameters) + out: dict[str, Any] = {} + for key in req: + prop = props.get(key) if isinstance(props.get(key), dict) else {} + t = prop.get("type") + lk = key.lower() + if t == "string": + if lk in ("path", "cwd", "repopath", "directory", "filepath") or lk.endswith("path"): + out[key] = workspace_root + elif lk == "url": + out[key] = "https://example.com" + elif lk == "query": + out[key] = "model context protocol" + elif lk == "message": + out[key] = "mcp-e2e" + elif lk == "timezone": + out[key] = "UTC" + else: + out[key] = "" + elif t == "boolean": + out[key] = True if lk in ("includeuntracked", "includetracked") else False + elif t in ("number", "integer"): + out[key] = 0 + elif t == "array": + out[key] = [] + elif t == "object": + out[key] = {} + else: + return None + return out + + +# MCP tool names (suffix after server_id) with explicit args when schema is missing or validation needs concrete values. +_KNOWN_MCP_TOOL_ARGS: dict[str, dict[str, Any]] = { + "sequentialthinking": { + "thought": "e2e", + "nextThoughtNeeded": False, + "thoughtNumber": 1, + "totalThoughts": 1, + }, +} + +# Lower index = higher priority when multiple tools are callable. +_PROBE_PRIORITY: tuple[str, ...] = ( + "browser_close", + "read_graph", + "db_info", + "list_pdfs", + "echo", + "list_directory", + "git_status", + "fetch_markdown", + "web_search", + "sequentialthinking", +) + + +def _probe_args_for_spec(spec: ToolSpec, *, workspace_root: str) -> dict[str, Any] | None: + parsed = parse_mcp_bound_tool_name(spec.name) + if not parsed: + return None + _, mcp_tool = parsed + params = spec.parameters if isinstance(spec.parameters, dict) else {} + + if mcp_tool in _KNOWN_MCP_TOOL_ARGS: + args = dict(_KNOWN_MCP_TOOL_ARGS[mcp_tool]) + ok, _ = validate_tool_arguments(params, args) + return args if ok else None + + req = _json_schema_required_keys(params) + if not req: + args: dict[str, Any] = {} + ok, _ = validate_tool_arguments(params, args) + return args if ok else None + + filled = _fill_required_from_properties(params, workspace_root=workspace_root) + if filled is None: + return None + ok, _ = validate_tool_arguments(params, filled) + return filled if ok else None + + +def _pick_probe_for_server(specs: list[ToolSpec], *, workspace_root: str) -> tuple[ToolSpec, dict[str, Any]] | None: + candidates: list[tuple[int, str, ToolSpec, dict[str, Any]]] = [] + for sp in specs: + args = _probe_args_for_spec(sp, workspace_root=workspace_root) + if args is None: + continue + ok, _ = validate_tool_arguments(sp.parameters or {}, args) + if not ok: + continue + parsed = parse_mcp_bound_tool_name(sp.name) + mcp_tool = (parsed or ("", ""))[1] + try: + pri = _PROBE_PRIORITY.index(mcp_tool) + except ValueError: + pri = 900 + candidates.append((pri, mcp_tool, sp, args)) + if not candidates: + return None + candidates.sort(key=lambda x: (x[0], x[1], x[2].name)) + return candidates[0][2], candidates[0][3] + + +def build_mcp_e2e_probe_plans( + reg: ToolRegistry, + *, + workspace_root: str, +) -> tuple[list[tuple[str, str, dict[str, Any]]], list[str]]: + """ + Returns (plans, skipped_server_ids). + + ``plans`` entries are ``(server_id, full_tool_name, arguments)`` for ``ToolExecutor.execute_tool_uses``. + One probe per MCP ``server_id`` discovered from registry names ``mcp__*__*`` with tag ``mcp``. + + ``skipped_server_ids`` lists servers that had MCP tools in the registry but no schema-safe probe + could be constructed (no false positives from arbitrary ``tools/call``). + """ + by_server: dict[str, list[ToolSpec]] = {} + for spec in reg.list(): + if "mcp" not in (spec.tags or frozenset()): + continue + parsed = parse_mcp_bound_tool_name(spec.name) + if not parsed: + continue + sid, _ = parsed + by_server.setdefault(sid, []).append(spec) + + plans: list[tuple[str, str, dict[str, Any]]] = [] + for sid in sorted(by_server.keys()): + picked = _pick_probe_for_server(by_server[sid], workspace_root=workspace_root) + if picked is None: + continue + spec, args = picked + plans.append((sid, spec.name, args)) + planned = {p[0] for p in plans} + skipped = [s for s in sorted(by_server.keys()) if s not in planned] + return plans, skipped + + +__all__ = [ + "build_mcp_e2e_probe_plans", + "parse_mcp_bound_tool_name", +] + diff --git a/admin/models_api.py b/admin/models_api.py new file mode 100644 index 00000000..8986b23b --- /dev/null +++ b/admin/models_api.py @@ -0,0 +1,561 @@ +"""Admin API: LLM profiles, agent bindings, UI language, eval (parity with Streamlit settings).""" + +from __future__ import annotations + +import csv +import io +import json +import os +from collections.abc import Callable +from typing import Any + +from fastapi import APIRouter, Body, Header, HTTPException, Query +from fastapi.responses import Response + +from oclaw.agents.factory import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL +from oclaw.agents.specialists import ( + AGENT_PROFILE_BINDINGS_KEY, + AGENT_ROLE_IDS, + dump_agent_profile_bindings, + parse_agent_profile_bindings, +) +from oclaw.platform.config.paths import db_path +from oclaw.orchestration.evaluation import eval_summary +from oclaw.platform.persistence.sqlite_store import ( + LLM_BUILTIN_OLLAMA_PROFILE_ID, + SqliteStore, + active_llm_profile_setting_key, + agent_profile_bindings_setting_key, + is_administrator_model_pool, +) + +_LLM_MODE_OPTIONS = frozenset({"openai", "openai_responses", "anthropic", "google", "ollama", "rule"}) +_LLM_USER_CREATE_MODES = frozenset({"openai", "anthropic", "google", "ollama", "rule"}) + + +def _require_permission(ctx: dict[str, Any], permission: str) -> None: + perms = set(str(x) for x in (ctx.get("permissions") or [])) + if permission in perms: + return + if str(ctx.get("role") or "") == "owner": + return + raise HTTPException(status_code=403, detail=f"forbidden:{permission}") + + +def _require_models_mutate(ctx: dict[str, Any]) -> None: + """administrator 编辑全局池需 tenant:write;其余用户编辑自己的复制池仅需 read。""" + uname = str(ctx.get("username") or "").strip() + if is_administrator_model_pool(uname): + _require_permission(ctx, "admin:tenant:write") + else: + _require_permission(ctx, "admin:read") + + +def _models_list_kwargs(ctx: dict[str, Any]) -> dict[str, Any]: + uid = str(ctx.get("user_id") or "").strip() + uname = str(ctx.get("username") or "").strip() + tid = str(ctx.get("tenant_id") or "").strip() + if not uid: + return {} + out: dict[str, Any] = {"viewer_user_id": uid, "viewer_username": uname or None} + if tid: + out["viewer_tenant_id"] = tid + return out + + +def _active_key(ctx: dict[str, Any]) -> str: + uid = str(ctx.get("user_id") or "").strip() + uname = str(ctx.get("username") or "").strip() + if not uid: + return "active_llm_profile_id" + return active_llm_profile_setting_key(uid, uname or None) + + +def _bindings_key(ctx: dict[str, Any]) -> str: + uid = str(ctx.get("user_id") or "").strip() + uname = str(ctx.get("username") or "").strip() + if not uid: + return AGENT_PROFILE_BINDINGS_KEY + return agent_profile_bindings_setting_key(uid, uname or None) + + +def _assert_profile_mutable(ctx: dict[str, Any], prof: dict[str, Any] | None) -> dict[str, Any]: + if not prof: + raise HTTPException(status_code=404, detail="profile_not_found") + if prof.get("is_builtin"): + return prof + own = str(prof.get("owner_user_id") or "").strip() + uid = str(ctx.get("user_id") or "").strip() + uname = str(ctx.get("username") or "").strip() + if is_administrator_model_pool(uname): + return prof + if own == uid: + return prof + raise HTTPException(status_code=403, detail="profile_forbidden") + + +def _can_manage_llm_grants(ctx: dict[str, Any]) -> bool: + uname = str(ctx.get("username") or "").strip() + if not is_administrator_model_pool(uname): + return False + perms = set(str(x) for x in (ctx.get("permissions") or [])) + if "admin:tenant:write" in perms: + return True + return str(ctx.get("role") or "") == "owner" + + +def _require_grant_manager(ctx: dict[str, Any]) -> None: + if not _can_manage_llm_grants(ctx): + raise HTTPException(status_code=403, detail="grants_administrator_only") + + +def _profile_shareable_for_admin_grant(ctx: dict[str, Any], prof: dict[str, Any] | None) -> bool: + """仅全局池(无 owner)或操作者本人名下的 profile 可被授权给团队/用户。""" + if not prof or prof.get("is_builtin"): + return False + own = str(prof.get("owner_user_id") or "").strip() + uid = str(ctx.get("user_id") or "").strip() + if not own: + return True + return bool(uid) and own == uid + + +def _normalize_active( + store: SqliteStore, profiles: list[dict[str, Any]], profile_ids: list[str], ctx: dict[str, Any] +) -> str: + if not profile_ids: + return "" + key = _active_key(ctx) + active_id = str(store.get_setting(key) or "").strip() + if active_id not in profile_ids: + store.set_setting(key, LLM_BUILTIN_OLLAMA_PROFILE_ID) + active_id = LLM_BUILTIN_OLLAMA_PROFILE_ID + if active_id not in profile_ids: + active_id = profile_ids[0] + store.set_setting(key, active_id) + return active_id + + +def include_model_mgmt_routes( + router: APIRouter, + *, + resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]], +) -> None: + mg = APIRouter(prefix="/admin/api/models", tags=["models"]) + + @mg.get("") + def api_models_state( + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + uid = str(ctx.get("user_id") or "").strip() + uname = str(ctx.get("username") or "").strip() + lk = _models_list_kwargs(ctx) + profiles = store.list_llm_profiles(visible_only=True, **lk) + profile_ids = [str(p["id"]) for p in profiles] + active_id = _normalize_active(store, profiles, profile_ids, ctx) + bindings = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx))) + ui_lang = str(store.get_setting("ui_lang") or "zh").strip().lower() + if ui_lang not in ("zh", "en"): + ui_lang = "zh" + secret = "" + if active_id and active_id in profile_ids: + active_prof = next((p for p in profiles if str(p.get("id") or "") == active_id), None) + if is_administrator_model_pool(uname) or (active_prof and active_prof.get("mutable", True)): + secret = store.get_llm_profile_secret(active_id) or "" + out: dict[str, Any] = { + "ok": True, + "active_llm_profile_id": active_id, + "profiles": profiles, + "bindings": bindings, + "ui_lang": ui_lang, + "builtin_ollama_profile_id": LLM_BUILTIN_OLLAMA_PROFILE_ID, + "has_openai_api_key_env": bool((os.getenv("OPENAI_API_KEY") or "").strip()), + "role_ids": list(AGENT_ROLE_IDS), + "profile_secret": secret, + "can_manage_llm_grants": _can_manage_llm_grants(ctx), + # 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」 + "db_path": db_path(), + } + return out + + @mg.post("/active") + def api_models_set_active( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + # 与能进入控制台一致:切换「当前选用」不写密钥,仅需读权限即可。 + _require_permission(ctx, "admin:read") + profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx)) + profile_ids = [str(p["id"]) for p in profiles] + pid = str(payload.get("profile_id") or "").strip() + if pid not in profile_ids: + raise HTTPException(status_code=400, detail="invalid_profile_id") + store.set_setting(_active_key(ctx), pid) + return {"ok": True, "active_llm_profile_id": pid} + + @mg.post("/bindings") + def api_models_set_bindings( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_models_mutate(ctx) + profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx)) + profile_ids = set(str(p["id"]) for p in profiles) + raw = payload.get("bindings") + if not isinstance(raw, dict): + raise HTTPException(status_code=400, detail="bindings_object_required") + cur = parse_agent_profile_bindings(store.get_setting(_bindings_key(ctx))) + for rid in AGENT_ROLE_IDS: + v = raw.get(rid) + if v is None: + continue + s = str(v).strip() + if s and s not in profile_ids: + raise HTTPException(status_code=400, detail=f"invalid_binding:{rid}") + cur[rid] = s + store.set_setting(_bindings_key(ctx), dump_agent_profile_bindings(cur)) + return {"ok": True, "bindings": cur} + + @mg.post("/profiles") + def api_models_create_profile( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_models_mutate(ctx) + name = str(payload.get("name") or "").strip() or "新配置" + mode = str(payload.get("mode") or "openai").strip().lower() + if mode not in _LLM_USER_CREATE_MODES: + raise HTTPException(status_code=400, detail="invalid_mode") + if mode == "openai": + new_model = "gpt-4o-mini" + new_bu = "" + else: + new_model = DEFAULT_OLLAMA_MODEL + new_bu = DEFAULT_OLLAMA_BASE_URL + own: str | None = None + uid = str(ctx.get("user_id") or "").strip() + uname = str(ctx.get("username") or "").strip() + if uid and not is_administrator_model_pool(uname): + own = uid + pid = store.create_llm_profile(name=name, mode=mode, model=new_model, base_url=new_bu or None, owner_user_id=own) + store.set_setting(_active_key(ctx), pid) + prof = store.get_llm_profile(pid) + return {"ok": True, "profile_id": pid, "profile": prof} + + @mg.patch("/profiles/{profile_id}") + def api_models_patch_profile( + profile_id: str, + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_models_mutate(ctx) + pid = str(profile_id or "").strip() + prof = _assert_profile_mutable(ctx, store.get_llm_profile(pid)) + name = str(payload.get("name") if payload.get("name") is not None else prof.get("name") or "").strip() or "未命名" + mode_raw = str(payload.get("mode") if payload.get("mode") is not None else prof.get("mode") or "openai").strip().lower() + if pid == LLM_BUILTIN_OLLAMA_PROFILE_ID: + mode_save = "ollama" + else: + if mode_raw not in _LLM_MODE_OPTIONS: + raise HTTPException(status_code=400, detail="invalid_mode") + mode_save = mode_raw + model = payload.get("model") + base_url = payload.get("base_url") + model_s = str(model).strip() if model is not None else str(prof.get("model") or "").strip() + bu_s = str(base_url).strip() if base_url is not None else str(prof.get("base_url") or "").strip() + store.update_llm_profile( + profile_id=pid, + name=name, + mode=mode_save, + model=model_s or None, + base_url=bu_s or None, + ) + store.set_setting(_active_key(ctx), pid) + return {"ok": True, "profile": store.get_llm_profile(pid)} + + @mg.post("/profiles/{profile_id}/secret") + def api_models_profile_secret( + profile_id: str, + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_models_mutate(ctx) + pid = str(profile_id or "").strip() + prof = _assert_profile_mutable(ctx, store.get_llm_profile(pid)) + remember = bool(payload.get("remember")) + key_text = str(payload.get("secret") or "").strip() + mode_save = str(prof.get("mode") or "openai").strip().lower() + if pid == LLM_BUILTIN_OLLAMA_PROFILE_ID: + mode_save = "ollama" + if mode_save in ("openai", "ollama"): + if remember: + if key_text: + store.set_llm_profile_secret(pid, key_text) + elif mode_save == "openai": + raise HTTPException(status_code=400, detail="remember_key_empty") + else: + store.clear_llm_profile_secret(pid) + else: + store.clear_llm_profile_secret(pid) + return {"ok": True, "profile": store.get_llm_profile(pid)} + + @mg.delete("/profiles/{profile_id}") + def api_models_delete_profile( + profile_id: str, + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_models_mutate(ctx) + pid = str(profile_id or "").strip() + _assert_profile_mutable(ctx, store.get_llm_profile(pid)) + try: + store.delete_llm_profile(pid) + except ValueError: + raise HTTPException(status_code=400, detail="cannot_delete_builtin") + remaining = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx)) + new_active = remaining[0]["id"] if remaining else LLM_BUILTIN_OLLAMA_PROFILE_ID + store.set_setting(_active_key(ctx), new_active) + return {"ok": True, "active_llm_profile_id": new_active} + + @mg.get("/members") + def api_models_members( + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_grant_manager(ctx) + tid = str(ctx.get("tenant_id") or "").strip() + if not tid: + raise HTTPException(status_code=400, detail="tenant_required") + users = store.list_users(tenant_id=tid, limit=500, offset=0, include_inactive=True) + members: list[dict[str, Any]] = [] + for u in users: + mid = str(u.get("id") or "").strip() + un = str(u.get("username") or "").strip() + if not mid: + continue + profs = store.list_llm_profiles( + visible_only=True, + viewer_user_id=mid, + viewer_username=un or None, + viewer_tenant_id=tid, + ) + members.append( + { + "user_id": mid, + "username": un, + "display_name": str(u.get("display_name") or "").strip(), + "role": str(u.get("role") or "").strip(), + "profiles": profs, + } + ) + return {"ok": True, "members": members} + + @mg.get("/grants/tenant") + def api_models_grants_tenant_get( + profile_id: str = Query(...), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_grant_manager(ctx) + tid = str(ctx.get("tenant_id") or "").strip() + pid = str(profile_id or "").strip() + if not tid or not pid: + raise HTTPException(status_code=400, detail="tenant_or_profile_required") + granted = store.tenant_has_llm_profile_grant(tid, pid) + return {"ok": True, "granted": granted} + + @mg.post("/grants/tenant") + def api_models_grants_tenant_create( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_grant_manager(ctx) + tid = str(ctx.get("tenant_id") or "").strip() + pid = str(payload.get("profile_id") or "").strip() + if not tid or not pid: + raise HTTPException(status_code=400, detail="profile_id_required") + prof = store.get_llm_profile(pid) + if not _profile_shareable_for_admin_grant(ctx, prof): + raise HTTPException(status_code=403, detail="profile_not_shareable") + actor = str(ctx.get("user_id") or "").strip() or None + try: + gid = store.grant_llm_profile_to_tenant( + tenant_id=tid, profile_id=pid, created_by_user_id=actor + ) + except ValueError as e: + code = str(e) + if code == "profile_not_found": + raise HTTPException(status_code=404, detail=code) from e + raise HTTPException(status_code=400, detail=code) from e + return {"ok": True, "grant_id": gid} + + @mg.delete("/grants/tenant") + def api_models_grants_tenant_revoke( + profile_id: str = Query(...), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_grant_manager(ctx) + tid = str(ctx.get("tenant_id") or "").strip() + pid = str(profile_id or "").strip() + if not tid or not pid: + raise HTTPException(status_code=400, detail="profile_id_required") + n = store.revoke_llm_profile_tenant_grant(tenant_id=tid, profile_id=pid) + return {"ok": True, "removed": int(n)} + + @mg.get("/grants") + def api_models_grants_list( + profile_id: str = Query(..., description="llm_profile id"), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_grant_manager(ctx) + tid = str(ctx.get("tenant_id") or "").strip() + pid = str(profile_id or "").strip() + if not tid or not pid: + raise HTTPException(status_code=400, detail="tenant_or_profile_required") + rows = store.list_llm_profile_grants_for_profile(tid, pid) + return {"ok": True, "grants": rows} + + @mg.post("/grants") + def api_models_grants_create( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_grant_manager(ctx) + tid = str(ctx.get("tenant_id") or "").strip() + pid = str(payload.get("profile_id") or "").strip() + uid = str(payload.get("user_id") or "").strip() + if not tid or not pid or not uid: + raise HTTPException(status_code=400, detail="profile_id_and_user_id_required") + prof = store.get_llm_profile(pid) + if not _profile_shareable_for_admin_grant(ctx, prof): + raise HTTPException(status_code=403, detail="profile_not_shareable") + actor = str(ctx.get("user_id") or "").strip() or None + try: + gid = store.grant_llm_profile_to_user( + tenant_id=tid, + profile_id=pid, + user_id=uid, + created_by_user_id=actor, + ) + except ValueError as e: + code = str(e) + if code == "profile_not_found": + raise HTTPException(status_code=404, detail=code) from e + if code == "user_not_found": + raise HTTPException(status_code=404, detail=code) from e + raise HTTPException(status_code=400, detail=code) from e + return {"ok": True, "grant_id": gid} + + @mg.delete("/grants") + def api_models_grants_revoke( + profile_id: str = Query(...), + user_id: str = Query(...), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_grant_manager(ctx) + tid = str(ctx.get("tenant_id") or "").strip() + pid = str(profile_id or "").strip() + uid = str(user_id or "").strip() + if not tid or not pid or not uid: + raise HTTPException(status_code=400, detail="profile_id_and_user_id_required") + n = store.revoke_llm_profile_grant(tenant_id=tid, profile_id=pid, user_id=uid) + return {"ok": True, "removed": int(n)} + + @mg.get("/eval") + def api_models_eval( + authorization: str | None = Header(default=None), + limit_logs: int = Query(default=100, ge=1, le=500), + limit_summary: int = Query(default=500, ge=1, le=5000), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + summary = eval_summary(store, limit=limit_summary) + logs = store.list_agent_eval_logs(limit=limit_logs) + return {"ok": True, "summary": summary, "logs": logs} + + _EVAL_EXPORT_FIELDS = ( + "timestamp", + "session_id", + "specialist", + "task_kind", + "success", + "latency_ms", + "cost_hint", + "notes", + ) + + @mg.get("/eval/export") + def api_models_eval_export( + authorization: str | None = Header(default=None), + format: str = Query(default="csv", description="csv or json"), + limit: int = Query(default=100_000, ge=1, le=200_000), + ) -> Response: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + fmt = str(format or "csv").strip().lower() + rows = store.list_agent_eval_logs(limit=limit) + if fmt == "json": + body = json.dumps(rows, ensure_ascii=False, indent=2) + return Response( + content=body.encode("utf-8"), + media_type="application/json; charset=utf-8", + headers={ + "Content-Disposition": 'attachment; filename="agent_eval_logs.json"', + }, + ) + if fmt != "csv": + raise HTTPException(status_code=400, detail="invalid_format") + buf = io.StringIO() + w = csv.DictWriter(buf, fieldnames=list(_EVAL_EXPORT_FIELDS), extrasaction="ignore") + w.writeheader() + for r in rows: + row = {k: r.get(k) for k in _EVAL_EXPORT_FIELDS} + if "success" in row: + row["success"] = 1 if bool(row.get("success")) else 0 + w.writerow(row) + payload = "\ufeff" + buf.getvalue() + return Response( + content=payload.encode("utf-8"), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="agent_eval_logs.csv"'}, + ) + + router.include_router(mg) + + +__all__ = ["include_model_mgmt_routes"] diff --git a/admin/routes.py b/admin/routes.py new file mode 100644 index 00000000..98336c5d --- /dev/null +++ b/admin/routes.py @@ -0,0 +1,3069 @@ +from __future__ import annotations + +from pathlib import Path +import hashlib +import hmac +import json +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any + +from fastapi import APIRouter, Body, Header, Query +from fastapi import HTTPException +from fastapi.responses import HTMLResponse + +from oclaw.ops.mcp_env import apply_gateway_mcp_env_to_os +from oclaw.ops.providers.registry import build_channel_registry +from oclaw.ops.runtime import cleanup_orphan_service_processes, detect_orphan_service_processes, status_services +from oclaw.ops.stack import cmd_stack_down, cmd_stack_status, cmd_stack_up +from oclaw.orchestration.vector_store import read_vector_memory_runtime +from oclaw.platform.config.paths import PROJECT_ROOT, db_path +from oclaw.platform.config.passwords import load_expected_password +from oclaw.platform.persistence.sqlite_store import SqliteStore +from oclaw.agents.specialists import SPECIALISTS +from oclaw.tools.mcp.installer import ( + _safe_server_id, + detect_local_dependencies, + install_mcp_server, + preflight_mcp_server, + uninstall_mcp_server, +) +from oclaw.tools.mcp.market import search_mcp_market, trending_mcp_market +from oclaw.tools.mcp.manifest import McpServerManifest +from oclaw.ops.mcp_registry_export import ( + build_mcp_install_export_document, + mcp_migrated_json_path, + persist_mcp_migrated_file, +) +from oclaw.tools.mcp.filesystem_argv import build_mcp_process_command +from oclaw.tools.mcp.registry import McpRegistry +from oclaw.tools.mcp.runtime import McpProcessRuntime +from oclaw.admin.mcp_e2e_probe import build_mcp_e2e_probe_plans +from oclaw.tools.catalog import default_registry +from oclaw.chat.tool_runtime import ToolExecutionContext, ToolExecutor +from oclaw.platform.llm.chat_models import LLMToolCall +from oclaw.openclaw_runtime.agent_core_attempt import ALL_ATTEMPT_ERROR_CODES +from oclaw.openclaw_runtime.agent_core_run import DEFAULT_RETRYABLE_ERROR_CODES, resolve_retryable_error_codes + +_WECOM_CLEAR_KEYS = [ + "wecom_mode", + "wecom_bot_id", + "wecom_bot_secret", + "wecom_corp_id", + "wecom_agent_id", + "wecom_agent_secret", + "wecom_access_token_cache", + "wecom_last_msg_ts", + "wecom_last_from_user", + "wecom_recent_from_users", + "wecom_last_cmd", + "wecom_last_unknown_cmd_payload", + "wecom_last_raw_body", + "wecom_last_raw_from_user", + "wecom_last_parse_error", +] + + +def _wecom_bot_secret_key(tenant_id: str, user_id: str, account_id: str) -> str: + return f"wecom:bot_secret:{tenant_id}:{user_id}:{account_id}" + + +def _expand_mcp_entry_args(raw: list[Any] | None) -> list[str]: + """Expand ``__REPO_ROOT__/...`` in argv (same as ``scripts/seed_mcp_registry.py``).""" + out: list[str] = [] + for x in raw or []: + s = str(x).strip() + if not s: + continue + if s.startswith("__REPO_ROOT__/"): + rel = s.replace("__REPO_ROOT__/", "", 1) + s = str((PROJECT_ROOT / rel).resolve()) + out.append(s) + return out + + +def _mcp_health_and_sync_one(store: SqliteStore, row: dict[str, Any]) -> dict[str, Any] | None: + """Run MCP initialize health + tools/list + persist tools (same semantics as check-all per row).""" + sid = str(row.get("server_id") or "").strip() + cmd = str(row.get("entry_command") or "").strip() + args = [str(x) for x in (row.get("entry_args") or []) if str(x).strip()] + if not sid: + return None + if not cmd: + item: dict[str, Any] = { + "server_id": sid, + "ok": False, + "error_code": "mcp_entry_missing", + "error": "entry_command_missing", + "health": {"ok": False, "error_code": "mcp_entry_missing", "error": "entry_command_missing"}, + "tools_synced": 0, + } + store.set_mcp_server_health(server_id=sid, status="error", detail=item["health"]) + return item + rt = McpProcessRuntime( + build_mcp_process_command(cmd, args, store=store), + timeout_s=float(row.get("timeout_s") or 30.0), + ) + try: + health = rt.health() + health_ok = bool(health.get("ok")) + if not health_ok: + item = { + "server_id": sid, + "ok": False, + "error_code": str(health.get("error_code") or "mcp_healthcheck_failed"), + "error": str(health.get("error") or "healthcheck_failed"), + "health": health, + "tools_synced": 0, + } + store.set_mcp_server_health(server_id=sid, status="error", detail=health) + return item + tools_res = rt.tools_list() + if not bool(tools_res.get("ok")): + item = { + "server_id": sid, + "ok": False, + "error_code": str(tools_res.get("error_code") or "mcp_tools_list_invalid"), + "error": str(tools_res.get("error") or "tools_list_failed"), + "health": health, + "tools_synced": 0, + } + store.set_mcp_server_health(server_id=sid, status="error", detail=tools_res) + return item + tools = tools_res.get("tools") if isinstance(tools_res.get("tools"), list) else [] + store.replace_mcp_server_tools(server_id=sid, tools=tools if isinstance(tools, list) else []) + store.set_mcp_server_health(server_id=sid, status="ok", detail={"synced_tools": len(tools)}) + return {"server_id": sid, "ok": True, "health": health, "tools_synced": len(tools)} + finally: + rt.stop() + + +def _enrich_wecom_channel_account(store: SqliteStore, tenant_id: str, user_id: str, item: dict[str, Any]) -> dict[str, Any]: + aid = str(item.get("account_id") or "") + cfg = item.get("config") + if not isinstance(cfg, dict): + cfg = {} + out = dict(item) + out["has_bot_secret"] = bool(store.get_secret(_wecom_bot_secret_key(tenant_id, user_id, aid))) + out["wecom_mode"] = "bot_api" + return out + + +def admin_static_dir() -> Path: + return Path(__file__).resolve().parent / "static" + + +def build_admin_router() -> APIRouter: + router = APIRouter() + + static_dir = admin_static_dir() + + def _sha256_hex(v: str) -> str: + return hashlib.sha256(str(v or "").encode("utf-8", errors="ignore")).hexdigest() + + def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + def _parse_iso(v: str) -> datetime | None: + try: + return datetime.fromisoformat(str(v)) + except Exception: + return None + + def _extract_bearer(authorization: str | None) -> str: + blob = str(authorization or "").strip() + if not blob: + return "" + parts = blob.split(None, 1) + if len(parts) == 2 and parts[0].lower() == "bearer": + return parts[1].strip() + return "" + + def _resolve_auth(store: SqliteStore, authorization: str | None) -> dict[str, Any]: + token = _extract_bearer(authorization) + if not token: + raise HTTPException(status_code=401, detail="missing_bearer_token") + session = store.get_auth_session(session_token_hash=_sha256_hex(token)) + if not session: + raise HTTPException(status_code=401, detail="invalid_session") + if session.get("revoked_at"): + raise HTTPException(status_code=401, detail="session_revoked") + expires_at = _parse_iso(str(session.get("expires_at") or "")) + if expires_at is None or expires_at <= _now_utc(): + raise HTTPException(status_code=401, detail="session_expired") + user = store.get_user_by_id( + tenant_id=str(session.get("tenant_id") or ""), + user_id=str(session.get("user_id") or ""), + ) + if not user: + raise HTTPException(status_code=401, detail="user_not_found") + if not bool(user.get("is_active")): + raise HTTPException(status_code=403, detail="user_disabled") + store.touch_auth_session(session_token_hash=str(session.get("session_token_hash") or "")) + perms = store.list_user_permissions( + tenant_id=str(user.get("tenant_id") or ""), + user_id=str(user.get("id") or ""), + role=str(user.get("role") or "member"), + ) + return { + "tenant_id": user["tenant_id"], + "user_id": user["id"], + "username": str(user.get("username") or ""), + "role": user["role"], + "permissions": perms, + } + + def _require_permission(ctx: dict[str, Any], permission: str) -> None: + perms = set(str(x) for x in (ctx.get("permissions") or [])) + if permission in perms: + return + if str(ctx.get("role") or "") == "owner": + return + raise HTTPException(status_code=403, detail=f"forbidden:{permission}") + + def _require_tenant_scope(ctx: dict[str, Any], tenant_id: str) -> None: + if str(ctx.get("tenant_id") or "") == str(tenant_id or ""): + return + # 控制台仅 administrator 可登录;该账号允许跨租户读写(与单租户会话 tenant_id 解耦)。 + if str(ctx.get("username") or "").strip().lower() == "administrator": + return + raise HTTPException(status_code=403, detail="cross_tenant_forbidden") + + def _ordered_specialists() -> list[str]: + base = [str(k).strip().lower() for k in SPECIALISTS.keys() if str(k).strip()] + preferred = [x for x in ("generalist", "ops", "image") if x in set(base)] + return preferred + [x for x in base if x not in set(preferred)] + + def _ordered_mcp_roles() -> list[str]: + specs = _ordered_specialists() + return ["manager", *[x for x in specs if x != "manager"]] + + def _ordered_roles() -> list[str]: + """Canonical role list used by Admin preview APIs.""" + specs = _ordered_specialists() + return ["manager", *[x for x in specs if x != "manager"]] + + @router.post("/admin/api/tools/internal/reload") + def api_internal_tools_reload( + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + + from oclaw.tools.expert_registry import clear_expert_tool_cache + from oclaw.tools.public_registry import clear_public_tool_cache + + clear_public_tool_cache() + clear_expert_tool_cache() + + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="internal_tools_reload", + target_type="tool_cache", + target_id="public+expert", + status="ok", + detail={"cleared": True}, + ) + return {"ok": True, "cleared": True} + + @router.get("/admin/api/tools/exposure-trace-setting") + def api_tools_exposure_trace_setting_get( + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + raw = str(store.get_setting("AIA_TRACE_TOOL_EXPOSURE_PLAN") or "").strip().lower() + enabled = raw in {"1", "true", "yes", "on"} + return {"ok": True, "enabled": bool(enabled)} + + @router.post("/admin/api/tools/exposure-trace-setting") + def api_tools_exposure_trace_setting_save( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + payload = payload or {} + enabled = bool(payload.get("enabled")) + store.set_setting("AIA_TRACE_TOOL_EXPOSURE_PLAN", "1" if enabled else "0") + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="tools_exposure_trace_setting_update", + target_type="app_setting", + target_id="AIA_TRACE_TOOL_EXPOSURE_PLAN", + status="ok", + detail={"enabled": bool(enabled)}, + ) + return {"ok": True, "enabled": bool(enabled)} + + @router.get("/admin/api/tools/internal/preview") + def api_internal_tools_preview( + role: str | None = Query(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + from oclaw.tools.exposure_plan import build_internal_tool_specs + + r = str(role or "").strip().lower() + available_roles = _ordered_roles() + if not r: + r = "generalist" if "generalist" in set(available_roles) else (available_roles[0] if available_roles else "") + if r not in set(available_roles): + raise HTTPException(status_code=400, detail="invalid_role") + + specs, diag = build_internal_tool_specs(role=r, preview=True) + + def _to_row(spec: Any, source: str) -> dict[str, Any]: + return { + "name": str(getattr(spec, "name", "") or ""), + "description": str(getattr(spec, "description", "") or ""), + "tags": sorted([str(x) for x in (getattr(spec, "tags", None) or [])]), + "read_only": bool(getattr(spec, "read_only", False)), + "risk_level": str(getattr(spec, "risk_level", "") or ""), + "timeout_s": float(getattr(spec, "timeout_s", 0.0) or 0.0), + } + + tools = [] + source_by_name = dict(diag.get("source_by_name") or {}) + for s in specs: + src = str(source_by_name.get(str(getattr(s, "name", "") or ""), "expert")) + tools.append({**_to_row(s, src), "source": src}) + tools.sort(key=lambda x: (str(x.get("source") or ""), str(x.get("name") or ""))) + + return {"ok": True, "role": r, "available_roles": available_roles, "tools": tools, **diag} + + @router.get("/admin/api/tools/self-check") + def api_tools_self_check( + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + """One-shot diagnostic summary for role-based tool exposure.""" + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + + from oclaw.platform.llm.tool_wire_policy import load_role_mode_for_role, load_tool_policies_dict_for_role + from oclaw.tools.exposure_plan import build_internal_tool_specs, build_llm_tools_plan + + roles = _ordered_roles() + items: list[dict[str, Any]] = [] + total_perm_ban = 0 + total_wired = 0 + total_internal = 0 + for role in roles: + internal_specs, internal_diag = build_internal_tool_specs(role=role, preview=True) + llm_plan = build_llm_tools_plan( + store=store, + role=role, + base_url=None, + max_json_bytes=None, + include_mcp=True, + preview_internal=True, + ) + policies = load_tool_policies_dict_for_role(store, role=role) + perm_ban = len([k for k, v in policies.items() if str(k).startswith("mcp__") and int(v) == 9999]) + total_perm_ban += perm_ban + total_wired += len(llm_plan.tools_wired) + total_internal += len(internal_specs) + items.append( + { + "role": role, + "role_mode": load_role_mode_for_role(store, role=role), + "internal_count": len(internal_specs), + "internal_public_count": int(internal_diag.get("public_count") or 0), + "internal_expert_count": int(internal_diag.get("expert_count") or 0), + "wired_count": len(llm_plan.tools_wired), + "raw_count": len(llm_plan.tools_raw), + "removed_total": len(llm_plan.removed_names), + "removed_mcp_total": len(llm_plan.removed_mcp_names), + "changed_total": len(llm_plan.changed_names), + "policy_perm_ban_9999": perm_ban, + "mcp_enabled": bool(llm_plan.mcp_enabled), + "wire_policy_effective": bool(llm_plan.wire_policy_effective), + } + ) + + return { + "ok": True, + "roles_total": len(roles), + "roles": roles, + "summary": { + "total_internal_tools": total_internal, + "total_wired_tools": total_wired, + "total_perm_ban_9999": total_perm_ban, + }, + "items": items, + } + + @router.get("/admin/api/tools/llm/preview") + def api_llm_tools_preview( + role: str | None = Query(default=None), + base_url: str | None = Query(default=None), + max_json_bytes: int | None = Query(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + """Preview the final tools injected to LLM for a role (internal + MCP + wire policy).""" + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + from oclaw.tools.exposure_plan import build_llm_tools_plan + + r = str(role or "").strip().lower() + available_roles = _ordered_roles() + if not r: + r = "generalist" if "generalist" in set(available_roles) else (available_roles[0] if available_roles else "") + if r not in set(available_roles): + raise HTTPException(status_code=400, detail="invalid_role") + plan = build_llm_tools_plan( + store=store, + role=r, + base_url=base_url, + max_json_bytes=max_json_bytes, + include_mcp=True, + preview_internal=True, + ) + return { + "ok": True, + "role": plan.role, + "available_roles": available_roles, + "base_url": plan.base_url, + "max_json_bytes": plan.max_json_bytes, + "mcp_enabled": plan.mcp_enabled, + "role_mode": plan.role_mode, + "wire_policy_effective": plan.wire_policy_effective, + "policy_keys": plan.policy_keys, + "raw_count": len(plan.tools_raw), + "wired_count": len(plan.tools_wired), + "removed_mcp_names": plan.removed_mcp_names, + "tools_raw": plan.tools_raw, + "tools_wired": plan.tools_wired, + "public_risk_gate_allow_high": plan.public_risk_gate_allow_high, + "public_blocked_high_risk_tools": plan.public_blocked_high_risk_tools, + "skipped_public": plan.skipped_public, + "skipped_expert": plan.skipped_expert, + } + + def _load_mcp_specialist_binding(store: SqliteStore) -> dict[str, list[str]]: + raw = str(store.get_setting("mcp_specialist_server_binding") or "").strip() + if not raw: + return {} + try: + obj = json.loads(raw) + except Exception: + return {} + if not isinstance(obj, dict): + return {} + out: dict[str, list[str]] = {} + for k, v in obj.items(): + sid = str(k or "").strip().lower() + if not sid: + continue + items = v if isinstance(v, list) else [] + vals = [str(x).strip() for x in items if str(x).strip()] + out[sid] = vals + return out + + def _normalize_mcp_specialist_binding( + *, + available_specialists: list[str], + servers: list[dict[str, Any]], + mapping: dict[str, Any], + ) -> dict[str, list[str]]: + available_set = set(available_specialists) + server_ids = {str(x.get("server_id") or "").strip() for x in servers if str(x.get("server_id") or "").strip()} + out: dict[str, list[str]] = {} + for sp in available_specialists: + rows = mapping.get(sp) if isinstance(mapping, dict) else None + items = rows if isinstance(rows, list) else [] + vals = [] + seen: set[str] = set() + for x in items: + sid = str(x or "").strip() + if not sid or sid not in server_ids or sid in seen: + continue + seen.add(sid) + vals.append(sid) + out[sp] = vals + # drop any unknown specialist keys by only returning available list keys + for key in list(out.keys()): + if key not in available_set: + out.pop(key, None) + return out + + def _ensure_admin_bootstrap(store: SqliteStore) -> None: + tenants = store.list_tenants(limit=500) + if not tenants: + store.create_tenant("Team") + tenants = store.list_tenants(limit=500) + pwd = load_expected_password(store) + if not pwd: + # Fail closed: require explicit password configuration. + raise HTTPException( + status_code=400, + detail="missing_admin_password: set AIA_ASSISTANT_PASSWORD env or store secret auth_password first", + ) + pwd_hash = _sha256_hex(pwd) + for tenant in tenants: + tid = str((tenant or {}).get("id") or "").strip() + if not tid: + continue + if store.get_user_by_username(tenant_id=tid, username="administrator"): + continue + store.create_user_account( + tenant_id=tid, + username="administrator", + display_name="Administrator", + role="admin", + password_hash=pwd_hash, + is_active=True, + ) + + @router.get("/admin", response_class=HTMLResponse) + def admin_root() -> Any: + p = static_dir / "index.html" + return HTMLResponse(p.read_text(encoding="utf-8")) + + @router.get("/admin/api/channels") + def api_channels(authorization: str | None = Header(default=None)) -> dict[str, Any]: + ctx = _resolve_auth(SqliteStore(db_path()), authorization) + _require_permission(ctx, "admin:read") + reg = build_channel_registry() + items = [{"name": k, "type": reg[k].__class__.__name__} for k in sorted(reg.keys())] + return {"ok": True, "channels": items} + + @router.get("/admin/api/stack/status") + def api_stack_status(authorization: str | None = Header(default=None)) -> dict[str, Any]: + ctx = _resolve_auth(SqliteStore(db_path()), authorization) + _require_permission(ctx, "admin:read") + items = [] + for s in status_services(): + items.append({"name": s.name, "pid": s.pid, "running": bool(s.running)}) + return {"ok": True, "items": items} + + @router.post("/admin/api/stack/up") + def api_stack_up(channel: str = "wecom", authorization: str | None = Header(default=None)) -> dict[str, Any]: + ctx = _resolve_auth(SqliteStore(db_path()), authorization) + _require_permission(ctx, "admin:runtime:write") + # Use same defaults as CLI; this starts detached processes and writes runtime state. + import argparse + + ns = argparse.Namespace( + channel=channel, + channel_mode="ws", + channel_interval=3.0, + deliver_outbound=True, + pull_url=None, + gateway_host="0.0.0.0", + gateway_port=8787, + with_ui=False, + ui_port=8501, + ) + cmd_stack_up(ns) + return api_stack_status() + + @router.post("/admin/api/stack/down") + def api_stack_down(authorization: str | None = Header(default=None)) -> dict[str, Any]: + ctx = _resolve_auth(SqliteStore(db_path()), authorization) + _require_permission(ctx, "admin:runtime:write") + import argparse + + ns = argparse.Namespace() + cmd_stack_down(ns) + return api_stack_status() + + @router.get("/admin/api/runtime/anomalies") + def api_runtime_anomalies(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + items = [] + running = status_services() + for s in running: + keep = {int(s.pid)} if s.running and int(s.pid or 0) > 0 else set() + orphans = detect_orphan_service_processes(s.name, keep_pids=keep) + if orphans: + items.append( + { + "type": "duplicate_process", + "service": s.name, + "expected_pid": int(s.pid or 0), + "orphan_pids": sorted(int(x) for x in orphans), + "severity": "critical", + "message": f"{s.name} has duplicate workers: {', '.join(str(x) for x in sorted(orphans))}", + } + ) + last_parse_error = str(store.get_setting("wecom_last_parse_error") or "").strip() + last_outbound_error = str(store.get_setting("wecom_last_outbound_error") or "").strip() + if last_parse_error: + sev = "warning" + if "disconnected_event" in last_parse_error.lower(): + sev = "critical" + items.append( + { + "type": "wecom_parse_error", + "service": "channel:wecom", + "severity": sev, + "message": f"wecom_last_parse_error={last_parse_error}", + } + ) + if last_outbound_error: + items.append( + { + "type": "wecom_outbound_error", + "service": "channel:wecom", + "severity": "critical", + "message": f"wecom_last_outbound_error={last_outbound_error}", + } + ) + must_cleanup = any(str(x.get("type") or "") == "duplicate_process" for x in items) + return {"ok": True, "must_cleanup": must_cleanup, "items": items} + + @router.post("/admin/api/runtime/cleanup") + def api_runtime_cleanup(authorization: str | None = Header(default=None)) -> dict[str, Any]: + ctx = _resolve_auth(SqliteStore(db_path()), authorization) + _require_permission(ctx, "admin:runtime:write") + killed: list[dict[str, Any]] = [] + for s in status_services(): + keep = {int(s.pid)} if s.running and int(s.pid or 0) > 0 else set() + dead = cleanup_orphan_service_processes(s.name, keep_pids=keep) + if dead: + killed.append({"service": s.name, "killed_pids": sorted(int(x) for x in dead)}) + anomalies = api_runtime_anomalies() + return {"ok": True, "killed": killed, "anomalies": anomalies} + + @router.get("/admin/api/tenants") + def api_tenants( + scope: str | None = Query(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:read") + rows = store.list_tenants(limit=500) + if str(scope or "").strip().lower() == "mine": + tid = str(ctx.get("tenant_id") or "").strip() + rows = [r for r in rows if str(r.get("id") or "") == tid] + return {"ok": True, "tenants": rows} + + @router.post("/admin/api/tenants/create") + def api_tenants_create( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + name = str(payload.get("name") or "").strip() or "Team" + tenant = store.create_tenant(name) + return {"ok": True, "tenant": tenant} + + @router.post("/admin/api/tenants/delete") + def api_tenants_delete( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + tenant_id = str(payload.get("tenant_id") or "").strip() + _require_tenant_scope(ctx, tenant_id) + if not tenant_id: + return {"ok": False, "error": "tenant_id is required"} + all_tenants = store.list_tenants(limit=1000) + if len(all_tenants) <= 1: + return {"ok": False, "error": "last_tenant_cannot_delete"} + if tenant_id == str(ctx.get("tenant_id") or "").strip(): + return {"ok": False, "error": "cannot_delete_current_tenant"} + if not any(str(r.get("id") or "") == tenant_id for r in all_tenants): + return {"ok": False, "error": "tenant_not_found"} + deleted = store.delete_tenant(tenant_id=tenant_id) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="tenant_delete", + target_type="tenant", + target_id=tenant_id, + status="ok" if deleted > 0 else "miss", + ) + return {"ok": True, "deleted": deleted} + + @router.get("/admin/api/bindings") + def api_bindings( + tenant_id: str, + channel: str = "wecom", + user_id: str | None = Query(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:user:read") + _require_tenant_scope(ctx, tenant_id) + uid = str(user_id or "").strip() or None + # Prefer v2 bindings for WeCom (includes account_id + account_name). + if str(channel or "").strip().lower() == "wecom": + rows = store.list_channel_identities_v2( + tenant_id=tenant_id, channel=channel, user_id=uid, limit=2000 + ) + else: + rows = store.list_channel_identities(tenant_id=tenant_id, channel=channel, limit=1000) + if uid: + rows = [r for r in rows if str(r.get("user_id") or "") == uid] + return {"ok": True, "bindings": rows, "version": 2 if str(channel or "").strip().lower() == "wecom" else 1} + + @router.get("/admin/api/user-channel-accounts") + def api_user_channel_accounts( + tenant_id: str, + user_id: str, + channel: str = "wecom", + include_inactive: int = Query(default=1), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:user:read") + _require_tenant_scope(ctx, tenant_id) + rows = store.list_user_channel_accounts( + tenant_id=tenant_id, + user_id=user_id, + channel=channel, + include_inactive=bool(int(include_inactive or 0)), + ) + if str(channel or "").strip().lower() == "wecom": + rows = [_enrich_wecom_channel_account(store, tenant_id, user_id, dict(x)) for x in rows] + return {"ok": True, "items": rows} + + @router.post("/admin/api/user-channel-accounts/upsert") + def api_user_channel_accounts_upsert( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:user:write") + tenant_id = str(payload.get("tenant_id") or "").strip() + user_id = str(payload.get("user_id") or "").strip() + channel = str(payload.get("channel") or "wecom").strip() or "wecom" + account_id = str(payload.get("account_id") or "").strip() + name = str(payload.get("name") or "").strip() + _require_tenant_scope(ctx, tenant_id) + if not tenant_id or not user_id or not account_id: + return {"ok": False, "error": "tenant_id, user_id, account_id are required"} + if not store.get_user_by_id(tenant_id=tenant_id, user_id=user_id): + return {"ok": False, "error": "user_not_found"} + is_active = payload.get("is_active") + cfg: dict[str, Any] = {} + existing_rows = store.list_user_channel_accounts( + tenant_id=tenant_id, user_id=user_id, channel=channel, include_inactive=True + ) + cur_row = next((x for x in existing_rows if str(x.get("account_id") or "") == account_id), None) + if cur_row and isinstance(cur_row.get("config"), dict): + cfg = dict(cur_row["config"]) + if isinstance(payload.get("config"), dict): + cfg.update(payload["config"]) + if str(channel or "").strip().lower() == "wecom": + cfg["wecom_mode"] = "bot_api" + cfg.pop("wecom_corp_id", None) + cfg.pop("wecom_agent_id", None) + bot_secret = str(payload.get("wecom_bot_secret") or payload.get("bot_secret") or "").strip() + if bot_secret: + store.set_secret(_wecom_bot_secret_key(tenant_id, user_id, account_id), bot_secret) + if payload.get("clear_bot_secret") is True: + store.delete_setting(_wecom_bot_secret_key(tenant_id, user_id, account_id)) + store.upsert_user_channel_account( + tenant_id=tenant_id, + user_id=user_id, + channel=channel, + account_id=account_id, + name=name, + config=cfg, + is_active=bool(is_active) if isinstance(is_active, bool) else True, + ) + rows = store.list_user_channel_accounts(tenant_id=tenant_id, user_id=user_id, channel=channel, include_inactive=True) + row = next((x for x in rows if str(x.get("account_id") or "") == account_id), None) + if str(channel or "").strip().lower() == "wecom" and row: + row = _enrich_wecom_channel_account(store, tenant_id, user_id, dict(row)) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="user_channel_account_upsert", + target_type="user_channel_account", + target_id=f"{tenant_id}:{user_id}:{channel}:{account_id}", + status="ok", + detail={"name": name, "is_active": bool((row or {}).get("is_active", True))}, + ) + return {"ok": True, "item": row or {}} + + @router.post("/admin/api/user-channel-accounts/delete") + def api_user_channel_accounts_delete( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:user:write") + tenant_id = str(payload.get("tenant_id") or "").strip() + user_id = str(payload.get("user_id") or "").strip() + channel = str(payload.get("channel") or "wecom").strip() or "wecom" + account_id = str(payload.get("account_id") or "").strip() + _require_tenant_scope(ctx, tenant_id) + if not tenant_id or not user_id or not account_id: + return {"ok": False, "error": "tenant_id, user_id, account_id are required"} + deleted = store.delete_user_channel_account( + tenant_id=tenant_id, + user_id=user_id, + channel=channel, + account_id=account_id, + ) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="user_channel_account_delete", + target_type="user_channel_account", + target_id=f"{tenant_id}:{user_id}:{channel}:{account_id}", + status="ok" if deleted > 0 else "miss", + ) + return {"ok": True, "deleted": deleted} + + @router.get("/admin/api/users") + def api_users( + tenant_id: str, + q: str | None = Query(default=None), + include_inactive: int = Query(default=1), + offset: int = Query(default=0), + limit: int = Query(default=1000), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:user:read") + _require_tenant_scope(ctx, tenant_id) + rows = store.list_users( + tenant_id=tenant_id, + q=q, + include_inactive=bool(int(include_inactive or 0)), + offset=offset, + limit=limit, + ) + return {"ok": True, "users": rows} + + @router.post("/admin/api/users/create") + def api_users_create( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:user:write") + tenant_id = str(payload.get("tenant_id") or "").strip() or str(ctx.get("tenant_id") or "").strip() + _require_tenant_scope(ctx, tenant_id) + username = str(payload.get("username") or "").strip().lower() + display_name = str(payload.get("display_name") or username or "User").strip() + role = str(payload.get("role") or "member").strip() or "member" + password = str(payload.get("password") or "").strip() + if not username or not password: + return {"ok": False, "error": "username, password are required"} + existing = store.get_user_by_username(tenant_id=tenant_id, username=username) + if existing: + return {"ok": False, "error": "username_conflict"} + row = store.create_user_account( + tenant_id=tenant_id, + username=username, + display_name=display_name, + role=role, + password_hash=_sha256_hex(password), + is_active=True, + ) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="user_create", + target_type="user", + target_id=str(row.get("id") or ""), + status="ok", + detail={"username": username, "role": role}, + ) + return {"ok": True, "user": row} + + @router.post("/admin/api/users/update") + def api_users_update( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:user:write") + tenant_id = str(payload.get("tenant_id") or "").strip() + user_id = str(payload.get("user_id") or "").strip() + _require_tenant_scope(ctx, tenant_id) + if not tenant_id or not user_id: + return {"ok": False, "error": "tenant_id and user_id are required"} + target = store.get_user_by_id(tenant_id=tenant_id, user_id=user_id) + if not target: + return {"ok": False, "error": "user_not_found"} + if str(target.get("username") or "").strip().lower() == "administrator" and payload.get("is_active") is False: + return {"ok": False, "error": "administrator_cannot_be_disabled"} + pwd = payload.get("password") + ok = store.update_user_account( + tenant_id=tenant_id, + user_id=user_id, + display_name=payload.get("display_name"), + role=payload.get("role"), + is_active=payload.get("is_active") if isinstance(payload.get("is_active"), bool) else None, + password_hash=_sha256_hex(str(pwd)) if isinstance(pwd, str) and pwd.strip() else None, + ) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="user_update", + target_type="user", + target_id=user_id, + status="ok" if ok else "miss", + detail={"fields": list(payload.keys())}, + ) + return {"ok": True, "updated": bool(ok)} + + @router.post("/admin/api/users/delete") + def api_users_delete( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:user:delete") + tenant_id = str(payload.get("tenant_id") or "").strip() + user_id = str(payload.get("user_id") or "").strip() + _require_tenant_scope(ctx, tenant_id) + if not tenant_id or not user_id: + return {"ok": False, "error": "tenant_id and user_id are required"} + target = store.get_user_by_id(tenant_id=tenant_id, user_id=user_id) + if not target: + return {"ok": False, "error": "user_not_found"} + if str(target.get("username") or "").strip().lower() == "administrator": + return {"ok": False, "error": "administrator_cannot_be_deleted"} + if user_id == str(ctx.get("user_id") or ""): + return {"ok": False, "error": "cannot_delete_self"} + deleted = store.delete_user_account(tenant_id=tenant_id, user_id=user_id) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="user_delete", + target_type="user", + target_id=user_id, + status="ok" if deleted > 0 else "miss", + ) + return {"ok": True, "deleted": deleted} + + def _workspace_path_policy_read_mode(ctx: dict[str, Any]) -> str | None: + """``full`` = any user in tenant; ``self`` = only own user_id; ``None`` = forbidden.""" + if str(ctx.get("role") or "") == "owner": + return "full" + perms = set(str(x) for x in (ctx.get("permissions") or [])) + if "admin:user:read" in perms: + return "full" + if "admin:workspace_paths:read" in perms: + return "self" + return None + + def _workspace_path_policy_write_mode(ctx: dict[str, Any]) -> str | None: + if str(ctx.get("role") or "") == "owner": + return "full" + perms = set(str(x) for x in (ctx.get("permissions") or [])) + if "admin:user:write" in perms: + return "full" + if "admin:workspace_paths:write" in perms: + return "self" + return None + + def _normalize_workspace_extra_roots(raw: str) -> tuple[str, str | None]: + """Split ``|``, resolve each segment; must be absolute. Returns (joined, error_code).""" + parts = [x.strip().strip('"').strip("'") for x in str(raw or "").split("|") if str(x).strip()] + if not parts: + return "", None + out: list[str] = [] + for p in parts: + try: + rp = Path(p).expanduser().resolve() + if not rp.is_absolute(): + return "", "extra_roots_must_be_absolute" + out.append(str(rp)) + except Exception: + return "", "extra_roots_invalid_path" + return "|".join(out), None + + @router.get("/admin/api/users/workspace-path-policy") + def api_users_workspace_path_policy_get( + tenant_id: str = Query(default=""), + user_id: str = Query(default=""), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + rmode = _workspace_path_policy_read_mode(ctx) + if rmode is None: + raise HTTPException(status_code=403, detail="forbidden") + tid = str(tenant_id or "").strip() + uid = str(user_id or "").strip() + _require_tenant_scope(ctx, tid) + if not tid or not uid: + return {"ok": False, "error": "tenant_id and user_id are required"} + if rmode == "self" and (tid != str(ctx.get("tenant_id") or "") or uid != str(ctx.get("user_id") or "")): + raise HTTPException(status_code=403, detail="workspace_paths_self_only") + row = store.get_user_workspace_path_allowlist(tenant_id=tid, user_id=uid) + if not row: + return {"ok": True, "from_db": False, "policy": {"extra_roots": "", "allow_any_path": False}} + return {"ok": True, "from_db": True, "policy": row} + + @router.post("/admin/api/users/workspace-path-policy") + def api_users_workspace_path_policy_save( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + wmode = _workspace_path_policy_write_mode(ctx) + if wmode is None: + raise HTTPException(status_code=403, detail="forbidden") + tid = str(payload.get("tenant_id") or "").strip() + uid = str(payload.get("user_id") or "").strip() + _require_tenant_scope(ctx, tid) + if not tid or not uid: + return {"ok": False, "error": "tenant_id and user_id are required"} + if wmode == "self" and (tid != str(ctx.get("tenant_id") or "") or uid != str(ctx.get("user_id") or "")): + raise HTTPException(status_code=403, detail="workspace_paths_self_only") + if not store.get_user_by_id(tenant_id=tid, user_id=uid): + return {"ok": False, "error": "user_not_found"} + extra_raw = str(payload.get("extra_roots") or "") + norm, err = _normalize_workspace_extra_roots(extra_raw) + if err: + return {"ok": False, "error": err} + allow_any = bool(payload.get("allow_any_path", False)) + store.upsert_user_workspace_path_allowlist( + tenant_id=tid, + user_id=uid, + extra_roots=norm, + allow_any_path=allow_any, + ) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="user_workspace_path_policy_save", + target_type="user", + target_id=uid, + status="ok", + detail={"tenant_id": tid, "allow_any_path": allow_any, "extra_roots_preview": norm[:500]}, + ) + row = store.get_user_workspace_path_allowlist(tenant_id=tid, user_id=uid) + return {"ok": True, "policy": row or {}} + + @router.post("/admin/api/users/delete-unbound") + def api_users_delete_unbound( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:user:delete") + tenant_id = str(payload.get("tenant_id") or "").strip() + _require_tenant_scope(ctx, tenant_id) + channel = str(payload.get("channel") or "wecom").strip() or "wecom" + if not tenant_id: + return {"ok": False, "error": "tenant_id is required"} + users = store.list_users(tenant_id=tenant_id, limit=5000) + if str(channel).strip().lower() == "wecom": + binds = store.list_channel_identities_v2(tenant_id=tenant_id, channel=channel, limit=5000) + else: + binds = store.list_channel_identities(tenant_id=tenant_id, channel=channel, limit=5000) + bound_user_ids = {str(b.get("user_id") or "") for b in binds} + orphan_ids = [ + str(u.get("id") or "") + for u in users + if str(u.get("id") or "") not in bound_user_ids + and str(u.get("username") or "").strip().lower() != "administrator" + ] + deleted = 0 + if orphan_ids: + with store._connect() as conn: + for uid in orphan_ids: + cur = conn.execute( + "DELETE FROM app_user WHERE tenant_id = ? AND id = ?", + (tenant_id, uid), + ) + deleted += int(cur.rowcount or 0) + return { + "ok": True, + "deleted": deleted, + "users_total": len(users), + "bound_users": len([x for x in bound_user_ids if x]), + "orphan_users": len(orphan_ids), + } + + @router.get("/admin/api/bind-codes") + def api_bind_codes(tenant_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:user:read") + _require_tenant_scope(ctx, tenant_id) + rows = store.list_bind_codes(tenant_id=tenant_id, limit=200) + return {"ok": True, "codes": rows} + + @router.post("/admin/api/bind-codes/create") + def api_bind_codes_create( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:user:write") + tenant_id = str(payload.get("tenant_id") or "").strip() + _require_tenant_scope(ctx, tenant_id) + role = str(payload.get("role") or "member").strip() or "member" + if not tenant_id: + return {"ok": False, "error": "tenant_id is required"} + code = str(payload.get("code") or "").strip() or secrets.token_urlsafe(6) + row = store.create_bind_code(tenant_id=tenant_id, role=role, code=code) + return {"ok": True, "code": row} + + @router.post("/admin/api/bind-codes/delete") + def api_bind_codes_delete( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:user:write") + tenant_id = str(payload.get("tenant_id") or "").strip() + _require_tenant_scope(ctx, tenant_id) + code = str(payload.get("code") or "").strip() + if not tenant_id: + return {"ok": False, "error": "tenant_id is required"} + if not code: + return {"ok": False, "error": "code is required"} + with store._connect() as conn: + cur = conn.execute( + "DELETE FROM bind_code WHERE tenant_id = ? AND code = ?", + (tenant_id, code), + ) + deleted = int(cur.rowcount or 0) + return {"ok": True, "deleted": deleted} + + @router.get("/admin/api/wecom/config") + def api_wecom_config(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + return { + "ok": True, + "config": { + "wecom_mode": "bot_api", + "wecom_bot_id": str(store.get_setting("wecom_bot_id") or ""), + "wecom_bot_secret": str(store.get_secret("wecom_bot_secret") or ""), + "wecom_auto_bind_enabled": str(store.get_setting("wecom_auto_bind_enabled") or "1"), + "wecom_auto_bind_tenant_name": str(store.get_setting("wecom_auto_bind_tenant_name") or "Team"), + "wecom_auto_bind_role": str(store.get_setting("wecom_auto_bind_role") or "member"), + }, + } + + @router.get("/admin/api/wecom/health") + def api_wecom_health(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + svc = next((s for s in status_services() if str(s.name) == "channel:wecom"), None) + def _gs(k: str) -> str: + return str(store.get_setting(k) or "").strip() + recent_raw = _gs("wecom_recent_from_users") + try: + recent = json.loads(recent_raw) if recent_raw else [] + if not isinstance(recent, list): + recent = [] + except Exception: + recent = [] + return { + "ok": True, + "service": { + "name": "channel:wecom", + "running": bool(getattr(svc, "running", False)) if svc else False, + "pid": int(getattr(svc, "pid", 0) or 0) if svc else 0, + }, + "inbound": { + "last_msg_ts": _gs("wecom_last_msg_ts"), + "last_from_user": _gs("wecom_last_from_user"), + "recent_from_users": recent[:20], + "last_cmd": _gs("wecom_last_cmd"), + "last_parse_error": _gs("wecom_last_parse_error"), + }, + "outbound": { + "last_mode": _gs("wecom_last_outbound_mode"), + "last_error": _gs("wecom_last_outbound_error"), + "last_ack_req_id": _gs("wecom_last_ack_req_id"), + "last_ack_errcode": _gs("wecom_last_ack_errcode"), + "last_ack_errmsg": _gs("wecom_last_ack_errmsg"), + }, + "raw": { + "last_raw_from_user": _gs("wecom_last_raw_from_user"), + "last_raw_body": _gs("wecom_last_raw_body"), + "last_unknown_cmd_payload": _gs("wecom_last_unknown_cmd_payload"), + }, + } + + @router.post("/admin/api/wecom/config") + def api_wecom_config_save( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + store.set_setting("wecom_mode", "bot_api") + store.set_setting("wecom_bot_id", str(payload.get("wecom_bot_id") or "").strip()) + bot_secret = str(payload.get("wecom_bot_secret") or "").strip() + if bot_secret: + store.set_secret("wecom_bot_secret", bot_secret) + auto_enabled = str(payload.get("wecom_auto_bind_enabled") or "").strip().lower() + enabled_truthy = auto_enabled in ("1", "true", "yes", "on") + store.set_setting("wecom_auto_bind_enabled", "1" if enabled_truthy else "0") + store.set_setting( + "wecom_auto_bind_tenant_name", + str(payload.get("wecom_auto_bind_tenant_name") or "").strip() or "Team", + ) + store.set_setting( + "wecom_auto_bind_role", + str(payload.get("wecom_auto_bind_role") or "").strip() or "member", + ) + return api_wecom_config() + + @router.post("/admin/api/wecom/unbind") + def api_wecom_unbind(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + with store._connect() as conn: + cur_ident = conn.execute("DELETE FROM channel_identity WHERE channel = ?", ("wecom",)) + ident_deleted = int(cur_ident.rowcount or 0) + cur_sess = conn.execute("DELETE FROM channel_session WHERE channel = ?", ("wecom",)) + sess_deleted = int(cur_sess.rowcount or 0) + for k in _WECOM_CLEAR_KEYS: + store.delete_setting(k) + with store._connect() as conn: + conn.execute( + "DELETE FROM app_setting WHERE key LIKE 'wecom:bot_secret:%' OR key LIKE 'wecom:agent_secret:%'" + ) + cfg = api_wecom_config() + return { + "ok": True, + "deleted_channel_identity": ident_deleted, + "deleted_channel_session": sess_deleted, + "config": cfg.get("config", {}), + } + + @router.get("/admin/api/audit") + def api_audit( + session_id: str | None = Query(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + rows = store.list_agent_audit_logs(limit=200, session_id=session_id) + return {"ok": True, "audit": rows} + + @router.get("/admin/api/audit/session-health") + def api_audit_session_health( + session_id: str | None = Query(default=None), + limit: int = Query(default=80), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + rows = store.list_session_tool_health(session_id=session_id, limit=limit) + warn = [x for x in rows if str(x.get("status") or "") == "warn_no_tool_calls"] + return {"ok": True, "items": rows, "warn_count": len(warn)} + + @router.get("/admin/api/trace") + def api_trace( + session_id: str | None = Query(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + if not session_id: + return {"ok": True, "trace": []} + rows = store.list_trace_events(session_id=session_id, limit=300) + return {"ok": True, "trace": rows} + + @router.get("/admin/api/oclaw/tasks") + @router.get("/admin/api/openclaw/tasks") + def api_openclaw_tasks( + task_id: str | None = Query(default=None), + session_id: str | None = Query(default=None), + status: str | None = Query(default=None), + limit: int = Query(default=80), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + tenant_id = str(ctx.get("tenant_id") or "") + sid = str(session_id or "").strip() or None + st = str(status or "").strip() or None + if task_id: + task = store.openclaw_task_get(task_id=str(task_id), tenant_id=tenant_id) + return {"ok": True, "task": None if not task else task.__dict__} + rows = store.openclaw_task_list( + status=st, + session_id=sid, + tenant_id=tenant_id, + limit=max(1, min(int(limit or 80), 300)), + ) + by_status: dict[str, int] = {"queued": 0, "claimed": 0, "done": 0, "failed": 0} + for r in rows: + s = str(r.status or "") + by_status[s] = int(by_status.get(s, 0)) + 1 + return {"ok": True, "tasks": [r.__dict__ for r in rows], "counts": by_status} + + @router.get("/admin/api/oclaw/runs") + @router.get("/admin/api/openclaw/runs") + def api_openclaw_runs( + run_id: str | None = Query(default=None), + session_id: str | None = Query(default=None), + status: str | None = Query(default=None), + limit: int = Query(default=80), + include_attempts: int = Query(default=1), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + tenant_id = str(ctx.get("tenant_id") or "") + if run_id: + row = store.openclaw_run_get(run_id=str(run_id), tenant_id=tenant_id) + if not row: + return {"ok": True, "run": None} + out = dict(row.__dict__) + if int(include_attempts or 0): + out["attempts"] = store.openclaw_attempt_list(run_id=row.run_id, limit=50) + return {"ok": True, "run": out} + rows = store.openclaw_run_list( + tenant_id=tenant_id, + session_id=str(session_id or "").strip() or None, + status=str(status or "").strip() or None, + limit=max(1, min(int(limit or 80), 300)), + ) + by_status: dict[str, int] = {"running": 0, "success": 0, "failed": 0} + out_rows = [] + for r in rows: + s = str(r.status or "") + by_status[s] = int(by_status.get(s, 0)) + 1 + item = dict(r.__dict__) + if int(include_attempts or 0): + item["attempts"] = store.openclaw_attempt_list(run_id=r.run_id, limit=20) + out_rows.append(item) + configured_retryable = sorted(resolve_retryable_error_codes(store=store)) + return { + "ok": True, + "runs": out_rows, + "counts": by_status, + "retry_policy": { + "setting_key": "AIA_OPENCLAW_RETRYABLE_ERROR_CODES", + "effective_retryable_error_codes": configured_retryable, + "default_retryable_error_codes": list(DEFAULT_RETRYABLE_ERROR_CODES), + }, + } + + @router.get("/admin/api/replay/turn") + def api_replay_turn( + session_id: str, + trace_id: str, + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + """Return a best-effort replay bundle for a single turn (trace_id).""" + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + sid = str(session_id or "").strip() + tid = str(trace_id or "").strip() + if not sid or not tid: + return {"ok": False, "error": "session_id_and_trace_id_required"} + # Restrict non-administrator users to their own sessions only. + uname = str(ctx.get("username") or "").strip().lower() + if uname != "administrator": + # Must be owned by current user in current tenant. + owner = store.get_session_for_user( + session_id=sid, tenant_id=str(ctx.get("tenant_id") or ""), user_id=str(ctx.get("user_id") or "") + ) + if not owner: + raise HTTPException(status_code=403, detail="forbidden") + + trace_rows = store.list_trace_events_for_trace(session_id=sid, trace_id=tid, limit=800) + start_ts, end_ts = store.get_turn_time_window(session_id=sid, trace_id=tid) + msgs = store.list_messages_in_time_window(session_id=sid, start_ts=start_ts, end_ts=end_ts, limit=1200) + return { + "ok": True, + "session_id": sid, + "trace_id": tid, + "start_ts": start_ts, + "end_ts": end_ts, + "trace": trace_rows, + "messages": msgs, + } + + @router.get("/admin/api/plugins") + def api_plugins(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:user:write") + rows = store.list_tool_plugins() + return {"ok": True, "plugins": rows} + + @router.get("/admin/api/tool-policy") + def api_tool_policy(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + # OpenClaw takeover: legacy tool-policy switches are disconnected (kept in DB for later). + disabled = False + retry_mode = "first_round_only" + loop_state_machine = True + signature_budget = 2 + tmw_raw = str(store.get_setting("AIA_TURN_MAX_TOOL_WORKERS") or "").strip() + tmr_raw = str(store.get_setting("AIA_TURN_MAX_TOOL_ROUNDS") or "").strip() + tmc_raw = str(store.get_setting("AIA_TURN_MAX_CONTEXT_MESSAGES") or "").strip() + turn_max_tool_workers = max(1, min(int(tmw_raw), 32)) if tmw_raw.isdigit() else 8 + turn_max_tool_rounds = max(1, min(int(tmr_raw), 30)) if tmr_raw.isdigit() else 8 + turn_max_context_messages = max(10, min(int(tmc_raw), 400)) if tmc_raw.isdigit() else 80 + # OpenClaw takeover: legacy runner switches are disconnected (kept in DB for later). + turn_runner_impl = "openclaw" + # OpenClaw takeover: legacy manager decision mode is disconnected (kept in DB for later). + manager_decision_mode = "" + sse_raw = str(store.get_setting("AIA_SSE_QUEUE_MAXSIZE") or "").strip() + sse_queue_maxsize = max(200, min(int(sse_raw), 50_000)) if sse_raw.isdigit() else 2000 + tl_raw = str(store.get_setting("AIA_TOOL_LOG_MAX_CHARS") or "").strip() + tool_log_max_chars = max(20_000, min(int(tl_raw), 2_000_000)) if tl_raw.isdigit() else 200_000 + emcp_raw = str(store.get_setting("AIA_ENABLE_MCP_TOOLS") or "").strip().lower() + enable_mcp_tools = emcp_raw not in ("0", "false", "no", "off") + epl_raw = str(store.get_setting("AIA_ENABLE_PLUGIN_TOOLS") or "").strip().lower() + enable_plugin_tools = epl_raw in ("1", "true", "yes", "on") + skl_raw = str(store.get_setting("AIA_SKILL_RUNTIME_ENABLED") or "").strip().lower() + skill_runtime_enabled = skl_raw not in ("0", "false", "no", "off") + sai_raw = str(store.get_setting("AIA_SKILL_AUTO_INSTALL_ENABLED") or "").strip().lower() + skill_auto_install_enabled = sai_raw not in ("0", "false", "no", "off") + tmsg_raw = str(store.get_setting("AIA_TOOL_LLM_MESSAGE_MAX_CHARS") or "").strip() + if tmsg_raw.isdigit(): + _n = int(tmsg_raw) + tool_llm_message_max_chars = 0 if _n == 0 else max(4096, min(_n, 500_000)) + else: + tool_llm_message_max_chars = 0 + mcp_fs_raw = str(store.get_setting("AIA_MCP_FILESYSTEM_EXTRA_ROOTS") or "").strip() + mcp_env_allow_raw = str(store.get_setting("AIA_MCP_ENV_ALLOWLIST") or "").strip() + retry_codes_raw = str(store.get_setting("AIA_OPENCLAW_RETRYABLE_ERROR_CODES") or "").strip() + if not retry_codes_raw: + retry_codes_raw = ",".join(DEFAULT_RETRYABLE_ERROR_CODES) + retry_strict_raw = str(store.get_setting("AIA_OPENCLAW_RETRY_CODES_STRICT_MODE") or "").strip().lower() + retry_codes_strict_mode = retry_strict_raw in ("1", "true", "yes", "on") + wc_workers_raw = str(store.get_setting("AIA_WECOM_LONGCONN_WORKERS") or "").strip() + if not wc_workers_raw: + wc_workers_raw = str(store.get_setting("WECOM_LONGCONN_WORKERS") or "").strip() + wc_in_q_raw = str(store.get_setting("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip() + if not wc_in_q_raw: + wc_in_q_raw = str(store.get_setting("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip() + wecom_longconn_workers = max(1, min(int(wc_workers_raw), 8)) if wc_workers_raw.isdigit() else 2 + wecom_longconn_inbound_queue_maxsize = max(20, min(int(wc_in_q_raw), 5000)) if wc_in_q_raw.isdigit() else 200 + return { + "ok": True, + "disable_tool_confirm": disabled, + "enforced_retry_mode": retry_mode, + "tool_loop_state_machine": loop_state_machine, + "tool_signature_budget": signature_budget, + "turn_max_tool_workers": turn_max_tool_workers, + "turn_max_tool_rounds": turn_max_tool_rounds, + "turn_max_context_messages": turn_max_context_messages, + "turn_runner_impl": turn_runner_impl, + "manager_decision_mode": manager_decision_mode, + "sse_queue_maxsize": sse_queue_maxsize, + "tool_log_max_chars": tool_log_max_chars, + "enable_mcp_tools": enable_mcp_tools, + "enable_plugin_tools": enable_plugin_tools, + "skill_runtime_enabled": skill_runtime_enabled, + "skill_auto_install_enabled": skill_auto_install_enabled, + "tool_llm_message_max_chars": tool_llm_message_max_chars, + "mcp_filesystem_extra_roots": mcp_fs_raw, + "mcp_env_allowlist": mcp_env_allow_raw, + "openclaw_retryable_error_codes": retry_codes_raw, + "openclaw_retry_codes_strict_mode": retry_codes_strict_mode, + "wecom_longconn_workers": wecom_longconn_workers, + "wecom_longconn_inbound_queue_maxsize": wecom_longconn_inbound_queue_maxsize, + } + + @router.post("/admin/api/tool-policy") + def api_tool_policy_save( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + # OpenClaw takeover: legacy tool-policy switches are disconnected (kept in DB for later). + disable_confirm = False + retry_mode = "first_round_only" + sm = True + sig_budget_n = 2 + tmw = payload.get("turn_max_tool_workers", 8) + tmr = payload.get("turn_max_tool_rounds", 8) + tmc = payload.get("turn_max_context_messages", 80) + tri = "openclaw" + try: + tmw_n = max(1, min(int(tmw), 32)) + except Exception: + tmw_n = 8 + try: + tmr_n = max(1, min(int(tmr), 30)) + except Exception: + tmr_n = 8 + try: + tmc_n = max(10, min(int(tmc), 400)) + except Exception: + tmc_n = 80 + md = "" + sse_q = payload.get("sse_queue_maxsize", 2000) + tl_cap = payload.get("tool_log_max_chars", 200000) + try: + sse_q_n = max(200, min(int(sse_q), 50_000)) + except Exception: + sse_q_n = 2000 + try: + tl_cap_n = max(20_000, min(int(tl_cap), 2_000_000)) + except Exception: + tl_cap_n = 200_000 + emcp = bool(payload.get("enable_mcp_tools", True)) + epl = bool(payload.get("enable_plugin_tools", False)) + skl_rt = bool(payload.get("skill_runtime_enabled", True)) + skl_ai = bool(payload.get("skill_auto_install_enabled", True)) + tmsg = payload.get("tool_llm_message_max_chars", 0) + mcp_fs_roots = str(payload.get("mcp_filesystem_extra_roots") or "").strip() + mcp_env_allow = str(payload.get("mcp_env_allowlist") or "").strip() + retry_codes = str(payload.get("openclaw_retryable_error_codes") or "").strip() + retry_codes_strict_mode = bool(payload.get("openclaw_retry_codes_strict_mode", False)) + unknown_retry_codes: list[str] = [] + if retry_codes: + vals = [x.strip().lower() for x in retry_codes.split(",") if x and x.strip()] + allowed = set(ALL_ATTEMPT_ERROR_CODES) + known_vals: list[str] = [] + for x in vals: + if x in allowed: + known_vals.append(x) + else: + unknown_retry_codes.append(x) + # dedupe while preserving order + seen: set[str] = set() + kept = [] + for x in known_vals: + if x in seen: + continue + seen.add(x) + kept.append(x) + retry_codes = ",".join(kept) + else: + retry_codes = ",".join(DEFAULT_RETRYABLE_ERROR_CODES) + if retry_codes_strict_mode and unknown_retry_codes: + raise HTTPException( + status_code=400, + detail={ + "code": "invalid_retryable_error_codes", + "message": "Unknown oclaw retryable error codes", + "unknown_retryable_error_codes": unknown_retry_codes, + "allowed_retryable_error_codes": list(ALL_ATTEMPT_ERROR_CODES), + }, + ) + if not retry_codes: + retry_codes = ",".join(DEFAULT_RETRYABLE_ERROR_CODES) + wc_workers = payload.get("wecom_longconn_workers", 2) + wc_in_q = payload.get("wecom_longconn_inbound_queue_maxsize", 200) + try: + _tn = int(tmsg) + tmsg_n = 0 if _tn == 0 else max(4096, min(_tn, 500_000)) + except Exception: + tmsg_n = 0 + try: + wc_workers_n = max(1, min(int(wc_workers), 8)) + except Exception: + wc_workers_n = 2 + try: + wc_in_q_n = max(20, min(int(wc_in_q), 5000)) + except Exception: + wc_in_q_n = 200 + # Canonical professional prefix. + # Legacy-only (disconnected): do not write the following settings: + # - AIA_DISABLE_TOOL_CONFIRM + # - AIA_TOOL_ENFORCED_RETRY_MODE + # - AIA_TOOL_LOOP_STATE_MACHINE + # - AIA_TOOL_SIGNATURE_BUDGET + store.set_setting("AIA_TURN_MAX_TOOL_WORKERS", str(tmw_n)) + store.set_setting("AIA_TURN_MAX_TOOL_ROUNDS", str(tmr_n)) + store.set_setting("AIA_TURN_MAX_CONTEXT_MESSAGES", str(tmc_n)) + # Legacy-only (disconnected): do not write AIA_MANAGER_DECISION_MODE here. + store.set_setting("AIA_SSE_QUEUE_MAXSIZE", str(sse_q_n)) + store.set_setting("AIA_TOOL_LOG_MAX_CHARS", str(tl_cap_n)) + store.set_setting("AIA_ENABLE_MCP_TOOLS", "1" if emcp else "0") + store.set_setting("AIA_ENABLE_PLUGIN_TOOLS", "1" if epl else "0") + store.set_setting("AIA_SKILL_RUNTIME_ENABLED", "1" if skl_rt else "0") + store.set_setting("AIA_SKILL_AUTO_INSTALL_ENABLED", "1" if skl_ai else "0") + store.set_setting("AIA_TOOL_LLM_MESSAGE_MAX_CHARS", str(tmsg_n)) + store.set_setting("AIA_MCP_FILESYSTEM_EXTRA_ROOTS", mcp_fs_roots) + store.set_setting("AIA_MCP_ENV_ALLOWLIST", mcp_env_allow) + store.set_setting("AIA_OPENCLAW_RETRYABLE_ERROR_CODES", retry_codes) + store.set_setting("AIA_OPENCLAW_RETRY_CODES_STRICT_MODE", "1" if retry_codes_strict_mode else "0") + store.set_setting("AIA_WECOM_LONGCONN_WORKERS", str(wc_workers_n)) + store.set_setting("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE", str(wc_in_q_n)) + store.set_setting("WECOM_LONGCONN_WORKERS", str(wc_workers_n)) + store.set_setting("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE", str(wc_in_q_n)) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="tool_policy_update", + target_type="tool_policy", + target_id="AIA_TURN_MAX_TOOL_WORKERS,AIA_TURN_MAX_TOOL_ROUNDS,AIA_TURN_MAX_CONTEXT_MESSAGES,AIA_SSE_QUEUE_MAXSIZE,AIA_TOOL_LOG_MAX_CHARS,AIA_ENABLE_MCP_TOOLS,AIA_ENABLE_PLUGIN_TOOLS,AIA_SKILL_RUNTIME_ENABLED,AIA_SKILL_AUTO_INSTALL_ENABLED,AIA_TOOL_LLM_MESSAGE_MAX_CHARS,AIA_MCP_FILESYSTEM_EXTRA_ROOTS,AIA_MCP_ENV_ALLOWLIST,AIA_OPENCLAW_RETRYABLE_ERROR_CODES,AIA_OPENCLAW_RETRY_CODES_STRICT_MODE,AIA_WECOM_LONGCONN_WORKERS,AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE", + status="ok", + detail={ + "disable_tool_confirm": disable_confirm, + "enforced_retry_mode": retry_mode, + "tool_loop_state_machine": sm, + "tool_signature_budget": sig_budget_n, + "turn_max_tool_workers": tmw_n, + "turn_max_tool_rounds": tmr_n, + "turn_max_context_messages": tmc_n, + "turn_runner_impl": tri, + "manager_decision_mode": md, + "sse_queue_maxsize": sse_q_n, + "tool_log_max_chars": tl_cap_n, + "enable_mcp_tools": emcp, + "enable_plugin_tools": epl, + "skill_runtime_enabled": skl_rt, + "skill_auto_install_enabled": skl_ai, + "tool_llm_message_max_chars": tmsg_n, + "mcp_filesystem_extra_roots": mcp_fs_roots, + "mcp_env_allowlist": mcp_env_allow, + "openclaw_retryable_error_codes": retry_codes, + "openclaw_retry_codes_strict_mode": retry_codes_strict_mode, + "unknown_retryable_error_codes": unknown_retry_codes, + "wecom_longconn_workers": wc_workers_n, + "wecom_longconn_inbound_queue_maxsize": wc_in_q_n, + }, + ) + return { + "ok": True, + "disable_tool_confirm": disable_confirm, + "enforced_retry_mode": retry_mode, + "tool_loop_state_machine": sm, + "tool_signature_budget": sig_budget_n, + "turn_max_tool_workers": tmw_n, + "turn_max_tool_rounds": tmr_n, + "turn_max_context_messages": tmc_n, + "turn_runner_impl": tri, + "manager_decision_mode": md, + "sse_queue_maxsize": sse_q_n, + "tool_log_max_chars": tl_cap_n, + "enable_mcp_tools": emcp, + "enable_plugin_tools": epl, + "skill_runtime_enabled": skl_rt, + "skill_auto_install_enabled": skl_ai, + "tool_llm_message_max_chars": tmsg_n, + "mcp_filesystem_extra_roots": mcp_fs_roots, + "mcp_env_allowlist": mcp_env_allow, + "openclaw_retryable_error_codes": retry_codes, + "openclaw_retry_codes_strict_mode": retry_codes_strict_mode, + "unknown_retryable_error_codes": unknown_retry_codes, + "wecom_longconn_workers": wc_workers_n, + "wecom_longconn_inbound_queue_maxsize": wc_in_q_n, + } + + @router.get("/admin/api/mcp/servers") + def api_mcp_servers(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + rows = McpRegistry(store).list_servers(enabled_only=False) + health = {str(x.get("server_id") or ""): x for x in store.list_mcp_server_health()} + for r in rows: + sid = str(r.get("server_id") or "") + r["health"] = health.get(sid) or {} + r["tools"] = store.list_mcp_server_tools(server_id=sid) + return {"ok": True, "servers": rows} + + @router.get("/admin/api/mcp/export") + def api_mcp_export(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + return { + "ok": True, + "local_path": str(mcp_migrated_json_path()), + "document": build_mcp_install_export_document(store), + } + + @router.get("/admin/api/mcp/failures") + def api_mcp_failures( + limit: int = Query(default=20), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + return {"ok": True, "items": store.list_mcp_install_failure_summary(limit=limit)} + + @router.get("/admin/api/mcp/usage") + def api_mcp_usage( + server_id: str | None = Query(default=None), + limit: int = Query(default=200), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + return { + "ok": True, + "summary": store.list_mcp_tool_usage_summary(limit=limit), + "calls": store.list_mcp_tool_call_logs(server_id=server_id, limit=limit), + } + + @router.get("/admin/api/mcp/tool-wire") + def api_mcp_tool_wire_get( + role: str | None = Query(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + from oclaw.platform.llm.tool_wire_policy import build_tool_wire_snapshot + + return build_tool_wire_snapshot(store, role=str(role or "").strip().lower() or None) + + @router.post("/admin/api/mcp/tool-wire/config") + def api_mcp_tool_wire_config_save( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + from oclaw.platform.llm.tool_wire_policy import SETTINGS_KEY_ADMIN_CONFIG, load_merged_admin_config + + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + raw = store.get_setting(SETTINGS_KEY_ADMIN_CONFIG) + cur: dict[str, Any] = {} + if raw: + try: + cur = json.loads(raw) if isinstance(raw, str) else {} + except Exception: + cur = {} + if not isinstance(cur, dict): + cur = {} + if "wire_policy" in payload: + wp = str(payload.get("wire_policy") or "").strip().lower() + if wp in ("inherit", "always", "never"): + cur["wire_policy"] = wp + if "top_n_full" in payload: + cur["top_n_full"] = max(3, min(80, int(payload.get("top_n_full") or 20))) + if "stale_hours" in payload: + cur["stale_hours"] = max(0.25, min(720.0, float(payload.get("stale_hours") or 3))) + if "penalty_minutes" in payload: + cur["penalty_minutes"] = max(1.0, min(24 * 60, float(payload.get("penalty_minutes") or 30))) + if "medium_rank_start" in payload: + cur["medium_rank_start"] = int(payload.get("medium_rank_start") or 21) + if "medium_rank_end" in payload: + cur["medium_rank_end"] = int(payload.get("medium_rank_end") or 50) + if "medium_desc_chars" in payload: + cur["medium_desc_chars"] = max(80, min(4000, int(payload.get("medium_desc_chars") or 520))) + if "minimal_desc_cap" in payload: + cur["minimal_desc_cap"] = max(0, min(2000, int(payload.get("minimal_desc_cap") or 80))) + if "penalty_disable" in payload: + cur["penalty_disable"] = bool(payload.get("penalty_disable")) + store.set_setting(SETTINGS_KEY_ADMIN_CONFIG, json.dumps(cur, ensure_ascii=False)) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_tool_wire_config_update", + target_type="app_setting", + target_id=SETTINGS_KEY_ADMIN_CONFIG, + status="ok", + detail={"keys": list(cur.keys())}, + ) + return {"ok": True, "config": load_merged_admin_config(store)} + + @router.post("/admin/api/mcp/tool-wire/role-mode") + def api_mcp_tool_wire_role_mode_save( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + from oclaw.platform.llm.tool_wire_policy import SETTINGS_KEY_ROLE_MODE_BY_ROLE + + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + role = str(payload.get("role") or "").strip().lower() + if not role: + raise HTTPException(status_code=400, detail="role_required") + valid_roles = set(_ordered_mcp_roles()) + if role not in valid_roles: + raise HTTPException(status_code=400, detail="invalid_role") + mode = str(payload.get("mode") or "").strip().lower() + if mode not in {"restricted", "unrestricted", "forbidden"}: + raise HTTPException(status_code=400, detail="invalid_mode") + raw = str(store.get_setting(SETTINGS_KEY_ROLE_MODE_BY_ROLE) or "").strip() or "{}" + try: + obj = json.loads(raw) + except Exception: + obj = {} + if not isinstance(obj, dict): + obj = {} + obj[role] = mode + store.set_setting(SETTINGS_KEY_ROLE_MODE_BY_ROLE, json.dumps(obj, ensure_ascii=False)) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_tool_wire_role_mode_update", + target_type="app_setting", + target_id=f"{SETTINGS_KEY_ROLE_MODE_BY_ROLE}:{role}", + status="ok", + detail={"role": role, "mode": mode}, + ) + return {"ok": True, "role": role, "mode": mode} + + @router.post("/admin/api/mcp/tool-wire/penalty/reset") + def api_mcp_tool_wire_penalty_reset( + role: str | None = Query(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + from oclaw.platform.llm.tool_wire_policy import SETTINGS_KEY_PENALTY_STATE, SETTINGS_KEY_PENALTY_STATE_BY_ROLE + + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + r = str(role or "").strip().lower() + if not r: + store.set_setting(SETTINGS_KEY_PENALTY_STATE, "{}") + target_id = SETTINGS_KEY_PENALTY_STATE + else: + # Reset only one role's penalty bucket. + raw = str(store.get_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE) or "").strip() or "{}" + try: + obj = json.loads(raw) + except Exception: + obj = {} + if not isinstance(obj, dict): + obj = {} + obj[r] = {} + store.set_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE, json.dumps(obj, ensure_ascii=False)) + target_id = f"{SETTINGS_KEY_PENALTY_STATE_BY_ROLE}:{r}" + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_tool_wire_penalty_reset", + target_type="app_setting", + target_id=target_id, + status="ok", + detail={"reset": True, "role": r}, + ) + return {"ok": True, "penalty_state": {}, "role": r} + + @router.post("/admin/api/mcp/tool-wire/policies") + def api_mcp_tool_wire_policies_save( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + from oclaw.platform.llm.tool_wire_policy import ( + SETTINGS_KEY_TOOL_POLICIES, + SETTINGS_KEY_TOOL_POLICIES_BY_ROLE, + load_tool_policies_dict_for_role, + ) + + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + role = str(payload.get("role") or "").strip().lower() + pol_in = payload.get("policies") + if not isinstance(pol_in, dict): + raise HTTPException(status_code=400, detail="policies must be an object") + merged = dict(load_tool_policies_dict_for_role(store, role=role or None)) + + def _coerce_lv(v: Any) -> int | None: + try: + n = int(v) + except (TypeError, ValueError): + return None + if n == 9999: + return 9999 + if n <= 0: + return 0 + return min(n, 9998) + + clears = payload.get("clears") + if isinstance(clears, list): + for w in clears: + wn = str(w or "").strip() + if wn.startswith("mcp__"): + merged.pop(wn, None) + for k, v in pol_in.items(): + wn = str(k or "").strip() + if not wn.startswith("mcp__"): + continue + co = _coerce_lv(v) + if co is None: + continue + merged[wn] = co + if not role: + store.set_setting(SETTINGS_KEY_TOOL_POLICIES, json.dumps(merged, ensure_ascii=False)) + target_id = SETTINGS_KEY_TOOL_POLICIES + else: + raw = str(store.get_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE) or "").strip() or "{}" + try: + outer = json.loads(raw) + except Exception: + outer = {} + if not isinstance(outer, dict): + outer = {} + outer[role] = merged + store.set_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE, json.dumps(outer, ensure_ascii=False)) + target_id = f"{SETTINGS_KEY_TOOL_POLICIES_BY_ROLE}:{role}" + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_tool_wire_policies_update", + target_type="app_setting", + target_id=target_id, + status="ok", + detail={"count": len(merged), "role": role}, + ) + return {"ok": True, "policies": merged, "role": role} + + @router.post("/admin/api/mcp/tool-wire/policies/batch") + def api_mcp_tool_wire_policies_batch( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + from oclaw.platform.llm.tool_wire_policy import ( + SETTINGS_KEY_TOOL_POLICIES, + SETTINGS_KEY_TOOL_POLICIES_BY_ROLE, + load_tool_policies_dict_for_role, + ) + + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + try: + lv = int(payload.get("level")) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="level must be int") + if lv != 9999 and (lv < 0 or lv > 9998): + raise HTTPException(status_code=400, detail="invalid level") + names = payload.get("wire_names") + if not isinstance(names, list) or not names: + raise HTTPException(status_code=400, detail="wire_names must be non-empty array") + role = str(payload.get("role") or "").strip().lower() + if role: + valid_roles = set(_ordered_mcp_roles()) + if role not in valid_roles: + raise HTTPException(status_code=400, detail="invalid_role") + merged = dict(load_tool_policies_dict_for_role(store, role=role or None)) + for wn in names: + s = str(wn or "").strip() + if not s.startswith("mcp__"): + continue + if lv == 9999: + merged[s] = 9999 + elif lv <= 0: + merged[s] = 0 + else: + merged[s] = min(lv, 9998) + if not role: + store.set_setting(SETTINGS_KEY_TOOL_POLICIES, json.dumps(merged, ensure_ascii=False)) + target_id = SETTINGS_KEY_TOOL_POLICIES + else: + raw = str(store.get_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE) or "").strip() or "{}" + try: + outer = json.loads(raw) + except Exception: + outer = {} + if not isinstance(outer, dict): + outer = {} + outer[role] = merged + store.set_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE, json.dumps(outer, ensure_ascii=False)) + target_id = f"{SETTINGS_KEY_TOOL_POLICIES_BY_ROLE}:{role}" + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_tool_wire_policies_batch", + target_type="app_setting", + target_id=target_id, + status="ok", + detail={"level": lv, "n": len(names), "role": role}, + ) + return {"ok": True, "policies": merged, "role": role} + + @router.get("/admin/api/mcp/market/search") + def api_mcp_market_search( + q: str = Query(default=""), + per_source_limit: int = Query(default=6), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + query = str(q or "").strip() + if not query: + return {"ok": True, "items": []} + items = search_mcp_market(query, per_source_limit=per_source_limit) + return {"ok": True, "items": items} + + @router.get("/admin/api/mcp/market/trending") + def api_mcp_market_trending( + per_source_limit: int = Query(default=5), + refresh: int = Query(default=0), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + items = trending_mcp_market(force_refresh=bool(int(refresh or 0)), per_source_limit=per_source_limit) + return {"ok": True, "items": items} + + @router.get("/admin/api/mcp/dependencies") + def api_mcp_dependencies(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + return {"ok": True, "items": detect_local_dependencies()} + + @router.get("/admin/api/mcp/binding") + def api_mcp_binding(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + available = _ordered_mcp_roles() + servers = store.list_mcp_servers(enabled_only=False) + normalized_servers = [ + { + "server_id": str(x.get("server_id") or ""), + "source_type": str(x.get("source_type") or ""), + "enabled": bool(x.get("enabled")), + } + for x in servers + if str(x.get("server_id") or "").strip() + ] + mapping = _normalize_mcp_specialist_binding( + available_specialists=available, + servers=normalized_servers, + mapping=_load_mcp_specialist_binding(store), + ) + return {"ok": True, "available_specialists": available, "servers": normalized_servers, "mapping": mapping} + + @router.post("/admin/api/mcp/binding") + def api_mcp_binding_save( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + available = _ordered_mcp_roles() + servers = store.list_mcp_servers(enabled_only=False) + normalized_servers = [ + { + "server_id": str(x.get("server_id") or ""), + "source_type": str(x.get("source_type") or ""), + "enabled": bool(x.get("enabled")), + } + for x in servers + if str(x.get("server_id") or "").strip() + ] + mapping_raw = payload.get("mapping") if isinstance(payload.get("mapping"), dict) else {} + mapping = _normalize_mcp_specialist_binding( + available_specialists=available, + servers=normalized_servers, + mapping=mapping_raw, + ) + store.set_setting("mcp_specialist_server_binding", json.dumps(mapping, ensure_ascii=False)) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_binding_update", + target_type="mcp_config", + target_id="specialist_server_binding", + status="ok", + detail={"mapping": mapping}, + ) + return {"ok": True, "available_specialists": available, "servers": normalized_servers, "mapping": mapping} + + @router.get("/admin/api/mcp/specialists") + def api_mcp_specialists(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + available = _ordered_mcp_roles() + raw = str(store.get_setting("mcp_allowed_specialists") or "").strip() or "generalist,manager" + allowed = [x.strip().lower() for x in raw.split(",") if x.strip()] + allowed_set = set(allowed) + ordered = [x for x in available if x in allowed_set] + if not ordered: + ordered = ["generalist"] if "generalist" in available else (available[:1] if available else []) + return {"ok": True, "available_specialists": available, "allowed_specialists": ordered} + + @router.post("/admin/api/mcp/specialists") + def api_mcp_specialists_save( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + available = _ordered_mcp_roles() + available_set = set(available) + raw = payload.get("allowed_specialists") + items = raw if isinstance(raw, list) else [] + allowset = {str(x).strip().lower() for x in items if str(x).strip().lower() in available_set} + if not allowset: + defaults = {x for x in ("generalist", "manager") if x in available_set} + allowset = defaults if defaults else (set(available[:1]) if available else set()) + ordered = [x for x in available if x in allowset] + store.set_setting("mcp_allowed_specialists", ",".join(ordered)) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_specialists_update", + target_type="mcp_config", + target_id="allowed_specialists", + status="ok", + detail={"allowed_specialists": ordered}, + ) + return {"ok": True, "available_specialists": available, "allowed_specialists": ordered} + + @router.post("/admin/api/mcp/install") + def api_mcp_install( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + source_type = str(payload.get("source_type") or "").strip().lower() + source_ref = str(payload.get("source_ref") or "").strip() + if source_type not in {"github", "npm", "pypi"} or not source_ref: + return {"ok": False, "error": "invalid_source"} + server_id = _safe_server_id(str(payload.get("server_id") or source_ref)) + manifest = McpServerManifest( + server_id=server_id, + source_type=source_type, + source_ref=source_ref, + version=str(payload.get("version") or "").strip(), + entry_command=str(payload.get("entry_command") or "").strip(), + entry_args=_expand_mcp_entry_args(payload.get("entry_args") if isinstance(payload.get("entry_args"), list) else []), + env_schema=payload.get("env_schema") if isinstance(payload.get("env_schema"), dict) else {}, + permissions=[str(x) for x in (payload.get("required_permissions") or [])], + risk_level=str(payload.get("risk_level") or "high"), + enabled=bool(payload.get("enabled", True)), + timeout_s=float(payload.get("timeout_s") or 30.0), + ) + dry_run = bool(payload.get("dry_run", False)) + install = install_mcp_server(manifest, dry_run=dry_run) + store.upsert_mcp_server( + server_id=manifest.server_id, + source_type=manifest.source_type, + source_ref=manifest.source_ref, + version=manifest.version, + entry_command=manifest.entry_command, + entry_args=manifest.entry_args, + env_schema=manifest.env_schema, + required_permissions=manifest.permissions, + risk_level=manifest.risk_level, + timeout_s=manifest.timeout_s, + enabled=manifest.enabled if install.ok else False, + ) + store.add_mcp_installation_log( + server_id=manifest.server_id, + status="ok" if install.ok else "error", + error_code=install.error_code, + detail={"error": install.error, **(install.details or {})}, + install_command=install.install_command, + ) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_install", + target_type="mcp_server", + target_id=manifest.server_id, + status="ok" if install.ok else "error", + detail={"source_type": source_type, "source_ref": source_ref, "error_code": install.error_code}, + ) + if not install.ok: + return {"ok": False, "server_id": manifest.server_id, "error_code": install.error_code, "error": install.error} + persist_mcp_migrated_file(store) + return { + "ok": True, + "server_id": manifest.server_id, + "install_command": install.install_command, + "mcp_migrated_saved": str(mcp_migrated_json_path()), + } + + @router.post("/admin/api/mcp/reinstall") + def api_mcp_reinstall( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + server_id = str(payload.get("server_id") or "").strip() + if not server_id: + return {"ok": False, "error": "server_id_required"} + rows = store.list_mcp_servers(enabled_only=False) + row = next((x for x in rows if str(x.get("server_id") or "") == server_id), None) + if not row: + return {"ok": False, "error": "server_not_found"} + manifest = McpServerManifest( + server_id=str(row.get("server_id") or ""), + source_type=str(row.get("source_type") or ""), + source_ref=str(row.get("source_ref") or ""), + version=str(row.get("version") or ""), + entry_command=str(row.get("entry_command") or ""), + entry_args=[str(x) for x in (row.get("entry_args") or []) if str(x).strip()], + env_schema=row.get("env_schema") if isinstance(row.get("env_schema"), dict) else {}, + permissions=[str(x) for x in (row.get("required_permissions") or [])], + risk_level=str(row.get("risk_level") or "high"), + enabled=bool(row.get("enabled")), + timeout_s=float(row.get("timeout_s") or 30.0), + ) + dry_run = bool(payload.get("dry_run", False)) + install = install_mcp_server(manifest, dry_run=dry_run) + store.upsert_mcp_server( + server_id=manifest.server_id, + source_type=manifest.source_type, + source_ref=manifest.source_ref, + version=manifest.version, + entry_command=manifest.entry_command, + entry_args=manifest.entry_args, + env_schema=manifest.env_schema, + required_permissions=manifest.permissions, + risk_level=manifest.risk_level, + timeout_s=manifest.timeout_s, + enabled=manifest.enabled if install.ok else False, + ) + store.add_mcp_installation_log( + server_id=manifest.server_id, + status="ok" if install.ok else "error", + error_code=install.error_code, + detail={"error": install.error, **(install.details or {}), "reinstall": True}, + install_command=install.install_command, + ) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_reinstall", + target_type="mcp_server", + target_id=manifest.server_id, + status="ok" if install.ok else "error", + detail={"error_code": install.error_code}, + ) + if not install.ok: + return {"ok": False, "server_id": manifest.server_id, "error_code": install.error_code, "error": install.error} + persist_mcp_migrated_file(store) + return { + "ok": True, + "server_id": manifest.server_id, + "install_command": install.install_command, + "mcp_migrated_saved": str(mcp_migrated_json_path()), + } + + @router.post("/admin/api/mcp/uninstall") + def api_mcp_uninstall( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + server_id = str(payload.get("server_id") or "").strip() + remove_record = bool(payload.get("remove_record", True)) + if not server_id: + return {"ok": False, "error": "server_id_required"} + rows = store.list_mcp_servers(enabled_only=False) + row = next((x for x in rows if str(x.get("server_id") or "") == server_id), None) + if not row: + return {"ok": False, "error": "server_not_found"} + manifest = McpServerManifest( + server_id=str(row.get("server_id") or ""), + source_type=str(row.get("source_type") or ""), + source_ref=str(row.get("source_ref") or ""), + version=str(row.get("version") or ""), + entry_command=str(row.get("entry_command") or ""), + entry_args=[str(x) for x in (row.get("entry_args") or []) if str(x).strip()], + env_schema=row.get("env_schema") if isinstance(row.get("env_schema"), dict) else {}, + permissions=[str(x) for x in (row.get("required_permissions") or [])], + risk_level=str(row.get("risk_level") or "high"), + enabled=False, + timeout_s=float(row.get("timeout_s") or 30.0), + ) + dry_run = bool(payload.get("dry_run", False)) + res = uninstall_mcp_server(manifest, dry_run=dry_run) + store.add_mcp_installation_log( + server_id=manifest.server_id, + status="ok" if res.ok else "error", + error_code=res.error_code, + detail={"error": res.error, **(res.details or {}), "uninstall": True}, + install_command=res.install_command, + ) + deleted = {"registry": 0, "tools": 0, "health": 0, "install_logs": 0} + if res.ok and remove_record and not dry_run: + deleted = store.delete_mcp_server(server_id=manifest.server_id) + mapping = _load_mcp_specialist_binding(store) + for sp, sids in list(mapping.items()): + mapping[sp] = [sid for sid in sids if sid != manifest.server_id] + store.set_setting("mcp_specialist_server_binding", json.dumps(mapping, ensure_ascii=False)) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_uninstall", + target_type="mcp_server", + target_id=manifest.server_id, + status="ok" if res.ok else "error", + detail={"error_code": res.error_code, "remove_record": remove_record, "deleted": deleted}, + ) + if not res.ok: + return {"ok": False, "server_id": manifest.server_id, "error_code": res.error_code, "error": res.error} + persist_mcp_migrated_file(store) + return {"ok": True, "server_id": manifest.server_id, "uninstall_command": res.install_command, "deleted": deleted} + + @router.post("/admin/api/mcp/delete") + def api_mcp_delete( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + server_id = str(payload.get("server_id") or "").strip() + if not server_id: + return {"ok": False, "error": "server_id_required"} + deleted = store.delete_mcp_server(server_id=server_id) + mapping = _load_mcp_specialist_binding(store) + for sp, sids in list(mapping.items()): + mapping[sp] = [sid for sid in sids if sid != server_id] + store.set_setting("mcp_specialist_server_binding", json.dumps(mapping, ensure_ascii=False)) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_delete", + target_type="mcp_server", + target_id=server_id, + status="ok" if any(int(v or 0) > 0 for v in deleted.values()) else "miss", + detail={"deleted": deleted}, + ) + if any(int(v or 0) > 0 for v in deleted.values()): + persist_mcp_migrated_file(store) + return {"ok": True, "server_id": server_id, "deleted": deleted} + + @router.post("/admin/api/mcp/preflight") + def api_mcp_preflight( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + source_type = str(payload.get("source_type") or "").strip().lower() + source_ref = str(payload.get("source_ref") or "").strip() + if source_type not in {"github", "npm", "pypi"} or not source_ref: + return {"ok": False, "error_code": "mcp_invalid_source", "error": "invalid_source"} + manifest = McpServerManifest( + server_id=_safe_server_id(str(payload.get("server_id") or source_ref)), + source_type=source_type, + source_ref=source_ref, + version=str(payload.get("version") or "").strip(), + entry_command=str(payload.get("entry_command") or "").strip(), + entry_args=_expand_mcp_entry_args(payload.get("entry_args") if isinstance(payload.get("entry_args"), list) else []), + env_schema=payload.get("env_schema") if isinstance(payload.get("env_schema"), dict) else {}, + permissions=[str(x) for x in (payload.get("required_permissions") or [])], + risk_level=str(payload.get("risk_level") or "high"), + enabled=bool(payload.get("enabled", True)), + timeout_s=float(payload.get("timeout_s") or 30.0), + ) + res = preflight_mcp_server(manifest) + return {"ok": bool(res.get("ok")), **res} + + @router.post("/admin/api/mcp/toggle") + def api_mcp_toggle( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + server_id = str(payload.get("server_id") or "").strip() + enabled = bool(payload.get("enabled")) + if not server_id: + return {"ok": False, "error": "server_id_required"} + n = store.set_mcp_server_enabled(server_id=server_id, enabled=enabled) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_toggle", + target_type="mcp_server", + target_id=server_id, + status="ok" if n > 0 else "miss", + detail={"enabled": enabled}, + ) + return {"ok": True, "updated": n} + + @router.post("/admin/api/mcp/healthcheck") + def api_mcp_healthcheck( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + apply_gateway_mcp_env_to_os() + server_id = str(payload.get("server_id") or "").strip() + if not server_id: + return {"ok": False, "error": "server_id_required"} + rows = store.list_mcp_servers(enabled_only=False) + row = next((x for x in rows if str(x.get("server_id") or "") == server_id), None) + if not row: + return {"ok": False, "error": "server_not_found"} + cmd = str(row.get("entry_command") or "").strip() + args = [str(x) for x in (row.get("entry_args") or []) if str(x).strip()] + if not cmd: + return {"ok": False, "error_code": "mcp_entry_missing", "error": "entry_command_missing"} + rt = McpProcessRuntime( + build_mcp_process_command(cmd, args, store=store), + timeout_s=float(row.get("timeout_s") or 30.0), + ) + try: + response = rt.health() + ok = bool(response.get("ok")) + store.set_mcp_server_health(server_id=server_id, status="ok" if ok else "error", detail=response) + if ok: + return {"ok": True, "response": response} + return { + "ok": False, + "error_code": str(response.get("error_code") or "mcp_healthcheck_failed"), + "error": str(response.get("error") or "healthcheck_failed"), + "response": response, + } + except Exception as exc: + store.set_mcp_server_health( + server_id=server_id, + status="error", + detail={"error_code": "mcp_healthcheck_failed", "error": f"{type(exc).__name__}: {exc}"}, + ) + return {"ok": False, "error_code": "mcp_healthcheck_failed", "error": f"{type(exc).__name__}: {exc}"} + finally: + rt.stop() + + @router.post("/admin/api/mcp/tools/sync") + def api_mcp_tools_sync( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + apply_gateway_mcp_env_to_os() + server_id = str(payload.get("server_id") or "").strip() + if not server_id: + return {"ok": False, "error": "server_id_required"} + rows = store.list_mcp_servers(enabled_only=False) + row = next((x for x in rows if str(x.get("server_id") or "") == server_id), None) + if not row: + return {"ok": False, "error": "server_not_found"} + cmd = str(row.get("entry_command") or "").strip() + args = [str(x) for x in (row.get("entry_args") or []) if str(x).strip()] + if not cmd: + return {"ok": False, "error_code": "mcp_entry_missing", "error": "entry_command_missing"} + rt = McpProcessRuntime( + build_mcp_process_command(cmd, args, store=store), + timeout_s=float(row.get("timeout_s") or 30.0), + ) + try: + response = rt.tools_list() + items = response.get("tools") if isinstance(response, dict) else None + if not isinstance(items, list): + return {"ok": False, "error_code": "mcp_tools_list_invalid", "error": "tools_list_invalid"} + norm = [] + for it in items: + if not isinstance(it, dict): + continue + tool_name = str(it.get("tool_name") or it.get("name") or "").strip() + if not tool_name: + continue + norm.append( + { + "tool_name": tool_name, + "description": str(it.get("description") or ""), + "parameters": it.get("parameters") if isinstance(it.get("parameters"), dict) else {}, + } + ) + store.replace_mcp_server_tools(server_id=server_id, tools=norm) + store.set_mcp_server_health(server_id=server_id, status="ok", detail={"synced_tools": len(norm)}) + return {"ok": True, "server_id": server_id, "tools": norm} + finally: + rt.stop() + + @router.post("/admin/api/mcp/check-all") + def api_mcp_check_all( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + apply_gateway_mcp_env_to_os() + enabled_only = bool(payload.get("enabled_only", True)) + rows = store.list_mcp_servers(enabled_only=enabled_only) + out: list[dict[str, Any]] = [] + for row in rows: + item = _mcp_health_and_sync_one(store, row) + if item is not None: + out.append(item) + ok_count = len([x for x in out if bool(x.get("ok"))]) + return {"ok": True, "total": len(out), "ok_count": ok_count, "error_count": len(out) - ok_count, "items": out} + + @router.post("/admin/api/mcp/repair-weak") + def api_mcp_repair_weak( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + """ + For enabled MCP rows whose persisted health is not ``ok`` and/or cached tool list is empty, + run the same health + tools/list + replace flow as ``check-all`` (only those servers). + """ + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + apply_gateway_mcp_env_to_os() + enabled_only = bool(payload.get("enabled_only", True)) + rows = store.list_mcp_servers(enabled_only=enabled_only) + health_by_sid = {str(x.get("server_id") or ""): x for x in store.list_mcp_server_health()} + targets: list[dict[str, Any]] = [] + skipped_healthy = 0 + for row in rows: + sid = str(row.get("server_id") or "").strip() + if not sid: + continue + h = health_by_sid.get(sid) or {} + st = str(h.get("status") or "").strip().lower() + n_tools = len(store.list_mcp_server_tools(server_id=sid)) + if st == "ok" and n_tools > 0: + skipped_healthy += 1 + continue + targets.append(row) + out: list[dict[str, Any]] = [] + for row in targets: + item = _mcp_health_and_sync_one(store, row) + if item is not None: + out.append(item) + ok_count = len([x for x in out if bool(x.get("ok"))]) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_repair_weak", + target_type="mcp_server", + target_id="batch", + status="ok", + detail={ + "selected": len(targets), + "skipped_healthy": skipped_healthy, + "ok_count": ok_count, + "error_count": len(out) - ok_count, + }, + ) + return { + "ok": True, + "selected": len(targets), + "skipped_healthy": skipped_healthy, + "total": len(out), + "ok_count": ok_count, + "error_count": len(out) - ok_count, + "items": out, + } + + @router.post("/admin/api/mcp/e2e-check") + def api_mcp_e2e_check( + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + apply_gateway_mcp_env_to_os() + sess = store.create_session("mcp-e2e-check") + session_id = str(sess.id) + try: + store.add_message(session_id=session_id, role="user", content="mcp e2e check") + reg = default_registry(expert="generalist+workspace+productivity", specialist="generalist") + runtime = ToolExecutor() + names = {t.name for t in reg.list()} + plans, skipped_servers = build_mcp_e2e_probe_plans(reg, workspace_root=str(PROJECT_ROOT.resolve())) + out: list[dict[str, Any]] = [] + for server_id, full_name, args in plans: + row = { + "server_id": server_id, + "tool_name": full_name, + "in_registry": full_name in names, + "ok": False, + "duration_ms": 0, + "error": "", + } + if not bool(row["in_registry"]): + row["error"] = "tool_not_in_registry" + out.append(row) + continue + call = LLMToolCall(id=f"call_{server_id}", name=full_name, arguments=args) + tool_ctx = ToolExecutionContext( + store=store, + tools=reg, + session_id=session_id, + lang="zh", + user_text=f"e2e check {full_name}", + specialist="generalist", + task_kind="mcp_e2e", + policy_engine=None, + trace_id="mcp-e2e", + parent_span_id="root", + ) + _tool_msgs, result_by_id = runtime.execute_tool_uses( + ctx=tool_ctx, + assistant_msg_id=0, + tool_uses=[call], + on_tool_ui=None, + should_stop=None, + ) + hit = result_by_id.get(call.id) + if hit: + result, dur = hit + row["duration_ms"] = int(dur or 0) + row["ok"] = bool((result or {}).get("ok")) + row["error"] = str((result or {}).get("error") or (result or {}).get("error_code") or "") + else: + row["error"] = "no_result" + out.append(row) + for sid in skipped_servers: + out.append( + { + "server_id": sid, + "tool_name": "", + "in_registry": True, + "ok": False, + "duration_ms": 0, + "error": "e2e_no_probe_candidate", + } + ) + ok_count = len([x for x in out if bool(x.get("ok"))]) + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="mcp_e2e_check", + target_type="mcp_server", + target_id="all", + status="ok", + detail={"session_id": session_id, "total": len(out), "ok_count": ok_count}, + ) + return { + "ok": True, + "session_id": session_id, + "total": len(out), + "ok_count": ok_count, + "error_count": len(out) - ok_count, + "items": out, + } + except Exception as e: + return {"ok": False, "session_id": session_id, "error": f"{type(e).__name__}: {e}"} + + @router.get("/admin/api/memory/hits") + def api_memory_hits( + tenant_id: str | None = Query(default=None), + user_id: str | None = Query(default=None), + limit: int = Query(default=100), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + if tenant_id: + _require_tenant_scope(ctx, tenant_id) + rows = store.list_memory_hit_logs(tenant_id=tenant_id, user_id=user_id, limit=limit) + return {"ok": True, "hits": rows} + + @router.get("/admin/api/memory/stats") + def api_memory_stats( + tenant_id: str | None = Query(default=None), + user_id: str | None = Query(default=None), + limit: int = Query(default=300), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + if tenant_id: + _require_tenant_scope(ctx, tenant_id) + hits = store.list_memory_hit_logs(tenant_id=tenant_id, user_id=user_id, limit=limit) + items = store.list_memory_items(tenant_id=tenant_id, user_id=user_id, limit=500, offset=0) + hit_count = len(hits) + avg_score = (sum(float(x.get("score") or 0.0) for x in hits) / hit_count) if hit_count else 0.0 + source_count: dict[str, int] = {} + for h in hits: + src = str(h.get("source") or "unknown") + source_count[src] = source_count.get(src, 0) + 1 + top_sources = sorted(source_count.items(), key=lambda x: x[1], reverse=True)[:5] + return { + "ok": True, + "stats": { + "hit_count": hit_count, + "avg_score": avg_score, + "item_count": len(items), + "top_sources": [{"source": k, "count": v} for k, v in top_sources], + }, + } + + @router.get("/admin/api/memory/items") + def api_memory_items( + tenant_id: str | None = Query(default=None), + user_id: str | None = Query(default=None), + limit: int = Query(default=100), + offset: int = Query(default=0), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + if tenant_id: + _require_tenant_scope(ctx, tenant_id) + rows = store.list_memory_items(tenant_id=tenant_id, user_id=user_id, limit=limit, offset=offset) + runtime = read_vector_memory_runtime(store) + return {"ok": True, "items": rows, "runtime": runtime.__dict__} + + @router.post("/admin/api/memory/delete") + def api_memory_delete( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:memory:write") + memory_id = str(payload.get("memory_id") or "").strip() + if not memory_id: + return {"ok": False, "error": "memory_id is required"} + deleted = store.delete_memory_item(memory_id=memory_id) + return {"ok": True, "deleted": deleted} + + @router.post("/admin/api/memory/reindex") + def api_memory_reindex( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:memory:write") + tenant_id = str(payload.get("tenant_id") or "").strip() or None + if tenant_id: + _require_tenant_scope(ctx, tenant_id) + user_id = str(payload.get("user_id") or "").strip() or None + from oclaw.platform.embeddings.embedding_client import build_default_embedding_client + from oclaw.orchestration.vector_store import build_vector_store, MemoryVectorItem + + client = build_default_embedding_client() + vector = build_vector_store(store) + items = store.list_memory_items(tenant_id=tenant_id, user_id=user_id, limit=500, offset=0) + updated = 0 + for row in items: + content = str(row.get("content") or "").strip() + if not content: + continue + emb = client.embed(content) + item = MemoryVectorItem( + memory_id=str(row.get("memory_id") or ""), + tenant_id=str(row.get("tenant_id") or ""), + user_id=str(row.get("user_id") or ""), + session_id=str(row.get("session_id") or ""), + memory_type=str(row.get("memory_type") or "semantic_memory"), + content=content, + confidence=float(row.get("confidence") or 0.0), + created_at=str(row.get("created_at") or ""), + updated_at=str(row.get("updated_at") or ""), + expires_at=str(row.get("expires_at") or "") or None, + metadata=row.get("metadata") if isinstance(row.get("metadata"), dict) else {}, + ) + vector.upsert(item, emb.vector, model=emb.model) + updated += 1 + return {"ok": True, "reindexed": updated} + + @router.get("/admin/api/memory/config") + def api_memory_config(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + runtime = read_vector_memory_runtime(store) + rag_mode = str(store.get_setting("rag_mode") or store.get_setting("AIA_RAG_MODE") or "keyword").strip().lower() + if rag_mode not in {"keyword", "vector"}: + rag_mode = "keyword" + emb_mode = str(store.get_setting("AIA_RAG_EMBEDDING_MODE") or "").strip().lower() + if emb_mode not in {"", "openai", "hash", "offline"}: + emb_mode = "" + ttl_raw = str( + store.get_setting("AIA_MEMORY_EPISODIC_TTL_DAYS") + or store.get_setting("MEMORY_EPISODIC_TTL_DAYS") + or "90" + ).strip() + try: + ttl_days = max(1, min(int(float(ttl_raw)), 3650)) + except Exception: + ttl_days = 90 + cfg = dict(runtime.__dict__) + cfg["rag_mode"] = rag_mode + cfg["rag_embedding_mode"] = emb_mode + cfg["memory_episodic_ttl_days"] = ttl_days + return {"ok": True, "config": cfg} + + @router.post("/admin/api/memory/config") + def api_memory_config_save( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:memory:write") + store.set_setting("MEMORY_VECTOR_ENABLED", "1" if str(payload.get("enabled") or "").lower() in ("1", "true", "yes", "on") else "0") + backend = str(payload.get("backend") or "sqlite").strip().lower() + if backend not in {"sqlite", "chroma", "qdrant"}: + backend = "sqlite" + store.set_setting("MEMORY_VECTOR_BACKEND", backend) + store.set_setting("MEMORY_VECTOR_TOPK", str(payload.get("top_k") or 5)) + store.set_setting( + "MEMORY_WRITE_ENABLED", + "1" if str(payload.get("writer_enabled") or "").lower() in ("1", "true", "yes", "on") else "0", + ) + store.set_setting("MEMORY_WRITE_MIN_CONFIDENCE", str(payload.get("write_min_confidence") or 0.75)) + rag_mode = str(payload.get("rag_mode") or "keyword").strip().lower() + if rag_mode not in {"keyword", "vector"}: + rag_mode = "keyword" + emb_mode = str(payload.get("rag_embedding_mode") or "").strip().lower() + if emb_mode not in {"", "openai", "hash", "offline"}: + emb_mode = "" + ttl_in = payload.get("memory_episodic_ttl_days", 90) + try: + ttl_days = max(1, min(int(float(ttl_in)), 3650)) + except Exception: + ttl_days = 90 + store.set_setting("rag_mode", rag_mode) + store.set_setting("AIA_RAG_MODE", rag_mode) + store.set_setting("AIA_RAG_EMBEDDING_MODE", emb_mode) + store.set_setting("AIA_MEMORY_EPISODIC_TTL_DAYS", str(ttl_days)) + store.set_setting("MEMORY_EPISODIC_TTL_DAYS", str(ttl_days)) + return api_memory_config() + + @router.post("/admin/api/memory/cleanup-low-confidence") + def api_memory_cleanup_low_conf( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:memory:write") + try: + threshold = float(payload.get("max_confidence") or 0.35) + except Exception: + threshold = 0.35 + deleted = store.clear_low_confidence_memory(max_confidence=threshold) + return {"ok": True, "deleted": deleted, "max_confidence": threshold} + + @router.post("/admin/api/secrets/migrate") + def api_secrets_migrate(authorization: str | None = Header(default=None)) -> dict[str, Any]: + """Migrate legacy b64 secrets to fernet (requires AIA_ASSISTANT_MASTER_KEY).""" + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:tenant:write") + try: + res = store.migrate_secrets_to_fernet() + except Exception as e: + return {"ok": False, "error": f"{type(e).__name__}: {e}"} + store.add_admin_audit_log( + actor_tenant_id=ctx["tenant_id"], + actor_user_id=ctx["user_id"], + action="secrets_migrate_to_fernet", + target_type="secrets", + target_id="all", + status="ok", + detail=res, + ) + return {"ok": True, **res} + + @router.get("/admin/api/secrets/status") + def api_secrets_status(authorization: str | None = Header(default=None)) -> dict[str, Any]: + """Expose legacy secret stats for admin UI warnings.""" + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:read") + stats = store.legacy_secret_stats() + import os + + has_master_key = bool((os.getenv("AIA_ASSISTANT_MASTER_KEY") or "").strip()) + return {"ok": True, "has_master_key": has_master_key, **stats} + + @router.post("/admin/api/auth/bootstrap") + def api_auth_bootstrap() -> dict[str, Any]: + store = SqliteStore(db_path()) + _ensure_admin_bootstrap(store) + return {"ok": True} + + @router.post("/admin/api/auth/login") + def api_auth_login(payload: dict[str, Any] | None = Body(default=None)) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + _ensure_admin_bootstrap(store) + tenant_id = str(payload.get("tenant_id") or "").strip() + if not tenant_id: + tenants = store.list_tenants(limit=1) + if tenants: + tenant_id = str((tenants[0] or {}).get("id") or "").strip() + purpose = str(payload.get("purpose") or "console").strip().lower() + if purpose not in {"console", "chat"}: + purpose = "console" + if purpose == "console": + username = str(payload.get("username") or "administrator").strip().lower() + else: + username = str(payload.get("username") or "").strip().lower() + password = str(payload.get("password") or "").strip() + if not tenant_id or not password: + return {"ok": False, "error": "tenant_id, username, password are required"} + if not username: + return {"ok": False, "error": "tenant_id, username, password are required"} + user = store.get_user_by_username(tenant_id=tenant_id, username=username) + if not user: + return {"ok": False, "error": "invalid_credentials"} + if not bool(user.get("is_active")): + return {"ok": False, "error": "user_disabled"} + expected = str(user.get("password_hash") or "") + if not expected or not hmac.compare_digest(expected, _sha256_hex(password)): + return {"ok": False, "error": "invalid_credentials"} + role = str(user.get("role") or "member") + if purpose == "console": + if role == "owner" and username == "administrator": + uid = str(user.get("id") or "") + if uid and store.update_user_account(tenant_id=tenant_id, user_id=uid, role="admin"): + user = store.get_user_by_username(tenant_id=tenant_id, username=username) or user + role = str(user.get("role") or "member") + perms = store.list_user_permissions( + tenant_id=tenant_id, + user_id=str(user.get("id") or ""), + role=role, + ) + token = secrets.token_urlsafe(32) + expires = (_now_utc() + timedelta(hours=12)).isoformat() + store.create_auth_session( + session_token_hash=_sha256_hex(token), + tenant_id=tenant_id, + user_id=str(user.get("id") or ""), + role=role, + expires_at=expires, + ) + return { + "ok": True, + "token": token, + "session": { + "tenant_id": tenant_id, + "user_id": str(user.get("id") or ""), + "username": str(user.get("username") or ""), + "display_name": str(user.get("display_name") or ""), + "role": str(user.get("role") or ""), + "permissions": perms, + "expires_at": expires, + }, + } + + @router.get("/admin/api/auth/me") + def api_auth_me(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + return {"ok": True, "session": ctx} + + @router.post("/admin/api/auth/logout") + def api_auth_logout(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + token = _extract_bearer(authorization) + if token: + store.revoke_auth_session(session_token_hash=_sha256_hex(token)) + return {"ok": True} + + @router.get("/admin/api/admin-audit") + def api_admin_audit( + limit: int = Query(default=200), + action: str | None = Query(default=None), + actor_user_id: str | None = Query(default=None), + status: str | None = Query(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_auth(store, authorization) + _require_permission(ctx, "admin:user:write") + rows = store.list_admin_audit_logs(tenant_id=str(ctx.get("tenant_id") or ""), limit=limit) + a = str(action or "").strip() + actor = str(actor_user_id or "").strip() + st = str(status or "").strip() + if a: + rows = [r for r in rows if str(r.get("action") or "") == a] + if actor: + rows = [r for r in rows if str(r.get("actor_user_id") or "") == actor] + if st: + rows = [r for r in rows if str(r.get("status") or "") == st] + return {"ok": True, "items": rows} + + from oclaw.admin.chat_api import include_chat_routes + from oclaw.admin.models_api import include_model_mgmt_routes + from oclaw.admin.skills_api import include_skill_routes + + include_chat_routes(router, resolve_auth=_resolve_auth) + include_model_mgmt_routes(router, resolve_auth=_resolve_auth) + include_skill_routes(router, resolve_auth=_resolve_auth) + + return router + + +__all__ = ["build_admin_router", "admin_static_dir"] + diff --git a/admin/skills_api.py b/admin/skills_api.py new file mode 100644 index 00000000..807750e0 --- /dev/null +++ b/admin/skills_api.py @@ -0,0 +1,604 @@ +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +from fastapi import APIRouter, Body, Header, HTTPException + +from oclaw.agents.factory import build_gateway_executor +from oclaw.openclaw_runtime.skill_installer import ( + auto_install_skill_from_payload, + create_skill_from_template, + install_skill_from_local_dir, + install_skill_from_registry_archive, + list_skills_with_status, + set_skill_enabled, +) +from oclaw.openclaw_runtime.skill_role_binding import ( + SKILL_ROLE_BINDING_ENABLED_SETTING, + SKILL_ROLE_BINDING_KEY, + load_skill_role_binding_dict, + normalize_skill_role_binding, + ordered_binding_roles, + skill_role_binding_enabled, +) +from oclaw.openclaw_runtime.skills_prompt import collect_skill_catalog_entries +from oclaw.openclaw_runtime.skills import _allowed_tool_names_after_wire_policy, discover_workspace_skill_manifests +from oclaw.platform.config.paths import db_path +from oclaw.platform.persistence.sqlite_store import SqliteStore +from oclaw.tools.skills.clawhub_client import get_skill_detail as clawhub_get_skill_detail +from oclaw.tools.skills.clawhub_client import search_skills as clawhub_search_skills + + +def include_skill_routes( + router: APIRouter, + *, + resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]], +) -> None: + sk = APIRouter(prefix="/admin/api/skills", tags=["skills"]) + + def _require_admin(ctx: dict[str, Any]) -> None: + perms = set(str(x) for x in (ctx.get("permissions") or [])) + if "admin:read" in perms or str(ctx.get("role") or "") == "owner": + return + raise HTTPException(status_code=403, detail="forbidden:admin:read") + + def _require_tenant_write(ctx: dict[str, Any]) -> None: + perms = set(str(x) for x in (ctx.get("permissions") or [])) + if "admin:tenant:write" in perms or str(ctx.get("role") or "") == "owner": + return + raise HTTPException(status_code=403, detail="forbidden:admin:tenant:write") + + def _audit(store: SqliteStore, ctx: dict[str, Any], *, action: str, target_id: str, status: str, detail: dict[str, Any] | None = None) -> None: + try: + store.add_admin_audit_log( + actor_tenant_id=str(ctx.get("tenant_id") or ""), + actor_user_id=str(ctx.get("user_id") or ""), + action=action, + target_type="skill", + target_id=str(target_id or ""), + status=status, + detail=detail or {}, + ) + except Exception: + pass + + def _normalized_skill_binding(store: SqliteStore) -> tuple[list[str], dict[str, list[str]], set[str]]: + roles = ordered_binding_roles() + valid = {str(m.name).strip() for m in discover_workspace_skill_manifests() if str(m.name or "").strip()} + mapping = normalize_skill_role_binding( + mapping_raw=load_skill_role_binding_dict(store), + valid_skill_names=valid, + available_roles=roles, + ) + return roles, mapping, valid + + @sk.get("") + def api_skills_list(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_admin(ctx) + items = list_skills_with_status(store=store) + return {"ok": True, "items": items} + + @sk.post("/install") + def api_skills_install( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_admin(ctx) + source_dir = str(payload.get("source_dir") or "").strip() + if not source_dir: + raise HTTPException(status_code=400, detail="source_dir_required") + overwrite = bool(payload.get("overwrite")) + _audit(store, ctx, action="skill_install_started", target_id=source_dir, status="start", detail={"source": "local"}) + out = install_skill_from_local_dir(store=store, source_dir=source_dir, overwrite=overwrite) + _audit( + store, + ctx, + action="skill_install_finished" if out.ok else "skill_install_failed", + target_id=out.name or source_dir, + status="ok" if out.ok else "fail", + detail={"detail": out.detail, "target_dir": out.target_dir, "source": "local", "input_target": source_dir}, + ) + return { + "ok": bool(out.ok), + "result": { + "name": out.name, + "target_dir": out.target_dir, + "detail": out.detail, + "error_code": out.error_code, + "retryable": bool(out.retryable), + }, + } + + @sk.post("/install-registry") + def api_skills_install_registry( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_admin(ctx) + archive_url = str(payload.get("archive_url") or "").strip() + if not archive_url: + raise HTTPException(status_code=400, detail="archive_url_required") + overwrite = bool(payload.get("overwrite")) + _audit(store, ctx, action="skill_install_started", target_id=archive_url, status="start", detail={"source": "registry"}) + out = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite) + _audit( + store, + ctx, + action="skill_install_finished" if out.ok else "skill_install_failed", + target_id=out.name or archive_url, + status="ok" if out.ok else "fail", + detail={"detail": out.detail, "target_dir": out.target_dir, "source": "registry", "input_target": archive_url}, + ) + return { + "ok": bool(out.ok), + "result": { + "name": out.name, + "target_dir": out.target_dir, + "detail": out.detail, + "error_code": out.error_code, + "retryable": bool(out.retryable), + }, + } + + @sk.get("/market/search") + def api_skills_market_search( + q: str | None = None, + limit: int | None = None, + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_admin(ctx) + query = str(q or "").strip() + lim = int(limit) if isinstance(limit, int) and limit > 0 else 20 + lim = max(1, min(lim, 200)) + items = clawhub_search_skills(query, limit=lim) + return {"ok": True, "items": items} + + @sk.get("/market/detail") + def api_skills_market_detail( + slug: str | None = None, + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_admin(ctx) + s = str(slug or "").strip() + if not s: + raise HTTPException(status_code=400, detail="slug_required") + detail = clawhub_get_skill_detail(s) + return {"ok": True, "detail": detail} + + @sk.post("/market/install") + def api_skills_market_install( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_admin(ctx) + s = str(payload.get("slug") or "").strip() + if not s: + raise HTTPException(status_code=400, detail="slug_required") + requested_version = str(payload.get("version") or "").strip() + overwrite = bool(payload.get("overwrite")) + + detail = clawhub_get_skill_detail(s) + archive_url = str(detail.get("archiveUrl") or "").strip() + chosen_version = str(detail.get("latestVersion") or "").strip() + if requested_version: + chosen_version = requested_version + archive_url = "" + for v in (detail.get("versions") or []): + if not isinstance(v, dict): + continue + if str(v.get("version") or "").strip() == requested_version: + archive_url = str(v.get("archiveUrl") or "").strip() + break + + if not archive_url: + raise HTTPException(status_code=400, detail="archive_url_unavailable") + + _audit( + store, + ctx, + action="skill_install_started", + target_id=archive_url, + status="start", + detail={"source": "clawhub", "slug": s, "version": chosen_version, "input_target": s}, + ) + out = install_skill_from_registry_archive(store=store, archive_url=archive_url, overwrite=overwrite) + _audit( + store, + ctx, + action="skill_install_finished" if out.ok else "skill_install_failed", + target_id=out.name or archive_url, + status="ok" if out.ok else "fail", + detail={ + "detail": out.detail, + "target_dir": out.target_dir, + "source": "clawhub", + "slug": s, + "version": chosen_version, + "input_target": archive_url, + }, + ) + return { + "ok": bool(out.ok), + "result": { + "name": out.name, + "target_dir": out.target_dir, + "detail": out.detail, + "error_code": out.error_code, + "retryable": bool(out.retryable), + }, + } + + @sk.get("/binding") + def api_skills_binding_get(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_tenant_write(ctx) + roles, mapping, _valid = _normalized_skill_binding(store) + items = list_skills_with_status(store=store) + return { + "ok": True, + "enabled": bool(skill_role_binding_enabled(store=store)), + "available_roles": roles, + "installed_skills": items, + "mapping": mapping, + } + + @sk.post("/binding") + def api_skills_binding_save( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_tenant_write(ctx) + if "enabled" in payload: + store.set_setting(SKILL_ROLE_BINDING_ENABLED_SETTING, "1" if bool(payload.get("enabled")) else "0") + roles, _prev_mapping, valid = _normalized_skill_binding(store) + mapping_raw = payload.get("mapping") if isinstance(payload.get("mapping"), dict) else {} + mapping = normalize_skill_role_binding( + mapping_raw=mapping_raw, + valid_skill_names=valid, + available_roles=roles, + ) + store.set_setting(SKILL_ROLE_BINDING_KEY, json.dumps(mapping, ensure_ascii=False)) + _audit( + store, + ctx, + action="skill_binding_update", + target_id="skill_role_binding", + status="ok", + detail={"mapping": mapping, "enabled": bool(skill_role_binding_enabled(store=store))}, + ) + return { + "ok": True, + "enabled": bool(skill_role_binding_enabled(store=store)), + "available_roles": roles, + "mapping": mapping, + } + + @sk.get("/effective") + def api_skills_effective(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_admin(ctx) + roles, mapping, _valid = _normalized_skill_binding(store) + role_rows: list[dict[str, Any]] = [] + for role in roles: + specialist = "generalist" if role == "manager" else role + ex = build_gateway_executor(store=store, specialist=specialist) + tools = getattr(ex, "tools", None) + model = getattr(ex, "model", None) + if tools is None: + role_rows.append( + { + "role": role, + "total": 0, + "workspace_total": 0, + "workspace_direct": 0, + "workspace_inherited_manager": 0, + "mcp_total": 0, + "tool_total": 0, + "names_preview": [], + } + ) + continue + base_url = str(getattr(model, "base_url", "") or "") + entries = collect_skill_catalog_entries( + store=store, + registry=tools, + base_url=base_url, + skill_binding_role=role, + ) + allowed_tool_names, _hidden_tool_names = _allowed_tool_names_after_wire_policy( + registry=tools, + store=store, + base_url=base_url, + ) + direct_set = set(mapping.get(role) or []) + manager_set = set(mapping.get("manager") or []) + workspace_total = 0 + workspace_direct = 0 + workspace_inherited = 0 + workspace_resolved_tool_match = 0 + workspace_docs_only = 0 + mcp_total = 0 + tool_total = 0 + names: list[str] = [] + docs_only_names: list[str] = [] + resolved_workspace_names: list[str] = [] + for nm, _desc, loc in entries: + names.append(str(nm)) + vloc = str(loc or "") + if vloc.endswith("SKILL.md"): + workspace_total += 1 + if nm in allowed_tool_names: + workspace_resolved_tool_match += 1 + resolved_workspace_names.append(str(nm)) + else: + workspace_docs_only += 1 + docs_only_names.append(str(nm)) + if nm in direct_set: + workspace_direct += 1 + elif role != "manager" and nm in manager_set: + workspace_inherited += 1 + elif str(nm).startswith("mcp__"): + mcp_total += 1 + else: + tool_total += 1 + role_rows.append( + { + "role": role, + "total": len(entries), + "workspace_total": workspace_total, + "workspace_direct": workspace_direct, + "workspace_inherited_manager": workspace_inherited, + "workspace_resolved_tool_match": workspace_resolved_tool_match, + "workspace_docs_only": workspace_docs_only, + "mcp_total": mcp_total, + "tool_total": tool_total, + "names_preview": names[:20], + "docs_only_names_preview": docs_only_names[:20], + "resolved_workspace_names_preview": resolved_workspace_names[:20], + } + ) + return {"ok": True, "enabled": bool(skill_role_binding_enabled(store=store)), "items": role_rows} + + @sk.post("/create") + def api_skills_create( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_admin(ctx) + name = str(payload.get("name") or "").strip() + desc = str(payload.get("description") or "").strip() + body = str(payload.get("body_markdown") or "").strip() + md = payload.get("metadata_openclaw") + md = dict(md) if isinstance(md, dict) else {} + overwrite = bool(payload.get("overwrite")) + out = create_skill_from_template( + store=store, + name=name, + description=desc, + body_markdown=body, + metadata_openclaw=md, + overwrite=overwrite, + ) + _audit( + store, + ctx, + action="skill_create", + target_id=out.name or name, + status="ok" if out.ok else "fail", + detail={"detail": out.detail, "target_dir": out.target_dir}, + ) + return { + "ok": bool(out.ok), + "result": { + "name": out.name, + "target_dir": out.target_dir, + "detail": out.detail, + "error_code": out.error_code, + "retryable": bool(out.retryable), + }, + } + + @sk.post("/enable") + def api_skills_enable( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_admin(ctx) + name = str(payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name_required") + set_skill_enabled(store=store, skill_name=name, enabled=True) + _audit(store, ctx, action="skill_enable", target_id=name, status="ok") + return {"ok": True} + + @sk.post("/disable") + def api_skills_disable( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_admin(ctx) + name = str(payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name_required") + set_skill_enabled(store=store, skill_name=name, enabled=False) + _audit(store, ctx, action="skill_disable", target_id=name, status="ok") + return {"ok": True} + + @sk.post("/auto-install") + def api_skills_auto_install( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_admin(ctx) + auto_name = str(payload.get("name") or "") + _audit( + store, + ctx, + action="skill_install_started", + target_id=auto_name, + status="start", + detail={"source": "auto", "input_target": auto_name}, + ) + out = auto_install_skill_from_payload(store=store, payload=payload) + _audit( + store, + ctx, + action="skill_install_finished" if out.ok else "skill_install_failed", + target_id=out.name or str(payload.get("name") or ""), + status="ok" if out.ok else "fail", + detail={"detail": out.detail, "target_dir": out.target_dir, "source": "auto", "input_target": auto_name}, + ) + return { + "ok": bool(out.ok), + "result": { + "name": out.name, + "target_dir": out.target_dir, + "detail": out.detail, + "error_code": out.error_code, + "retryable": bool(out.retryable), + }, + } + + @sk.post("/retry-install") + def api_skills_retry_install( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_admin(ctx) + source = str(payload.get("source") or "").strip().lower() + target = str(payload.get("target") or "").strip() + if source not in {"local", "registry", "auto"}: + raise HTTPException(status_code=400, detail="invalid_source") + if not target: + raise HTTPException(status_code=400, detail="target_required") + _audit( + store, + ctx, + action="skill_install_started", + target_id=target, + status="start", + detail={"source": source, "retry": True, "input_target": target}, + ) + if source == "local": + out = install_skill_from_local_dir(store=store, source_dir=target, overwrite=True) + elif source == "registry": + out = install_skill_from_registry_archive(store=store, archive_url=target, overwrite=True) + else: + out = auto_install_skill_from_payload( + store=store, + payload={ + "name": str(payload.get("name") or "").strip() or target, + "description": str(payload.get("description") or "retry auto install"), + "body_markdown": str(payload.get("body_markdown") or ""), + "metadata_openclaw": dict(payload.get("metadata_openclaw") or {}) + if isinstance(payload.get("metadata_openclaw"), dict) + else {}, + }, + ) + _audit( + store, + ctx, + action="skill_install_finished" if out.ok else "skill_install_failed", + target_id=out.name or target, + status="ok" if out.ok else "fail", + detail={ + "detail": out.detail, + "target_dir": out.target_dir, + "source": source, + "retry": True, + "input_target": target, + }, + ) + return { + "ok": bool(out.ok), + "result": { + "name": out.name, + "target_dir": out.target_dir, + "detail": out.detail, + "error_code": out.error_code, + "retryable": bool(out.retryable), + }, + } + + @sk.post("/test-run") + def api_skills_test_run( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_admin(ctx) + name = str(payload.get("name") or "").strip() + args = payload.get("args") + if not name: + raise HTTPException(status_code=400, detail="name_required") + if args is None: + args = {} + if not isinstance(args, dict): + raise HTTPException(status_code=400, detail="args_must_be_object") + + ex = build_gateway_executor(store=store, specialist="generalist") + tools = getattr(ex, "tools", None) + if tools is None: + raise HTTPException(status_code=500, detail="tool_registry_unavailable") + spec = tools.get(name) + if spec is None: + raise HTTPException(status_code=404, detail="tool_not_found") + try: + result = spec.handler(dict(args)) + except Exception as exc: + result = {"ok": False, "error_code": "exception", "error": f"{type(exc).__name__}:{exc}"} + + _audit( + store, + ctx, + action="skill_test_run", + target_id=name, + status="ok" if bool((result or {}).get("ok")) else "fail", + detail={"args_keys": list(args.keys()), "result_ok": bool((result or {}).get("ok"))}, + ) + return {"ok": True, "name": name, "result": result} + + router.include_router(sk) + + +__all__ = ["include_skill_routes"] + diff --git a/admin/static/app.js b/admin/static/app.js new file mode 100644 index 00000000..760edd7b --- /dev/null +++ b/admin/static/app.js @@ -0,0 +1,7578 @@ +const I18N = { + zh: { + "brand.title": "oliver", + "nav.chat": "对话", + "nav.models": "模型管理", + "nav.apiGrants": "API 使用授权", + "nav.stack": "运行时", + "nav.users": "用户管理", + "nav.workspacePaths": "工作区路径", + "nav.memory": "记忆", + "nav.audit": "审计与追踪", + "nav.sessionMonitor": "会话监控", + "nav.adminAudit": "管理审计", + "nav.plugins": "插件", + "nav.skills": "技能", + "nav.attachments": "附件", + "nav.profile": "设置", + "notice.noLogin": "v2 已启用登录鉴权:请仅在内网访问", + "action.refresh": "刷新", + "title.stack": "运行时", + "title.users": "用户管理", + "title.workspacePaths": "工作区路径(按用户)", + "title.memory": "记忆", + "title.models": "模型管理", + "title.apiGrants": "API 使用授权", + "title.audit": "审计与追踪", + "title.sessionMonitor": "会话监控", + "title.adminAudit": "管理审计", + "title.plugins": "插件", + "title.skills": "技能", + "title.attachments": "附件", + "skills.docsTitle": "oclaw 文档快捷入口:", + "skills.docsTroubleshooting": "- oclaw/docs/oclaw-skill-troubleshooting.md(安装与运行排障)", + "skills.docsTraceTaxonomy": "- oclaw/docs/oclaw-trace-taxonomy.md(trace 字段与阶段)", + "title.profile": "设置", + "attachments.title": "附件", + "attachments.excelPolicy": "Excel 策略", + "attachments.excelPolicyHint": "针对 Excel/表格附件的解析与 SQL 查询保护策略。该配置写入 oclaw.json(优先级低于环境变量)。", + "attachments.maxRowsRead": "最大读取行数", + "attachments.maxColumns": "最大保留列数", + "attachments.maxCellChars": "单元格最大字符数", + "attachments.maxExcelSheets": "Excel 最大 Sheet 数", + "attachments.largePreviewRows": "大表摘要预览行数", + "attachments.toolModeEnabled": "启用大表工具模式", + "attachments.toolModeMinRows": "触发工具模式最小行数", + "attachments.toolModeMaxBytes": "工具模式最大文件字节数", + "attachments.sqlTimeoutMs": "SQL 超时(ms)", + "attachments.highPreviewWarn": "大表摘要预览行数超过 200,可能显著增加 token 消耗。确认继续?", + "attachments.invalidNumber": "请输入有效正整数", + "attachments.sqlTimeoutHint": "硬超时(wall-clock)。范围 100..120000,默认 8000。命中会返回 sql_timeout 结构化错误。", + "attachments.loadError": "加载失败", + "attachments.saved": "已保存", + "attachments.save": "保存", + "attachments.resetDefaults": "恢复默认", + "profile.help": "在此调整显示名称、头像及账号标识;对话页中头像为圆形,助手使用站点 Logo。", + "profile.displayName": "显示名称", + "profile.username": "用户名", + "profile.userId": "用户 ID", + "profile.tenantId": "团队 ID", + "profile.role": "角色", + "profile.createdAt": "注册时间", + "profile.avatar": "头像", + "profile.avatarHint": "支持 PNG、JPEG、WebP、GIF,最大 2MB。", + "profile.chooseImage": "选择图片", + "profile.removeAvatar": "移除头像", + "profile.save": "保存设置", + "profile.saved": "已保存", + "profile.openChat": "打开对话", + "profile.loadError": "加载失败", + "profile.uploadError": "上传失败", + "profile.chatPrefs": "对话偏好", + "profile.memoryMode": "记忆模式", + "profile.memoryModeDefault": "默认(可注入)", + "profile.memoryModeStoreOnly": "仅记录不注入", + "profile.memoryCurator": "记忆整理专家", + "profile.memoryCuratorEnabled": "启用", + "profile.memoryCuratorDisabled": "停用", + "table.name": "名称", + "table.provider": "提供方", + "stack.title": "服务栈", + "stack.alerts": "异常告警", + "stack.mustCleanup": "检测到重复 worker,必须先清理再继续操作。", + "stack.missingRequired": "关键服务未运行,请先启动栈。", + "stack.missingServices": "缺失服务:{services}", + "stack.noAlerts": "未发现异常。", + "stack.cleanup": "清理异常进程", + "stack.cleanupDone": "清理完成,已终止异常进程数:{count}", + "stack.cleanupNone": "未发现可清理的异常进程。", + "stack.up": "启动栈", + "stack.down": "停止栈", + "table.service": "服务", + "table.pid": "进程号", + "table.status": "状态", + "status.running": "运行中", + "status.stopped": "已停止", + "tenants.title": "团队", + "tenants.select": "选择团队", + "tenants.create": "新建团队", + "tenants.createPlaceholder": "团队名称(默认 Team)", + "tenants.users": "用户", + "tenants.bindings": "渠道绑定", + "tenants.bindCodes": "绑定码", + "tenants.createCode": "生成绑定码", + "tenants.deleteCode": "删除", + "tenants.codeStatus": "状态", + "tenants.codeUsed": "已使用", + "tenants.codeUnused": "未使用", + "tenants.codeUsedBy": "绑定人", + "tenants.codeDeleteConfirm": "确认删除该绑定码?", + "tenants.codeDeleted": "已删除绑定码:{code}", + "tenants.role": "角色", + "tenants.codeOptional": "Code(可留空自动生成)", + "tenants.deleteUnbound": "删除未绑定用户", + "tenants.deleted": "已删除未绑定用户数:{count}", + "tenants.deleteUnboundResult": "未绑定用户清理:deleted={deleted}, orphan={orphan}, bound={bound}, total={total}", + "tenants.saved": "配置已保存", + "tenants.noTenants": "暂无团队数据。请先通过绑定流程或初始化脚本创建团队。", + "tenants.noUsers": "该团队暂无用户。", + "tenants.noBindings": "该团队暂无渠道绑定。", + "table.id": "ID", + "table.createdAt": "创建时间", + "table.userId": "用户ID", + "table.username": "用户名", + "table.externalUserId": "外部用户ID", + "table.channel": "渠道", + "tenants.userSource": "来源", + "tenants.sourceWecom": "企业微信", + "tenants.sourceChannel": "其他渠道", + "tenants.sourceConsole": "控制台", + "tenants.chatLogin": "Web 登录", + "tenants.chatLoginYes": "可(已设密码)", + "tenants.chatLoginNo": "否", + "tenants.noSessionTenant": "缺少会话团队信息,请重新登录。", + "tenants.sessionTenantMissing": "当前登录会话对应的团队不在列表中,请重新登录或联系管理员。", + "tenants.scopeHint": "「选择团队」决定下方用户/渠道绑定/绑定码所针对的团队。administrator 账号可切换并管理任意团队。上表最后一列标出登录会话最初绑定的团队。", + "tenants.colScope": "登录标识", + "tenants.scopeLoginHome": "登录所属", + "tenants.allTenantsHint": "多团队时:「登录所属」仅作提示;实际操作范围以「选择团队」为准。", + "tenants.rowActions": "操作", + "tenants.colTenantName": "团队名", + "tenants.colTenantId": "团队 ID", + "tenants.deleteTenant": "删除团队", + "tenants.deleteTenantConfirm": "确定删除团队「{name}」?将级联删除该团队下用户、绑定与数据,且不可恢复。", + "tenants.errLastTenant": "至少保留一个团队,无法删除。", + "tenants.errCannotDeleteCurrentTenant": "不能删除当前登录会话所属团队,请先切换到其他团队再删,或使用其他账号。", + "tenants.errTenantDeleteMiss": "删除未生效(可能数据库约束失败),请查看服务端日志。", + "tenants.deleteDisabledLast": "至少需保留一个团队。", + "tenants.deleteDisabledCurrent": "不能删除当前登录会话所属团队;请先在登录页使用另一团队的 UUID 登录后再删。", + "audit.title": "审计与追踪", + "audit.sessionIdPlaceholder": "session_id(可选)", + "audit.query": "查询", + "audit.result": "结果", + "audit.note": "支持表格 + JSON 双视图。", + "audit.auditTable": "审计表", + "audit.traceTable": "追踪表", + "audit.rawJson": "原始 JSON", + "audit.empty": "暂无数据", + "audit.columns": "显示列", + "table.timestamp": "时间", + "table.specialist": "专家", + "table.action": "动作", + "table.statusText": "状态", + "table.reason": "原因", + "table.durationMs": "耗时(ms)", + "table.routeAttachmentIds": "路由附件IDs", + "table.inputAttachmentIds": "输入附件IDs", + "table.outputAttachmentIds": "输出附件IDs", + "table.outputAttachmentUrls": "输出附件URLs", + "table.noOutputAttachment": "无输出附件", + "table.eventType": "事件类型", + "table.traceId": "trace_id", + "table.spanId": "span_id", + "table.parentSpanId": "parent_span_id", + "table.payload": "payload", + "table.user": "用户", + "table.sessionsCount": "会话数", + "table.activeSessions30m": "活跃会话(30m)", + "table.activeLogins30m": "活跃登录(30m)", + "table.totalTokensEst": "总 Token(估算)", + "wecom.title": "企业微信配置", + "wecom.botId": "Bot ID", + "wecom.botSecret": "Bot Secret", + "wecom.autoBind": "首条消息自动绑定", + "wecom.autoBindTenant": "自动绑定团队", + "wecom.autoBindRole": "自动绑定角色", + "wecom.unbind": "解绑 WeCom", + "wecom.unbindConfirm": "将清空本地 WeCom 配置和全部 WeCom 绑定/会话,确认继续?", + "wecom.unbindDone": "已解绑 WeCom。identity={ident},session={sess}", + "action.save": "保存", + "plugins.title": "工具插件", + "workspacePaths.help": + "为每个用户配置除主工作区(AIA_WORKSPACE_ROOT / 项目根)外可访问的绝对路径,用竖线 | 分隔,例如 D:\\\\|E:\\\\repos\\\\other。与网关环境变量 AIA_WORKSPACE_EXTRA_ROOTS 合并生效。下方「额外根路径」会参与内置工具校验,并在用户聊天时合并进官方 MCP @modelcontextprotocol/server-filesystem 的启动参数(仅该用户)。", + "workspacePaths.tenant": "团队", + "workspacePaths.user": "用户", + "workspacePaths.extraRoots": "额外根路径(| 分隔)", + "workspacePaths.allowAny": "允许任意路径(高风险)", + "workspacePaths.allowAnyHint": + "仅作用于内置工作区工具(read_file / glob 等)的路径校验;不会放开 MCP filesystem,也不会自动把整盘写进 MCP。需要 MCP 列目录的盘符/目录请填在「额外根路径」或环境变量 AIA_WORKSPACE_EXTRA_ROOTS。", + "workspacePaths.load": "加载", + "workspacePaths.save": "保存", + "workspacePaths.status": "状态", + "workspacePaths.fromDb": "已保存到数据库", + "workspacePaths.selfOnly": "当前账号仅能查看并编辑自己的工作区路径策略(不能代他人配置)。", + "chat.newSession": "新会话", + "chat.send": "发送", + "chat.sessions": "会话", + "chat.placeholder": "输入消息…", + "chat.empty": "暂无消息", + "chat.noSessions": "暂无会话,点击新建开始。", + "chat.loading": "加载中…", + "chat.sending": "发送中…", + "chat.error": "请求失败", + "table.version": "版本", + "table.entryPoint": "入口点", + "table.enabled": "启用", + "common.notFound": "未找到页面", + "common.forbidden": "无权访问该页面。", + "common.error": "错误", + "lang.switch": "English", + "memory.title": "记忆治理", + "memory.hits": "最近命中", + "memory.items": "记忆条目", + "memory.config": "向量配置", + "memory.enabled": "开启向量检索", + "memory.backend": "后端", + "memory.topk": "TopK", + "memory.writerEnabled": "开启自动写入", + "memory.minConfidence": "最小写入置信度", + "memory.save": "保存配置", + "memory.reindex": "重建向量索引", + "memory.cleanup": "清理低置信记忆", + "memory.delete": "删除", + "memory.noData": "暂无记忆数据", + "memory.filters": "筛选", + "memory.tenantId": "团队 ID / tenant_id(可选)", + "memory.userId": "user_id(可选)", + "memory.applyFilters": "应用筛选", + "memory.clearFilters": "清空筛选", + "memory.stats": "统计", + "memory.hitCount": "命中总数", + "memory.itemCount": "记忆条目数", + "memory.avgScore": "平均得分", + "memory.topSources": "最近来源", + "memory.wikiCard": "Wiki", + "memory.wikiStatus": "插件状态", + "memory.wikiPluginFound": "已安装:{name} v{version}", + "memory.wikiPluginMissing": "未检测到 memory-wiki 插件", + "memory.openPlugins": "打开插件页", + "auth.login": "登录", + "auth.logout": "退出登录", + "auth.username": "用户名", + "auth.consoleUsernameHint": "填写用户名与密码;各菜单仍按账号权限显示。", + "auth.password": "密码", + "auth.invalid": "登录失败,请检查凭据", + "auth.disabled": "账号已被禁用,请联系管理员", + "users.title": "用户管理", + "users.search": "搜索用户", + "users.colUser": "用户", + "users.createNamePlaceholder": "名称(登录名,唯一)", + "users.createNameRequired": "请填写名称。", + "users.deleteConfirm": "确定删除该用户?不可恢复。", + "users.create": "创建用户", + "users.update": "更新", + "users.delete": "删除", + "users.disable": "禁用", + "users.enable": "启用", + "users.passwordOptional": "新密码(可选)", + "users.wecomAccounts": "企微实例(每用户多个,具名)", + "users.wecomInstanceName": "实例名", + "users.wecomBotSecret": "Bot Secret", + "users.hasBotSecret": "已配Bot密钥", + "users.clearBotSecret": "清除Bot密钥", + "users.loadForm": "填入表单", + "users.noBindingsForUser": "暂无企微绑定或已保存实例;用户在微信侧绑定后会出现行,填齐参数后保存。", + "users.accountId": "账号ID", + "users.wecomBotId": "Bot ID", + "users.botIdRequired": "请先填写 Bot ID", + "users.accountName": "名称", + "users.accountActive": "启用", + "users.accountActions": "账号操作", + "users.accountSave": "保存账号", + "users.accountDelete": "删除账号", + "users.wecomUserLabel": "用户", + "users.wecomUserEmpty": "请在上表点击一行选中用户", + "users.accountLoadHint": "选中用户后,表格列出绑定与实例;需编辑时点「填入表单」,再填写 Bot Secret 等并保存(不会自动填入表单)。", + "users.noTenant": "请先登录并选择团队", + "users.needUserRead": "需要 admin:user:read 权限以查看与编辑用户列表。", + "users.colActive": "启用", + "users.colSetRole": "调整角色", + "adminAudit.title": "管理操作审计", + "adminAudit.action": "动作", + "adminAudit.actor": "操作者", + "adminAudit.status": "状态", + "models.sectionActive": "当前模型配置", + "models.sectionBindings": "智能体与模型绑定", + "models.sectionNew": "新建模型配置", + "models.sectionApi": "API 与密钥", + "models.sectionEval": "Agent Evaluation", + "models.pickModel": "选用配置", + "models.agentBindingHelp": "可为不同角色指定模型配置;留空则跟随上方全局选用。", + "models.bindingsExtraHint": "被定向授权或团队共享的 API 会出现在上方「选用配置」列表中;成员需在此为各角色自行选择要绑定的配置。", + "models.bindingsScopeHint": "选用与绑定下拉仅含:内置、您自建的配置,以及管理员授权给团队或您个人的配置(与当前账号可见列表一致)。", + "models.linkApiGrants": "打开 API 使用授权", + "models.grantsNavHint": "管理谁能看到并选用可分享的模型配置(不含在此页改密钥或端点)。", + "apiGrants.intro": "选择下方列表中的模型配置(profile),将「使用权」授予整个团队或指定用户。密钥与 Base URL 仍在「模型管理」中配置;成员获得可见性后,在「模型管理」里为各角色绑定 profile。", + "apiGrants.pickApi": "选择要授权的 API", + "apiGrants.teamSection": "授权给整个团队", + "apiGrants.teamHint": "授予后,租户内所有成员在其「选用配置」列表中可见该 API;端点与密钥仍在「模型管理」中维护。", + "apiGrants.userSection": "授权给指定用户", + "apiGrants.userHint": "仅该用户在其「选用配置」列表中可见;对方在「模型管理」里自行绑定各角色。", + "apiGrants.noShareable": "(暂无可分享的模型配置)", + "apiGrants.forbidden": "仅 administrator 账号且具备团队写权限(或 owner 角色)可管理 API 使用权。", + "apiGrants.openModels": "前往模型管理", + "models.useGlobal": "(跟随全局)", + "models.role.manager": "编排 / Manager", + "models.role.ops": "网络运维", + "models.role.generalist": "通用", + "models.role.image": "图像", + "models.role.memory_curator": "记忆整理", + "models.profileName": "配置名称", + "models.mode": "模式", + "models.mode.openai": "OpenAI 兼容", + "models.mode.openai_responses": "OpenAI Responses", + "models.mode.anthropic": "Anthropic", + "models.mode.google": "Google (Gemini)", + "models.mode.ollama": "Ollama", + "models.mode.rule": "规则(无 LLM)", + "models.model": "模型名", + "models.baseUrl": "Base URL", + "models.baseUrlPlaceholder": "可选,留空用环境默认", + "models.apiKey": "API Key", + "models.rememberKey": "记住密钥(存库加密)", + "models.save": "保存", + "models.delete": "删除此配置", + "models.deleteConfirm": "确定删除该模型配置?此操作不可撤销。", + "models.cannotDeleteBuiltin": "无法删除内置 Ollama 配置。", + "models.createNamePlaceholder": "配置显示名", + "models.newProfileDefault": "新配置", + "models.createBtn": "创建", + "models.builtinLocked": "内置 Ollama 配置:模式固定为 Ollama,可改地址与模型名。", + "models.warnKeyInDb": "数据库中已有密钥:若输入框为空,保存时不会覆盖(请先填入新密钥或取消勾选「记住密钥」以清空)。", + "models.openaiKeyHint": "未配置密钥时,将尝试使用环境变量 OPENAI_API_KEY。", + "models.ollamaHint": "Ollama 本地默认 http://127.0.0.1:11434;一般无需 API Key。", + "models.evalTotal": "样本数", + "models.evalSuccess": "成功率", + "models.evalP95": "P95 延迟", + "models.evalLogs": "最近评估日志", + "models.evalToggle": "Agent Evaluation · 点击展开(页内预览最多 100 条)", + "models.evalLogsPreview": "页内预览", + "models.evalDownloadCsv": "下载全量 CSV", + "models.evalDownloadJson": "下载全量 JSON", + "models.noEvalLogs": "暂无评估记录", + "models.noProfiles": "暂无模型配置数据。", + "models.readonly": "只读:当前账号无团队数据写权限(admin:tenant:write)。", + "models.profileReadonlyHint": "当前配置为管理员授权,仅可选用与对话,不能改名称、端点或密钥。", + "models.grantsTitle": "授权其他用户使用当前配置", + "models.grantsHint": "仅 administrator 可管理。被授权用户可在其模型列表中看到并选用此配置(共用密钥)。", + "models.grantsGrantBtn": "授权", + "models.grantsRevoke": "撤销", + "models.grantsEmpty": "尚无定向授权用户。", + "models.grantsUserHint": "定向授权给单个用户:", + "models.grantTenantBtn": "全团队可用", + "models.revokeTenantGrant": "撤销全团队", + "models.tenantGrantOn": "全团队:已授权", + "models.tenantGrantOff": "全团队:未授权", + "models.membersTitle": "各成员可见的模型配置", + "models.vis.builtin": "内置", + "models.vis.owned": "我的", + "models.vis.grantUser": "定向授权", + "models.vis.grantTenant": "团队共享", + "models.vis.global": "全局", + "models.vis.otherUser": "他人配置", + "models.loadFailed": "加载失败,请展开下方错误信息。", + "models.dbPath": "当前使用的数据库文件", + "secrets.migrateTitle": "密钥加密迁移", + "secrets.migrateHint": "检测到旧版 b64 编码的密钥(弱保护)。建议设置 AIA_ASSISTANT_MASTER_KEY 后一键迁移到更安全的加密格式。", + "secrets.migrateBtn": "立即迁移", + "secrets.migrateDone": "迁移完成:settings={s},profiles={p}", + "secrets.migrateNoop": "未发现需要迁移的旧密钥。", + "secrets.migrateNeedKey": "未配置 AIA_ASSISTANT_MASTER_KEY,无法迁移。", + "secrets.migrateForbidden": "需要 admin:tenant:write 权限。", + "sessionMonitor.totals": "总览统计", + "sessionMonitor.totalTokensEst": "总 Token(估算)", + "sessionMonitor.activeSessions30m": "活跃会话(30m)", + "sessionMonitor.activeLogins30m": "活跃登录(30m)", + "sessionMonitor.usersCount": "用户数", + "sessionMonitor.userStats": "用户统计", + "sessionMonitor.sessions": "会话明细", + "sessionMonitor.filterUser": "筛选用户", + "sessionMonitor.filterSession": "筛选会话", + "sessionMonitor.selectUserFirst": "请先选择一个用户查看会话。", + "sessionMonitor.viewAudit": "查看审计", + "sessionMonitor.viewDetail": "查看详情", + "sessionMonitor.messages": "消息明细", + "sessionMonitor.exportMd": "导出 MD", + "sessionMonitor.exportJson": "导出 JSON", + "sessionMonitor.pagePrev": "上一页", + "sessionMonitor.pageNext": "下一页", + "sessionMonitor.pageInfo": "第 {page} 页 / 共 {totalPages} 页", + "sessionMonitor.noMessages": "该会话暂无消息。", + "sessionMonitor.closeDetail": "关闭详情", + "sessionMonitor.roleFilter": "角色过滤", + "sessionMonitor.roleAll": "全部", + "sessionMonitor.roleUser": "用户", + "sessionMonitor.roleAssistant": "助手", + "sessionMonitor.roleTool": "工具", + "sessionMonitor.onlyAdministrator": "仅 administrator 账号可访问该页面。", + }, + en: { + "brand.title": "oliver", + "nav.chat": "Chat", + "nav.models": "Models", + "nav.apiGrants": "API access", + "nav.stack": "Runtime", + "nav.users": "Users", + "nav.workspacePaths": "Workspace paths", + "nav.memory": "Memory", + "nav.audit": "Audit & Trace", + "nav.sessionMonitor": "Session Monitor", + "nav.adminAudit": "Admin Audit", + "nav.plugins": "Plugins", + "nav.skills": "Skills", + "nav.attachments": "Attachments", + "nav.profile": "Settings", + "notice.noLogin": "v2 login enabled: internal network only", + "action.refresh": "Refresh", + "title.stack": "Runtime", + "title.users": "Users", + "title.workspacePaths": "Workspace paths (per user)", + "title.memory": "Memory", + "title.models": "Model Management", + "title.apiGrants": "API access grants", + "title.audit": "Audit & Trace", + "title.sessionMonitor": "Session Monitor", + "title.adminAudit": "Admin Audit", + "title.plugins": "Plugins", + "title.skills": "Skills", + "title.attachments": "Attachments", + "skills.docsTitle": "oclaw docs quick links:", + "skills.docsTroubleshooting": "- oclaw/docs/oclaw-skill-troubleshooting.md (install/runtime troubleshooting)", + "skills.docsTraceTaxonomy": "- oclaw/docs/oclaw-trace-taxonomy.md (trace fields and stages)", + "title.profile": "Settings", + "attachments.title": "Attachments", + "attachments.excelPolicy": "Excel policy", + "attachments.excelPolicyHint": "Policy for Excel/tabular attachments parsing and SQL query safeguards. Saved into oclaw.json (lower priority than env vars).", + "attachments.maxRowsRead": "Max rows read", + "attachments.maxColumns": "Max columns kept", + "attachments.maxCellChars": "Max chars per cell", + "attachments.maxExcelSheets": "Max Excel sheets", + "attachments.largePreviewRows": "Large-table preview rows", + "attachments.toolModeEnabled": "Enable large-table tool mode", + "attachments.toolModeMinRows": "Min rows to trigger tool mode", + "attachments.toolModeMaxBytes": "Max file bytes for tool mode", + "attachments.sqlTimeoutMs": "SQL timeout (ms)", + "attachments.highPreviewWarn": "Large-table preview rows is above 200, which may significantly increase token usage. Continue?", + "attachments.invalidNumber": "Please enter valid positive integers", + "attachments.sqlTimeoutHint": "Hard wall-clock timeout. Range 100..120000, default 8000. Timeout returns structured sql_timeout error.", + "attachments.loadError": "Load failed", + "attachments.saved": "Saved", + "attachments.save": "Save", + "attachments.resetDefaults": "Reset defaults", + "profile.help": "Adjust display name, avatar, and account identifiers here. In chat, avatars are circular; the assistant uses the site logo.", + "profile.displayName": "Display name", + "profile.username": "Username", + "profile.userId": "User ID", + "profile.tenantId": "Team ID", + "profile.role": "Role", + "profile.createdAt": "Created at", + "profile.avatar": "Avatar", + "profile.avatarHint": "PNG, JPEG, WebP, or GIF, up to 2 MB.", + "profile.chooseImage": "Choose image", + "profile.removeAvatar": "Remove avatar", + "profile.save": "Save settings", + "profile.saved": "Saved", + "profile.openChat": "Open chat", + "profile.loadError": "Load failed", + "profile.uploadError": "Upload failed", + "profile.chatPrefs": "Chat Preferences", + "profile.memoryMode": "Memory mode", + "profile.memoryModeDefault": "Default (inject allowed)", + "profile.memoryModeStoreOnly": "Store only (no inject)", + "profile.memoryCurator": "Memory curator specialist", + "profile.memoryCuratorEnabled": "Enabled", + "profile.memoryCuratorDisabled": "Disabled", + "table.name": "name", + "table.provider": "provider", + "stack.title": "Stack", + "stack.alerts": "Alerts", + "stack.mustCleanup": "Duplicate workers detected. Cleanup is required before continuing.", + "stack.missingRequired": "Required services are not running. Start the stack first.", + "stack.missingServices": "Missing services: {services}", + "stack.noAlerts": "No anomalies detected.", + "stack.cleanup": "Cleanup orphan workers", + "stack.cleanupDone": "Cleanup completed. Killed orphan processes: {count}", + "stack.cleanupNone": "No orphan processes found.", + "stack.up": "stack up", + "stack.down": "stack down", + "table.service": "service", + "table.pid": "pid", + "table.status": "status", + "status.running": "running", + "status.stopped": "stopped", + "tenants.title": "Teams", + "tenants.select": "Select Team", + "tenants.create": "Create Team", + "tenants.createPlaceholder": "Team name (default Team)", + "tenants.users": "Users", + "tenants.bindings": "Channel Bindings", + "tenants.bindCodes": "Bind Codes", + "tenants.createCode": "Create Bind Code", + "tenants.deleteCode": "Delete", + "tenants.codeStatus": "Status", + "tenants.codeUsed": "Used", + "tenants.codeUnused": "Unused", + "tenants.codeUsedBy": "Used By", + "tenants.codeDeleteConfirm": "Delete this bind code?", + "tenants.codeDeleted": "Bind code deleted: {code}", + "tenants.role": "Role", + "tenants.codeOptional": "Code (optional, auto-generated if blank)", + "tenants.deleteUnbound": "Delete Unbound Users", + "tenants.deleted": "Deleted unbound users: {count}", + "tenants.deleteUnboundResult": "Unbound cleanup: deleted={deleted}, orphan={orphan}, bound={bound}, total={total}", + "tenants.saved": "Configuration saved", + "tenants.noTenants": "No teams found. Create one via bind flow or bootstrap script first.", + "tenants.noUsers": "No users under this team.", + "tenants.noBindings": "No channel bindings under this team.", + "table.id": "id", + "table.createdAt": "created_at", + "table.userId": "user_id", + "table.username": "username", + "table.externalUserId": "external_user_id", + "table.channel": "channel", + "tenants.userSource": "Source", + "tenants.sourceWecom": "WeCom", + "tenants.sourceChannel": "Other channel", + "tenants.sourceConsole": "Console", + "tenants.chatLogin": "Web login", + "tenants.chatLoginYes": "Yes (password set)", + "tenants.chatLoginNo": "No", + "tenants.noSessionTenant": "Missing team in session. Please sign in again.", + "tenants.sessionTenantMissing": "The team for this session is not in the list. Sign in again or contact an admin.", + "tenants.scopeHint": "The team selector controls which team’s users, bindings, and bind codes are shown below. Accounts named administrator may switch and manage any team. The last column marks the team your login session was issued for.", + "tenants.colScope": "Session marker", + "tenants.scopeLoginHome": "Logged-in team", + "tenants.allTenantsHint": "With multiple teams: “Logged-in team” is informational; the selected team in the dropdown is what you edit.", + "tenants.rowActions": "Actions", + "tenants.colTenantName": "Team name", + "tenants.colTenantId": "Team ID", + "tenants.deleteTenant": "Delete team", + "tenants.deleteTenantConfirm": "Delete team “{name}”? All users, bindings, and data under it will be removed. This cannot be undone.", + "tenants.errLastTenant": "At least one team must remain.", + "tenants.errCannotDeleteCurrentTenant": "You cannot delete the team of your current session. Switch to another team first or use another account.", + "tenants.errTenantDeleteMiss": "Delete had no effect (constraints may have blocked it). Check server logs.", + "tenants.deleteDisabledLast": "At least one team must remain.", + "tenants.deleteDisabledCurrent": "You cannot delete your current session team; log in with another team UUID on the login page first.", + "audit.title": "Audit & Trace", + "audit.sessionIdPlaceholder": "session_id (optional)", + "audit.query": "Query", + "audit.result": "Result", + "audit.note": "Supports dual view: table + raw JSON.", + "audit.auditTable": "Audit Table", + "audit.traceTable": "Trace Table", + "audit.rawJson": "Raw JSON", + "audit.empty": "No data", + "audit.columns": "Columns", + "table.timestamp": "timestamp", + "table.specialist": "specialist", + "table.action": "action", + "table.statusText": "status", + "table.reason": "reason", + "table.durationMs": "duration(ms)", + "table.routeAttachmentIds": "route_attachment_ids", + "table.inputAttachmentIds": "input_attachment_ids", + "table.outputAttachmentIds": "output_attachment_ids", + "table.outputAttachmentUrls": "output_attachment_urls", + "table.noOutputAttachment": "no_output_attachment", + "table.eventType": "event_type", + "table.traceId": "trace_id", + "table.spanId": "span_id", + "table.parentSpanId": "parent_span_id", + "table.payload": "payload", + "table.user": "user", + "table.sessionsCount": "sessions", + "table.activeSessions30m": "active_sessions(30m)", + "table.activeLogins30m": "active_logins(30m)", + "table.totalTokensEst": "total_tokens(est)", + "plugins.title": "Tool Plugins", + "workspacePaths.help": + "Per-user extra absolute roots (besides AIA_WORKSPACE_ROOT / project root). Use | as separator, e.g. D:\\\\|E:\\\\repos\\\\other. Merged with gateway env AIA_WORKSPACE_EXTRA_ROOTS. These roots are enforced for built-in workspace tools and, for this user’s chat sessions, appended to the official @modelcontextprotocol/server-filesystem process argv.", + "workspacePaths.tenant": "Team", + "workspacePaths.user": "User", + "workspacePaths.extraRoots": "Extra roots (| separated)", + "workspacePaths.allowAny": "Allow any path (high risk)", + "workspacePaths.allowAnyHint": + "Applies only to built-in workspace tools (read_file / glob, etc.); it does not unlock MCP filesystem or auto-mount whole disks for MCP. List directories you need in “Extra roots” or AIA_WORKSPACE_EXTRA_ROOTS.", + "workspacePaths.load": "Load", + "workspacePaths.save": "Save", + "workspacePaths.status": "Status", + "workspacePaths.fromDb": "Saved in database", + "workspacePaths.selfOnly": "You can only view and edit your own workspace path policy (not other users).", + "chat.newSession": "New chat", + "chat.send": "Send", + "chat.sessions": "Sessions", + "chat.placeholder": "Message…", + "chat.empty": "No messages yet", + "chat.noSessions": "No sessions yet. Create one to start.", + "chat.loading": "Loading…", + "chat.sending": "Sending…", + "chat.error": "Request failed", + "wecom.title": "WeCom Config", + "wecom.botId": "Bot ID", + "wecom.botSecret": "Bot Secret", + "wecom.autoBind": "Auto bind on first message", + "wecom.autoBindTenant": "Auto bind team", + "wecom.autoBindRole": "Auto bind role", + "wecom.unbind": "Unbind WeCom", + "wecom.unbindConfirm": "Clear local WeCom config and all WeCom bindings/sessions?", + "wecom.unbindDone": "WeCom unbound. identities={ident}, sessions={sess}", + "action.save": "Save", + "table.version": "version", + "table.entryPoint": "entry_point", + "table.enabled": "enabled", + "common.notFound": "Not found", + "common.forbidden": "You do not have access to this page.", + "common.error": "Error", + "lang.switch": "中文", + "memory.title": "Memory Governance", + "memory.hits": "Recent Hits", + "memory.items": "Memory Items", + "memory.config": "Vector Config", + "memory.enabled": "Enable vector retrieval", + "memory.backend": "Backend", + "memory.topk": "TopK", + "memory.writerEnabled": "Enable auto write", + "memory.minConfidence": "Min write confidence", + "memory.save": "Save Config", + "memory.reindex": "Reindex vectors", + "memory.cleanup": "Cleanup low-confidence", + "memory.delete": "Delete", + "memory.noData": "No memory data", + "memory.filters": "Filters", + "memory.tenantId": "team ID / tenant_id (optional)", + "memory.userId": "user_id (optional)", + "memory.applyFilters": "Apply Filters", + "memory.clearFilters": "Clear Filters", + "memory.stats": "Stats", + "memory.hitCount": "Hit Count", + "memory.itemCount": "Memory Items", + "memory.avgScore": "Average Score", + "memory.topSources": "Recent Sources", + "memory.wikiCard": "Wiki", + "memory.wikiStatus": "Plugin status", + "memory.wikiPluginFound": "Installed: {name} v{version}", + "memory.wikiPluginMissing": "memory-wiki plugin not found", + "memory.openPlugins": "Open plugins page", + "auth.login": "Login", + "auth.logout": "Logout", + "auth.username": "Username", + "auth.consoleUsernameHint": "Username and password; menus still follow your permissions.", + "auth.password": "Password", + "auth.invalid": "Login failed", + "auth.disabled": "Account is disabled", + "users.title": "User Management", + "users.search": "Search users", + "users.colUser": "User", + "users.createNamePlaceholder": "Name (login id, unique)", + "users.createNameRequired": "Enter a name.", + "users.deleteConfirm": "Delete this user? This cannot be undone.", + "users.create": "Create User", + "users.update": "Update", + "users.delete": "Delete", + "users.disable": "Disable", + "users.enable": "Enable", + "users.passwordOptional": "New password (optional)", + "users.wecomAccounts": "WeCom instances (named, multiple per user)", + "users.wecomInstanceName": "Instance name", + "users.wecomBotSecret": "Bot Secret", + "users.hasBotSecret": "Bot secret set", + "users.clearBotSecret": "Clear bot secret", + "users.loadForm": "Load into form", + "users.noBindingsForUser": "No WeCom binding or saved instance yet; after the user binds on WeChat, a row appears—fill secrets and save.", + "users.accountId": "Account ID", + "users.wecomBotId": "Bot ID", + "users.botIdRequired": "Bot ID is required", + "users.accountName": "Name", + "users.accountActive": "Active", + "users.accountActions": "Account Actions", + "users.accountSave": "Save Account", + "users.accountDelete": "Delete Account", + "users.wecomUserLabel": "User", + "users.wecomUserEmpty": "Click a user row in the table above", + "users.accountLoadHint": "After selecting a user, the table lists bindings and instances. Click “Load into form” to edit, then fill Bot Secret and save (no auto-fill).", + "users.noTenant": "Sign in first and choose a team scope", + "users.needUserRead": "admin:user:read is required to view and edit the user list.", + "users.colActive": "Active", + "users.colSetRole": "Set role", + "adminAudit.title": "Admin Operation Audit", + "adminAudit.action": "Action", + "adminAudit.actor": "Actor", + "adminAudit.status": "Status", + "models.sectionActive": "Active profile", + "models.sectionBindings": "Agent ↔ profile bindings", + "models.sectionNew": "New profile", + "models.sectionApi": "API & secret", + "models.sectionEval": "Agent Evaluation", + "models.pickModel": "Profile", + "models.agentBindingHelp": "Optional per-role profile; leave empty to use the global selection above.", + "models.bindingsExtraHint": "Profiles shared via user or team grants appear in the picker above; each member binds roles here themselves.", + "models.bindingsScopeHint": "Pickers only include built-in profiles, ones you created, and those granted by an admin (team-wide or to you)—the same set your account can see.", + "models.linkApiGrants": "Open API access grants", + "models.grantsNavHint": "Control who can see and select shareable profiles (keys and endpoints are edited on Model Management).", + "apiGrants.intro": "Pick a profile below, then grant visibility to the whole team or specific users. Keys and URLs are still edited under Model Management; members bind roles there after they can see the profile.", + "apiGrants.pickApi": "Profile to grant", + "apiGrants.teamSection": "Grant to entire team", + "apiGrants.teamHint": "Everyone in the tenant can then see this profile in their picker; endpoints and secrets stay under Model Management.", + "apiGrants.userSection": "Grant to one user", + "apiGrants.userHint": "Only that user sees the profile in their picker; they bind roles under Model Management.", + "apiGrants.noShareable": "(No shareable profiles)", + "apiGrants.forbidden": "Only the administrator console user with team write permission (or owner role) can manage API access grants.", + "apiGrants.openModels": "Go to Model Management", + "models.useGlobal": "(use global)", + "models.role.manager": "Manager", + "models.role.ops": "Network ops", + "models.role.generalist": "Generalist", + "models.role.image": "Image", + "models.role.memory_curator": "Memory Curator", + "models.profileName": "Name", + "models.mode": "Mode", + "models.mode.openai": "OpenAI-compatible", + "models.mode.openai_responses": "OpenAI Responses", + "models.mode.anthropic": "Anthropic", + "models.mode.google": "Google (Gemini)", + "models.mode.ollama": "Ollama", + "models.mode.rule": "Rule (no LLM)", + "models.model": "Model", + "models.baseUrl": "Base URL", + "models.baseUrlPlaceholder": "Optional", + "models.apiKey": "API key", + "models.rememberKey": "Remember key (encrypted in DB)", + "models.save": "Save", + "models.delete": "Delete profile", + "models.deleteConfirm": "Delete this profile? This cannot be undone.", + "models.cannotDeleteBuiltin": "Cannot delete the built-in Ollama profile.", + "models.createNamePlaceholder": "Display name", + "models.newProfileDefault": "New profile", + "models.createBtn": "Create", + "models.builtinLocked": "Built-in Ollama profile: mode is fixed; you can edit URL/model.", + "models.warnKeyInDb": "A key exists in DB: empty field won’t overwrite until you enter a new key or uncheck remember.", + "models.openaiKeyHint": "Without a key, OPENAI_API_KEY from the environment may be used.", + "models.ollamaHint": "Local Ollama defaults to http://127.0.0.1:11434; API key is usually empty.", + "models.evalTotal": "Samples", + "models.evalSuccess": "Success rate", + "models.evalP95": "P95 latency", + "models.evalLogs": "Recent eval logs", + "models.evalToggle": "Agent Evaluation · click to expand (preview up to 100 rows)", + "models.evalLogsPreview": "Preview", + "models.evalDownloadCsv": "Download full CSV", + "models.evalDownloadJson": "Download full JSON", + "models.noEvalLogs": "No evaluation logs yet", + "models.noProfiles": "No LLM profiles.", + "models.readonly": "Read-only: missing admin:tenant:write.", + "models.profileReadonlyHint": "This profile is shared by an administrator: you may select it for chat but cannot edit name, endpoint, or secret.", + "models.grantsTitle": "Grant other users access to the selected profile", + "models.grantsHint": "Only the administrator account can manage grants. Granted users see and can select this profile (shared API key).", + "models.grantsGrantBtn": "Grant", + "models.grantsRevoke": "Revoke", + "models.grantsEmpty": "No per-user grants yet.", + "models.grantsUserHint": "Grant to one user:", + "models.grantTenantBtn": "Entire team", + "models.revokeTenantGrant": "Revoke team", + "models.tenantGrantOn": "Team: granted", + "models.tenantGrantOff": "Team: not granted", + "models.membersTitle": "Model profiles visible to each member", + "models.vis.builtin": "Built-in", + "models.vis.owned": "Mine", + "models.vis.grantUser": "User grant", + "models.vis.grantTenant": "Team grant", + "models.vis.global": "Global", + "models.vis.otherUser": "Other user", + "models.loadFailed": "Load failed; see error below.", + "models.dbPath": "SQLite file in use", + "secrets.migrateTitle": "Secret migration", + "secrets.migrateHint": "Legacy b64-encoded secrets detected (weak protection). Set AIA_ASSISTANT_MASTER_KEY and migrate to a stronger encrypted format.", + "secrets.migrateBtn": "Migrate now", + "secrets.migrateDone": "Migration done: settings={s}, profiles={p}", + "secrets.migrateNoop": "No legacy secrets found.", + "secrets.migrateNeedKey": "AIA_ASSISTANT_MASTER_KEY is not set; cannot migrate.", + "secrets.migrateForbidden": "Requires admin:tenant:write permission.", + "sessionMonitor.totals": "Overview", + "sessionMonitor.totalTokensEst": "Total tokens (est)", + "sessionMonitor.activeSessions30m": "Active sessions (30m)", + "sessionMonitor.activeLogins30m": "Active logins (30m)", + "sessionMonitor.usersCount": "Users", + "sessionMonitor.userStats": "User stats", + "sessionMonitor.sessions": "Sessions", + "sessionMonitor.filterUser": "Filter users", + "sessionMonitor.filterSession": "Filter sessions", + "sessionMonitor.selectUserFirst": "Select a user to view sessions.", + "sessionMonitor.viewAudit": "Open audit", + "sessionMonitor.viewDetail": "View details", + "sessionMonitor.messages": "Messages", + "sessionMonitor.exportMd": "Export MD", + "sessionMonitor.exportJson": "Export JSON", + "sessionMonitor.pagePrev": "Prev", + "sessionMonitor.pageNext": "Next", + "sessionMonitor.pageInfo": "Page {page} / {totalPages}", + "sessionMonitor.noMessages": "No messages in this session.", + "sessionMonitor.closeDetail": "Close details", + "sessionMonitor.roleFilter": "Role filter", + "sessionMonitor.roleAll": "All", + "sessionMonitor.roleUser": "User", + "sessionMonitor.roleAssistant": "Assistant", + "sessionMonitor.roleTool": "Tool", + "sessionMonitor.onlyAdministrator": "This page is available to administrator only.", + }, +}; + +const LANG_KEY = "ops_admin_lang"; +const SESSION_MONITOR_ROLE_FILTER_KEY = "ops_session_monitor_role_filter"; +let currentLang = (localStorage.getItem(LANG_KEY) || "zh").toLowerCase(); +if (!I18N[currentLang]) currentLang = "zh"; +const AUTH_TOKEN_KEY = "ops_admin_token"; +const AUTH_SESSION_KEY = "ops_admin_session"; +const CHAT_MEMORY_MODE_KEY = "ops_chat_memory_mode"; +/** 与 localStorage 不同:sessionStorage 按标签页隔离,避免「管理员页 + 普通用户控制台」互相覆盖 token。 */ +function authStoreGet(key) { + try { + return sessionStorage.getItem(key); + } catch (_) { + return null; + } +} +function authStoreSet(key, value) { + try { + sessionStorage.setItem(key, value); + } catch (_) {} +} +function authStoreRemove(key) { + try { + sessionStorage.removeItem(key); + } catch (_) {} +} +/** 历史版本把 token 放在 localStorage,多标签会串号;启动时清掉以免误读。 */ +try { + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_SESSION_KEY); +} catch (_) {} +/** + * /chat 与 /admin 历史上使用不同 key;当用户在同一窗口从 /chat 跳到 /admin 时, + * 没有 opener 可用,需要从 chat 存储主动迁移一次以复用登录态。 + */ +(function seedAdminAuthFromChatStorageIfNeeded() { + try { + if (authStoreGet(AUTH_TOKEN_KEY)) return; + const pairs = [ + [sessionStorage, "ops_chat_token", "ops_chat_session"], + [localStorage, "ops_chat_token", "ops_chat_session"], + ]; + for (const [store, kTok, kSess] of pairs) { + let tok = null; + let sess = null; + try { + tok = store.getItem(kTok); + sess = store.getItem(kSess); + } catch (_) { + continue; + } + const t = String(tok || "").trim(); + const s = String(sess || "").trim(); + if (t && s) { + authStoreSet(AUTH_TOKEN_KEY, t); + authStoreSet(AUTH_SESSION_KEY, s); + break; + } + } + } catch (_) {} +})(); +/** 从聊天页新开 /admin(审计、设置等)时,复用 opener 同一登录态(chat 或 admin token)。勿对 window.open 使用 noopener,否则读不到 opener。 */ +(function seedAdminAuthFromOpenerIfNeeded() { + try { + if (authStoreGet(AUTH_TOKEN_KEY)) return; + const op = window.opener; + if (!op || op === window) return; + if (String(location.origin) !== String(op.location.origin)) return; + const pairs = [ + ["ops_admin_token", "ops_admin_session"], + ["ops_chat_token", "ops_chat_session"], + ]; + for (const [kTok, kSess] of pairs) { + let tok = null; + let sess = null; + try { + tok = op.sessionStorage.getItem(kTok) || op.localStorage.getItem(kTok); + sess = op.sessionStorage.getItem(kSess) || op.localStorage.getItem(kSess); + } catch (_) { + continue; + } + const t = String(tok || "").trim(); + const s = String(sess || "").trim(); + if (t && s) { + authStoreSet(AUTH_TOKEN_KEY, t); + authStoreSet(AUTH_SESSION_KEY, s); + break; + } + } + } catch (_) {} +})(); +let authSession = null; + +function t(key) { + return (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en && I18N.en[key]) || key; +} + +function tf(key, vars = {}) { + const template = t(key); + return template.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? "")); +} + +/** 合并展示名与登录名(列表/团队页共用) */ +function formatUserLabel(u) { + const un = String(u.username ?? "").trim(); + const dn = String(u.display_name ?? "").trim(); + if (dn && un && dn.toLowerCase() !== un.toLowerCase()) { + return `${dn} (${un})`; + } + return dn || un || "—"; +} + +function formatAuditActor(r) { + const un = String(r.actor_username ?? "").trim(); + const dn = String(r.actor_display_name ?? "").trim(); + const id = String(r.actor_user_id ?? "").trim(); + if (dn && un && dn.toLowerCase() !== un.toLowerCase()) { + return `${dn} (${un})`; + } + if (dn || un) return dn || un; + return id ? id.slice(0, 8) + (id.length > 8 ? "…" : "") : "—"; +} + +function applyI18nStatic() { + document.documentElement.lang = currentLang === "zh" ? "zh-CN" : "en"; + document.querySelectorAll("[data-i18n]").forEach((node) => { + const key = node.getAttribute("data-i18n"); + node.textContent = t(key); + }); + const langBtn = document.getElementById("btnLang"); + if (langBtn) langBtn.textContent = t("lang.switch"); +} + +function toggleLang() { + currentLang = currentLang === "zh" ? "en" : "zh"; + localStorage.setItem(LANG_KEY, currentLang); +} + +function getRoute() { + const raw = (location.hash || "#/stack").replace(/^#\//, ""); + const [pageRaw, queryRaw] = raw.split("?", 2); + let page = (pageRaw || "stack").trim(); + if (page === "channels") page = "stack"; + if (page === "tenants") page = "users"; + const params = new URLSearchParams(queryRaw || ""); + return { page, params }; +} + +/** 当控制台挂在子路径(如 /gw/admin)时,把 /admin/api/... 解析为 /gw/admin/api/... */ +function resolveAdminApiUrl(path) { + const p = String(path || ""); + if (!p.startsWith("/admin/")) return p; + const pathname = (location.pathname || "").replace(/\/+$/, "") || "/"; + const marker = "/admin"; + const pos = pathname.lastIndexOf(marker); + if (pos <= 0) return p; + return pathname.slice(0, pos) + p; +} + +function resolveChatUrl() { + const pathname = (location.pathname || "").replace(/\/+$/, "") || "/"; + const marker = "/admin"; + if (pathname.endsWith(marker)) { + return `${pathname.slice(0, -marker.length) || ""}/chat`; + } + return "/chat"; +} + +function getStoredAuthToken() { + return String(authStoreGet(AUTH_TOKEN_KEY) || "").trim(); +} + +/** 会话在服务端失效或本地缺 token 时清理,并下一帧回到登录(避免在 router 内部 await router 盖住登录页) */ +function scheduleReauthAfter401(requestUrl) { + const u = String(requestUrl || ""); + if (u.includes("/admin/api/auth/login") || u.includes("/admin/api/auth/bootstrap")) return; + authStoreRemove(AUTH_TOKEN_KEY); + authStoreRemove(AUTH_SESSION_KEY); + authSession = null; + setTimeout(() => { + router().catch(() => {}); + }, 0); +} + +function _haltAfter401() { + // 401 means "login required". We re-route to login; callers should not show a "request failed" popup. + // Returning a never-resolving promise avoids bubbling errors into UI code that would flash an alert. + return new Promise(() => {}); +} + +async function apiGet(path) { + const url = resolveAdminApiUrl(path); + const token = getStoredAuthToken(); + const headers = { "accept": "application/json" }; + if (token) headers["authorization"] = `Bearer ${token}`; + const res = await fetch(url, { headers }); + if (res.status === 401) { + scheduleReauthAfter401(url); + return await _haltAfter401(); + } + if (!res.ok) throw new Error(`GET ${url} ${res.status}`); + return await res.json(); +} + +async function apiPost(path, body) { + const url = resolveAdminApiUrl(path); + const token = getStoredAuthToken(); + const headers = { "content-type": "application/json", "accept": "application/json" }; + if (token) headers["authorization"] = `Bearer ${token}`; + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body ?? {}), + }); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch (_) { + data = null; + } + if (res.status === 401) { + scheduleReauthAfter401(url); + return await _haltAfter401(); + } + if (!res.ok) { + const d = data && typeof data === "object" ? data : {}; + const detail = (d).detail != null ? String((d).detail) : ""; + const errKey = (d).error != null ? String((d).error) : ""; + const msg = detail || errKey || `POST ${url} ${res.status}`; + throw new Error(msg); + } + return data ?? {}; +} + +async function apiRequest(method, path, body) { + const url = resolveAdminApiUrl(path); + const token = getStoredAuthToken(); + const headers = { "accept": "application/json" }; + if (token) headers["authorization"] = `Bearer ${token}`; + if (body !== undefined && method !== "GET" && method !== "HEAD") { + headers["content-type"] = "application/json"; + } + const res = await fetch(url, { + method, + headers, + body: body !== undefined && method !== "GET" && method !== "HEAD" ? JSON.stringify(body ?? {}) : undefined, + }); + if (res.status === 401) { + scheduleReauthAfter401(url); + return await _haltAfter401(); + } + if (!res.ok) { + let msg = `${method} ${url} ${res.status}`; + try { + const j = await res.json(); + if (j && j.detail !== undefined) { + msg = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); + } + } catch (_) {} + throw new Error(msg); + } + const ct = res.headers.get("content-type") || ""; + if (!ct.includes("application/json")) return {}; + return await res.json(); +} + +async function apiPostFormData(path, formData) { + const url = resolveAdminApiUrl(path); + const token = getStoredAuthToken(); + const headers = {}; + if (token) headers.authorization = `Bearer ${token}`; + const res = await fetch(url, { method: "POST", headers, body: formData }); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch (_) { + data = null; + } + if (res.status === 401) { + scheduleReauthAfter401(url); + return await _haltAfter401(); + } + if (!res.ok) { + const d = data && typeof data === "object" ? data : {}; + const detail = d.detail != null ? String(d.detail) : ""; + const errKey = d.error != null ? String(d.error) : ""; + throw new Error(detail || errKey || `POST ${url} ${res.status}`); + } + return data ?? {}; +} + +async function apiDeleteJson(path) { + const url = resolveAdminApiUrl(path); + const token = getStoredAuthToken(); + const headers = { accept: "application/json" }; + if (token) headers.authorization = `Bearer ${token}`; + const res = await fetch(url, { method: "DELETE", headers }); + if (res.status === 401) { + scheduleReauthAfter401(url); + return await _haltAfter401(); + } + if (!res.ok) { + let msg = `DELETE ${url} ${res.status}`; + try { + const j = await res.json(); + if (j && j.detail !== undefined) msg = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); + } catch (_) {} + throw new Error(msg); + } + try { + return await res.json(); + } catch (_) { + return {}; + } +} + +function _activeCopyCell() { + return document.querySelector(".cell-copyable.cell-selected"); +} + +function _selectCopyCell(cell) { + const prev = _activeCopyCell(); + if (prev && prev !== cell) prev.classList.remove("cell-selected"); + if (cell) cell.classList.add("cell-selected"); +} + +function _shouldIgnoreCopyShortcut(ev) { + const t = ev && ev.target ? ev.target : null; + if (!t || !(t instanceof HTMLElement)) return false; + const tag = String(t.tagName || "").toLowerCase(); + if (t.isContentEditable) return true; + return tag === "input" || tag === "textarea" || tag === "select"; +} + +function _installCellCopyKeyboardShortcut() { + if (window.__adminCellCopyShortcutInstalled) return; + window.__adminCellCopyShortcutInstalled = true; + document.addEventListener("keydown", async (ev) => { + if (_shouldIgnoreCopyShortcut(ev)) return; + if (!(ev.ctrlKey || ev.metaKey) || String(ev.key || "").toLowerCase() !== "c") return; + const active = _activeCopyCell(); + if (!active) return; + try { + const txt = String(active.getAttribute("data-copy-text") || active.getAttribute("title") || active.textContent || ""); + if (!txt.trim()) return; + ev.preventDefault(); + await navigator.clipboard.writeText(txt); + active.classList.add("cell-copied"); + setTimeout(() => active.classList.remove("cell-copied"), 2600); + } catch (_) { + // no-op + } + }); +} + +function attachCellCopyBehavior(cell, getCopyText) { + if (!cell || cell.dataset.copyBound === "1") return; + _installCellCopyKeyboardShortcut(); + cell.dataset.copyBound = "1"; + cell.classList.add("cell-copyable"); + cell.addEventListener("click", () => { + _selectCopyCell(cell); + }); + cell.addEventListener("dblclick", async () => { + try { + const txt = String(typeof getCopyText === "function" ? getCopyText() : (cell.textContent || "")); + if (!txt) return; + cell.setAttribute("data-copy-text", txt); + await navigator.clipboard.writeText(txt); + cell.classList.add("cell-copied"); + setTimeout(() => cell.classList.remove("cell-copied"), 2600); + } catch (_) { + // keep tooltip fallback when clipboard API unavailable + } + }); +} + +function el(tag, attrs = {}, children = []) { + const e = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (v === undefined || v === null) continue; + if (k === "class") e.className = v; + else if (k === "text") e.textContent = v; + else if (k.startsWith("on") && typeof v === "function") e.addEventListener(k.slice(2), v); + else if (k === "disabled" && v === false) { + e.removeAttribute("disabled"); + } else e.setAttribute(k, v); + } + for (const c of children) e.appendChild(c); + if (String(tag || "").toLowerCase() === "td" && String(e.getAttribute("data-copy-disabled") || "") !== "1") { + const txt = String(e.textContent || "").trim(); + if (txt) { + if (!e.getAttribute("title")) e.setAttribute("title", txt); + attachCellCopyBehavior(e, () => String(e.getAttribute("title") || e.textContent || "")); + } + } + return e; +} + +function tdCell(value, maxLen = 120) { + const full = String(value ?? ""); + const shown = formatSystemLocalDateTime(full); + const title = shown === full ? full : `${shown}\n${full}`; + const td = el("td", { text: shortText(shown, maxLen), title }); + td.setAttribute("data-copy-text", shown); + attachCellCopyBehavior(td, () => shown); + return td; +} + +function enableTableColumnResize(tableEl, columnIndexes = []) { + if (!tableEl) return; + const heads = Array.from(tableEl.querySelectorAll("thead th")); + if (!heads.length) return; + tableEl.classList.add("table--resizable"); + heads.forEach((th) => th.querySelectorAll(".table-col-resizer").forEach((x) => x.remove())); + const enabled = new Set(Array.isArray(columnIndexes) ? columnIndexes : []); + heads.forEach((th, idx) => { + if (enabled.size && !enabled.has(idx)) return; + const handle = document.createElement("span"); + handle.className = "table-col-resizer"; + handle.addEventListener("mousedown", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const startX = ev.clientX; + const startW = th.getBoundingClientRect().width; + document.body.classList.add("col-resize-active"); + const onMove = (mv) => { + const next = Math.max(72, Math.round(startW + (mv.clientX - startX))); + th.style.width = `${next}px`; + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.classList.remove("col-resize-active"); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }); + th.appendChild(handle); + }); +} + +function setActive(page) { + document.querySelectorAll(".nav__item").forEach((a) => { + a.classList.toggle("nav__item--active", a.dataset.page === page); + }); + document.getElementById("topTitle").textContent = ({ + stack: t("title.stack"), + users: t("title.users"), + memory: t("title.memory"), + models: t("title.models"), + "api-grants": t("title.apiGrants"), + audit: t("title.audit"), + "session-monitor": t("title.sessionMonitor"), + "admin-audit": t("title.adminAudit"), + plugins: t("title.plugins"), + skills: t("title.skills"), + attachments: t("title.attachments"), + "workspace-paths": t("title.workspacePaths"), + profile: t("title.profile"), + }[page] || page); +} + +function mount(node) { + const c = document.getElementById("content"); + c.innerHTML = ""; + c.appendChild(node); +} + +function shortText(v, maxLen = 200) { + const s = String(v ?? ""); + if (s.length <= maxLen) return s; + return s.slice(0, Math.max(0, maxLen - 3)) + "..."; +} + +function formatSystemLocalDateTime(value) { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + // Keep non-datetime strings unchanged. + if (!/^\d{4}-\d{2}-\d{2}T/.test(raw)) return raw; + const d = new Date(raw); + if (Number.isNaN(d.getTime())) return raw; + const locale = currentLang === "zh" ? "zh-CN" : "en-US"; + try { + return d.toLocaleString(locale, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + } catch (_) { + return raw; + } +} + +function formatIds(v) { + if (!Array.isArray(v)) return ""; + const vals = v.map((x) => String(x ?? "").trim()).filter(Boolean); + if (!vals.length) return ""; + if (vals.length > 4) return vals.slice(0, 4).join(", ") + ` (+${vals.length - 4})`; + return vals.join(", "); +} + +function yesNo(v) { + return v ? "yes" : "no"; +} + +async function renderStack() { + const [st, anomaliesResp] = await Promise.all([ + apiGet("/admin/api/stack/status"), + apiGet("/admin/api/runtime/anomalies"), + ]); + const requiredServices = ["gateway", "channel:wecom"]; + const runningNames = new Set( + (Array.isArray(st.items) ? st.items : []) + .filter((x) => Boolean(x && x.running)) + .map((x) => String(x.name || "")), + ); + const missingRequired = requiredServices.filter((n) => !runningNames.has(n)); + const items = (st.items || []).map((x) => el("tr", {}, [ + el("td", { text: String(x.name || "") }), + el("td", { text: String(x.pid || "") }), + el("td", {}, [ + el("span", { class: "badge " + (x.running ? "badge--ok" : "badge--bad"), text: x.running ? t("status.running") : t("status.stopped") }), + ]), + ])); + const btnUp = el("button", { class: "btn btn--primary", text: t("stack.up"), onclick: async () => { + await apiPost("/admin/api/stack/up", { channel: "wecom" }); + router(); + }}); + const btnDown = el("button", { class: "btn btn--danger", text: t("stack.down"), onclick: async () => { + await apiPost("/admin/api/stack/down", {}); + router(); + }}); + const cleanupStatus = el("div", { class: "muted", text: "" }); + const btnCleanup = el("button", { class: "btn btn--danger", text: t("stack.cleanup"), onclick: async () => { + const resp = await apiPost("/admin/api/runtime/cleanup", {}); + const killed = Array.isArray(resp.killed) ? resp.killed : []; + const total = killed.reduce((acc, x) => acc + ((x && Array.isArray(x.killed_pids)) ? x.killed_pids.length : 0), 0); + cleanupStatus.textContent = total > 0 ? tf("stack.cleanupDone", { count: total }) : t("stack.cleanupNone"); + router(); + }}); + const alerts = Array.isArray(anomaliesResp.items) ? anomaliesResp.items : []; + const mustCleanup = Boolean(anomaliesResp.must_cleanup); + const mergedAlerts = alerts.slice(); + if (missingRequired.length) { + mergedAlerts.unshift({ + severity: "critical", + message: tf("stack.missingServices", { services: missingRequired.join(", ") }), + }); + } + const alertRows = mergedAlerts.length + ? mergedAlerts.map((a) => el("li", { + text: `[${String(a.severity || "info").toUpperCase()}] ${String(a.message || "")}`, + })) + : [el("li", { text: t("stack.noAlerts") })]; + return el("div", {}, [ + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("stack.alerts") }), + (mustCleanup || missingRequired.length) + ? el("div", { + class: "alert alert--critical", + text: missingRequired.length ? t("stack.missingRequired") : t("stack.mustCleanup"), + }) + : el("div", { class: "muted", text: t("stack.noAlerts") }), + el("ul", { class: "alert-list" }, alertRows), + el("div", { class: "row" }, [btnCleanup]), + cleanupStatus, + ]), + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("stack.title") }), + el("div", { class: "row" }, [btnUp, btnDown]), + el("div", { style: "height:10px" }), + el("table", { class: "table" }, [ + el("thead", {}, [el("tr", {}, [el("th", { text: t("table.service") }), el("th", { text: t("table.pid") }), el("th", { text: t("table.status") })])]), + el("tbody", {}, items), + ]), + ]), + ]); +} + +async function renderUserManagement() { + const sessionTid = String((authSession && authSession.tenant_id) || "").trim(); + if (!sessionTid) { + return el("div", {}, [ + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("users.title") }), + el("div", { class: "muted", text: t("tenants.noSessionTenant") }), + ]), + ]); + } + + let allTenants = []; + try { + const allTenantsResp = await apiGet("/admin/api/tenants"); + allTenants = allTenantsResp.tenants || []; + } catch (err) { + if (!hasPermission("admin:user:read")) { + return el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("common.error") }), + el("div", { class: "pre", text: String(err) }), + ]); + } + allTenants = [{ id: sessionTid, name: "", created_at: "" }]; + } + + if (!allTenants.length) { + return el("div", {}, [ + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("users.title") }), + el("div", { class: "muted", text: t("tenants.noTenants") }), + ]), + ]); + } + + const status = el("div", { class: "muted", text: "" }); + + const tenantRows = allTenants.map((x) => { + const tid = String(x.id || ""); + const isLoginHome = tid === sessionTid; + const tname = String(x.name || "").trim() || tid.slice(0, 8); + const canDelete = allTenants.length > 1 && tid !== sessionTid; + const btnDel = el("button", { + type: "button", + class: "btn btn--danger", + text: t("tenants.deleteTenant"), + disabled: canDelete ? undefined : "disabled", + title: canDelete + ? undefined + : allTenants.length <= 1 + ? t("tenants.deleteDisabledLast") + : t("tenants.deleteDisabledCurrent"), + onclick: async (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!canDelete) return; + if (!window.confirm(tf("tenants.deleteTenantConfirm", { name: tname }))) return; + status.textContent = ""; + try { + const resp = await apiPost("/admin/api/tenants/delete", { tenant_id: tid }); + if (!resp.ok) { + const err = String(resp.error || "error"); + status.textContent = + err === "last_tenant_cannot_delete" + ? t("tenants.errLastTenant") + : err === "cannot_delete_current_tenant" + ? t("tenants.errCannotDeleteCurrentTenant") + : err; + return; + } + if (!Number(resp.deleted || 0)) { + status.textContent = t("tenants.errTenantDeleteMiss"); + return; + } + await router(); + } catch (err) { + status.textContent = String(err && err.message ? err.message : err); + } + }, + }); + return el("tr", {}, [ + el("td", { text: String(x.name || "") }), + el("td", { text: formatSystemLocalDateTime(String(x.created_at || "")) }), + el("td", { + class: isLoginHome ? "" : "muted", + text: isLoginHome ? t("tenants.scopeLoginHome") : "—", + }), + el("td", {}, [btnDel]), + ]); + }); + + const selector = el("select", { class: "input" }); + for (const item of allTenants) { + const optLabel = String(item.name || "").trim() || String(item.id || "").slice(0, 8); + selector.appendChild(el("option", { value: String(item.id || ""), text: optLabel })); + } + const preferredIdx = allTenants.findIndex((x) => String(x.id || "") === sessionTid); + selector.selectedIndex = preferredIdx >= 0 ? preferredIdx : 0; + + const bindingsBody = el("tbody"); + const codesBody = el("tbody"); + + const searchInput = el("input", { class: "input", placeholder: t("users.search") }); + const userStatus = el("div", { class: "muted", text: "" }); + const userBody = el("tbody"); + const accountStatus = el("div", { class: "muted", text: "" }); + const selectedUserField = el("input", { + class: "input input--readonly", + readonly: "readonly", + placeholder: t("users.wecomUserEmpty"), + }); + const accountBody = el("tbody"); + const accountIdInput = el("input", { class: "input", placeholder: t("users.wecomBotId") }); + const accountNameInput = el("input", { class: "input", placeholder: t("users.wecomInstanceName") }); + const botSecretInput = el("input", { class: "input", type: "password", placeholder: t("users.wecomBotSecret") }); + const clearBotChk = el("input", { type: "checkbox" }); + const accountActiveInput = el("input", { type: "checkbox" }); + accountActiveInput.checked = true; + let selectedUserId = ""; + let selectedUsername = ""; + let selectedDisplayName = ""; + + const refreshSelectedUserField = () => { + if (!selectedUserId) { + selectedUserField.value = ""; + return; + } + const who = formatUserLabel({ username: selectedUsername, display_name: selectedDisplayName }); + selectedUserField.value = who; + }; + + const clearWecomForm = () => { + accountIdInput.value = ""; + accountNameInput.value = ""; + botSecretInput.value = ""; + clearBotChk.checked = false; + accountActiveInput.checked = true; + }; + + const newUserName = el("input", { class: "input", placeholder: t("users.createNamePlaceholder") }); + const newUserRole = el("select", { class: "input" }, [ + el("option", { value: "member", text: "member" }), + el("option", { value: "admin", text: "admin" }), + el("option", { value: "guest", text: "guest" }), + el("option", { value: "owner", text: "owner" }), + ]); + const newUserPassword = el("input", { class: "input", type: "password", placeholder: t("auth.password") }); + + const getTenantId = () => String(selector.value || ""); + + const mergeWecomBindingAndInstances = (bindings, items) => { + const byAid = new Map(); + for (const it of items) { + const aid = String(it.account_id || "").trim(); + if (aid) byAid.set(aid, it); + } + const seen = new Set(); + const out = []; + for (const b of bindings) { + const aid = String(b.account_id || "").trim(); + if (!aid || seen.has(aid)) continue; + seen.add(aid); + out.push({ binding: b, instance: byAid.get(aid) || null }); + } + for (const it of items) { + const aid = String(it.account_id || "").trim(); + if (!aid || seen.has(aid)) continue; + out.push({ binding: null, instance: it }); + } + return out; + }; + + const prefillFromMerged = (m) => { + const inst = m.instance; + const bind = m.binding; + const aid = inst ? String(inst.account_id || "").trim() : String(bind?.account_id || "").trim(); + accountIdInput.value = aid; + const nameFromInst = inst ? String(inst.name || "").trim() : ""; + const nameFromBind = bind ? String(bind.account_name || "").trim() : ""; + accountNameInput.value = nameFromInst || nameFromBind; + if (inst) { + accountActiveInput.checked = !!inst.is_active; + } else { + accountActiveInput.checked = true; + } + botSecretInput.value = ""; + clearBotChk.checked = false; + }; + + const loadAccounts = async () => { + accountBody.innerHTML = ""; + clearWecomForm(); + const tenantId = getTenantId(); + if (!selectedUserId) { + accountStatus.textContent = ""; + accountBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "8" })])); + return; + } + const bindUrl = `/admin/api/bindings?tenant_id=${encodeURIComponent(tenantId)}&channel=wecom&user_id=${encodeURIComponent(selectedUserId)}`; + const accUrl = `/admin/api/user-channel-accounts?tenant_id=${encodeURIComponent(tenantId)}&user_id=${encodeURIComponent(selectedUserId)}&channel=wecom&include_inactive=1`; + const [bindResp, accResp] = await Promise.all([apiGet(bindUrl), apiGet(accUrl)]); + const bindings = Array.isArray(bindResp.bindings) ? bindResp.bindings : []; + const items = Array.isArray(accResp.items) ? accResp.items : []; + const merged = mergeWecomBindingAndInstances(bindings, items); + accountStatus.textContent = ""; + if (!merged.length) { + accountBody.appendChild(el("tr", {}, [el("td", { text: t("users.noBindingsForUser"), colspan: "8" })])); + return; + } + merged.forEach((m) => { + const inst = m.instance; + const bind = m.binding; + const accountId = inst ? String(inst.account_id || "") : String(bind?.account_id || ""); + const displayName = (inst && String(inst.name || "").trim()) || (bind && String(bind.account_name || "").trim()) || ""; + const extUid = bind ? String(bind.external_user_id || "") : ""; + const ts = (inst && String(inst.updated_at || "").trim()) || (bind && String(bind.created_at || "").trim()) || ""; + const btnFill = el("button", { + class: "btn", + text: t("users.loadForm"), + onclick: (e) => { + e.stopPropagation(); + prefillFromMerged(m); + }, + }); + const deleteCell = el("td", {}); + if (inst) { + const btnDeleteAccount = el("button", { class: "btn btn--danger", text: t("users.accountDelete"), onclick: async (e) => { + e.stopPropagation(); + await apiPost("/admin/api/user-channel-accounts/delete", { + tenant_id: tenantId, + user_id: selectedUserId, + channel: "wecom", + account_id: accountId, + }); + await loadAccounts(); + }}); + deleteCell.appendChild(btnDeleteAccount); + } else { + deleteCell.appendChild(document.createTextNode("—")); + } + accountBody.appendChild(el("tr", {}, [ + tdCell(accountId, 22), + tdCell(displayName, 16), + tdCell(extUid || "—", 18), + tdCell(inst ? (inst.has_bot_secret ? "Y" : "-") : "—", 4), + tdCell(inst ? (inst.is_active ? "1" : "0") : "—", 4), + tdCell(ts, 20), + el("td", {}, [btnFill]), + deleteCell, + ])); + }); + }; + + const btnSaveAccount = el("button", { class: "btn", text: t("users.accountSave"), onclick: async () => { + if (!selectedUserId) { + accountStatus.textContent = "select user first"; + return; + } + const aid = accountIdInput.value.trim(); + if (!aid) { + accountStatus.textContent = t("users.botIdRequired"); + return; + } + const tenantId = getTenantId(); + const resp = await apiPost("/admin/api/user-channel-accounts/upsert", { + tenant_id: tenantId, + user_id: selectedUserId, + channel: "wecom", + account_id: aid, + name: accountNameInput.value.trim(), + wecom_mode: "bot_api", + bot_secret: botSecretInput.value.trim(), + clear_bot_secret: !!clearBotChk.checked, + is_active: !!accountActiveInput.checked, + }); + accountStatus.textContent = resp.ok ? "ok" : String(resp.error || "error"); + if (resp.ok) { + botSecretInput.value = ""; + clearBotChk.checked = false; + await loadAccounts(); + await loadList(); + } + }}); + + const loadList = async () => { + if (!hasPermission("admin:user:read")) { + userBody.innerHTML = ""; + userBody.appendChild(el("tr", {}, [el("td", { text: t("users.needUserRead"), colspan: "7" })])); + return; + } + const tenantId = getTenantId(); + const tMeta = allTenants.find((r) => String(r.id || "") === tenantId) || {}; + const sessionTenantName = String(tMeta.name || "").trim(); + const q = encodeURIComponent(searchInput.value.trim()); + const resp = await apiGet(`/admin/api/users?tenant_id=${encodeURIComponent(tenantId)}&include_inactive=1&q=${q}&limit=300`); + const users = Array.isArray(resp.users) ? resp.users : []; + userBody.innerHTML = ""; + if (!users.length) { + userBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "7" })])); + return; + } + users.forEach((u) => { + const newRole = el("select", { class: "input" }, [ + el("option", { value: "member", text: "member" }), + el("option", { value: "admin", text: "admin" }), + el("option", { value: "guest", text: "guest" }), + el("option", { value: "owner", text: "owner" }), + ]); + newRole.value = String(u.role || "member"); + const pwd = el("input", { class: "input", type: "password", placeholder: t("users.passwordOptional") }); + const btnToggle = el("button", { class: "btn", text: u.is_active ? t("users.disable") : t("users.enable"), onclick: async () => { + await apiPost("/admin/api/users/update", { + tenant_id: tenantId, + user_id: u.id, + is_active: !u.is_active, + }); + await loadList(); + }}); + const btnUpdate = el("button", { class: "btn", text: t("users.update"), onclick: async () => { + await apiPost("/admin/api/users/update", { + tenant_id: tenantId, + user_id: u.id, + role: newRole.value, + password: pwd.value.trim() || undefined, + }); + await loadList(); + }}); + const btnDelete = el("button", { class: "btn btn--danger", text: t("users.delete"), onclick: async (e) => { + e.stopPropagation(); + if (!window.confirm(t("users.deleteConfirm"))) return; + await apiPost("/admin/api/users/delete", { tenant_id: tenantId, user_id: u.id }); + await loadList(); + }}); + const actionWrap = el("div", { class: "table__cell-actions" }, [btnToggle, btnUpdate, btnDelete]); + const tr = el("tr", {}, [ + tdCell(sessionTenantName || "—", 14), + tdCell(formatUserLabel(u), 28), + tdCell(u.role || "", 10), + tdCell(u.is_active ? "1" : "0", 4), + el("td", { class: "table__cell--form" }, [newRole]), + el("td", { class: "table__cell--form" }, [pwd]), + el("td", { class: "table__cell--actions" }, [actionWrap]), + ]); + tr.style.cursor = "pointer"; + tr.addEventListener("click", async (e) => { + if (e.target && e.target.closest && e.target.closest("button,select,input")) return; + selectedUserId = String(u.id || ""); + selectedUsername = String(u.username || ""); + selectedDisplayName = String(u.display_name || ""); + refreshSelectedUserField(); + await loadAccounts(); + }); + userBody.appendChild(tr); + }); + }; + + const btnCreateUser = el("button", { class: "btn btn--primary", text: t("users.create"), onclick: async () => { + if (!hasPermission("admin:user:write")) { + userStatus.textContent = "forbidden"; + return; + } + try { + const raw = newUserName.value.trim(); + if (!raw) { + userStatus.textContent = t("users.createNameRequired"); + return; + } + const resp = await apiPost("/admin/api/users/create", { + tenant_id: getTenantId(), + username: raw, + display_name: raw, + role: newUserRole.value, + password: newUserPassword.value.trim(), + }); + userStatus.textContent = resp.ok ? "ok" : String(resp.error || "error"); + if (resp.ok) { + newUserName.value = ""; + newUserPassword.value = ""; + await loadList(); + await loadBindingsAndCodes(); + } + } catch (e) { + userStatus.textContent = String(e && e.message ? e.message : e); + } + }}); + + const btnSearchUsers = el("button", { class: "btn", text: t("audit.query"), onclick: loadList }); + + const createName = el("input", { class: "input", placeholder: t("tenants.createPlaceholder") }); + const btnCreateTenant = el("button", { class: "btn", text: t("tenants.create"), onclick: async () => { + if (!hasPermission("admin:tenant:write")) { + status.textContent = "forbidden"; + return; + } + await apiPost("/admin/api/tenants/create", { name: createName.value.trim() || "Team" }); + await router(); + }}); + + const roleInput = el("select", { class: "input" }, [ + el("option", { value: "member", text: "member" }), + el("option", { value: "admin", text: "admin" }), + el("option", { value: "guest", text: "guest" }), + ]); + const codeInput = el("input", { class: "input", placeholder: t("tenants.codeOptional") }); + const btnCreateCode = el("button", { class: "btn", text: t("tenants.createCode"), onclick: async () => { + const tid = String(selector.value || ""); + await apiPost("/admin/api/bind-codes/create", { + tenant_id: tid, + role: roleInput.value || "member", + code: codeInput.value.trim(), + }); + codeInput.value = ""; + await loadBindingsAndCodes(); + }}); + const btnDeleteUnbound = el("button", { class: "btn btn--danger", text: t("tenants.deleteUnbound"), onclick: async () => { + const tid = String(selector.value || ""); + const resp = await apiPost("/admin/api/users/delete-unbound", { tenant_id: tid, channel: "wecom" }); + status.textContent = tf("tenants.deleteUnboundResult", { + deleted: Number(resp.deleted || 0), + orphan: Number(resp.orphan_users || 0), + bound: Number(resp.bound_users || 0), + total: Number(resp.users_total || 0), + }); + await loadBindingsAndCodes(); + await loadList(); + }}); + + const loadBindingsAndCodes = async () => { + const tid = String(selector.value || ""); + const [bindingsResp, codesResp] = await Promise.all([ + apiGet("/admin/api/bindings?tenant_id=" + encodeURIComponent(tid) + "&channel=wecom"), + apiGet("/admin/api/bind-codes?tenant_id=" + encodeURIComponent(tid)), + ]); + const bindings = bindingsResp.bindings || []; + const codes = codesResp.codes || []; + + const bindingTenantLabel = (tenantId) => { + const id = String(tenantId || ""); + const meta = allTenants.find((x) => String(x.id || "") === id); + const name = String(meta && meta.name ? meta.name : "").trim(); + return name || id.slice(0, 8) || "—"; + }; + const bindingUserLabel = (b) => + formatUserLabel({ + username: String(b.username != null && b.username !== "" ? b.username : b.user_id || ""), + display_name: String(b.display_name || ""), + }); + + bindingsBody.innerHTML = ""; + if (!bindings.length) { + bindingsBody.appendChild(el("tr", {}, [el("td", { text: t("tenants.noBindings"), colspan: "6" })])); + } else { + bindings.forEach((b) => { + bindingsBody.appendChild(el("tr", {}, [ + el("td", { text: bindingTenantLabel(b.tenant_id) }), + el("td", { text: bindingUserLabel(b) }), + el("td", { text: String(b.channel || "") }), + el("td", { text: String(b.account_id || "") }), + el("td", { text: String(b.account_name || "") }), + el("td", { text: String(b.external_user_id || "") }), + ])); + }); + } + + codesBody.innerHTML = ""; + if (!codes.length) { + codesBody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "6" })])); + } else { + codes.forEach((c) => { + const codeText = String(c.code || ""); + const isUsed = Boolean(c.used_at); + const btnDeleteCode = el("button", { class: "btn btn--danger", text: t("tenants.deleteCode"), onclick: async () => { + if (!window.confirm(t("tenants.codeDeleteConfirm"))) return; + await apiPost("/admin/api/bind-codes/delete", { tenant_id: tid, code: codeText }); + status.textContent = tf("tenants.codeDeleted", { code: codeText }); + await loadBindingsAndCodes(); + }}); + codesBody.appendChild(el("tr", {}, [ + el("td", { text: codeText }), + el("td", { text: String(c.role || "") }), + el("td", { text: isUsed ? t("tenants.codeUsed") : t("tenants.codeUnused") }), + el("td", { text: String(c.used_by_external_user_id || "") }), + el("td", { text: formatSystemLocalDateTime(String(c.created_at || "")) }), + el("td", {}, [btnDeleteCode]), + ])); + }); + } + }; + selector.addEventListener("change", () => { + selectedUserId = ""; + selectedUsername = ""; + selectedDisplayName = ""; + refreshSelectedUserField(); + Promise.all([loadBindingsAndCodes(), loadList(), loadAccounts()]).catch((err) => { + mount(el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.error") }), el("div", { class: "pre", text: String(err) })])); + }); + }); + await loadBindingsAndCodes(); + await loadList(); + refreshSelectedUserField(); + await loadAccounts(); + + const tenantsListHint = allTenants.length > 1 + ? el("div", { class: "muted", style: "margin-top:8px", text: t("tenants.allTenantsHint") }) + : el("div", {}); + + return el("div", {}, [ + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("tenants.title") }), + el("div", { class: "row" }, [createName, btnCreateTenant]), + el("div", { class: "row" }, [el("label", { text: t("tenants.select") }), selector]), + el("div", { class: "muted", style: "margin-top:6px", text: t("tenants.scopeHint") }), + el("table", { class: "table" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: t("tenants.colTenantName") }), + el("th", { text: t("table.createdAt") }), + el("th", { text: t("tenants.colScope") }), + el("th", { text: t("tenants.rowActions") }), + ])]), + el("tbody", {}, tenantRows), + ]), + status, + tenantsListHint, + ]), + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("users.title") }), + el("div", { class: "row" }, [searchInput, btnSearchUsers]), + el("div", { class: "row" }, [newUserName, newUserRole, newUserPassword, btnCreateUser]), + userStatus, + el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact table--users-mgmt" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: t("tenants.colTenantName") }), + el("th", { text: t("users.colUser") }), + el("th", { text: t("tenants.role") }), + el("th", { text: t("users.colActive") }), + el("th", { text: t("users.colSetRole") }), + el("th", { text: t("users.passwordOptional") }), + el("th", { text: t("tenants.rowActions") }), + ])]), + userBody, + ])]), + ]), + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("users.wecomAccounts") }), + el("div", {}, [el("div", { class: "muted", text: t("users.wecomUserLabel") }), selectedUserField]), + el("div", { class: "muted", style: "margin-bottom:8px", text: t("users.accountLoadHint") }), + el("div", { class: "row row--wecom-form" }, [ + el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomBotId") }), accountIdInput]), + el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomInstanceName") }), accountNameInput]), + el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomBotSecret") }), botSecretInput]), + el("label", { class: "kv row--wecom-form__chk" }, [accountActiveInput, document.createTextNode(" " + t("users.accountActive"))]), + el("label", { class: "kv row--wecom-form__chk" }, [clearBotChk, document.createTextNode(" " + t("users.clearBotSecret"))]), + btnSaveAccount, + ]), + accountStatus, + el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: t("users.wecomBotId") }), + el("th", { text: t("users.wecomInstanceName") }), + el("th", { text: t("table.externalUserId") }), + el("th", { text: t("users.hasBotSecret") }), + el("th", { text: t("users.accountActive") }), + el("th", { text: t("table.timestamp") }), + el("th", { text: t("users.loadForm") }), + el("th", { text: t("users.accountActions") }), + ])]), + accountBody, + ])]), + ]), + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("tenants.bindings") }), + el("table", { class: "table" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: t("tenants.title") }), + el("th", { text: t("tenants.users") }), + el("th", { text: t("table.channel") }), + el("th", { text: "account_id" }), + el("th", { text: "account_name" }), + el("th", { text: t("table.externalUserId") }), + ])]), + bindingsBody, + ]), + ]), + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("tenants.bindCodes") }), + el("div", { class: "row" }, [ + el("label", { text: t("tenants.role") }), + roleInput, + codeInput, + btnCreateCode, + btnDeleteUnbound, + ]), + el("table", { class: "table" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "code" }), + el("th", { text: t("tenants.role") }), + el("th", { text: t("tenants.codeStatus") }), + el("th", { text: t("tenants.codeUsedBy") }), + el("th", { text: t("table.createdAt") }), + el("th", { text: t("tenants.deleteCode") }), + ])]), + codesBody, + ]), + ]), + ]); +} + +function hasPermission(permission) { + const role = String((authSession && authSession.role) || ""); + const perms = new Set(Array.isArray(authSession && authSession.permissions) ? authSession.permissions : []); + if (role === "owner") return true; + return perms.has(permission); +} + +/** 与 models_api._can_manage_llm_grants 一致:仅控制台 administrator 用户可管理授权 */ +function canManageApiGrants() { + const un = String((authSession && authSession.username) || "").trim().toLowerCase(); + if (un !== "administrator") return false; + const role = String((authSession && authSession.role) || "").trim(); + if (role === "owner") return true; + return hasPermission("admin:tenant:write"); +} + +function isAdministratorUsername() { + return String((authSession && authSession.username) || "").trim().toLowerCase() === "administrator"; +} + +/** 与 models_api._profile_shareable_for_admin_grant 一致 */ +function profileShareableForGrant(p, sessionUserId) { + if (!p || p.is_builtin) return false; + const own = String(p.owner_user_id || "").trim(); + if (!own) return true; + return own === String(sessionUserId || "").trim(); +} + +async function renderAudit(initialSessionId = "") { + const input = el("input", { class: "input", placeholder: t("audit.sessionIdPlaceholder") }); + const resultWrap = el("div"); + let selectedTraceId = ""; + const onlyFailures = el("input", { type: "checkbox" }); + const formatTracePayload = (row) => { + const et = String((row && row.event_type) || ""); + const p = (row && typeof row.payload === "object" && row.payload) ? row.payload : {}; + if (et === "tool_wire_filter") { + const before = Number(p.tools_before || 0); + const after = Number(p.tools_after || 0); + const hidden = Number(p.hidden_total || 0); + const hiddenMcp = Number(p.hidden_mcp_total || 0); + const preview = Array.isArray(p.hidden_mcp_preview) ? p.hidden_mcp_preview.slice(0, 8).join(", ") : ""; + const head = `wire_filter before=${before} after=${after} hidden=${hidden} hidden_mcp=${hiddenMcp}`; + return preview ? `${head} | mcp: ${preview}` : head; + } + if (et === "llm_first_token") { + const ms = Number(p.first_token_ms || 0); + return `ttft_ms=${ms}`; + } + if (et === "memory_retrieval_finished") { + return `memory short=${Number(p.short_term_count || 0)} semantic=${Number(p.semantic_hit_count || 0)} enabled=${Boolean(p.enabled)}`; + } + if (et === "router_decision") { + return `route mode=${String(p.mode || "")} reason=${String(p.reason || "")}`; + } + if (et === "run_started") { + return `run_started run_id=${String(p.run_id || "")} max_attempts=${Number(p.max_attempts || 0)}`; + } + if (et === "attempt_started") { + return `attempt_started run_id=${String(p.run_id || "")} no=${Number(p.attempt_no || 0)}`; + } + if (et === "attempt_finished") { + return `attempt_finished run_id=${String(p.run_id || "")} no=${Number(p.attempt_no || 0)} status=${String(p.status || "")}`; + } + if (et === "run_retry") { + return `run_retry run_id=${String(p.run_id || "")} next_attempt=${Number(p.next_attempt_no || 0)}`; + } + if (et === "run_compact") { + return `run_compact run_id=${String(p.run_id || "")} count=${Number(p.compact_count || 0)}`; + } + if (et === "run_finished") { + return `run_finished run_id=${String(p.run_id || "")} status=${String(p.status || "")} attempts=${Number(p.attempts || 0)}`; + } + if (et === "task_enqueued" || et === "task_claimed" || et === "task_finished" || et === "task_failed") { + return `${et} task_id=${String(p.task_id || "")} ok=${p.ok === undefined ? "-" : Boolean(p.ok)}`; + } + if (et === "turn_stage" && String(p.stage || "") === "finish") { + const ttft = p.first_token_ms; + const elapsed = Number(p.turn_elapsed_ms || 0); + const tools = Number(p.tool_trace_count || 0); + return `finish ttft_ms=${ttft === null || ttft === undefined ? "-" : Number(ttft)} elapsed_ms=${elapsed} tools=${tools}`; + } + return JSON.stringify(p || {}); + }; + const auditColumnDefs = [ + { key: "timestamp", label: t("table.timestamp"), getter: (r) => r.timestamp || "", maxLen: 24 }, + { key: "specialist", label: t("table.specialist"), getter: (r) => r.specialist || "", maxLen: 24 }, + { key: "action", label: t("table.action"), getter: (r) => r.action || "", maxLen: 24 }, + { key: "status", label: t("table.statusText"), getter: (r) => r.status || "", maxLen: 16 }, + { key: "reason", label: t("table.reason"), getter: (r) => r.reason || "", maxLen: 40 }, + { key: "duration", label: t("table.durationMs"), getter: (r) => String(r.duration_ms || ""), maxLen: 20 }, + { + key: "noOutput", + label: t("table.noOutputAttachment"), + getter: (r) => yesNo(((r.payload && typeof r.payload === "object") ? r.payload.no_output_attachment : false)), + maxLen: 8, + }, + { + key: "attachments", + label: "attachments", + getter: (r) => { + const payload = (r && typeof r.payload === "object" && r.payload) ? r.payload : {}; + const routeIds = formatIds(payload.attachment_ids); + const inIds = formatIds(payload.input_attachment_ids); + const outIds = formatIds(payload.output_attachment_ids); + const outUrls = formatIds(payload.output_attachment_urls); + return [routeIds ? `route:${routeIds}` : "", inIds ? `in:${inIds}` : "", outIds ? `out:${outIds}` : "", outUrls ? `url:${outUrls}` : ""] + .filter(Boolean) + .join(" | ") || "-"; + }, + maxLen: 120, + }, + { + key: "payload", + label: t("table.payload"), + getter: (r) => JSON.stringify(r.payload || {}), + maxLen: 120, + }, + ]; + const traceColumnDefs = [ + { key: "timestamp", label: t("table.timestamp"), getter: (r) => r.timestamp || "", maxLen: 24 }, + { key: "eventType", label: t("table.eventType"), getter: (r) => r.event_type || "", maxLen: 28 }, + { key: "traceId", label: t("table.traceId"), getter: (r) => r.trace_id || "", maxLen: 20 }, + { key: "spanId", label: t("table.spanId"), getter: (r) => r.span_id || "", maxLen: 20 }, + { key: "parentSpanId", label: t("table.parentSpanId"), getter: (r) => r.parent_span_id || "", maxLen: 20 }, + { + key: "errorCode", + label: "error_code", + getter: (r) => String((r.payload && r.payload.error_code) || ""), + maxLen: 32, + }, + { key: "payload", label: t("table.payload"), getter: (r) => formatTracePayload(r), maxLen: 120 }, + ]; + const auditVisible = new Set(auditColumnDefs.map((x) => x.key)); + const traceVisible = new Set(traceColumnDefs.map((x) => x.key)); + + const buildColumnToggleRow = (defs, visibleSet) => { + const wrap = el("div", { class: "row" }); + defs.forEach((d) => { + const cb = el("input", { type: "checkbox" }); + cb.checked = visibleSet.has(d.key); + cb.addEventListener("change", () => { + if (cb.checked) visibleSet.add(d.key); + else visibleSet.delete(d.key); + }); + wrap.appendChild(el("label", { class: "kv" }, [cb, document.createTextNode(" " + d.label)])); + }); + return wrap; + }; + + const auditColsToggle = buildColumnToggleRow(auditColumnDefs, auditVisible); + const traceColsToggle = buildColumnToggleRow(traceColumnDefs, traceVisible); + const runQuery = async () => { + const sid = input.value.trim(); + const a = await apiGet("/admin/api/audit" + (sid ? ("?session_id=" + encodeURIComponent(sid)) : "")); + const tr = await apiGet("/admin/api/trace" + (sid ? ("?session_id=" + encodeURIComponent(sid)) : "")); + const tasks = await apiGet("/admin/api/oclaw/tasks?" + (sid ? ("session_id=" + encodeURIComponent(sid) + "&") : "") + "limit=120"); + const runs = await apiGet("/admin/api/oclaw/runs?" + (sid ? ("session_id=" + encodeURIComponent(sid) + "&") : "") + "limit=80&include_attempts=1"); + const health = await apiGet( + "/admin/api/audit/session-health?" + (sid ? ("session_id=" + encodeURIComponent(sid) + "&limit=20") : "limit=40"), + ); + const auditRows = Array.isArray(a.audit) ? a.audit : []; + const traceRows = Array.isArray(tr.trace) ? tr.trace : []; + const traceIds = Array.from(new Set(traceRows.map((r) => String(r.trace_id || "")).filter(Boolean))); + if (selectedTraceId && !traceIds.includes(selectedTraceId)) selectedTraceId = ""; + const filteredByTrace = selectedTraceId ? traceRows.filter((r) => String(r.trace_id || "") === selectedTraceId) : traceRows; + const filteredTraceRows = onlyFailures.checked + ? filteredByTrace.filter((r) => { + const p = (r && typeof r.payload === "object") ? r.payload : {}; + return p.ok === false || String(p.error_code || "").trim() !== ""; + }) + : filteredByTrace; + const auditBody = el("tbody"); + const visibleAuditCols = auditColumnDefs.filter((c) => auditVisible.has(c.key)); + if (!auditRows.length) { + auditBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: String(Math.max(1, visibleAuditCols.length)) })])); + } else { + auditRows.forEach((r) => { + const row = el("tr"); + visibleAuditCols.forEach((c) => row.appendChild(tdCell(c.getter(r), c.maxLen || 120))); + auditBody.appendChild(row); + }); + } + const traceBody = el("tbody"); + const visibleTraceCols = traceColumnDefs.filter((c) => traceVisible.has(c.key)); + if (!filteredTraceRows.length) { + traceBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: String(Math.max(1, visibleTraceCols.length)) })])); + } else { + filteredTraceRows.forEach((r) => { + const row = el("tr"); + visibleTraceCols.forEach((c) => row.appendChild(tdCell(c.getter(r), c.maxLen || 120))); + traceBody.appendChild(row); + }); + } + const pre = el("div", { class: "pre", text: JSON.stringify({ audit: a, trace: tr, health }, null, 2) }); + resultWrap.innerHTML = ""; + const healthItems = Array.isArray(health.items) ? health.items : []; + const healthRows = healthItems.map((x) => el("tr", {}, [ + tdCell(String(x.session_id || ""), 26), + tdCell(String(x.title || ""), 26), + tdCell(String(x.assistant_count || 0), 8), + tdCell(String(x.tool_count || 0), 8), + tdCell(String(x.mcp_tool_count || 0), 8), + tdCell(String(x.last_tool_at || "-"), 20), + tdCell(String(x.status || ""), 20), + ])); + resultWrap.appendChild(el("div", { class: "card" }, [ + el("div", { class: "card__title", text: "Session Tool Health" }), + el("div", { class: "muted", text: `warn=${Number(health.warn_count || 0)} (assistant replied but no tool uses)` }), + el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "session_id" }), + el("th", { text: "title" }), + el("th", { text: "assistant_msgs" }), + el("th", { text: "tool_uses" }), + el("th", { text: "mcp_calls" }), + el("th", { text: "last_tool_at" }), + el("th", { text: "status" }), + ])]), + el("tbody", {}, healthRows.length ? healthRows : [el("tr", {}, [el("td", { text: "-", colspan: "7" })])]), + ])]), + ])); + const taskRows = Array.isArray(tasks.tasks) ? tasks.tasks : []; + const queued = Number((tasks.counts || {}).queued || 0); + const claimed = Number((tasks.counts || {}).claimed || 0); + const done = Number((tasks.counts || {}).done || 0); + const failed = Number((tasks.counts || {}).failed || 0); + resultWrap.appendChild(el("div", { class: "card" }, [ + el("div", { class: "card__title", text: "oclaw Tasks" }), + el("div", { class: "muted", text: `queued=${queued} claimed=${claimed} done=${done} failed=${failed}` }), + el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "task_id" }), + el("th", { text: "status" }), + el("th", { text: "attempt_count" }), + el("th", { text: "session_id" }), + el("th", { text: "updated_at" }), + el("th", { text: "last_error" }), + ])]), + el( + "tbody", + {}, + taskRows.length + ? taskRows.slice(0, 80).map((x) => el("tr", {}, [ + tdCell(String(x.id || ""), 24), + tdCell(String(x.status || ""), 10), + tdCell(String(x.attempt_count || 0), 10), + tdCell(String(x.session_id || ""), 20), + tdCell(String(x.updated_at || ""), 22), + tdCell(String(x.last_error || ""), 40), + ])) + : [el("tr", {}, [el("td", { text: "-", colspan: "6" })])], + ), + ])]), + ])); + const runRows = Array.isArray(runs.runs) ? runs.runs : []; + const runRunning = Number((runs.counts || {}).running || 0); + const runSuccess = Number((runs.counts || {}).success || 0); + const runFailed = Number((runs.counts || {}).failed || 0); + const rp = (runs && typeof runs.retry_policy === "object" && runs.retry_policy) ? runs.retry_policy : {}; + const effRetryCodes = Array.isArray(rp.effective_retryable_error_codes) ? rp.effective_retryable_error_codes.join(", ") : ""; + resultWrap.appendChild(el("div", { class: "card" }, [ + el("div", { class: "card__title", text: "oclaw Runs" }), + el("div", { class: "muted", text: `running=${runRunning} success=${runSuccess} failed=${runFailed}` }), + el("div", { class: "muted", text: effRetryCodes ? `retryable_error_codes=${effRetryCodes}` : "" }), + el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "run_id" }), + el("th", { text: "status" }), + el("th", { text: "session_id" }), + el("th", { text: "attempts" }), + el("th", { text: "updated_at" }), + ])]), + el( + "tbody", + {}, + runRows.length + ? runRows.slice(0, 60).map((x) => { + const attempts = Array.isArray(x.attempts) ? x.attempts : []; + const attemptSummary = attempts.length + ? attempts.map((a) => `${Number(a.attempt_no || 0)}:${String(a.status || "")}`).join(" | ") + : "-"; + return el("tr", {}, [ + tdCell(String(x.run_id || ""), 24), + tdCell(String(x.status || ""), 10), + tdCell(String(x.session_id || ""), 20), + tdCell(attemptSummary, 60), + tdCell(String(x.updated_at || ""), 22), + ]); + }) + : [el("tr", {}, [el("td", { text: "-", colspan: "5" })])], + ), + ])]), + ])); + + const deliveryRows = auditRows + .filter((r) => r && typeof r === "object" && r.action === "specialist_step") + .map((r) => { + const payload = (r && typeof r.payload === "object" && r.payload) ? r.payload : {}; + const d = (payload && typeof payload.specialist_delivery === "object" && payload.specialist_delivery) + ? payload.specialist_delivery + : null; + if (!d) return null; + const traces = Array.isArray(d.tool_traces) ? d.tool_traces : []; + const toolsText = traces.length + ? traces + .map((x) => `${String(x.name || "")}(ok=${Boolean(x.ok)}, ${Number(x.latency_ms || 0)}ms)`) + .join(" | ") + : "-"; + return { + timestamp: String(r.timestamp || ""), + specialist: String(r.specialist || d.specialist || ""), + stepId: String(d.step_id || payload.step_id || ""), + answer: String(d.answer_text || ""), + tools: toolsText, + notes: String(d.notes || ""), + }; + }) + .filter(Boolean); + resultWrap.appendChild(el("div", { class: "card" }, [ + el("div", { class: "card__title", text: "Specialist Delivery Timeline" }), + el("div", { class: "muted", text: "专家侧工具执行与交付摘要(specialist -> core)" }), + el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "timestamp" }), + el("th", { text: "specialist" }), + el("th", { text: "step_id" }), + el("th", { text: "answer_for_user" }), + el("th", { text: "tools_executed_inside_specialist" }), + el("th", { text: "notes" }), + ])]), + el( + "tbody", + {}, + deliveryRows.length + ? deliveryRows.map((x) => + el("tr", {}, [ + tdCell(x.timestamp, 24), + tdCell(x.specialist, 14), + tdCell(x.stepId, 20), + tdCell(x.answer, 120), + tdCell(x.tools, 120), + tdCell(x.notes, 24), + ]), + ) + : [el("tr", {}, [el("td", { text: "-", colspan: "6" })])], + ), + ])]), + ])); + + // Turn summary (group by trace_id) for quick navigation. + if (traceIds.length) { + const sel = el("select", { class: "input" }); + sel.appendChild(el("option", { value: "", text: `trace_id (${traceIds.length})` })); + traceIds.forEach((tid) => sel.appendChild(el("option", { value: tid, text: tid }))); + sel.value = selectedTraceId; + const summary = el("div", { class: "muted", text: "" }); + const failSummary = el("div", { class: "muted", text: "" }); + const recomputeSummary = () => { + const rows = filteredTraceRows; + const counts = {}; + rows.forEach((r) => { + const et = String(r.event_type || ""); + counts[et] = (counts[et] || 0) + 1; + }); + const get = (k) => Number(counts[k] || 0); + summary.textContent = [ + `route_decided=${get("route_decided")}`, + `plan_created=${get("plan_created")}`, + `tool_wire_filter=${get("tool_wire_filter")}`, + `llm_first_token=${get("llm_first_token")}`, + `llm_called=${get("llm_called")}`, + `llm_result=${get("llm_result")}`, + `tool_use_called=${get("tool_called")}`, + `tool_result=${get("tool_result")}`, + ].join(" | "); + const agg = {}; + rows.forEach((r) => { + const p = (r && typeof r.payload === "object") ? r.payload : {}; + const ec = String(p.error_code || "").trim(); + if (!ec) return; + agg[ec] = (agg[ec] || 0) + 1; + }); + const parts = Object.entries(agg).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([k, v]) => `${k}:${v}`); + failSummary.textContent = parts.length ? `Failure summary: ${parts.join(" | ")}` : "Failure summary: -"; + }; + sel.addEventListener("change", () => { + selectedTraceId = String(sel.value || ""); + runQuery(); + }); + recomputeSummary(); + resultWrap.appendChild(el("div", { class: "card" }, [ + el("div", { class: "card__title", text: "Turn summary" }), + el("div", { class: "row" }, [sel, el("label", { class: "kv" }, [onlyFailures, document.createTextNode(" only failures")])]), + summary, + failSummary, + ])); + } + resultWrap.appendChild(el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("audit.auditTable") }), + el("div", { class: "muted", text: t("audit.columns") }), + auditColsToggle, + el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, auditColumnDefs.filter((c) => auditVisible.has(c.key)).map((c) => el("th", { text: c.label })))]), + auditBody, + ])]), + ])); + resultWrap.appendChild(el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("audit.traceTable") }), + el("div", { class: "muted", text: t("audit.columns") }), + traceColsToggle, + el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, traceColumnDefs.filter((c) => traceVisible.has(c.key)).map((c) => el("th", { text: c.label })))]), + traceBody, + ])]), + ])); + const details = el("details", { class: "details" }, [ + el("summary", { text: t("audit.rawJson") }), + pre, + ]); + resultWrap.appendChild(el("div", { class: "card" }, [details])); + }; + const btn = el("button", { class: "btn btn--primary", text: t("audit.query"), onclick: async () => { + await runQuery(); + }}); + onlyFailures.addEventListener("change", runQuery); + auditColsToggle.addEventListener("change", runQuery); + traceColsToggle.addEventListener("change", runQuery); + const card = el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("audit.title") }), + el("div", { class: "row" }, [input, btn]), + el("div", { class: "muted", text: t("audit.note") }), + ]); + if (initialSessionId) { + input.value = initialSessionId; + await runQuery(); + } + return el("div", {}, [card, resultWrap]); +} + +async function renderMemory() { + const route = getRoute(); + const tenantQ = route.params.get("tenant_id") || ""; + const userQ = route.params.get("user_id") || ""; + const buildQS = (limit = 50) => { + const p = new URLSearchParams(); + p.set("limit", String(limit)); + if (tenantInput.value.trim()) p.set("tenant_id", tenantInput.value.trim()); + if (userInput.value.trim()) p.set("user_id", userInput.value.trim()); + return p.toString(); + }; + + const tenantInput = el("input", { class: "input", placeholder: t("memory.tenantId"), value: tenantQ }); + const userInput = el("input", { class: "input", placeholder: t("memory.userId"), value: userQ }); + const status = el("div", { class: "muted", text: "" }); + const statsWrap = el("div", { class: "muted", text: "" }); + const hitsWrap = el("div"); + const itemsWrap = el("div"); + const wikiStatus = el("div", { class: "muted", text: "" }); + const memoryModeSelect = el("select", { class: "input", style: "max-width:320px;" }); + memoryModeSelect.appendChild(el("option", { value: "default", text: t("profile.memoryModeDefault") })); + memoryModeSelect.appendChild(el("option", { value: "store_only", text: t("profile.memoryModeStoreOnly") })); + const memoryCuratorSelect = el("select", { class: "input", style: "max-width:220px;" }); + memoryCuratorSelect.appendChild(el("option", { value: "1", text: t("profile.memoryCuratorEnabled") })); + memoryCuratorSelect.appendChild(el("option", { value: "0", text: t("profile.memoryCuratorDisabled") })); + + const normalizeMemoryMode = (raw) => { + const mm = String(raw || "").trim().toLowerCase(); + return mm === "store_only" ? "store_only" : "default"; + }; + + const enabled = el("input", { type: "checkbox" }); + const backend = el("select", { class: "input" }, [ + el("option", { value: "sqlite", text: "sqlite" }), + el("option", { value: "chroma", text: "chroma" }), + el("option", { value: "qdrant", text: "qdrant" }), + ]); + const topk = el("input", { class: "input", type: "number", value: "5", min: "1", max: "20" }); + const writerEnabled = el("input", { type: "checkbox" }); + const ragMode = el("select", { class: "input" }, [ + el("option", { value: "keyword", text: "keyword" }), + el("option", { value: "vector", text: "vector" }), + ]); + const embeddingMode = el("select", { class: "input" }, [ + el("option", { value: "", text: "openai(default/fallback)" }), + el("option", { value: "openai", text: "openai" }), + el("option", { value: "hash", text: "hash/offline" }), + ]); + const episodicTtlDays = el("input", { class: "input", type: "number", value: "90", min: "1", max: "3650" }); + const minConf = el("input", { + class: "input", + type: "number", + value: "0.75", + min: "0", + max: "1", + step: "0.05", + }); + const btnSave = el("button", { class: "btn btn--primary", text: t("memory.save"), onclick: async () => { + const saved = await apiPost("/admin/api/memory/config", { + enabled: enabled.checked, + backend: backend.value, + top_k: Number(topk.value || 5), + writer_enabled: writerEnabled.checked, + write_min_confidence: Number(minConf.value || 0.75), + rag_mode: String(ragMode.value || "keyword"), + rag_embedding_mode: String(embeddingMode.value || ""), + memory_episodic_ttl_days: Number(episodicTtlDays.value || 90), + }); + localStorage.setItem(CHAT_MEMORY_MODE_KEY, normalizeMemoryMode(memoryModeSelect.value)); + await apiPost("/admin/api/chat/settings/specialist-flags", { + flags: { + generalist: true, + memory_curator: String(memoryCuratorSelect.value || "1") !== "0", + }, + }); + status.textContent = JSON.stringify(saved.config || {}); + }}); + const btnReindex = el("button", { class: "btn", text: t("memory.reindex"), onclick: async () => { + const resp = await apiPost("/admin/api/memory/reindex", {}); + status.textContent = `reindexed=${Number(resp.reindexed || 0)}`; + }}); + const btnCleanup = el("button", { class: "btn btn--danger", text: t("memory.cleanup"), onclick: async () => { + const resp = await apiPost("/admin/api/memory/cleanup-low-confidence", { max_confidence: 0.35 }); + status.textContent = `deleted=${Number(resp.deleted || 0)}`; + router(); + }}); + + const loadData = async () => { + const qs = buildQS(50); + const [cfgResp, hitsResp, itemsResp, statsResp] = await Promise.all([ + apiGet("/admin/api/memory/config"), + apiGet("/admin/api/memory/hits?" + qs), + apiGet("/admin/api/memory/items?" + qs), + apiGet("/admin/api/memory/stats?" + qs), + ]); + const cfg = cfgResp.config || {}; + enabled.checked = !!cfg.enabled; + backend.value = String(cfg.backend || "sqlite"); + topk.value = String(cfg.top_k || 5); + writerEnabled.checked = !!cfg.writer_enabled; + minConf.value = String(cfg.write_min_confidence ?? 0.75); + ragMode.value = String(cfg.rag_mode || "keyword"); + embeddingMode.value = String(cfg.rag_embedding_mode || ""); + episodicTtlDays.value = String(cfg.memory_episodic_ttl_days || 90); + memoryModeSelect.value = normalizeMemoryMode(localStorage.getItem(CHAT_MEMORY_MODE_KEY)); + try { + const sf = await apiGet("/admin/api/chat/settings/specialist-flags"); + const flags = sf && sf.flags && typeof sf.flags === "object" ? sf.flags : {}; + memoryCuratorSelect.value = flags.memory_curator === false ? "0" : "1"; + } catch (_) { + memoryCuratorSelect.value = "1"; + } + const hits = Array.isArray(hitsResp.hits) ? hitsResp.hits : []; + const items = Array.isArray(itemsResp.items) ? itemsResp.items : []; + const stats = (statsResp && statsResp.stats) || {}; + const srcText = Array.isArray(stats.top_sources) ? stats.top_sources.map((x) => `${x.source}:${x.count}`).join(", ") : ""; + statsWrap.textContent = `${t("memory.hitCount")}: ${Number(stats.hit_count || 0)} | ${t("memory.itemCount")}: ${Number(stats.item_count || 0)} | ${t("memory.avgScore")}: ${Number(stats.avg_score || 0).toFixed(3)} | ${t("memory.topSources")}: ${srcText || "-"}`; + + const hitBody = el("tbody"); + if (!hits.length) { + hitBody.appendChild(el("tr", {}, [el("td", { text: t("memory.noData"), colspan: "6" })])); + } else { + hits.forEach((h) => { + hitBody.appendChild(el("tr", {}, [ + tdCell(h.timestamp || "", 24), + tdCell(h.tenant_id || "", 18), + tdCell(h.user_id || "", 18), + tdCell(h.query_text || "", 40), + tdCell(h.memory_id || "", 18), + tdCell(String(h.score || ""), 10), + ])); + }); + } + hitsWrap.innerHTML = ""; + hitsWrap.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: t("table.timestamp") }), + el("th", { text: "tenant_id" }), + el("th", { text: "user_id" }), + el("th", { text: "query" }), + el("th", { text: "memory_id" }), + el("th", { text: "score" }), + ])]), + hitBody, + ])])); + + const itemBody = el("tbody"); + if (!items.length) { + itemBody.appendChild(el("tr", {}, [el("td", { text: t("memory.noData"), colspan: "8" })])); + } else { + items.forEach((it) => { + const btnDelete = el("button", { class: "btn btn--danger", text: t("memory.delete"), onclick: async () => { + await apiPost("/admin/api/memory/delete", { memory_id: it.memory_id }); + await loadData(); + }}); + itemBody.appendChild(el("tr", {}, [ + tdCell(it.memory_id || "", 18), + tdCell(it.tenant_id || "", 18), + tdCell(it.user_id || "", 18), + tdCell(it.memory_type || "", 14), + tdCell(String(it.confidence ?? ""), 8), + tdCell(it.content || "", 80), + tdCell(it.updated_at || "", 24), + el("td", {}, [btnDelete]), + ])); + }); + } + itemsWrap.innerHTML = ""; + itemsWrap.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "memory_id" }), + el("th", { text: "tenant_id" }), + el("th", { text: "user_id" }), + el("th", { text: "type" }), + el("th", { text: "confidence" }), + el("th", { text: "content" }), + el("th", { text: t("table.timestamp") }), + el("th", { text: t("memory.delete") }), + ])]), + itemBody, + ])])); + + try { + const pluginsResp = await apiGet("/admin/api/plugins"); + const rows = Array.isArray(pluginsResp.plugins) ? pluginsResp.plugins : []; + const wikiPlugin = rows.find((x) => String((x && x.plugin_name) || "").toLowerCase() === "memory-wiki"); + if (wikiPlugin) { + wikiStatus.textContent = t("memory.wikiPluginFound", { + name: String(wikiPlugin.plugin_name || "memory-wiki"), + version: String(wikiPlugin.plugin_version || "-"), + }); + } else { + wikiStatus.textContent = t("memory.wikiPluginMissing"); + } + } catch (_) { + wikiStatus.textContent = t("memory.wikiPluginMissing"); + } + }; + + const btnApplyFilters = el("button", { class: "btn", text: t("memory.applyFilters"), onclick: async () => { + const p = new URLSearchParams(); + if (tenantInput.value.trim()) p.set("tenant_id", tenantInput.value.trim()); + if (userInput.value.trim()) p.set("user_id", userInput.value.trim()); + location.hash = "#/memory" + (p.toString() ? ("?" + p.toString()) : ""); + await loadData(); + }}); + const btnClearFilters = el("button", { class: "btn", text: t("memory.clearFilters"), onclick: async () => { + tenantInput.value = ""; + userInput.value = ""; + location.hash = "#/memory"; + await loadData(); + }}); + await loadData(); + + return el("div", {}, [ + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("memory.config") }), + el("div", { class: "row" }, [el("label", { text: t("memory.enabled") }), enabled]), + el("div", { class: "row" }, [el("label", { text: t("memory.backend") }), backend]), + el("div", { class: "row" }, [el("label", { text: t("memory.topk") }), topk]), + el("div", { class: "row" }, [el("label", { text: t("memory.writerEnabled") }), writerEnabled]), + el("div", { class: "row" }, [el("label", { text: "RAG mode" }), ragMode]), + el("div", { class: "row" }, [el("label", { text: "Embedding mode" }), embeddingMode]), + el("div", { class: "row" }, [el("label", { text: "Episodic TTL days" }), episodicTtlDays]), + el("div", { class: "row" }, [el("label", { text: t("memory.minConfidence") }), minConf]), + el("div", { class: "row" }, [btnSave, btnReindex, btnCleanup]), + status, + ]), + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("memory.wikiCard") }), + el("div", { class: "row" }, [el("label", { text: t("profile.memoryMode") }), memoryModeSelect]), + el("div", { class: "row" }, [el("label", { text: t("profile.memoryCurator") }), memoryCuratorSelect]), + el("div", { class: "row" }, [el("label", { text: t("memory.wikiStatus") }), wikiStatus]), + el("div", { class: "row" }, [ + el("button", { + class: "btn", + text: t("memory.openPlugins"), + onclick: () => { + location.hash = "#/plugins"; + }, + }), + ]), + ]), + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("memory.filters") }), + el("div", { class: "row" }, [tenantInput, userInput, btnApplyFilters, btnClearFilters]), + statsWrap, + ]), + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("memory.hits") }), + hitsWrap, + ]), + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("memory.items") }), + itemsWrap, + ]), + ]); +} + +async function renderApiGrants() { + const status = el("div", { class: "muted", text: "" }); + const profileSel = el("select", { class: "input" }); + const grantListEl = el("div", {}); + const grantUserSel = el("select", { class: "input" }); + const grantMsg = el("div", { class: "muted", text: "" }); + const grantBtn = el("button", { class: "btn btn--primary", text: t("models.grantsGrantBtn") }); + const tenantGrantStatus = el("span", { class: "muted", text: "" }); + + let state = null; + + const VIS_KEYS = { + builtin: "models.vis.builtin", + owned: "models.vis.owned", + grant_user: "models.vis.grantUser", + grant_tenant: "models.vis.grantTenant", + global: "models.vis.global", + other_user: "models.vis.otherUser", + }; + + function visibilityTag(vr) { + const k = VIS_KEYS[String(vr || "")]; + return k ? t(k) : ""; + } + + async function paintGrants() { + grantMsg.textContent = ""; + const pid = String(profileSel.value || "").trim(); + if (!pid) { + grantListEl.innerHTML = ""; + tenantGrantStatus.textContent = ""; + return; + } + try { + const tg = await apiGet("/admin/api/models/grants/tenant?profile_id=" + encodeURIComponent(pid)); + tenantGrantStatus.textContent = tg.granted ? t("models.tenantGrantOn") : t("models.tenantGrantOff"); + const g = await apiGet("/admin/api/models/grants?profile_id=" + encodeURIComponent(pid)); + const rows = Array.isArray(g.grants) ? g.grants : []; + grantListEl.innerHTML = ""; + if (!rows.length) { + grantListEl.appendChild(el("div", { class: "muted", text: t("models.grantsEmpty") })); + return; + } + rows.forEach((r) => { + const disp = String(r.display_name || r.username || r.user_id || "").trim(); + const rev = el("button", { class: "btn", text: t("models.grantsRevoke"), onclick: async () => { + try { + await apiRequest( + "DELETE", + "/admin/api/models/grants?profile_id=" + encodeURIComponent(pid) + "&user_id=" + encodeURIComponent(String(r.user_id || "")), + undefined, + ); + await paintGrants(); + } catch (e) { + grantMsg.textContent = String(e.message || e); + } + } }); + grantListEl.appendChild(el("div", { class: "row" }, [ + el("span", { text: disp || r.user_id }), + rev, + ])); + }); + } catch (e) { + grantMsg.textContent = String(e.message || e); + } + } + + const btnTenantGrant = el("button", { + class: "btn btn--primary", + text: t("models.grantTenantBtn"), + onclick: async () => { + const pid = String(profileSel.value || "").trim(); + if (!pid) return; + try { + await apiPost("/admin/api/models/grants/tenant", { profile_id: pid }); + await paintGrants(); + } catch (e) { + grantMsg.textContent = String(e.message || e); + } + }, + }); + const btnTenantRevoke = el("button", { + class: "btn", + text: t("models.revokeTenantGrant"), + onclick: async () => { + const pid = String(profileSel.value || "").trim(); + if (!pid) return; + try { + await apiRequest( + "DELETE", + "/admin/api/models/grants/tenant?profile_id=" + encodeURIComponent(pid), + undefined, + ); + await paintGrants(); + } catch (e) { + grantMsg.textContent = String(e.message || e); + } + }, + }); + const grantTenantRow = el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [ + btnTenantGrant, + btnTenantRevoke, + tenantGrantStatus, + ]); + + const membersBody = el("div", {}); + let membersLoaded = false; + const membersDetails = el("details", { class: "details" }); + membersDetails.appendChild(el("summary", { text: t("models.membersTitle") })); + membersDetails.appendChild(membersBody); + const membersWrap = el("div", { class: "card" }, [membersDetails]); + + membersDetails.addEventListener("toggle", async () => { + if (!membersDetails.open || membersLoaded) return; + try { + const r = await apiGet("/admin/api/models/members"); + membersLoaded = true; + membersBody.innerHTML = ""; + const list = Array.isArray(r.members) ? r.members : []; + if (!list.length) { + membersBody.appendChild(el("div", { class: "muted", text: "—" })); + return; + } + list.forEach((m) => { + const names = (m.profiles || []).map((p) => { + const nm = String(p.name || p.id || "").trim(); + const vr = p.visibility_reason ? ` (${visibilityTag(p.visibility_reason)})` : ""; + return nm + vr; + }); + membersBody.appendChild(el("div", { class: "card", style: "margin-bottom:10px" }, [ + el("div", { class: "card__title", text: formatUserLabel(m) }), + el("div", { class: "muted", text: names.filter(Boolean).length ? names.join(";") : "—" }), + ])); + }); + } catch (e) { + membersLoaded = false; + membersBody.appendChild(el("div", { class: "muted", text: String(e.message || e) })); + } + }); + + async function ensureGrantUsers() { + grantUserSel.innerHTML = ""; + const tid = String((authSession && authSession.tenant_id) || "").trim(); + if (!tid) return; + try { + const u = await apiGet("/admin/api/users?tenant_id=" + encodeURIComponent(tid) + "&limit=500"); + grantUserSel.appendChild(el("option", { value: "", text: "—" })); + (u.users || []).forEach((x) => { + const uid = String(x.id || "").trim(); + if (!uid) return; + grantUserSel.appendChild(el("option", { value: uid, text: formatUserLabel(x) || uid })); + }); + } catch (e) { + grantMsg.textContent = String(e.message || e); + } + } + + function fillProfileSelect() { + profileSel.innerHTML = ""; + const uid = String((authSession && authSession.user_id) || "").trim(); + const all = (state && Array.isArray(state.profiles)) ? state.profiles : []; + const list = all.filter((p) => profileShareableForGrant(p, uid)); + if (!list.length) { + profileSel.appendChild(el("option", { value: "", text: t("apiGrants.noShareable") })); + profileSel.disabled = true; + return; + } + profileSel.disabled = false; + list.forEach((p) => { + const own = String(p.owner_user_id || "").trim(); + const tag = own ? t("models.vis.owned") : t("models.vis.global"); + const name = String(p.name || p.id || "").trim(); + profileSel.appendChild(el("option", { value: String(p.id), text: `${name} (${tag})` })); + }); + } + + profileSel.addEventListener("change", async () => { + await paintGrants(); + }); + + grantBtn.addEventListener("click", async () => { + const pid = String(profileSel.value || "").trim(); + const uid = String(grantUserSel.value || "").trim(); + if (!pid || !uid) return; + try { + await apiPost("/admin/api/models/grants", { profile_id: pid, user_id: uid }); + await paintGrants(); + } catch (e) { + grantMsg.textContent = String(e.message || e); + } + }); + + async function refresh() { + try { + const data = await apiGet("/admin/api/models"); + state = data; + status.textContent = ""; + if (!data || data.can_manage_llm_grants !== true) return; + fillProfileSelect(); + await ensureGrantUsers(); + await paintGrants(); + } catch (e) { + state = null; + status.textContent = String(e.message || e); + } + } + + await refresh(); + + const forbiddenCard = el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("title.apiGrants") }), + el("div", { class: "muted", text: t("apiGrants.forbidden") }), + el("div", { class: "row" }, [ + el("a", { href: "#/models", text: t("apiGrants.openModels") }), + ]), + ]); + + if (!state || state.can_manage_llm_grants !== true) { + const bits = [forbiddenCard]; + if (status.textContent) bits.push(el("div", { class: "card" }, [el("pre", { class: "pre", text: status.textContent })])); + return el("div", {}, bits); + } + + const introCard = el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("title.apiGrants") }), + el("div", { class: "muted", text: t("apiGrants.intro") }), + status, + ]); + const pickCard = el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("apiGrants.pickApi") }), + el("div", { class: "row" }, [profileSel]), + ]); + const teamCard = el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("apiGrants.teamSection") }), + el("div", { class: "muted", text: t("apiGrants.teamHint") }), + grantTenantRow, + ]); + const userCard = el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("apiGrants.userSection") }), + el("div", { class: "muted", text: t("apiGrants.userHint") }), + el("div", { class: "row" }, [grantUserSel, grantBtn]), + grantListEl, + grantMsg, + ]); + + return el("div", {}, [introCard, pickCard, teamCard, userCard, membersWrap]); +} + +async function renderModels() { + const canPickActive = hasPermission("admin:read"); + const canConfigureBindings = canPickActive; + const status = el("div", { class: "muted", text: "" }); + const isAdminPool = String((authSession && authSession.username) || "").trim().toLowerCase() === "administrator"; + // 与后端 _require_models_mutate 对齐:administrator 写全局池需 tenant:write;普通用户写自己的配置只需 admin:read + const canMutateProfiles = isAdminPool ? hasPermission("admin:tenant:write") : canPickActive; + const readonlyHint = el("div", { + class: "muted", + text: isAdminPool && !canMutateProfiles ? t("models.readonly") : "", + }); + const activeSelect = el("select", { class: "input", disabled: !canPickActive }); + const bindingWrap = el("div", {}); + const modelsGrantsLinkRow = el("div", { class: "muted", style: "display:none" }); + const newName = el("input", { class: "input", placeholder: t("models.createNamePlaceholder") }); + const newMode = el("select", { class: "input", disabled: !canMutateProfiles }, [ + el("option", { value: "openai", text: t("models.mode.openai") }), + el("option", { value: "anthropic", text: t("models.mode.anthropic") }), + el("option", { value: "google", text: t("models.mode.google") }), + el("option", { value: "ollama", text: t("models.mode.ollama") }), + el("option", { value: "rule", text: t("models.mode.rule") }), + ]); + const profName = el("input", { class: "input" }); + const modeSel = el("select", { class: "input" }); + const modelInp = el("input", { class: "input" }); + const baseInp = el("input", { class: "input", placeholder: t("models.baseUrlPlaceholder") }); + const keyInp = el("input", { class: "input", type: "password", autocomplete: "off" }); + const rememberCb = el("input", { type: "checkbox" }); + const readonlyProfileHint = el("div", { class: "muted", text: "" }); + const builtinCap = el("div", { class: "muted", text: "" }); + const warnKey = el("div", { class: "muted", text: "" }); + const openaiHint = el("div", { class: "muted", text: "" }); + const ollamaHint = el("div", { class: "muted", text: "" }); + const evalMetrics = el("div", { class: "row" }); + const evalTableWrap = el("div"); + const evalDetails = el("details", { class: "details" }); + const evalSummary = el("summary", { text: t("models.evalToggle") }); + const evalInner = el("div", {}); + + async function downloadEvalExport(format) { + const fmt = format === "json" ? "json" : "csv"; + const url = resolveAdminApiUrl(`/admin/api/models/eval/export?format=${encodeURIComponent(fmt)}`); + const token = getStoredAuthToken(); + const res = await fetch(url, { + headers: { + accept: fmt === "json" ? "application/json" : "text/csv", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + }); + if (!res.ok) throw new Error(`export ${res.status}`); + const blob = await res.blob(); + const a = document.createElement("a"); + const name = fmt === "json" ? "agent_eval_logs.json" : "agent_eval_logs.csv"; + a.href = URL.createObjectURL(blob); + a.download = name; + a.click(); + URL.revokeObjectURL(a.href); + } + + const btnEvalCsv = el("button", { + class: "btn", + text: t("models.evalDownloadCsv"), + onclick: async () => { + try { + await downloadEvalExport("csv"); + } catch (e) { + status.textContent = String(e.message || e); + } + }, + }); + const btnEvalJson = el("button", { + class: "btn", + text: t("models.evalDownloadJson"), + onclick: async () => { + try { + await downloadEvalExport("json"); + } catch (e) { + status.textContent = String(e.message || e); + } + }, + }); + const evalActionRow = el("div", { class: "row" }, [ + el("span", { class: "muted", text: t("models.evalLogsPreview") }), + btnEvalCsv, + btnEvalJson, + ]); + evalInner.appendChild(evalMetrics); + evalInner.appendChild(evalActionRow); + evalInner.appendChild(evalTableWrap); + evalDetails.appendChild(evalSummary); + evalDetails.appendChild(evalInner); + + let state = null; + let evalPack = null; + let secretsStatus = null; + + const secretsStatusMsg = el("div", { class: "muted", text: "" }); + const secretsMigrateBtn = el("button", { class: "btn", text: t("secrets.migrateBtn") }); + const secretsCard = el("div", { class: "card", style: "display:none" }, [ + el("div", { class: "card__title", text: t("secrets.migrateTitle") }), + el("div", { class: "muted", text: t("secrets.migrateHint") }), + el("div", { class: "row" }, [secretsMigrateBtn]), + secretsStatusMsg, + ]); + + function fmtSecretsDone(s, p) { + return t("secrets.migrateDone").replace("{s}", String(s)).replace("{p}", String(p)); + } + + function paintSecretsCard() { + secretsStatusMsg.textContent = ""; + if (!secretsStatus || secretsStatus.ok !== true) { + secretsCard.style.display = "none"; + return; + } + const n1 = intOr0(secretsStatus.legacy_b64_app_settings); + const n2 = intOr0(secretsStatus.legacy_b64_llm_profiles); + const total = n1 + n2; + const canMigrate = hasPermission("admin:tenant:write"); + if (total <= 0) { + secretsCard.style.display = "none"; + return; + } + secretsCard.style.display = ""; + if (!canMigrate) { + secretsMigrateBtn.disabled = true; + secretsStatusMsg.textContent = t("secrets.migrateForbidden"); + return; + } + if (!secretsStatus.has_master_key) { + secretsMigrateBtn.disabled = true; + secretsStatusMsg.textContent = t("secrets.migrateNeedKey"); + return; + } + secretsMigrateBtn.disabled = false; + secretsStatusMsg.textContent = `legacy: settings=${n1}, profiles=${n2}`; + } + + const VIS_KEYS = { + builtin: "models.vis.builtin", + owned: "models.vis.owned", + grant_user: "models.vis.grantUser", + grant_tenant: "models.vis.grantTenant", + global: "models.vis.global", + other_user: "models.vis.otherUser", + }; + + function visibilityTag(vr) { + const k = VIS_KEYS[String(vr || "")]; + return k ? t(k) : ""; + } + + function labelFor(pid) { + const profiles = (state && state.profiles) || []; + const p = profiles.find((x) => String(x.id) === String(pid)); + const base = (p && String(p.name || "").trim()) || pid; + const vr = p && p.visibility_reason; + if (!vr) return base; + const tag = visibilityTag(vr); + return tag ? `${base} (${tag})` : base; + } + + function fillModeSelect(sel, modes, current) { + sel.innerHTML = ""; + modes.forEach((m) => { + const label = + m === "openai" + ? t("models.mode.openai") + : m === "openai_responses" + ? t("models.mode.openai_responses") + : m === "anthropic" + ? t("models.mode.anthropic") + : m === "google" + ? t("models.mode.google") + : m === "ollama" + ? t("models.mode.ollama") + : t("models.mode.rule"); + sel.appendChild(el("option", { + value: m, + text: label, + })); + }); + if (modes.includes(current)) sel.value = current; + else sel.value = modes[0]; + } + + async function refresh() { + try { + const data = await apiGet("/admin/api/models"); + state = data; + status.textContent = ""; + paint(); + try { + secretsStatus = await apiGet("/admin/api/secrets/status"); + } catch (_) { + secretsStatus = null; + } + paintSecretsCard(); + try { + evalPack = await apiGet("/admin/api/models/eval?limit_logs=100"); + paintEval(); + } catch (ee) { + evalPack = { ok: false, summary: {}, logs: [] }; + paintEval(); + status.textContent = [status.textContent, `${t("models.sectionEval")}: ${String(ee.message || ee)}`].filter(Boolean).join(" | "); + } + } catch (e) { + state = null; + evalPack = null; + secretsStatus = null; + status.textContent = String(e.message || e); + } + } + + secretsMigrateBtn.addEventListener("click", async () => { + if (!hasPermission("admin:tenant:write")) { + secretsStatusMsg.textContent = t("secrets.migrateForbidden"); + return; + } + secretsMigrateBtn.disabled = true; + secretsStatusMsg.textContent = ""; + try { + const res = await apiPost("/admin/api/secrets/migrate", {}); + if (res && res.ok) { + const s = intOr0(res.migrated_app_settings); + const p = intOr0(res.migrated_llm_profiles); + secretsStatusMsg.textContent = (s + p) > 0 ? fmtSecretsDone(s, p) : t("secrets.migrateNoop"); + } else { + secretsStatusMsg.textContent = String((res && (res.error || res.detail)) || "migrate_failed"); + } + try { + secretsStatus = await apiGet("/admin/api/secrets/status"); + } catch (_) { + secretsStatus = null; + } + paintSecretsCard(); + } catch (e) { + secretsStatusMsg.textContent = String(e.message || e); + paintSecretsCard(); + } finally { + secretsMigrateBtn.disabled = false; + } + }); + + function paintBindings() { + bindingWrap.innerHTML = ""; + if (!state || !state.bindings) return; + const profiles = state.profiles || []; + const profileIds = profiles.map((p) => String(p.id)); + const roleIds = Array.isArray(state.role_ids) ? state.role_ids : []; + roleIds.forEach((rid) => { + const sel = el("select", { class: "input", disabled: !canConfigureBindings }); + sel.appendChild(el("option", { value: "", text: t("models.useGlobal") })); + profiles.forEach((p) => { + sel.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) })); + }); + const v = String(state.bindings[rid] || "").trim(); + sel.value = profileIds.includes(v) ? v : ""; + sel.addEventListener("change", async () => { + if (!canConfigureBindings) return; + const next = Object.assign({}, state.bindings); + next[rid] = String(sel.value || ""); + try { + await apiPost("/admin/api/models/bindings", { bindings: next }); + await refresh(); + } catch (e) { + status.textContent = String(e.message || e); + } + }); + bindingWrap.appendChild(el("div", { class: "row" }, [ + el("label", { text: t("models.role." + rid) + " " }), + sel, + ])); + }); + } + + function paintEval() { + evalMetrics.innerHTML = ""; + evalTableWrap.innerHTML = ""; + const summary = (evalPack && evalPack.summary) || {}; + const total = intOr0(summary.total); + const sr = Number(summary.success_rate || 0); + const p95 = intOr0(summary.p95_latency_ms); + evalMetrics.appendChild(el("span", { text: `${t("models.evalTotal")}: ${total} ` })); + evalMetrics.appendChild(el("span", { text: `${t("models.evalSuccess")}: ${(sr * 100).toFixed(1)}% ` })); + evalMetrics.appendChild(el("span", { text: `${t("models.evalP95")}: ${p95} ms` })); + const logs = Array.isArray(evalPack && evalPack.logs) ? evalPack.logs : []; + if (!logs.length) { + evalTableWrap.appendChild(el("div", { class: "muted", text: t("models.noEvalLogs") })); + return; + } + const tbody = el("tbody"); + logs.forEach((r) => { + tbody.appendChild(el("tr", {}, [ + tdCell(r.timestamp || "", 24), + tdCell(r.session_id || "", 20), + tdCell(r.specialist || "", 12), + tdCell(r.task_kind || "", 14), + tdCell(r.success ? "1" : "0", 4), + tdCell(String(r.latency_ms ?? ""), 8), + tdCell(String(r.notes || ""), 60), + ])); + }); + evalTableWrap.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: t("table.timestamp") }), + el("th", { text: "session_id" }), + el("th", { text: t("table.specialist") }), + el("th", { text: "task_kind" }), + el("th", { text: t("table.status") }), + el("th", { text: t("table.durationMs") }), + el("th", { text: "notes" }), + ])]), + tbody, + ])])); + } + + function intOr0(x) { + const n = parseInt(String(x), 10); + return Number.isFinite(n) ? n : 0; + } + + function paint() { + if (!state) return; + if (state.ok !== true) { + status.textContent = status.textContent || t("models.loadFailed"); + return; + } + const profiles = Array.isArray(state.profiles) ? state.profiles : []; + const builtin = String(state.builtin_ollama_profile_id || ""); + activeSelect.innerHTML = ""; + profiles.forEach((p) => { + activeSelect.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) })); + }); + const aid = String(state.active_llm_profile_id || ""); + if (profiles.some((p) => String(p.id) === aid)) activeSelect.value = aid; + activeSelect.disabled = !canPickActive; + + paintBindings(); + + const selProf = profiles.find((p) => String(p.id) === aid) || profiles[0] || {}; + const pid = String(selProf.id || ""); + const rowMutable = selProf.mutable !== false; + const canEditFields = canMutateProfiles && rowMutable; + readonlyProfileHint.textContent = rowMutable ? "" : t("models.profileReadonlyHint"); + const isBuiltin = pid === builtin; + profName.value = String(selProf.name || ""); + const rawMode = String(selProf.mode || "openai").toLowerCase(); + const baseAllowedModes = ["openai", "anthropic", "google", "ollama", "rule"]; + const allowedModes = rawMode === "openai_responses" ? ["openai_responses", ...baseAllowedModes] : baseAllowedModes; + const modeVal = allowedModes.includes(rawMode) ? rawMode : "openai"; + if (isBuiltin) { + fillModeSelect(modeSel, ["ollama"], "ollama"); + modeSel.disabled = true; + builtinCap.textContent = t("models.builtinLocked"); + } else { + modeSel.disabled = !canEditFields; + builtinCap.textContent = ""; + fillModeSelect(modeSel, allowedModes, modeVal); + } + modelInp.value = String(selProf.model || ""); + baseInp.value = String(selProf.base_url || ""); + keyInp.value = String(state.profile_secret || ""); + rememberCb.checked = !!selProf.has_key; + + profName.disabled = !canEditFields; + modelInp.disabled = !canEditFields; + baseInp.disabled = !canEditFields; + keyInp.disabled = !canEditFields; + rememberCb.disabled = !canEditFields; + + const m = isBuiltin ? "ollama" : modeVal; + warnKey.textContent = ""; + openaiHint.textContent = ""; + ollamaHint.textContent = ""; + if (m === "openai" || m === "openai_responses") { + ollamaHint.textContent = ""; + if (state.has_openai_api_key_env) openaiHint.textContent = t("models.openaiKeyHint"); + if (selProf.has_key && !String(keyInp.value || "").trim()) { + warnKey.textContent = t("models.warnKeyInDb"); + } + } else if (m === "google") { + openaiHint.textContent = ""; + ollamaHint.textContent = ""; + } else if (m === "ollama") { + ollamaHint.textContent = t("models.ollamaHint"); + } + + btnDelete.disabled = !canMutateProfiles || !rowMutable || pid === builtin; + + modelsGrantsLinkRow.innerHTML = ""; + if (state.can_manage_llm_grants) { + modelsGrantsLinkRow.style.display = ""; + modelsGrantsLinkRow.appendChild(el("span", { text: `${t("models.grantsNavHint")} ` })); + modelsGrantsLinkRow.appendChild(el("a", { href: "#/api-grants", text: t("models.linkApiGrants") })); + } else { + modelsGrantsLinkRow.style.display = "none"; + } + + paintEval(); + } + + activeSelect.addEventListener("change", async () => { + if (!canPickActive) return; + try { + await apiPost("/admin/api/models/active", { profile_id: activeSelect.value }); + await refresh(); + } catch (e) { + status.textContent = String(e.message || e); + } + }); + + const btnCreate = el("button", { class: "btn btn--primary", text: t("models.createBtn"), onclick: async () => { + if (!canMutateProfiles) return; + try { + await apiPost("/admin/api/models/profiles", { + name: newName.value.trim() || t("models.newProfileDefault"), + mode: newMode.value, + }); + newName.value = ""; + await refresh(); + } catch (e) { + status.textContent = String(e.message || e); + } + }}); + + const btnSave = el("button", { class: "btn btn--primary", text: t("models.save"), onclick: async () => { + if (!canMutateProfiles) return; + const sel = (state.profiles || []).find((p) => String(p.id) === String(state.active_llm_profile_id || "")); + if (sel && sel.mutable === false) return; + const pid = String(state.active_llm_profile_id || ""); + const builtin = String(state.builtin_ollama_profile_id || ""); + try { + let modeSave = modeSel.value; + if (pid === builtin) modeSave = "ollama"; + await apiRequest("PATCH", "/admin/api/models/profiles/" + encodeURIComponent(pid), { + name: profName.value.trim() || t("models.newProfileDefault"), + mode: modeSave, + model: modelInp.value.trim(), + base_url: baseInp.value.trim(), + }); + await apiRequest("POST", "/admin/api/models/profiles/" + encodeURIComponent(pid) + "/secret", { + remember: rememberCb.checked, + secret: keyInp.value, + }); + await refresh(); + } catch (e) { + status.textContent = String(e.message || e); + } + }}); + + const btnDelete = el("button", { class: "btn btn--danger", text: t("models.delete"), onclick: async () => { + if (!canMutateProfiles) return; + const pid = String(state.active_llm_profile_id || ""); + const builtin = String(state.builtin_ollama_profile_id || ""); + if (pid === builtin) { + status.textContent = t("models.cannotDeleteBuiltin"); + return; + } + if (!globalThis.confirm(t("models.deleteConfirm"))) return; + try { + await apiRequest("DELETE", "/admin/api/models/profiles/" + encodeURIComponent(pid), {}); + await refresh(); + } catch (e) { + status.textContent = String(e.message || e); + } + }}); + + await refresh(); + + if (!state) { + return el("div", {}, [ + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("title.models") }), + el("div", { class: "muted", text: t("models.loadFailed") }), + el("pre", { class: "pre", text: status.textContent || "" }), + ]), + ]); + } + + if (state.ok === true && Array.isArray(state.profiles) && state.profiles.length === 0) { + const noProfBody = [ + el("div", { class: "card__title", text: t("title.models") }), + el("div", { class: "muted", text: t("models.noProfiles") }), + ]; + if (state.db_path) { + noProfBody.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${state.db_path}` })); + } + noProfBody.push(status); + return el("div", {}, [el("div", { class: "card" }, noProfBody)]); + } + + if (state.ok !== true) { + return el("div", {}, [ + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("title.models") }), + el("div", { class: "muted", text: t("models.loadFailed") }), + el("pre", { class: "pre", text: JSON.stringify(state, null, 2) }), + status, + ]), + ]); + } + + const topBits = [readonlyHint, status, modelsGrantsLinkRow]; + if (state.db_path) { + topBits.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${state.db_path}` })); + } + // Show secret migration helper when legacy secrets exist. + topBits.push(secretsCard); + + return el("div", {}, [ + ...topBits, + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("models.sectionActive") }), + el("div", { class: "row" }, [el("label", { text: t("models.pickModel") }), activeSelect]), + ]), + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("models.sectionBindings") }), + el("div", { class: "muted", text: t("models.agentBindingHelp") }), + el("div", { class: "muted", text: t("models.bindingsExtraHint") }), + el("div", { class: "muted", text: t("models.bindingsScopeHint") }), + bindingWrap, + ]), + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("models.sectionNew") }), + el("div", { class: "row" }, [newName, newMode, btnCreate]), + ]), + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("models.sectionApi") }), + builtinCap, + readonlyProfileHint, + el("div", { class: "row" }, [el("label", { text: t("models.profileName") }), profName]), + el("div", { class: "row" }, [el("label", { text: t("models.mode") }), modeSel]), + el("div", { class: "row" }, [el("label", { text: t("models.model") }), modelInp]), + el("div", { class: "row" }, [el("label", { text: t("models.baseUrl") }), baseInp]), + warnKey, + openaiHint, + ollamaHint, + el("div", { class: "row" }, [el("label", { text: t("models.apiKey") }), keyInp]), + el("div", { class: "row" }, [el("label", { text: t("models.rememberKey") }), rememberCb]), + el("div", { class: "row" }, [btnSave, btnDelete]), + ]), + el("div", { class: "card" }, [evalDetails]), + ]); +} + +const PLUGINS_PAGE_SIZE = 15; + +function pluginsFold(summaryText, innerNodes) { + const det = el("details", { class: "details plugins-fold" }); + det.appendChild(el("summary", { text: summaryText })); + const inner = el("div", { class: "plugins-fold__inner" }); + innerNodes.forEach((n) => inner.appendChild(n)); + det.appendChild(inner); + return det; +} + +/** totalHolder.value = row count; pageRef.value = 1-based page */ +function pluginsPagerBar(totalHolder, pageRef, onRepaint) { + const wrap = el("div", { + class: "row plugins-pager", + style: "gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap;", + }); + const lab = el("span", { class: "muted", text: "" }); + const maxP = () => Math.max(1, Math.ceil(Math.max(0, totalHolder.value) / PLUGINS_PAGE_SIZE)); + const prevBtn = el("button", { class: "btn btn--small", text: "上一页" }); + const nextBtn = el("button", { class: "btn btn--small", text: "下一页" }); + const sync = () => { + const total = totalHolder.value; + let cur = pageRef.value; + const mp = maxP(); + if (cur > mp) { + cur = mp; + pageRef.value = cur; + } + if (cur < 1) { + cur = 1; + pageRef.value = 1; + } + const start = total === 0 ? 0 : (cur - 1) * PLUGINS_PAGE_SIZE + 1; + const end = Math.min(cur * PLUGINS_PAGE_SIZE, total); + lab.textContent = total ? `第 ${start}–${end} / 共 ${total} 条` : "无数据"; + prevBtn.disabled = cur <= 1 || total === 0; + nextBtn.disabled = cur >= mp || total === 0; + }; + prevBtn.addEventListener("click", () => { + if (pageRef.value > 1) { + pageRef.value--; + onRepaint(); + sync(); + } + }); + nextBtn.addEventListener("click", () => { + if (pageRef.value < maxP()) { + pageRef.value++; + onRepaint(); + sync(); + } + }); + wrap.appendChild(prevBtn); + wrap.appendChild(lab); + wrap.appendChild(nextBtn); + sync(); + return { wrap, sync }; +} + +/** 与后端 `load_tool_policies_dict` 一致:0;1–9998;≥9999 → 9999 */ +function normalizeWirePolicyLevel(raw) { + const s = String(raw ?? "").trim(); + if (s === "") return 0; + const n = Number(s); + if (!Number.isFinite(n)) return 0; + if (n >= 9999) return 9999; + if (n <= 0) return 0; + return Math.min(Math.trunc(n), 9998); +} + +async function renderPlugins() { + const p = await apiGet("/admin/api/plugins"); + let toolPolicy = { + disable_tool_confirm: false, + enforced_retry_mode: "first_round_only", + tool_loop_state_machine: true, + tool_signature_budget: 2, + turn_max_tool_workers: 8, + turn_max_tool_rounds: 8, + turn_max_context_messages: 80, + turn_runner_impl: "openclaw", + manager_decision_mode: "", + sse_queue_maxsize: 2000, + tool_log_max_chars: 200000, + enable_mcp_tools: true, + enable_plugin_tools: false, + tool_llm_message_max_chars: 0, + mcp_filesystem_extra_roots: "", + mcp_env_allowlist: "", + openclaw_retryable_error_codes: "", + openclaw_retry_codes_strict_mode: false, + wecom_longconn_workers: 2, + wecom_longconn_inbound_queue_maxsize: 200, + }; + let mcp = { servers: [] }; + let mcpFailures = { items: [] }; + let marketTrending = { items: [] }; + let depStatus = { items: [] }; + let mcpBinding = { available_specialists: ["generalist"], servers: [], mapping: {} }; + let mcpUsage = { summary: [], calls: [] }; + let mcpActionMenuEl = null; + const closeMcpActionMenu = () => { + if (mcpActionMenuEl && mcpActionMenuEl.parentNode) { + mcpActionMenuEl.parentNode.removeChild(mcpActionMenuEl); + } + mcpActionMenuEl = null; + }; + try { + mcp = await apiGet("/admin/api/mcp/servers"); + } catch (_) {} + try { + mcpFailures = await apiGet("/admin/api/mcp/failures?limit=20"); + } catch (_) {} + try { + marketTrending = await apiGet("/admin/api/mcp/market/trending?per_source_limit=4"); + } catch (_) {} + try { + depStatus = await apiGet("/admin/api/mcp/dependencies"); + } catch (_) {} + try { + mcpBinding = await apiGet("/admin/api/mcp/binding"); + } catch (_) {} + try { + toolPolicy = await apiGet("/admin/api/tool-policy"); + } catch (_) {} + try { + mcpUsage = await apiGet("/admin/api/mcp/usage?limit=500"); + } catch (_) {} + const pluginCatalog = Array.isArray(p.plugins) ? p.plugins : []; + const pluginPageRef = { value: 1 }; + const pluginTotalHolder = { value: pluginCatalog.length }; + const pluginTbody = el("tbody"); + const buildPluginRow = (x) => + el("tr", {}, [ + el("td", { text: String(x.plugin_name || "") }), + el("td", { text: String(x.plugin_version || "") }), + el("td", { text: String(x.entry_point || "") }), + el("td", { text: String(x.enabled ? 1 : 0) }), + ]); + const repaintPlugins = () => { + pluginTbody.innerHTML = ""; + const start = (pluginPageRef.value - 1) * PLUGINS_PAGE_SIZE; + pluginCatalog.slice(start, start + PLUGINS_PAGE_SIZE).forEach((x) => { + pluginTbody.appendChild(buildPluginRow(x)); + }); + }; + repaintPlugins(); + const pluginPager = pluginsPagerBar(pluginTotalHolder, pluginPageRef, repaintPlugins); + const sourceType = el("select", { class: "input" }, [ + el("option", { value: "github", text: "github" }), + el("option", { value: "npm", text: "npm" }), + el("option", { value: "pypi", text: "pypi" }), + ]); + const sourceRef = el("input", { class: "input", placeholder: "source_ref (repo url / package)" }); + const version = el("input", { class: "input", placeholder: "version(optional)" }); + const entryCmd = el("input", { class: "input", placeholder: "entry_command (required for runtime)" }); + const entryArgs = el("input", { class: "input", placeholder: "entry_args (space separated)" }); + const marketQ = el("input", { class: "input", placeholder: "search MCP market (github/npm/pypi)" }); + const marketBtn = el("button", { class: "btn", text: "Search" }); + const marketRefreshBtn = el("button", { class: "btn", text: "Refresh Trending" }); + const checkAllBtn = el("button", { + class: "btn", + text: "Check Installed", + onclick: async () => { + const r = await apiPost("/admin/api/mcp/check-all", { enabled_only: true }); + const items = Array.isArray(r.items) ? r.items : []; + const parts = items.map((x) => { + const sid = String(x.server_id || "-"); + if (x.ok) return `${sid}:ok(${Number(x.tools_synced || 0)})`; + return `${sid}:err(${String(x.error_code || "unknown")})`; + }); + installStatus.textContent = `[check-all] ok=${Number(r.ok_count || 0)} err=${Number(r.error_count || 0)} ${parts.join(" | ")}`; + router(); + }, + }); + const e2eCheckBtn = el("button", { + class: "btn", + text: "E2E Check", + onclick: async () => { + const r = await apiPost("/admin/api/mcp/e2e-check", {}); + const items = Array.isArray(r.items) ? r.items : []; + const parts = items.map((x) => { + const sid = String(x.server_id || "-"); + return `${sid}:${x.ok ? "ok" : ("err(" + String(x.error || "unknown") + ")")}`; + }); + installStatus.textContent = `[e2e] ok=${Number(r.ok_count || 0)} err=${Number(r.error_count || 0)} session=${String(r.session_id || "-")} ${parts.join(" | ")}`; + router(); + }, + }); + const repairWeakIncludeDisabledCb = el("input", { type: "checkbox" }); + repairWeakIncludeDisabledCb.checked = false; + const repairWeakBtn = el("button", { + class: "btn", + text: "Repair weak", + title: + "MCPs with health≠ok or zero cached tools: run health + sync. By default only enabled rows; tick “Include disabled” to repair disabled installs too.", + onclick: async () => { + const enabledOnly = !repairWeakIncludeDisabledCb.checked; + const r = await apiPost("/admin/api/mcp/repair-weak", { enabled_only: enabledOnly }); + const items = Array.isArray(r.items) ? r.items : []; + const parts = items.map((x) => { + const sid = String(x.server_id || "-"); + if (x.ok) return `${sid}:ok(${Number(x.tools_synced || 0)})`; + return `${sid}:err(${String(x.error_code || "unknown")})`; + }); + const scope = enabledOnly ? "enabled_only" : "include_disabled"; + installStatus.textContent = + `[repair-weak:${scope}] selected=${Number(r.selected || 0)} skip_ok=${Number(r.skipped_healthy || 0)} ` + + `ok=${Number(r.ok_count || 0)} err=${Number(r.error_count || 0)} ${parts.join(" | ")}`; + router(); + }, + }); + const repairWeakScopeLabel = el("label", { class: "row", style: "align-items:center;gap:6px;" }, [ + repairWeakIncludeDisabledCb, + el("span", { class: "muted", text: "Include disabled" }), + ]); + const marketWrap = el("div"); + const marketTableMount = el("div"); + const marketPagerMount = el("div"); + marketWrap.appendChild(marketTableMount); + marketWrap.appendChild(marketPagerMount); + let marketSearchItems = []; + const marketSearchPageRef = { value: 1 }; + const marketSearchTotalHolder = { value: 0 }; + const trendingWrap = el("div"); + const trendingTableMount = el("div"); + const trendingPagerMount = el("div"); + trendingWrap.appendChild(trendingTableMount); + trendingWrap.appendChild(trendingPagerMount); + let trendingItemsCache = []; + const trendingPageRef = { value: 1 }; + const trendingTotalHolder = { value: 0 }; + const depWrap = el("div"); + const depTableMount = el("div"); + const depPagerMount = el("div"); + depWrap.appendChild(depTableMount); + depWrap.appendChild(depPagerMount); + const depItemsList = Array.isArray(depStatus.items) ? depStatus.items : []; + const depPageRef = { value: 1 }; + const depTotalHolder = { value: depItemsList.length }; + const bindingStatus = el("div", { class: "muted", text: "" }); + const installStatus = el("div", { class: "muted", text: "" }); + const preflightFixWrap = el("div"); + const toolPolicyStatus = el("div", { class: "muted", text: "" }); + const legacyToolPolicyNote = el("div", { + class: "muted", + text: "Legacy tool-policy switches (confirm/retry/state-machine/signature budget) are disconnected under oclaw.", + }); + const turnMaxWorkersInput = el("input", { + class: "input", + type: "number", + min: "1", + max: "32", + value: String(Number(toolPolicy.turn_max_tool_workers || 8)), + style: "max-width:120px", + }); + const turnMaxRoundsInput = el("input", { + class: "input", + type: "number", + min: "1", + max: "30", + value: String(Number(toolPolicy.turn_max_tool_rounds || 8)), + style: "max-width:120px", + }); + const turnMaxCtxInput = el("input", { + class: "input", + type: "number", + min: "10", + max: "400", + value: String(Number(toolPolicy.turn_max_context_messages || 80)), + style: "max-width:120px", + }); + const turnRunnerImplNote = el("div", { + class: "muted", + text: "Turn runner: oclaw (legacy runners disconnected)", + }); + const managerDecisionModeNote = el("div", { + class: "muted", + text: "Manager decision mode: (legacy disconnected)", + }); + const sseQueueMaxsizeInput = el("input", { + class: "input", + type: "number", + min: "200", + max: "50000", + value: String(Number(toolPolicy.sse_queue_maxsize || 2000)), + style: "max-width:140px", + }); + const toolLogMaxCharsInput = el("input", { + class: "input", + type: "number", + min: "20000", + max: "2000000", + value: String(Number(toolPolicy.tool_log_max_chars || 200000)), + style: "max-width:140px", + }); + const enableMcpToolsCb = el("input", { type: "checkbox" }); + enableMcpToolsCb.checked = !!toolPolicy.enable_mcp_tools; + const enablePluginToolsCb = el("input", { type: "checkbox" }); + enablePluginToolsCb.checked = !!toolPolicy.enable_plugin_tools; + const toolLlmMessageMaxCharsInput = el("input", { + class: "input", + type: "number", + min: "0", + max: "500000", + value: String(Number(toolPolicy.tool_llm_message_max_chars ?? 0)), + style: "max-width:140px", + }); + const mcpFilesystemExtraRootsInput = el("input", { + class: "input", + value: String(toolPolicy.mcp_filesystem_extra_roots || ""), + placeholder: "D:\\work|D:\\docs", + }); + const mcpEnvAllowlistInput = el("input", { + class: "input", + value: String(toolPolicy.mcp_env_allowlist || ""), + placeholder: "BRAVE_API_KEY,GOOGLE_OAUTH_CREDENTIALS,...", + }); + const openclawRetryableErrorCodesInput = el("input", { + class: "input", + value: String(toolPolicy.openclaw_retryable_error_codes || ""), + placeholder: "provider_timeout,provider_rate_limited,...", + }); + const openclawRetryCodesStrictModeCb = el("input", { type: "checkbox" }); + openclawRetryCodesStrictModeCb.checked = !!toolPolicy.openclaw_retry_codes_strict_mode; + const wecomLongconnWorkersInput = el("input", { + class: "input", + type: "number", + min: "1", + max: "8", + value: String(Number(toolPolicy.wecom_longconn_workers || 2)), + style: "max-width:120px", + }); + const wecomLongconnInboundQueueInput = el("input", { + class: "input", + type: "number", + min: "20", + max: "5000", + value: String(Number(toolPolicy.wecom_longconn_inbound_queue_maxsize || 200)), + style: "max-width:140px", + }); + const saveToolPolicyBtn = el("button", { + class: "btn", + text: "Save Tool Policy", + onclick: async () => { + const r = await apiPost("/admin/api/tool-policy", { + turn_max_tool_workers: Number(turnMaxWorkersInput.value || 8), + turn_max_tool_rounds: Number(turnMaxRoundsInput.value || 8), + turn_max_context_messages: Number(turnMaxCtxInput.value || 80), + // manager_decision_mode is legacy-only; no longer submitted. + sse_queue_maxsize: Number(sseQueueMaxsizeInput.value || 2000), + tool_log_max_chars: Number(toolLogMaxCharsInput.value || 200000), + enable_mcp_tools: !!enableMcpToolsCb.checked, + enable_plugin_tools: !!enablePluginToolsCb.checked, + tool_llm_message_max_chars: Number(toolLlmMessageMaxCharsInput.value || 0), + mcp_filesystem_extra_roots: String(mcpFilesystemExtraRootsInput.value || ""), + mcp_env_allowlist: String(mcpEnvAllowlistInput.value || ""), + openclaw_retryable_error_codes: String(openclawRetryableErrorCodesInput.value || ""), + openclaw_retry_codes_strict_mode: !!openclawRetryCodesStrictModeCb.checked, + wecom_longconn_workers: Number(wecomLongconnWorkersInput.value || 2), + wecom_longconn_inbound_queue_maxsize: Number(wecomLongconnInboundQueueInput.value || 200), + }); + const unknownRetry = Array.isArray(r.unknown_retryable_error_codes) ? r.unknown_retryable_error_codes : []; + if (unknownRetry.length) { + toolPolicyStatus.textContent = `[tool-policy] saved with warnings: unknown_retryable_error_codes=${unknownRetry.join(", ")} | ` + JSON.stringify(r); + } else { + toolPolicyStatus.textContent = `[tool-policy] ` + JSON.stringify(r); + } + }, + }); + const availableSpecialists = Array.isArray(mcpBinding.available_specialists) && mcpBinding.available_specialists.length + ? mcpBinding.available_specialists.map((x) => String(x)) + : ["generalist"]; + const bindingServers = Array.isArray(mcpBinding.servers) + ? mcpBinding.servers.filter((x) => x && String(x.server_id || "").trim()) + : []; + let bindingDraft = mcpBinding.mapping && typeof mcpBinding.mapping === "object" ? { ...mcpBinding.mapping } : {}; + const allSpecialistIds = () => { + const s = new Set(availableSpecialists.map((x) => String(x))); + Object.keys(bindingDraft).forEach((k) => s.add(String(k))); + return Array.from(s).sort(); + }; + const specialistsBoundToServer = (sid) => { + const out = []; + allSpecialistIds().forEach((sp) => { + const arr = Array.isArray(bindingDraft[sp]) ? bindingDraft[sp].map((x) => String(x)) : []; + if (arr.includes(String(sid))) out.push(sp); + }); + return out; + }; + let repaintExpertBindingDashboard = () => {}; + const specialistSelect = el("select", { class: "input" }, availableSpecialists.map((sp) => + el("option", { value: sp, text: sp }), + )); + const bindingListWrap = el("div"); + const bindingListRowsMount = el("div"); + const bindingListPagerMount = el("div"); + bindingListWrap.appendChild(bindingListRowsMount); + bindingListWrap.appendChild(bindingListPagerMount); + const bindingListPageRef = { value: 1 }; + const bindingListTotalHolder = { value: 0 }; + const bindingReverseWrap = el("div"); + const bindingReverseTableMount = el("div"); + const bindingReversePagerMount = el("div"); + bindingReverseWrap.appendChild(bindingReverseTableMount); + bindingReverseWrap.appendChild(bindingReversePagerMount); + const bindingReversePageRef = { value: 1 }; + const bindingReverseTotalHolder = { value: 0 }; + const flashInstalledRow = (sid) => { + const row = document.getElementById(`mcp-installed-${sid}`); + if (!row) { + bindingStatus.textContent = `[binding] installed row not found: ${sid}`; + return; + } + row.scrollIntoView({ behavior: "smooth", block: "center" }); + const oldBg = row.style.backgroundColor; + row.style.backgroundColor = "rgba(255, 215, 0, 0.22)"; + setTimeout(() => { + row.style.backgroundColor = oldBg || ""; + }, 1800); + }; + const renderBindingReverse = () => { + bindingReverseTotalHolder.value = bindingServers.length; + const start = (bindingReversePageRef.value - 1) * PLUGINS_PAGE_SIZE; + const slice = bindingServers.slice(start, start + PLUGINS_PAGE_SIZE); + const rows = slice.map((srv) => { + const sid = String(srv.server_id || ""); + const specialists = allSpecialistIds().filter((sp) => { + const mapped = Array.isArray(bindingDraft[sp]) ? bindingDraft[sp].map((x) => String(x)) : []; + return mapped.includes(sid); + }); + const locateBtn = el("button", { + class: "btn btn--small", + text: "Locate", + onclick: () => flashInstalledRow(sid), + }); + return el("tr", {}, [ + tdCell(sid, 38), + tdCell(specialists.length ? specialists.join(", ") : "-", 62), + el("td", {}, [locateBtn]), + ]); + }); + bindingReverseTableMount.innerHTML = ""; + bindingReverseTableMount.appendChild( + el("div", { class: "table-wrap" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [el("th", { text: "server_id" }), el("th", { text: "bound specialists" }), el("th", { text: "action" })])]), + el("tbody", {}, rows.length ? rows : [el("tr", {}, [el("td", { text: "-", colspan: "3" })])]), + ]), + ]), + ); + bindingReversePagerMount.innerHTML = ""; + const brBar = pluginsPagerBar(bindingReverseTotalHolder, bindingReversePageRef, renderBindingReverse); + bindingReversePagerMount.appendChild(brBar.wrap); + repaintExpertBindingDashboard(); + }; + const renderBindingList = () => { + const current = String(specialistSelect.value || ""); + const existing = Array.isArray(bindingDraft[current]) ? bindingDraft[current].map((x) => String(x)) : []; + const selected = new Set(existing); + bindingListTotalHolder.value = bindingServers.length; + const start = (bindingListPageRef.value - 1) * PLUGINS_PAGE_SIZE; + const slice = bindingServers.slice(start, start + PLUGINS_PAGE_SIZE); + bindingListRowsMount.innerHTML = ""; + if (!bindingServers.length) { + bindingListRowsMount.appendChild(el("div", { class: "muted", text: "No MCP servers installed yet." })); + bindingListPagerMount.innerHTML = ""; + const blBar0 = pluginsPagerBar(bindingListTotalHolder, bindingListPageRef, renderBindingList); + bindingListPagerMount.appendChild(blBar0.wrap); + repaintExpertBindingDashboard(); + return; + } + slice.forEach((srv) => { + const sid = String(srv.server_id || ""); + const cb = el("input", { type: "checkbox" }); + cb.checked = selected.has(sid); + cb.addEventListener("change", () => { + const prev = new Set(Array.isArray(bindingDraft[current]) ? bindingDraft[current].map((x) => String(x)) : []); + if (cb.checked) prev.add(sid); + else prev.delete(sid); + bindingDraft[current] = Array.from(prev); + renderBindingReverse(); + }); + const label = `${sid}${srv.enabled ? "" : " (disabled)"}`; + bindingListRowsMount.appendChild(el("label", { class: "row" }, [cb, el("span", { text: label })])); + }); + bindingListPagerMount.innerHTML = ""; + const blBar = pluginsPagerBar(bindingListTotalHolder, bindingListPageRef, renderBindingList); + bindingListPagerMount.appendChild(blBar.wrap); + repaintExpertBindingDashboard(); + }; + specialistSelect.addEventListener("change", () => { + bindingListPageRef.value = 1; + bindingReversePageRef.value = 1; + renderBindingList(); + renderBindingReverse(); + }); + const selectAllBindingBtn = el("button", { + class: "btn", + text: "Select All", + onclick: () => { + const current = String(specialistSelect.value || ""); + bindingDraft[current] = bindingServers.map((x) => String(x.server_id || "")).filter((x) => x); + renderBindingList(); + renderBindingReverse(); + }, + }); + const clearBindingBtn = el("button", { + class: "btn", + text: "Clear", + onclick: () => { + const current = String(specialistSelect.value || ""); + bindingDraft[current] = []; + renderBindingList(); + renderBindingReverse(); + }, + }); + const saveBindingBtn = el("button", { + class: "btn", + text: "Save Binding", + onclick: async () => { + const r = await apiPost("/admin/api/mcp/binding", { mapping: bindingDraft }); + bindingStatus.textContent = `[binding] ` + JSON.stringify(r); + router(); + }, + }); + renderBindingList(); + renderBindingReverse(); + const jsonInstallInput = el("textarea", { + class: "input", + placeholder: + "Paste install JSON: one object, array of objects, or { servers: [...] } / { payload: {...} } (seed file shape)", + rows: "6", + }); + const installBtn = el("button", { + class: "btn btn--primary", + text: "Install MCP", + onclick: async () => { + const payload = { + source_type: sourceType.value, + source_ref: sourceRef.value.trim(), + version: version.value.trim(), + entry_command: entryCmd.value.trim(), + entry_args: entryArgs.value.trim() ? entryArgs.value.trim().split(/\s+/) : [], + }; + const pre = await apiPost("/admin/api/mcp/preflight", payload); + if (!pre.ok) { + installStatus.textContent = `[preflight] ${String(pre.error_code || "")} ${String(pre.error || "")}`.trim(); + const fixes = Array.isArray(pre.fix_suggestions) ? pre.fix_suggestions : []; + preflightFixWrap.innerHTML = ""; + if (fixes.length) { + const rows = fixes.map((f) => { + const cmd = String((f && f.command) || ""); + const copyBtn = el("button", { + class: "btn", + text: "Copy", + onclick: async () => { + try { + await navigator.clipboard.writeText(cmd); + installStatus.textContent = `[copied] ${cmd}`; + } catch (_) { + installStatus.textContent = `[copy_failed] ${cmd}`; + } + }, + }); + return el("tr", {}, [ + tdCell(String((f && f.title) || ""), 24), + tdCell(cmd, 72), + el("td", {}, [copyBtn]), + ]); + }); + preflightFixWrap.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [el("th", { text: "fix" }), el("th", { text: "command/url" }), el("th", { text: "action" })])]), + el("tbody", {}, rows), + ])])); + } + return; + } + preflightFixWrap.innerHTML = ""; + const warns = Array.isArray(pre.warnings) ? pre.warnings : []; + if (warns.length) { + installStatus.textContent = `[preflight warnings] ${warns.join(", ")}`; + } + const res = await apiPost("/admin/api/mcp/install", payload); + installStatus.textContent = JSON.stringify(res); + router(); + }, + }); + const jsonInstallBtn = el("button", { + class: "btn", + text: "Install from JSON", + onclick: async () => { + preflightFixWrap.innerHTML = ""; + const raw = String(jsonInstallInput.value || "").trim(); + if (!raw) { + installStatus.textContent = "[json] empty payload"; + return; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + installStatus.textContent = `[json] invalid JSON: ${String((err && err.message) || err || "parse_failed")}`; + return; + } + let list; + if (Array.isArray(parsed)) { + list = parsed; + } else if (parsed && typeof parsed === "object" && Array.isArray(parsed.servers)) { + list = parsed.servers; + } else if (parsed && typeof parsed === "object" && parsed.payload && typeof parsed.payload === "object") { + list = [parsed.payload]; + } else { + list = [parsed]; + } + const results = []; + for (const item of list) { + if (!item || typeof item !== "object") { + results.push({ ok: false, error: "item_not_object" }); + continue; + } + const payload = { + source_type: String(item.source_type || "").trim(), + source_ref: String(item.source_ref || "").trim(), + server_id: String(item.server_id || "").trim(), + version: String(item.version || "").trim(), + entry_command: String(item.entry_command || "").trim(), + entry_args: Array.isArray(item.entry_args) ? item.entry_args.map((x) => String(x)) : [], + env_schema: item.env_schema && typeof item.env_schema === "object" ? item.env_schema : {}, + required_permissions: Array.isArray(item.required_permissions) + ? item.required_permissions.map((x) => String(x)) + : [], + risk_level: String(item.risk_level || "high"), + enabled: Object.prototype.hasOwnProperty.call(item, "enabled") ? !!item.enabled : true, + timeout_s: Number(item.timeout_s || 30), + dry_run: !!item.dry_run, + }; + const pre = await apiPost("/admin/api/mcp/preflight", payload); + if (!pre.ok) { + results.push({ + ok: false, + server_id: payload.server_id || payload.source_ref || "-", + phase: "preflight", + error_code: pre.error_code || "", + error: pre.error || "preflight_failed", + }); + continue; + } + const res = await apiPost("/admin/api/mcp/install", payload); + results.push({ + ok: !!res.ok, + server_id: res.server_id || payload.server_id || payload.source_ref || "-", + phase: "install", + error_code: res.error_code || "", + error: res.error || "", + }); + } + installStatus.textContent = JSON.stringify(results); + router(); + }, + }); + const mcpServerList = Array.isArray(mcp.servers) ? mcp.servers : []; + const expertBindingDashTbody = el("tbody"); + repaintExpertBindingDashboard = () => { + expertBindingDashTbody.innerHTML = ""; + const ids = allSpecialistIds(); + if (!ids.length) { + expertBindingDashTbody.appendChild(el("tr", {}, [el("td", { class: "muted", text: "—", colspan: "3" })])); + return; + } + ids.forEach((sp) => { + const sids = new Set((bindingDraft[sp] || []).map(String).filter(Boolean)); + let toolCnt = 0; + mcpServerList.forEach((row) => { + const rsid = String(row.server_id || ""); + if (!sids.has(rsid)) return; + const tools = Array.isArray(row.tools) ? row.tools : []; + toolCnt += tools.length; + }); + expertBindingDashTbody.appendChild(el("tr", {}, [ + tdCell(sp, 28), + tdCell(String(sids.size), 10), + tdCell(String(toolCnt), 12), + ])); + }); + }; + repaintExpertBindingDashboard(); + const mcpInstalledPageRef = { value: 1 }; + const mcpInstalledTotalHolder = { value: mcpServerList.length }; + const mcpInstalledTbody = el("tbody"); + const buildMcpInstalledRow = (x) => { + const sid = String(x.server_id || ""); + const toggleBtn = el("button", { + class: "btn", + text: x.enabled ? "Disable" : "Enable", + onclick: async () => { + closeMcpActionMenu(); + await apiPost("/admin/api/mcp/toggle", { server_id: sid, enabled: !x.enabled }); + router(); + }, + }); + const healthBtn = el("button", { + class: "btn", + text: "Health", + onclick: async () => { + closeMcpActionMenu(); + const r = await apiPost("/admin/api/mcp/healthcheck", { server_id: sid }); + installStatus.textContent = `[health:${sid}] ` + JSON.stringify(r); + router(); + }, + }); + const syncBtn = el("button", { + class: "btn", + text: "Sync Tools", + onclick: async () => { + closeMcpActionMenu(); + const r = await apiPost("/admin/api/mcp/tools/sync", { server_id: sid }); + installStatus.textContent = `[sync:${sid}] ` + JSON.stringify(r); + router(); + }, + }); + const reinstallBtn = el("button", { + class: "chat-sess-menu-item", + text: "Reinstall", + onclick: async () => { + closeMcpActionMenu(); + const r = await apiPost("/admin/api/mcp/reinstall", { server_id: sid }); + installStatus.textContent = `[reinstall:${sid}] ` + JSON.stringify(r); + router(); + }, + }); + const uninstallBtn = el("button", { + class: "chat-sess-menu-item", + text: "Uninstall", + onclick: async () => { + closeMcpActionMenu(); + if (!window.confirm(`Uninstall ${sid} and remove from MCP registry?`)) return; + const r = await apiPost("/admin/api/mcp/uninstall", { server_id: sid, remove_record: true }); + installStatus.textContent = `[uninstall:${sid}] ` + JSON.stringify(r); + router(); + }, + }); + const deleteBtn = el("button", { + class: "chat-sess-menu-item", + text: "Delete", + onclick: async () => { + closeMcpActionMenu(); + if (!window.confirm(`Delete ${sid} from MCP registry only?`)) return; + const r = await apiPost("/admin/api/mcp/delete", { server_id: sid }); + installStatus.textContent = `[delete:${sid}] ` + JSON.stringify(r); + router(); + }, + }); + toggleBtn.className = "chat-sess-menu-item"; + healthBtn.className = "chat-sess-menu-item"; + syncBtn.className = "chat-sess-menu-item"; + const actionMenuBtn = el("button", { + class: "chat-sess-more", + text: "⋯", + title: "MCP actions", + onclick: (ev) => { + ev.stopPropagation(); + closeMcpActionMenu(); + const menu = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;z-index:250;" }, [ + toggleBtn, + healthBtn, + syncBtn, + reinstallBtn, + uninstallBtn, + deleteBtn, + ]); + const rect = ev.currentTarget.getBoundingClientRect(); + document.body.appendChild(menu); + const mrect = menu.getBoundingClientRect(); + const pad = 8; + let left = rect.left - 120; + let top = rect.bottom + 4; + if (top + mrect.height > window.innerHeight - pad) { + top = rect.top - 4 - mrect.height; + } + left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width)); + top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height)); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + mcpActionMenuEl = menu; + const close = (e) => { + if (!menu.contains(e.target)) { + closeMcpActionMenu(); + document.removeEventListener("click", close); + } + }; + setTimeout(() => document.addEventListener("click", close), 0); + }, + }); + const tools = Array.isArray(x.tools) ? x.tools.map((t) => String(t.tool_name || "")).join(", ") : ""; + const healthObj = x.health && typeof x.health === "object" ? x.health : {}; + const healthStatus = String(healthObj.status || "-"); + const healthDetail = healthObj.detail && typeof healthObj.detail === "object" ? healthObj.detail : {}; + const healthErrCode = String(healthDetail.error_code || ""); + const healthErrMsg = String(healthDetail.error || ""); + const healthText = healthErrCode ? `${healthStatus}:${healthErrCode}` : healthStatus; + const healthTitle = healthErrCode || healthErrMsg + ? `${healthErrCode || "error"} ${healthErrMsg}`.trim() + : (healthDetail.synced_tools != null ? `synced_tools=${Number(healthDetail.synced_tools || 0)}` : healthStatus); + return el("tr", { id: `mcp-installed-${sid}` }, [ + tdCell(sid, 28), + tdCell(String(x.source_type || ""), 10), + tdCell(String(x.source_ref || ""), 42), + tdCell(String(x.version || ""), 16), + tdCell(String(x.entry_command || ""), 24), + tdCell(tools || "-", 48), + tdCell(String(x.enabled ? 1 : 0), 8), + el("td", { text: healthText, title: healthTitle }), + el("td", { class: "table__cell-actions" }, [actionMenuBtn]), + ]); + }; + const repaintMcpInstalled = () => { + mcpInstalledTbody.innerHTML = ""; + const start = (mcpInstalledPageRef.value - 1) * PLUGINS_PAGE_SIZE; + mcpServerList.slice(start, start + PLUGINS_PAGE_SIZE).forEach((x) => { + mcpInstalledTbody.appendChild(buildMcpInstalledRow(x)); + }); + }; + repaintMcpInstalled(); + const mcpInstalledPager = pluginsPagerBar(mcpInstalledTotalHolder, mcpInstalledPageRef, repaintMcpInstalled); + const renderMarket = async () => { + const q = marketQ.value.trim(); + if (!q) { + marketTableMount.innerHTML = ""; + marketPagerMount.innerHTML = ""; + marketSearchItems = []; + marketSearchTotalHolder.value = 0; + return; + } + const resp = await apiGet("/admin/api/mcp/market/search?q=" + encodeURIComponent(q) + "&per_source_limit=60"); + marketSearchItems = Array.isArray(resp.items) ? resp.items : []; + marketSearchTotalHolder.value = marketSearchItems.length; + marketSearchPageRef.value = 1; + const paintMarket = () => { + const start = (marketSearchPageRef.value - 1) * PLUGINS_PAGE_SIZE; + const pageItems = marketSearchItems.slice(start, start + PLUGINS_PAGE_SIZE); + const bodyRows = pageItems.map((it) => { + const useBtn = el("button", { + class: "btn", + text: "Use", + onclick: () => { + sourceType.value = String(it.source_type || "github"); + sourceRef.value = String(it.source_ref || ""); + version.value = String(it.version || ""); + const tpl = (it && typeof it.install_template === "object") ? it.install_template : {}; + entryCmd.value = String(tpl.entry_command || entryCmd.value || ""); + const args = Array.isArray(tpl.entry_args) ? tpl.entry_args.map((x) => String(x)).join(" ") : ""; + if (args) entryArgs.value = args; + }, + }); + return el("tr", {}, [ + tdCell(String(it.source_type || ""), 10), + tdCell(String(it.name || ""), 24), + tdCell(String(it.description || ""), 60), + tdCell(String(it.source_ref || ""), 40), + tdCell(String(it.version || ""), 12), + el("td", {}, [useBtn]), + ]); + }); + marketTableMount.innerHTML = ""; + marketTableMount.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "source" }), + el("th", { text: "name" }), + el("th", { text: "description" }), + el("th", { text: "ref" }), + el("th", { text: "version" }), + el("th", { text: "action" }), + ])]), + el("tbody", {}, bodyRows.length ? bodyRows : [el("tr", {}, [el("td", { text: "-", colspan: "6" })])]), + ])])); + }; + paintMarket(); + marketPagerMount.innerHTML = ""; + const mkBar = pluginsPagerBar(marketSearchTotalHolder, marketSearchPageRef, paintMarket); + marketPagerMount.appendChild(mkBar.wrap); + }; + marketBtn.addEventListener("click", renderMarket); + const renderTrending = async () => { + trendingItemsCache = Array.isArray(marketTrending.items) ? marketTrending.items : []; + trendingTotalHolder.value = trendingItemsCache.length; + trendingPageRef.value = 1; + const paintTrending = () => { + const start = (trendingPageRef.value - 1) * PLUGINS_PAGE_SIZE; + const pageItems = trendingItemsCache.slice(start, start + PLUGINS_PAGE_SIZE); + const row = pageItems.map((x) => { + const useBtn = el("button", { + class: "btn", + text: "Use", + onclick: () => { + sourceType.value = String(x.source_type || "github"); + sourceRef.value = String(x.source_ref || ""); + version.value = String(x.version || ""); + const tpl = (x && typeof x.install_template === "object") ? x.install_template : {}; + entryCmd.value = String(tpl.entry_command || entryCmd.value || ""); + const args = Array.isArray(tpl.entry_args) ? tpl.entry_args.map((k) => String(k)).join(" ") : ""; + if (args) entryArgs.value = args; + }, + }); + return el("tr", {}, [ + tdCell(String(x.source_type || ""), 10), + tdCell(String(x.name || ""), 26), + tdCell(String(x.description || ""), 70), + tdCell(String(x.stars || 0), 8), + el("td", {}, [useBtn]), + ]); + }); + trendingTableMount.innerHTML = ""; + trendingTableMount.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [el("th", { text: "source" }), el("th", { text: "name" }), el("th", { text: "description" }), el("th", { text: "stars" }), el("th", { text: "action" })])]), + el("tbody", {}, row.length ? row : [el("tr", {}, [el("td", { text: "-", colspan: "5" })])]), + ])])); + }; + paintTrending(); + trendingPagerMount.innerHTML = ""; + const trBar = pluginsPagerBar(trendingTotalHolder, trendingPageRef, paintTrending); + trendingPagerMount.appendChild(trBar.wrap); + }; + const renderDeps = () => { + depTotalHolder.value = depItemsList.length; + const paintDeps = () => { + const start = (depPageRef.value - 1) * PLUGINS_PAGE_SIZE; + const slice = depItemsList.slice(start, start + PLUGINS_PAGE_SIZE); + const rows = slice.map((x) => + el("tr", {}, [ + tdCell(String(x.name || ""), 12), + tdCell(String(x.ok ? "ok" : "missing"), 12), + tdCell(String(x.version || ""), 28), + tdCell(String(x.path || ""), 50), + ]), + ); + depTableMount.innerHTML = ""; + depTableMount.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [el("th", { text: "dep" }), el("th", { text: "status" }), el("th", { text: "version" }), el("th", { text: "path" })])]), + el("tbody", {}, rows.length ? rows : [el("tr", {}, [el("td", { text: "-", colspan: "4" })])]), + ])])); + }; + paintDeps(); + depPagerMount.innerHTML = ""; + const depBar = pluginsPagerBar(depTotalHolder, depPageRef, paintDeps); + depPagerMount.appendChild(depBar.wrap); + }; + marketRefreshBtn.addEventListener("click", async () => { + marketTrending = await apiGet("/admin/api/mcp/market/trending?per_source_limit=4&refresh=1"); + await renderTrending(); + }); + renderTrending(); + renderDeps(); + const failureText = Array.isArray(mcpFailures.items) + ? mcpFailures.items.map((x) => `${x.server_id}/${x.error_code || "-"}:${x.count}`).join(" | ") + : ""; + const usageSummaryList = Array.isArray(mcpUsage.summary) ? mcpUsage.summary : []; + const usageCallsList = Array.isArray(mcpUsage.calls) ? mcpUsage.calls : []; + const usageSummaryPageRef = { value: 1 }; + const usageSummaryTotalHolder = { value: usageSummaryList.length }; + const usageSummaryTbody = el("tbody"); + const usageCallsPageRef = { value: 1 }; + const usageCallsTotalHolder = { value: usageCallsList.length }; + const usageCallsTbody = el("tbody"); + const buildUsageSummaryRow = (x) => + el("tr", {}, [ + tdCell(String(x.server_id || ""), 24), + tdCell(String(x.mcp_tool_name || x.tool_name || ""), 30), + tdCell(String(x.specialist || "-"), 16), + tdCell(String(x.count || 0), 10), + tdCell(String(x.last_ts || ""), 24), + ]); + const buildUsageCallRow = (x) => + el("tr", {}, [ + tdCell(String(x.timestamp || ""), 22), + tdCell(String(x.server_id || ""), 18), + tdCell(String(x.mcp_tool_name || x.tool_name || ""), 22), + tdCell(String(x.specialist || "-"), 14), + tdCell(String(x.session_id || ""), 22), + tdCell(String(x.duration_ms || 0), 10), + ]); + const repaintUsageSummary = () => { + usageSummaryTbody.innerHTML = ""; + const start = (usageSummaryPageRef.value - 1) * PLUGINS_PAGE_SIZE; + usageSummaryList.slice(start, start + PLUGINS_PAGE_SIZE).forEach((x) => { + usageSummaryTbody.appendChild(buildUsageSummaryRow(x)); + }); + }; + const repaintUsageCalls = () => { + usageCallsTbody.innerHTML = ""; + const start = (usageCallsPageRef.value - 1) * PLUGINS_PAGE_SIZE; + usageCallsList.slice(start, start + PLUGINS_PAGE_SIZE).forEach((x) => { + usageCallsTbody.appendChild(buildUsageCallRow(x)); + }); + }; + repaintUsageSummary(); + repaintUsageCalls(); + const usageSummaryPager = pluginsPagerBar(usageSummaryTotalHolder, usageSummaryPageRef, repaintUsageSummary); + const usageCallsPager = pluginsPagerBar(usageCallsTotalHolder, usageCallsPageRef, repaintUsageCalls); + let toolWire = { tools: [], config: {}, policies: {}, penalty_state: {}, role: "" }; + try { + toolWire = await apiGet("/admin/api/mcp/tool-wire"); + } catch (_) {} + const twc = toolWire.config || {}; + const wireTools = Array.isArray(toolWire.tools) ? toolWire.tools : []; + const wireRoleMode = String(toolWire.role_mode || "restricted"); + const wireRoleSelect = el("select", { class: "input" }, [ + el("option", { value: "", text: "global(默认)" }), + el("option", { value: "manager", text: "manager(总控)" }), + ...availableSpecialists + .filter((x) => String(x) !== "manager") + .map((sp) => el("option", { value: String(sp), text: String(sp) })), + ]); + wireRoleSelect.value = String(toolWire.role || ""); + const wireCfgStatus = el("div", { class: "muted", text: "" }); + const wirePolStatus = el("div", { class: "muted", text: "" }); + const wireRoleModeSelect = el("select", { class: "input" }, [ + el("option", { value: "restricted", text: "受限(启用惩罚机制)" }), + el("option", { value: "unrestricted", text: "不受限(惩罚无效)" }), + el("option", { value: "forbidden", text: "禁止(MCP 全禁)" }), + ]); + wireRoleModeSelect.value = wireRoleMode; + const saveWireRoleModeBtn = el("button", { + class: "btn", + text: "保存 role 模式", + onclick: async () => { + const role = String(wireRoleSelect.value || "").trim(); + if (!role) { + wireCfgStatus.textContent = "[role-mode] 请选择具体 role(非 global)"; + return; + } + const r = await apiPost("/admin/api/mcp/tool-wire/role-mode", { + role, + mode: String(wireRoleModeSelect.value || "restricted"), + }); + wireCfgStatus.textContent = `[role-mode] ` + JSON.stringify(r); + router(); + }, + }); + const applyWireRoleSelectorState = () => { + const isGlobal = !String(wireRoleSelect.value || "").trim(); + saveWireRoleModeBtn.disabled = isGlobal; + wireRoleModeSelect.disabled = isGlobal; + if (isGlobal) { + wireCfgStatus.textContent = "[role-mode] global 不支持 role 模式设置,请选择具体 role。"; + } + }; + const inpWirePolicy = el("select", { class: "input" }, [ + el("option", { value: "inherit", text: "inherit(随 URL;DashScope 默认开)" }), + el("option", { value: "always", text: "always(不按 URL,始终启用分层)" }), + el("option", { value: "never", text: "never(关分层;9999 仍过滤)" }), + ]); + inpWirePolicy.value = String(twc.wire_policy || "inherit"); + const inpTopN = el("input", { class: "input", type: "number", min: "3", max: "80", value: String(twc.top_n_full ?? 20) }); + const inpStaleH = el("input", { class: "input", type: "number", step: "0.25", value: String(twc.stale_hours ?? 3) }); + const inpPenMin = el("input", { class: "input", type: "number", value: String(twc.penalty_minutes ?? 30) }); + const inpMedS = el("input", { class: "input", type: "number", value: String(twc.medium_rank_start ?? 21) }); + const inpMedE = el("input", { class: "input", type: "number", value: String(twc.medium_rank_end ?? 50) }); + const inpMedDesc = el("input", { class: "input", type: "number", value: String(twc.medium_desc_chars ?? 520) }); + const inpMinCap = el("input", { class: "input", type: "number", value: String(twc.minimal_desc_cap ?? 80) }); + const inpPenaltyEnabled = el("input", { type: "checkbox" }); + inpPenaltyEnabled.checked = !Boolean(twc.penalty_disable); + const saveWireCfgBtn = el("button", { + class: "btn", + text: "保存全局参数", + onclick: async () => { + const r = await apiPost("/admin/api/mcp/tool-wire/config", { + wire_policy: inpWirePolicy.value, + top_n_full: Number(inpTopN.value), + stale_hours: Number(inpStaleH.value), + penalty_minutes: Number(inpPenMin.value), + medium_rank_start: Number(inpMedS.value), + medium_rank_end: Number(inpMedE.value), + medium_desc_chars: Number(inpMedDesc.value), + minimal_desc_cap: Number(inpMinCap.value), + penalty_disable: !Boolean(inpPenaltyEnabled.checked), + }); + wireCfgStatus.textContent = JSON.stringify(r); + router(); + }, + }); + const resetPenaltyStateBtn = el("button", { + class: "btn", + text: "重置惩罚状态", + onclick: async () => { + if (!window.confirm("确认重置当前 MCP 工具惩罚状态?该操作会立即清空 penalty state。")) return; + const qs = wireRoleSelect.value ? ("?role=" + encodeURIComponent(wireRoleSelect.value)) : ""; + const r = await apiPost("/admin/api/mcp/tool-wire/penalty/reset" + qs, {}); + wireCfgStatus.textContent = JSON.stringify(r); + router(); + }, + }); + const draftPolicies = {}; + wireTools.forEach((t) => { + if (t.policy_in_db) draftPolicies[t.wire_name] = normalizeWirePolicyLevel(t.policy_level); + else draftPolicies[t.wire_name] = null; + }); + const wireToolBody = el("tbody"); + const wireToolsPageRef = { value: 1 }; + const wireToolsTotalHolder = { value: wireTools.length }; + const wireCheckedSet = new Set(); + const wireSubMatch = (a, pat) => { + const p = String(pat || "").trim(); + if (!p) return true; + return String(a ?? "").toLowerCase().includes(p.toLowerCase()); + }; + const wireFOnlyChecked = el("input", { type: "checkbox", title: "仅显示已勾选行" }); + const wireFServer = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" }); + const wireFTool = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" }); + const wireFWire = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" }); + const wireFExpert = el("input", { class: "input", placeholder: "专家含", style: "width:100%;min-width:64px;box-sizing:border-box;" }); + const wireFCountMin = el("input", { class: "input", type: "number", placeholder: "≥", style: "width:100%;box-sizing:border-box;" }); + const wireFCountMax = el("input", { class: "input", type: "number", placeholder: "≤", style: "width:100%;box-sizing:border-box;" }); + const wireFLastTs = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" }); + const wireFPenalty = el("input", { class: "input", placeholder: "含", style: "width:100%;min-width:64px;box-sizing:border-box;" }); + const wireFLevelMin = el("input", { class: "input", type: "number", placeholder: "等级≥", style: "width:100%;box-sizing:border-box;" }); + const wireFLevelMax = el("input", { class: "input", type: "number", placeholder: "等级≤", style: "width:100%;box-sizing:border-box;" }); + const wireFilterCountLabel = el("span", { class: "muted", text: "" }); + const wireRoleModeHint = el("div", { class: "muted", text: "" }); + const wireRowMatchesFilters = (t) => { + if (wireFOnlyChecked.checked && !wireCheckedSet.has(t.wire_name)) return false; + if (!wireSubMatch(t.server_id, wireFServer.value)) return false; + if (!wireSubMatch(t.mcp_tool_name, wireFTool.value)) return false; + if (!wireSubMatch(t.wire_name, wireFWire.value)) return false; + const ex = specialistsBoundToServer(t.server_id).join(", "); + if (!wireSubMatch(ex, wireFExpert.value)) return false; + const cnt = Number(t.count || 0); + if (String(wireFCountMin.value).trim() && cnt < Number(wireFCountMin.value)) return false; + if (String(wireFCountMax.value).trim() && cnt > Number(wireFCountMax.value)) return false; + const ph = (t.penalty && t.penalty.unblock_hint) || ""; + if (!wireSubMatch(ph, wireFPenalty.value)) return false; + if (!wireSubMatch(t.last_ts || "", wireFLastTs.value)) return false; + const lvRaw = draftPolicies[t.wire_name]; + const lvNum = lvRaw === null || lvRaw === undefined ? null : Number(lvRaw); + const minS = String(wireFLevelMin.value).trim(); + if (minS) { + const m = Number(minS); + if (Number.isFinite(m)) { + if (lvNum === null || lvNum === undefined) { + if (m > 0) return false; + } else if (lvNum < m) return false; + } + } + const maxS = String(wireFLevelMax.value).trim(); + if (maxS && lvNum !== null && lvNum !== undefined) { + const m = Number(maxS); + if (Number.isFinite(m) && lvNum > m) return false; + } + return true; + }; + const roleModeBadge = (modeRaw) => { + const mode = String(modeRaw || "restricted"); + if (mode === "unrestricted") { + return el("span", { class: "badge badge--ok", text: "unrestricted" }); + } + if (mode === "forbidden") { + return el("span", { class: "badge badge--bad", text: "forbidden" }); + } + return el("span", { class: "badge badge--mode-restricted", text: "restricted" }); + }; + const getWireToolsFiltered = () => wireTools.filter((x) => wireRowMatchesFilters(x)); + const paintWireToolRows = () => { + wireToolBody.innerHTML = ""; + if (!wireTools.length) { + wireToolBody.appendChild(el("tr", {}, [el("td", { text: "暂无已缓存工具(对各 MCP 点 Sync Tools)", colspan: "9" })])); + return; + } + const list = getWireToolsFiltered(); + if (!list.length) { + wireToolBody.appendChild(el("tr", {}, [el("td", { text: "无匹配行(请调整筛选)", colspan: "9" })])); + return; + } + const start = (wireToolsPageRef.value - 1) * PLUGINS_PAGE_SIZE; + list.slice(start, start + PLUGINS_PAGE_SIZE).forEach((t) => { + const rowCb = el("input", { type: "checkbox" }); + rowCb.checked = wireCheckedSet.has(t.wire_name); + rowCb.addEventListener("change", () => { + if (rowCb.checked) wireCheckedSet.add(t.wire_name); + else wireCheckedSet.delete(t.wire_name); + }); + const lv0 = draftPolicies[t.wire_name]; + const lvlSel = el( + "select", + { + class: "input", + title: "按 role:默认(继承全局惩罚)/ 不惩罚 / 永禁", + style: "width:140px;max-width:100%;", + "data-wire-level": "1", + }, + [ + el("option", { value: "", text: "默认(继承)" }), + el("option", { value: "0", text: "不惩罚(0)" }), + el("option", { value: "9999", text: "永禁(9999)" }), + ], + ); + lvlSel.value = lv0 === null || lv0 === undefined ? "" : String(normalizeWirePolicyLevel(lv0)); + const syncLevelFromSelect = () => { + const v = String(lvlSel.value || "").trim(); + if (!v) { + draftPolicies[t.wire_name] = null; + lvlSel.value = ""; + return; + } + const n = normalizeWirePolicyLevel(v); + draftPolicies[t.wire_name] = n; + lvlSel.value = String(n); + }; + lvlSel.addEventListener("change", syncLevelFromSelect); + const ph = (t.penalty && t.penalty.unblock_hint) || "-"; + const exCell = specialistsBoundToServer(t.server_id).join(", ") || "—"; + wireToolBody.appendChild( + el( + "tr", + { "data-wire-name": t.wire_name }, + [ + el("td", {}, [rowCb]), + tdCell(t.server_id, 20), + tdCell(t.mcp_tool_name, 22), + tdCell(t.wire_name, 32), + tdCell(exCell, 20), + tdCell(String(t.count || 0), 8), + tdCell(String(t.last_ts || "-"), 22), + el("td", {}, [roleModeBadge(wireRoleModeSelect.value || "restricted")]), + el("td", { + text: ph, + title: ph, + style: "max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;", + }), + el("td", {}, [lvlSel]), + ], + ), + ); + }); + }; + const applyWireRoleModeUiState = () => { + const mode = String(wireRoleModeSelect.value || "restricted"); + const disabled = mode !== "restricted"; + if (mode === "unrestricted") { + wireRoleModeHint.textContent = "当前 role 为不受限:单工具惩罚策略不生效。"; + } else if (mode === "forbidden") { + wireRoleModeHint.textContent = "当前 role 为禁止:MCP 全禁,单工具惩罚策略不生效。"; + } else { + wireRoleModeHint.textContent = ""; + } + const staticControls = [ + wireFOnlyChecked, + wireFServer, + wireFTool, + wireFWire, + wireFExpert, + wireFCountMin, + wireFCountMax, + wireFLastTs, + wireFPenalty, + wireFLevelMin, + wireFLevelMax, + clearWireFiltersBtn, + bulkLvlInput, + applyBulkWireBtn, + saveWirePolBtn, + ]; + staticControls.forEach((node) => { + if (node) node.disabled = disabled; + }); + Array.from(wireToolBody.querySelectorAll("input,select,button")).forEach((el0) => { + el0.disabled = disabled; + }); + }; + const wireToolsPager = pluginsPagerBar(wireToolsTotalHolder, wireToolsPageRef, paintWireToolRows); + const refreshWireToolsFiltered = () => { + const list = getWireToolsFiltered(); + wireToolsTotalHolder.value = list.length; + wireFilterCountLabel.textContent = `筛选 ${list.length} / 共 ${wireTools.length} 条`; + const maxPage = Math.max(1, Math.ceil(list.length / PLUGINS_PAGE_SIZE) || 1); + if (wireToolsPageRef.value > maxPage) wireToolsPageRef.value = maxPage; + if (wireToolsPageRef.value < 1) wireToolsPageRef.value = 1; + paintWireToolRows(); + wireToolsPager.sync(); + applyWireRoleModeUiState(); + }; + const onWireFilterChange = () => { + wireToolsPageRef.value = 1; + refreshWireToolsFiltered(); + }; + [ + wireFServer, + wireFTool, + wireFWire, + wireFExpert, + wireFCountMin, + wireFCountMax, + wireFLastTs, + wireFPenalty, + wireFLevelMin, + wireFLevelMax, + ].forEach((inp) => inp.addEventListener("input", onWireFilterChange)); + wireFOnlyChecked.addEventListener("change", onWireFilterChange); + wireRoleModeSelect.addEventListener("change", applyWireRoleModeUiState); + wireRoleSelect.addEventListener("change", applyWireRoleSelectorState); + const bulkLvlInput = el( + "select", + { class: "input", title: "批量写入", style: "width:140px;" }, + [ + el("option", { value: "0", text: "不惩罚(0)" }), + el("option", { value: "9999", text: "永禁(9999)" }), + el("option", { value: "", text: "清空(继承)" }), + ], + ); + bulkLvlInput.value = "0"; + const clearWireFiltersBtn = el("button", { + class: "btn", + text: "清除筛选", + onclick: () => { + wireFServer.value = ""; + wireFTool.value = ""; + wireFWire.value = ""; + wireFExpert.value = ""; + wireFCountMin.value = ""; + wireFCountMax.value = ""; + wireFLastTs.value = ""; + wireFPenalty.value = ""; + wireFLevelMin.value = ""; + wireFLevelMax.value = ""; + wireFOnlyChecked.checked = false; + onWireFilterChange(); + }, + }); + const applyBulkWireBtn = el("button", { + class: "btn", + text: "批量应用到选中", + onclick: async () => { + const bulkRaw = String(bulkLvlInput.value ?? "").trim(); + const lv = bulkRaw === "" ? null : normalizeWirePolicyLevel(bulkRaw); + const selectedWireNames = []; + Array.from(wireToolBody.querySelectorAll("tr")).forEach((tr) => { + const cb = tr.querySelector("input[type=checkbox]"); + if (!cb || !cb.checked) return; + const wn = tr.getAttribute("data-wire-name"); + const sel = tr.querySelector("[data-wire-level]"); + if (wn && sel) { + selectedWireNames.push(String(wn)); + draftPolicies[wn] = lv; + sel.value = lv === null ? "" : String(lv); + } + }); + if (selectedWireNames.length) { + try { + if (lv === null) { + await apiPost("/admin/api/mcp/tool-wire/policies", { + role: String(wireRoleSelect.value || ""), + policies: {}, + clears: selectedWireNames, + }); + } else { + await apiPost("/admin/api/mcp/tool-wire/policies/batch", { + role: String(wireRoleSelect.value || ""), + level: Number(lv), + wire_names: selectedWireNames, + }); + } + } catch (err) { + wirePolStatus.textContent = `[批量] 后端批量写入失败: ${String((err && err.message) || err)}`; + return; + } + } + wirePolStatus.textContent = "[批量] 已写入后端并更新本地视图"; + }, + }); + const saveWirePolBtn = el("button", { + class: "btn btn--primary", + text: "保存工具策略", + onclick: async () => { + Array.from(wireToolBody.querySelectorAll("tr")).forEach((tr) => { + const wn = tr.getAttribute("data-wire-name"); + const sel = tr.querySelector("[data-wire-level]"); + if (wn && sel) { + const raw = String(sel.value ?? "").trim(); + draftPolicies[wn] = raw === "" ? null : normalizeWirePolicyLevel(raw); + } + }); + const pol = {}; + const clears = []; + wireTools.forEach((t) => { + const wn = t.wire_name; + const v = draftPolicies[wn]; + if (v === null || v === undefined) clears.push(wn); + else pol[wn] = v; + }); + const r = await apiPost("/admin/api/mcp/tool-wire/policies", { + role: String(wireRoleSelect.value || ""), + policies: pol, + clears, + }); + wirePolStatus.textContent = JSON.stringify(r); + router(); + }, + }); + refreshWireToolsFiltered(); + applyWireRoleSelectorState(); + applyWireRoleModeUiState(); + const foldToolPolicy = pluginsFold(`【1】工具策略与已注册插件(${pluginCatalog.length})`, [ + el("div", { class: "muted", text: "Tool policy(确认 / 重试)与 Python 工具插件表" }), + legacyToolPolicyNote, + el("div", { class: "row" }, [ + el("label", { text: "Turn max tool workers (1-32)" }), + turnMaxWorkersInput, + ]), + el("div", { class: "row" }, [ + el("label", { text: "Turn max tool rounds (1-30)" }), + turnMaxRoundsInput, + ]), + el("div", { class: "row" }, [ + el("label", { text: "Turn max context messages (10-400)" }), + turnMaxCtxInput, + ]), + el("div", { class: "row" }, [ + el("label", { text: "Turn runner implementation" }), + turnRunnerImplNote, + ]), + el("div", { class: "row" }, [ + el("label", { text: "Manager decision mode" }), + managerDecisionModeNote, + ]), + el("div", { class: "row" }, [ + el("label", { text: "SSE queue maxsize (200-50000)" }), + sseQueueMaxsizeInput, + ]), + el("div", { class: "row" }, [ + el("label", { text: "Tool log max chars (20000-2000000)" }), + toolLogMaxCharsInput, + ]), + el("label", { class: "kv" }, [enableMcpToolsCb, document.createTextNode(" Enable MCP tools")]), + el("label", { class: "kv" }, [enablePluginToolsCb, document.createTextNode(" Enable plugin tools")]), + el("div", { class: "row" }, [ + el("label", { text: "Tool message max chars to LLM (0=unlimited, 4096-500000 recommended)" }), + toolLlmMessageMaxCharsInput, + ]), + el("div", { class: "muted", text: "Set 0 to disable truncation. If some gateways return 400 for oversized tool messages, set back to 24000." }), + el("div", { class: "row" }, [ + el("label", { text: "MCP filesystem extra roots (| separated)" }), + mcpFilesystemExtraRootsInput, + ]), + el("div", { class: "row" }, [ + el("label", { text: "MCP env allowlist (comma separated)" }), + mcpEnvAllowlistInput, + ]), + el("div", { class: "row" }, [ + el("label", { text: "oclaw retryable error codes (comma separated)" }), + openclawRetryableErrorCodesInput, + ]), + el("label", { class: "kv" }, [openclawRetryCodesStrictModeCb, document.createTextNode(" Strict mode: reject unknown retry codes")]), + el("div", { class: "row" }, [ + el("label", { text: "WeCom longconn workers (1-8)" }), + wecomLongconnWorkersInput, + ]), + el("div", { class: "row" }, [ + el("label", { text: "WeCom inbound queue maxsize (20-5000)" }), + wecomLongconnInboundQueueInput, + ]), + el("div", { class: "row" }, [saveToolPolicyBtn]), + toolPolicyStatus, + el("div", { class: "table-wrap" }, [ + el("table", { class: "table" }, [ + el("thead", {}, [el("tr", {}, [el("th", { text: t("table.name") }), el("th", { text: t("table.version") }), el("th", { text: t("table.entryPoint") }), el("th", { text: t("table.enabled") })])]), + pluginTbody, + ]), + ]), + pluginPager.wrap, + ]); + const foldMcpMarket = pluginsFold("【2】MCP 市场 / 依赖 / Trending / 检索结果", [ + el("div", { class: "muted", text: failureText ? `Failure summary: ${failureText}` : "Failure summary: -" }), + el("div", { class: "muted", text: "本地依赖检查" }), + depWrap, + el("div", { class: "row" }, [marketQ, marketBtn, marketRefreshBtn]), + el("div", { class: "muted", text: "Trending" }), + trendingWrap, + el("div", { class: "muted", text: "Market search" }), + marketWrap, + ]); + const foldMcpInstall = pluginsFold("【3】MCP 安装(表单 / JSON / 运维)", [ + el("div", { class: "row" }, [sourceType, sourceRef, version]), + el("div", { class: "row" }, [entryCmd, entryArgs, installBtn]), + el("div", { class: "muted", text: "JSON install (single object or array)" }), + jsonInstallInput, + el("div", { class: "row" }, [jsonInstallBtn]), + el("div", { class: "row", style: "flex-wrap:wrap;align-items:center;gap:8px;" }, [ + checkAllBtn, + e2eCheckBtn, + repairWeakBtn, + repairWeakScopeLabel, + ]), + installStatus, + preflightFixWrap, + ]); + const mcpExportJsonBtn = el("button", { + class: "btn", + text: "Export JSON (download)", + title: "Download uninstall/reinstallable snapshot (same shape as “Install from JSON”)", + onclick: async () => { + let r; + try { + r = await apiGet("/admin/api/mcp/export"); + } catch (err) { + installStatus.textContent = "[export] " + String((err && err.message) || err); + return; + } + if (!r || r.ok !== true || !r.document) { + installStatus.textContent = "[export] failed: " + JSON.stringify(r); + return; + } + const text = JSON.stringify(r.document, null, 2) + "\n"; + const blob = new Blob([text], { type: "application/json" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "mcp_registry_migrated.json"; + a.click(); + URL.revokeObjectURL(a.href); + installStatus.textContent = + "[export] downloaded mcp_registry_migrated.json" + (r.local_path ? " ; on server: " + r.local_path : ""); + }, + }); + const foldMcpInstalled = pluginsFold(`【4】已安装 MCP 服务(${mcpServerList.length})`, [ + el("div", { class: "row", style: "align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:8px;" }, [ + mcpExportJsonBtn, + el("div", { + class: "muted", + text: "新安装/重装/卸载(删记录)成功后自动写入: src/_local/mcp_registry_migrated.json,便于换机迁移。", + }), + ]), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "server_id" }), + el("th", { text: "source" }), + el("th", { text: "ref" }), + el("th", { text: "version" }), + el("th", { text: "entry" }), + el("th", { text: "tools" }), + el("th", { text: "enabled" }), + el("th", { text: "health" }), + el("th", { text: "actions" }), + ])]), + mcpInstalledTbody, + ]), + ]), + mcpInstalledPager.wrap, + ]); + const foldMcpUsage = pluginsFold( + `【5】MCP 用量(summary ${usageSummaryList.length} / calls ${usageCallsList.length})`, + [ + el("div", { class: "muted", text: "MCP Usage Summary" }), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "server_id" }), + el("th", { text: "tool_name" }), + el("th", { text: "specialist" }), + el("th", { text: "count" }), + el("th", { text: "last_called_at" }), + ])]), + usageSummaryTbody, + ]), + ]), + usageSummaryPager.wrap, + el("div", { class: "muted", text: "Recent MCP Calls", style: "margin-top:10px;" }), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "timestamp" }), + el("th", { text: "server_id" }), + el("th", { text: "tool_name" }), + el("th", { text: "specialist" }), + el("th", { text: "session_id" }), + el("th", { text: "duration_ms" }), + ])]), + usageCallsTbody, + ]), + ]), + usageCallsPager.wrap, + ], + ); + const foldWireGlobal = pluginsFold("【6】线侧策略 — 全局参数", [ + el("div", { + class: "muted", + text: "按 mcp__server__tool;wire_policy=always 不依赖 base_url。", + }), + el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [ + el("label", { text: "wire_policy" }), + inpWirePolicy, + el("label", { text: "Top N 全量" }), + inpTopN, + el("label", { text: "全局闲置(h)" }), + inpStaleH, + el("label", { text: "罚时长(min)" }), + inpPenMin, + ]), + el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [ + el("label", { text: "medium rank" }), + inpMedS, + inpMedE, + el("label", { text: "medium 描述上限" }), + inpMedDesc, + el("label", { text: "minimal 描述" }), + inpMinCap, + el("label", {}, [inpPenaltyEnabled, el("span", { text: "启用惩罚机制", style: "margin-left:6px;" })]), + ]), + el("div", { class: "row" }, [ + el("label", { text: "role" }), + wireRoleSelect, + wireRoleModeSelect, + saveWireRoleModeBtn, + saveWireCfgBtn, + resetPenaltyStateBtn, + wireCfgStatus, + ]), + ]); + const wireLevelHint = el("div", { + class: "muted", + style: "font-size:12px;line-height:1.45;margin-bottom:6px;", + text: + "留空=未配置(运行时走全局闲置惩罚与线侧分层/压缩)。0=该 role 下此工具不参与闲置惩罚。任意整数 1–9998:闲置与罚均为 N×10 分钟;≥9999 视为 9999 永久不上送。新安装 MCP 在 Sync Tools 后出现新行,默认留空即自动走全局,直至你在本页保存。", + }); + const foldWireTools = pluginsFold(`【7】线侧策略 — 已安装工具(${wireTools.length})`, [ + wireLevelHint, + wireRoleModeHint, + el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [ + wireFilterCountLabel, + clearWireFiltersBtn, + el("span", { class: "muted", text: "批量等级" }), + bulkLvlInput, + applyBulkWireBtn, + saveWirePolBtn, + wirePolStatus, + ]), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [ + el("tr", {}, [ + el("th", { text: "选" }), + el("th", { text: "server" }), + el("th", { text: "tool" }), + el("th", { text: "wire_name" }), + el("th", { text: "专家(绑定推导)" }), + el("th", { text: "count" }), + el("th", { text: "last_ts" }), + el("th", { text: "effective_mode" }), + el("th", { text: "惩罚/解封" }), + el("th", { text: "策略" }), + ]), + el("tr", {}, [ + el("th", {}, [wireFOnlyChecked]), + el("th", {}, [wireFServer]), + el("th", {}, [wireFTool]), + el("th", {}, [wireFWire]), + el("th", {}, [wireFExpert]), + el("th", {}, [ + el("div", { style: "display:flex;flex-direction:column;gap:4px;" }, [wireFCountMin, wireFCountMax]), + ]), + el("th", {}, [wireFLastTs]), + el("th", { text: "-" }), + el("th", {}, [wireFPenalty]), + el("th", {}, [ + el("div", { style: "display:flex;flex-direction:column;gap:4px;" }, [wireFLevelMin, wireFLevelMax]), + ]), + ]), + ]), + wireToolBody, + ]), + ]), + wireToolsPager.wrap, + ]); + wireRoleSelect.addEventListener("change", async () => { + try { + const qs = wireRoleSelect.value ? ("?role=" + encodeURIComponent(wireRoleSelect.value)) : ""; + toolWire = await apiGet("/admin/api/mcp/tool-wire" + qs); + wireCfgStatus.textContent = `[role] switched to ${String(toolWire.role || "global")}`; + wireRoleModeSelect.value = String(toolWire.role_mode || "restricted"); + // reload current page to rebuild wireTools + drafts cleanly + router(); + } catch (err) { + wireCfgStatus.textContent = `[role] load failed: ${String((err && err.message) || err)}`; + } + }); + const foldExpertBindingDash = pluginsFold("【8】专家 MCP 绑定看板(自动)", [ + el("div", { + class: "muted", + text: "按当前绑定草稿与已安装 MCP 的 tools 列表汇总;专家列表来自 SPECIALISTS 与绑定 mapping 键,随扩展自动增减。", + }), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "专家" }), + el("th", { text: "绑定 MCP 数" }), + el("th", { text: "tools 数(各 server 累加)" }), + ])]), + expertBindingDashTbody, + ]), + ]), + ]); + const foldMcpBinding = pluginsFold("【9】MCP 专家绑定(编辑)", [ + el("div", { class: "muted", text: "Bind MCP servers to specialists (many-to-many)." }), + el("div", { class: "row" }, [el("label", { text: "Specialist" }), specialistSelect, selectAllBindingBtn, clearBindingBtn, saveBindingBtn]), + bindingListWrap, + el("div", { class: "muted", text: "Reverse view: server -> specialists" }), + bindingReverseWrap, + bindingStatus, + ]); + const pluginsPageHeader = el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("plugins.title") }), + el("div", { + class: "muted", + text: "以下按功能分为多个区块,默认折叠;表格均支持翻页(每页 " + PLUGINS_PAGE_SIZE + " 条)。", + }), + ]); + return el("div", {}, [ + pluginsPageHeader, + foldToolPolicy, + foldMcpMarket, + foldMcpInstall, + foldMcpInstalled, + foldMcpUsage, + foldWireGlobal, + foldWireTools, + foldExpertBindingDash, + foldMcpBinding, + ]); +} + +async function renderAdminAudit() { + const action = el("input", { class: "input", placeholder: t("adminAudit.action") }); + const actor = el("input", { class: "input", placeholder: t("adminAudit.actor") }); + const status = el("input", { class: "input", placeholder: t("adminAudit.status") }); + const tbody = el("tbody"); + const load = async () => { + const p = new URLSearchParams(); + p.set("limit", "300"); + if (action.value.trim()) p.set("action", action.value.trim()); + if (actor.value.trim()) p.set("actor_user_id", actor.value.trim()); + if (status.value.trim()) p.set("status", status.value.trim()); + const resp = await apiGet("/admin/api/admin-audit?" + p.toString()); + const rows = Array.isArray(resp.items) ? resp.items : []; + tbody.innerHTML = ""; + if (!rows.length) { + tbody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "8" })])); + return; + } + rows.forEach((r) => { + tbody.appendChild(el("tr", {}, [ + tdCell(r.timestamp || "", 24), + tdCell(formatAuditActor(r), 28), + tdCell(r.action || "", 20), + tdCell(r.target_type || "", 16), + tdCell(r.target_id || "", 24), + tdCell(r.status || "", 12), + tdCell(r.actor_tenant_id || "", 24), + tdCell(JSON.stringify(r.detail || {}), 120), + ])); + }); + }; + await load(); + const btn = el("button", { class: "btn btn--primary", text: t("audit.query"), onclick: load }); + return el("div", {}, [ + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("adminAudit.title") }), + el("div", { class: "row" }, [action, actor, status, btn]), + el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: t("table.timestamp") }), + el("th", { text: t("adminAudit.actor") }), + el("th", { text: t("adminAudit.action") }), + el("th", { text: "target_type" }), + el("th", { text: "target_id" }), + el("th", { text: t("adminAudit.status") }), + el("th", { text: "tenant_id" }), + el("th", { text: t("table.payload") }), + ])]), + tbody, + ])]), + ]), + ]); +} + +async function renderSessionMonitor() { + const userQ = el("input", { class: "input", placeholder: t("sessionMonitor.filterUser") }); + const sessQ = el("input", { class: "input", placeholder: t("sessionMonitor.filterSession") }); + const activeOnlyChk = el("input", { type: "checkbox" }); + const selectedHint = el("div", { class: "muted", text: t("sessionMonitor.selectUserFirst") }); + const userBody = el("tbody"); + const sessBody = el("tbody"); + const sessionPagerTop = el("div", { class: "row" }); + const sessionPagerBottom = el("div", { class: "row" }); + const detailBody = el("tbody"); + const detailStatus = el("div", { class: "muted", text: t("sessionMonitor.noMessages") }); + const detailPager = el("div", { class: "row" }); + const detailRoleFilter = el("select", { class: "input" }); + const detailModal = el("div", { class: "session-monitor-modal", style: "display:none;" }); + const detailModalCard = el("div", { class: "session-monitor-modal__card" }); + const totalsWrap = el("div", { class: "row" }); + let selectedUserId = ""; + let selectedSessionId = ""; + let sessionLimit = 20; + let sessionOffset = 0; + let sessionTotal = 0; + let detailMessages = []; + let detailPage = 1; + const detailPageSize = 20; + let sessionMenuEl = null; + + const rebuildDetailRoleFilter = () => { + const saved = String(localStorage.getItem(SESSION_MONITOR_ROLE_FILTER_KEY) || "").trim(); + const prev = String(detailRoleFilter.value || saved || "all"); + detailRoleFilter.innerHTML = ""; + detailRoleFilter.appendChild(el("option", { value: "all", text: t("sessionMonitor.roleAll") })); + detailRoleFilter.appendChild(el("option", { value: "user", text: t("sessionMonitor.roleUser") })); + detailRoleFilter.appendChild(el("option", { value: "assistant", text: t("sessionMonitor.roleAssistant") })); + detailRoleFilter.appendChild(el("option", { value: "tool", text: t("sessionMonitor.roleTool") })); + detailRoleFilter.value = ["all", "user", "assistant", "tool"].includes(prev) ? prev : "all"; + }; + + const buildPageInfo = (page, totalPages) => tf("sessionMonitor.pageInfo", { page, totalPages }); + + const downloadSessionExport = async (sessionId, format) => { + const q = format === "json" ? "format=json" : "format=md"; + const path = `/admin/api/chat/sessions/${encodeURIComponent(sessionId)}/export?${q}`; + const url = resolveAdminApiUrl(path); + const tok = getStoredAuthToken(); + const res = await fetch(url, { + headers: tok ? { authorization: `Bearer ${tok}` } : {}, + }); + if (!res.ok) throw new Error(`export_failed_${res.status}`); + const blob = await res.blob(); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `chat-${String(sessionId || "").slice(0, 8)}.${format === "json" ? "json" : "md"}`; + a.click(); + URL.revokeObjectURL(a.href); + }; + + const closeSessionMenu = () => { + if (sessionMenuEl && sessionMenuEl.parentNode) { + sessionMenuEl.remove(); + } + sessionMenuEl = null; + }; + + const closeDetailModal = () => { + detailModal.style.display = "none"; + }; + + const renderTotals = (totals) => { + totalsWrap.innerHTML = ""; + const cards = [ + { key: "total_tokens_est", label: t("sessionMonitor.totalTokensEst") }, + { key: "active_sessions_30m", label: t("sessionMonitor.activeSessions30m") }, + { key: "active_logins_30m", label: t("sessionMonitor.activeLogins30m") }, + { key: "users_count", label: t("sessionMonitor.usersCount") }, + ]; + cards.forEach((c) => { + totalsWrap.appendChild( + el("div", { class: "kv", text: `${c.label}: ${String((totals && totals[c.key]) ?? 0)}` }), + ); + }); + }; + + const renderDetailPager = () => { + detailPager.innerHTML = ""; + const role = String(detailRoleFilter.value || "all"); + const filtered = Array.isArray(detailMessages) + ? detailMessages.filter((m) => { + if (role === "all") return true; + const r = String(m.role || "").toLowerCase(); + return role === "tool" ? r === "tool" || r === "function" : r === role; + }) + : []; + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / detailPageSize)); + const info = el("span", { class: "muted", text: buildPageInfo(detailPage, totalPages) }); + const btnPrev = el("button", { + class: "btn btn--small", + text: t("sessionMonitor.pagePrev"), + disabled: detailPage <= 1, + onclick: () => { + if (detailPage <= 1) return; + detailPage -= 1; + renderDetailMessages(); + }, + }); + const btnNext = el("button", { + class: "btn btn--small", + text: t("sessionMonitor.pageNext"), + disabled: detailPage >= totalPages, + onclick: () => { + if (detailPage >= totalPages) return; + detailPage += 1; + renderDetailMessages(); + }, + }); + detailPager.appendChild(btnPrev); + detailPager.appendChild(btnNext); + detailPager.appendChild(info); + }; + + const renderDetailMessages = () => { + detailBody.innerHTML = ""; + if (!selectedSessionId) { + detailStatus.textContent = t("sessionMonitor.selectUserFirst"); + detailPager.innerHTML = ""; + return; + } + const role = String(detailRoleFilter.value || "all"); + const all = Array.isArray(detailMessages) ? detailMessages : []; + const filtered = all.filter((m) => { + if (role === "all") return true; + const r = String(m.role || "").toLowerCase(); + return role === "tool" ? r === "tool" || r === "function" : r === role; + }); + if (!filtered.length) { + detailStatus.textContent = t("sessionMonitor.noMessages"); + detailPager.innerHTML = ""; + return; + } + const totalPages = Math.max(1, Math.ceil(filtered.length / detailPageSize)); + if (detailPage > totalPages) detailPage = totalPages; + const start = (detailPage - 1) * detailPageSize; + const rows = filtered.slice(start, start + detailPageSize); + detailStatus.textContent = `session_id: ${selectedSessionId} | ${filtered.length}/${all.length}`; + rows.forEach((m) => { + detailBody.appendChild( + el("tr", {}, [ + tdCell(m.id || "", 8), + tdCell(m.role || "", 10), + tdCell(m.content || "", 120), + tdCell(m.timestamp || "", 24), + ]), + ); + }); + renderDetailPager(); + }; + + const loadSessionDetail = async (sessionId, opts = {}) => { + const openModal = Boolean(opts && opts.openModal); + selectedSessionId = String(sessionId || ""); + detailPage = 1; + detailBody.innerHTML = ""; + detailStatus.textContent = `${t("chat.loading")}...`; + detailPager.innerHTML = ""; + if (!selectedSessionId) { + detailStatus.textContent = t("sessionMonitor.noMessages"); + if (openModal) detailModal.style.display = "flex"; + return; + } + try { + const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(selectedSessionId)}/messages`); + detailMessages = Array.isArray(resp.messages) ? resp.messages : []; + renderDetailMessages(); + } catch (err) { + detailMessages = []; + detailBody.innerHTML = ""; + detailPager.innerHTML = ""; + detailStatus.textContent = `${t("chat.error")}: ${String(err && err.message ? err.message : err)}`; + } + if (openModal) detailModal.style.display = "flex"; + }; + + const renderSessionPager = () => { + const totalPages = Math.max(1, Math.ceil(Math.max(0, sessionTotal) / sessionLimit)); + const current = Math.floor(sessionOffset / sessionLimit) + 1; + const row = el("div", { class: "row" }, [ + el("button", { + class: "btn btn--small", + text: t("sessionMonitor.pagePrev"), + disabled: sessionOffset <= 0, + onclick: async () => { + if (sessionOffset <= 0) return; + sessionOffset = Math.max(0, sessionOffset - sessionLimit); + await loadSessions(); + }, + }), + el("button", { + class: "btn btn--small", + text: t("sessionMonitor.pageNext"), + disabled: sessionOffset + sessionLimit >= sessionTotal, + onclick: async () => { + if (sessionOffset + sessionLimit >= sessionTotal) return; + sessionOffset += sessionLimit; + await loadSessions(); + }, + }), + el("span", { class: "muted", text: buildPageInfo(current, totalPages) }), + ]); + return row; + }; + + const updateSessionPagers = () => { + sessionPagerTop.innerHTML = ""; + sessionPagerBottom.innerHTML = ""; + sessionPagerTop.appendChild(renderSessionPager()); + sessionPagerBottom.appendChild(renderSessionPager()); + }; + detailRoleFilter.addEventListener("change", () => { + try { + localStorage.setItem(SESSION_MONITOR_ROLE_FILTER_KEY, String(detailRoleFilter.value || "all")); + } catch (_) {} + detailPage = 1; + renderDetailMessages(); + }); + + const loadSessions = async () => { + sessBody.innerHTML = ""; + if (!selectedUserId) { + sessBody.appendChild(el("tr", {}, [el("td", { text: t("sessionMonitor.selectUserFirst"), colspan: "8" })])); + selectedHint.textContent = t("sessionMonitor.selectUserFirst"); + sessionTotal = 0; + updateSessionPagers(); + return; + } + const sp = new URLSearchParams(); + sp.set("user_id", selectedUserId); + sp.set("limit", String(sessionLimit)); + sp.set("offset", String(sessionOffset)); + if (sessQ.value.trim()) sp.set("q", sessQ.value.trim()); + if (activeOnlyChk.checked) sp.set("active_only", "1"); + const resp = await apiGet("/admin/api/chat/admin/sessions?" + sp.toString()); + const rows = Array.isArray(resp.sessions) ? resp.sessions : []; + sessionTotal = Number(resp.total || 0); + selectedHint.textContent = `${t("table.userId")}: ${selectedUserId}`; + if (!rows.length) { + sessBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "8" })])); + if (selectedSessionId && !rows.some((x) => String(x.session_id || "") === selectedSessionId)) { + selectedSessionId = ""; + detailMessages = []; + renderDetailMessages(); + } + updateSessionPagers(); + return; + } + rows.forEach((r) => { + const sid = String(r.session_id || ""); + const btnMore = el("button", { + class: "chat-sess-more" + (sid === selectedSessionId ? " chat-sess-more--active" : ""), + text: "⋯", + title: t("chat.sessionMenu"), + onclick: (ev) => { + ev.stopPropagation(); + closeSessionMenu(); + const menu = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;z-index:250;" }, [ + el("button", { + class: "chat-sess-menu-item", + text: t("sessionMonitor.viewDetail"), + onclick: async () => { + closeSessionMenu(); + await loadSessionDetail(sid, { openModal: true }); + }, + }), + el("button", { + class: "chat-sess-menu-item", + text: t("sessionMonitor.viewAudit"), + onclick: () => { + closeSessionMenu(); + location.hash = `#/audit?session_id=${encodeURIComponent(sid)}`; + }, + }), + el("button", { + class: "chat-sess-menu-item", + text: t("sessionMonitor.exportMd"), + onclick: async () => { + closeSessionMenu(); + await downloadSessionExport(sid, "md"); + }, + }), + el("button", { + class: "chat-sess-menu-item", + text: t("sessionMonitor.exportJson"), + onclick: async () => { + closeSessionMenu(); + await downloadSessionExport(sid, "json"); + }, + }), + ]); + const rect = ev.currentTarget.getBoundingClientRect(); + document.body.appendChild(menu); + // Clamp into viewport; flip above if near bottom. + const mrect = menu.getBoundingClientRect(); + const pad = 8; + let left = rect.left - 120; + let top = rect.bottom + 4; + if (top + mrect.height > window.innerHeight - pad) { + top = rect.top - 4 - mrect.height; + } + left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width)); + top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height)); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + sessionMenuEl = menu; + const close = (e) => { + if (!menu.contains(e.target)) { + closeSessionMenu(); + document.removeEventListener("click", close); + } + }; + setTimeout(() => document.addEventListener("click", close), 0); + }, + }); + sessBody.appendChild( + el( + "tr", + { + "data-session-id": sid, + class: sid === selectedSessionId ? "session-monitor-row--active" : "", + onclick: async () => { + selectedSessionId = sid; + closeSessionMenu(); + const sessionRows = Array.from(sessBody.querySelectorAll("tr[data-session-id]")); + sessionRows.forEach((tr) => { + const curr = String(tr.getAttribute("data-session-id") || ""); + tr.classList.toggle("session-monitor-row--active", curr === selectedSessionId); + }); + }, + }, + [ + tdCell(sid, 24), + tdCell(r.title || "", 30), + tdCell(r.username || "", 16), + tdCell(r.message_count || "", 10), + tdCell(r.last_message_at || "", 24), + tdCell(r.is_active_30m ? "yes" : "no", 8), + el("td", { class: "table__cell-actions" }, [btnMore]), + ], + ), + ); + }); + updateSessionPagers(); + }; + + const loadUsers = async () => { + const p = new URLSearchParams(); + p.set("limit", "200"); + if (userQ.value.trim()) p.set("q", userQ.value.trim()); + const resp = await apiGet("/admin/api/chat/admin/user-stats?" + p.toString()); + renderTotals(resp.totals || {}); + const rows = Array.isArray(resp.users) ? resp.users : []; + userBody.innerHTML = ""; + if (!rows.length) { + userBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "9" })])); + selectedUserId = ""; + selectedSessionId = ""; + detailMessages = []; + await loadSessions(); + return; + } + if (!selectedUserId || !rows.some((x) => String(x.user_id || "") === selectedUserId)) { + selectedUserId = String(rows[0].user_id || ""); + } + rows.forEach((r) => { + const uid = String(r.user_id || ""); + const pickBtn = el("button", { + class: "btn btn--small", + "data-user-pick": "1", + "data-user-id": uid, + text: uid === selectedUserId ? "●" : "○", + onclick: async () => { + selectedUserId = uid; + sessionOffset = 0; + selectedSessionId = ""; + detailMessages = []; + const pickBtns = Array.from(userBody.querySelectorAll("button[data-user-pick='1']")); + pickBtns.forEach((btnEl) => { + const id = String(btnEl.getAttribute("data-user-id") || ""); + btnEl.textContent = id === selectedUserId ? "●" : "○"; + }); + await loadSessions(); + }, + }); + userBody.appendChild( + el("tr", {}, [ + el("td", {}, [pickBtn]), + tdCell(r.username || "", 16), + tdCell(r.display_name || "", 16), + tdCell(r.role || "", 10), + tdCell(r.sessions_count || "", 10), + tdCell(r.active_sessions_30m || "", 10), + tdCell(r.active_login_30m || "", 10), + tdCell(r.total_tokens_est || "", 14), + tdCell(r.last_message_at || "", 24), + ]), + ); + }); + await loadSessions(); + }; + + const btnQueryUsers = el("button", { class: "btn btn--primary", text: t("audit.query"), onclick: loadUsers }); + const btnQuerySessions = el("button", { + class: "btn", + text: t("audit.query"), + onclick: async () => { + sessionOffset = 0; + selectedSessionId = ""; + detailMessages = []; + await loadSessions(); + }, + }); + const activeOnlyLabel = el("label", { class: "row" }, [ + activeOnlyChk, + el("span", { class: "muted", text: t("sessionMonitor.activeSessions30m") }), + ]); + + await loadUsers(); + rebuildDetailRoleFilter(); + const btnCloseDetail = el("button", { + class: "btn btn--small", + text: t("sessionMonitor.closeDetail"), + onclick: () => { + selectedSessionId = ""; + detailMessages = []; + detailBody.innerHTML = ""; + detailStatus.textContent = t("sessionMonitor.noMessages"); + detailPager.innerHTML = ""; + closeDetailModal(); + loadSessions().catch(() => {}); + }, + }); + const detailTable = el("table", { class: "table table--compact session-monitor-detail-table" }, [ + el("thead", {}, [ + el("tr", {}, [ + el("th", { text: "id" }), + el("th", { text: "role" }), + el("th", { text: "content" }), + el("th", { text: t("table.timestamp") }), + ]), + ]), + detailBody, + ]); + detailModalCard.appendChild( + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("sessionMonitor.messages") }), + el("div", { class: "row" }, [ + el("span", { class: "muted", text: t("sessionMonitor.roleFilter") }), + detailRoleFilter, + btnCloseDetail, + ]), + detailStatus, + detailPager, + el("div", { class: "table-wrap" }, [ + detailTable, + ]), + ]), + ); + enableTableColumnResize(detailTable, [2, 3]); + detailModal.appendChild(detailModalCard); + detailModal.addEventListener("click", (ev) => { + if (ev.target === detailModal) closeDetailModal(); + }); + return el("div", {}, [ + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("sessionMonitor.totals") }), + totalsWrap, + ]), + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("sessionMonitor.userStats") }), + el("div", { class: "row" }, [userQ, btnQueryUsers]), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [ + el("tr", {}, [ + el("th", { text: "#" }), + el("th", { text: t("table.username") }), + el("th", { text: t("table.name") }), + el("th", { text: t("tenants.role") }), + el("th", { text: t("table.sessionsCount") }), + el("th", { text: t("table.activeSessions30m") }), + el("th", { text: t("table.activeLogins30m") }), + el("th", { text: t("table.totalTokensEst") }), + el("th", { text: t("table.timestamp") }), + ]), + ]), + userBody, + ]), + ]), + ]), + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("sessionMonitor.sessions") }), + el("div", { class: "row" }, [sessQ, activeOnlyLabel, btnQuerySessions]), + selectedHint, + sessionPagerTop, + el("div", { class: "table-wrap" }, [ + el("table", { class: "table table--compact session-monitor-session-table" }, [ + el("thead", {}, [ + el("tr", {}, [ + el("th", { text: "session_id" }), + el("th", { text: t("table.name") }), + el("th", { text: t("table.username") }), + el("th", { text: "msg_count" }), + el("th", { text: t("table.timestamp") }), + el("th", { text: t("table.activeSessions30m") }), + el("th", { text: t("table.action") }), + ]), + ]), + sessBody, + ]), + ]), + sessionPagerBottom, + ]), + detailModal, + ]); +} + +async function renderWorkspacePaths() { + const sessionTid = String((authSession && authSession.tenant_id) || "").trim(); + const sessionUid = String((authSession && authSession.user_id) || "").trim(); + const canUserRead = hasPermission("admin:user:read"); + const canWsRead = hasPermission("admin:workspace_paths:read"); + const canWsWrite = hasPermission("admin:workspace_paths:write"); + const selfService = !canUserRead && canWsRead; + if (!sessionTid || (!canUserRead && !canWsRead)) { + return el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("title.workspacePaths") }), + el("div", { class: "muted", text: t("common.forbidden") }), + ]); + } + if (selfService && !sessionUid) { + return el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("title.workspacePaths") }), + el("div", { class: "muted", text: t("tenants.noSessionTenant") }), + ]); + } + let allTenants = []; + try { + const allTenantsResp = await apiGet("/admin/api/tenants"); + allTenants = allTenantsResp.tenants || []; + } catch (_) { + allTenants = [{ id: sessionTid, name: "", created_at: "" }]; + } + if (!allTenants.length) { + return el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("title.workspacePaths") }), + el("div", { class: "muted", text: t("tenants.noTenants") }), + ]); + } + const tenantSel = el("select", { class: "input" }); + for (const item of allTenants) { + const optLabel = String(item.name || "").trim() || String(item.id || "").slice(0, 8); + tenantSel.appendChild(el("option", { value: String(item.id || ""), text: optLabel })); + } + const preferredIdx = allTenants.findIndex((x) => String(x.id || "") === sessionTid); + tenantSel.selectedIndex = preferredIdx >= 0 ? preferredIdx : 0; + if (selfService) { + tenantSel.disabled = true; + tenantSel.title = t("workspacePaths.selfOnly"); + } + const userSel = el("select", { class: "input" }); + const selfUserLabel = el("div", { + class: "muted", + text: formatUserLabel({ + username: authSession && authSession.username, + display_name: authSession && authSession.display_name, + }) + (sessionUid ? ` · ${sessionUid.slice(0, 8)}…` : ""), + }); + if (selfService) { + userSel.style.display = "none"; + } + const extraInput = el("textarea", { + class: "input", + rows: "4", + placeholder: "D:\\\\|E:\\\\|D:\\\\repos\\\\other", + style: "min-height:88px;font-family:monospace;", + }); + const allowAnyCb = el("input", { type: "checkbox" }); + const status = el("div", { class: "muted", text: "" }); + const canWrite = hasPermission("admin:user:write") || canWsWrite; + + const getEffectiveTid = () => (selfService ? sessionTid : String(tenantSel.value || "")); + const getEffectiveUid = () => (selfService ? sessionUid : String(userSel.value || "")); + + const loadUsers = async () => { + if (selfService) return; + const tid = String(tenantSel.value || ""); + userSel.innerHTML = ""; + if (!tid) return; + const resp = await apiGet( + "/admin/api/users?tenant_id=" + + encodeURIComponent(tid) + + "&include_inactive=1&q=&limit=500", + ); + const users = Array.isArray(resp.users) ? resp.users : []; + for (const u of users) { + const id = String(u.id || ""); + if (!id) continue; + const label = formatUserLabel(u); + userSel.appendChild(el("option", { value: id, text: label })); + } + if (userSel.options.length) userSel.selectedIndex = 0; + }; + + const loadPolicy = async () => { + const tid = getEffectiveTid(); + const uid = getEffectiveUid(); + if (!tid || !uid) { + status.textContent = ""; + return; + } + status.textContent = "…"; + try { + const r = await apiGet( + "/admin/api/users/workspace-path-policy?tenant_id=" + + encodeURIComponent(tid) + + "&user_id=" + + encodeURIComponent(uid), + ); + if (!r.ok) { + status.textContent = String(r.error || "error"); + return; + } + const pol = r.policy || {}; + extraInput.value = String(pol.extra_roots || ""); + allowAnyCb.checked = !!pol.allow_any_path; + status.textContent = (r.from_db ? t("workspacePaths.fromDb") + " · " : "") + JSON.stringify(pol); + } catch (e) { + status.textContent = String(e && e.message ? e.message : e); + } + }; + + tenantSel.addEventListener("change", async () => { + await loadUsers(); + await loadPolicy(); + }); + userSel.addEventListener("change", () => { + loadPolicy(); + }); + + await loadUsers(); + await loadPolicy(); + + const loadBtn = el("button", { + class: "btn", + text: t("workspacePaths.load"), + onclick: () => loadPolicy(), + }); + const saveBtn = el("button", { + class: "btn btn--primary", + text: t("workspacePaths.save"), + disabled: canWrite ? undefined : "disabled", + onclick: async () => { + if (!canWrite) return; + const tid = getEffectiveTid(); + const uid = getEffectiveUid(); + if (!tid || !uid) return; + status.textContent = "…"; + try { + const r = await apiPost("/admin/api/users/workspace-path-policy", { + tenant_id: tid, + user_id: uid, + extra_roots: extraInput.value, + allow_any_path: !!allowAnyCb.checked, + }); + if (!r.ok) { + status.textContent = String(r.error || "error"); + return; + } + await loadPolicy(); + } catch (e) { + status.textContent = String(e && e.message ? e.message : e); + } + }, + }); + + const userRowChildren = selfService ? [selfUserLabel] : [userSel]; + const cardParts = [ + el("div", { class: "card__title", text: t("title.workspacePaths") }), + el("div", { class: "muted", text: t("workspacePaths.help") }), + ]; + if (selfService) { + cardParts.push(el("div", { class: "muted", style: "margin-top:6px;", text: t("workspacePaths.selfOnly") })); + } + cardParts.push( + el("div", { style: "height:10px" }), + el("div", { class: "row" }, [ + el("label", { text: t("workspacePaths.tenant") }), + tenantSel, + ]), + el("div", { class: "row" }, [ + el("label", { text: t("workspacePaths.user") }), + ...userRowChildren, + ]), + el("div", { class: "row" }, [el("label", { text: t("workspacePaths.extraRoots") })]), + extraInput, + el("label", { class: "row", style: "align-items:center;gap:8px;margin-top:8px;" }, [ + allowAnyCb, + el("span", { text: t("workspacePaths.allowAny") }), + ]), + el("div", { class: "muted", style: "margin-top:6px;line-height:1.45;", text: t("workspacePaths.allowAnyHint") }), + el("div", { class: "row", style: "margin-top:10px;gap:8px;" }, [loadBtn, saveBtn]), + el("div", { class: "muted", style: "margin-top:8px;" }, [el("span", { text: t("workspacePaths.status") + ": " }), status]), + ); + return el("div", { class: "card" }, cardParts); +} + +async function renderAttachments() { + const status = el("div", { class: "muted", text: "" }); + const hint = el("div", { class: "muted", style: "line-height:1.45;", text: t("attachments.excelPolicyHint") }); + const loadBtn = el("button", { class: "btn", type: "button", text: t("action.refresh") }); + const saveBtn = el("button", { class: "btn btn--primary", type: "button", text: t("attachments.save") }); + const resetBtn = el("button", { class: "btn", type: "button", text: t("attachments.resetDefaults") }); + + const rowInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); + const colInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); + const cellInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); + const maxSheetsInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); + const largePreviewRowsInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); + const toolEnabledInput = el("input", { type: "checkbox" }); + const toolMinRowsInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); + const toolMaxBytesInput = el("input", { class: "input", type: "number", min: "1", step: "1" }); + const sqlTimeoutInput = el("input", { class: "input", type: "number", min: "100", max: "120000", step: "1" }); + + const clampTimeout = (raw, fallback = 8000) => { + const n = parseInt(String(raw ?? "").trim(), 10); + if (!Number.isFinite(n)) return fallback; + return Math.max(100, Math.min(120000, n)); + }; + + const parsePositiveInt = (v) => { + const n = parseInt(String(v ?? "").trim(), 10); + if (!Number.isFinite(n) || n < 1) return null; + return n; + }; + + const readUiLimits = () => { + const rows = parsePositiveInt(rowInput.value); + const cols = parsePositiveInt(colInput.value); + const chars = parsePositiveInt(cellInput.value); + const maxSheets = parsePositiveInt(maxSheetsInput.value); + const previewRows = parsePositiveInt(largePreviewRowsInput.value); + const minRows = parsePositiveInt(toolMinRowsInput.value); + const maxBytes = parsePositiveInt(toolMaxBytesInput.value); + const sqlTimeoutMs = parsePositiveInt(sqlTimeoutInput.value); + if ( + rows === null || + cols === null || + chars === null || + maxSheets === null || + previewRows === null || + minRows === null || + maxBytes === null || + sqlTimeoutMs === null + ) { + throw new Error(t("attachments.invalidNumber")); + } + if (previewRows > 200 && !window.confirm(t("attachments.highPreviewWarn"))) { + throw new Error("cancelled"); + } + return { + max_rows_read: rows, + max_columns: cols, + max_cell_chars: chars, + max_excel_sheets: maxSheets, + large_table_preview_rows: previewRows, + tool_mode_enabled: !!toolEnabledInput.checked, + tool_mode_min_rows: minRows, + tool_mode_max_bytes: maxBytes, + sql_timeout_ms: clampTimeout(sqlTimeoutMs, 8000), + }; + }; + + const applyUiLimits = (limits) => { + const l = limits && typeof limits === "object" ? limits : {}; + rowInput.value = String(l.max_rows_read || 5000); + colInput.value = String(l.max_columns || 200); + cellInput.value = String(l.max_cell_chars || 500); + maxSheetsInput.value = String(l.max_excel_sheets || 50); + largePreviewRowsInput.value = String(l.large_table_preview_rows || 20); + toolEnabledInput.checked = !!l.tool_mode_enabled; + toolMinRowsInput.value = String(l.tool_mode_min_rows || 20000); + toolMaxBytesInput.value = String(l.tool_mode_max_bytes || 31457280); + sqlTimeoutInput.value = String(clampTimeout(l.sql_timeout_ms, 8000)); + }; + + const load = async () => { + status.textContent = t("chat.loading"); + try { + const r = await apiGet("/admin/api/chat/settings/attachment-limits"); + const limits = r && typeof r.limits === "object" ? r.limits : {}; + applyUiLimits(limits); + status.textContent = ""; + } catch (_) { + status.textContent = t("attachments.loadError"); + } + }; + + const save = async () => { + status.textContent = t("chat.sending"); + try { + const next = readUiLimits(); + await apiPost("/admin/api/chat/settings/attachment-limits", { limits: next }); + status.textContent = t("attachments.saved"); + setTimeout(() => { + if (status.textContent === t("attachments.saved")) status.textContent = ""; + }, 1500); + } catch (e) { + if (String(e && e.message ? e.message : e) === "cancelled") { + status.textContent = ""; + return; + } + status.textContent = `${t("common.error")}: ${String(e)}`; + } + }; + + const reset = async () => { + status.textContent = t("chat.sending"); + try { + const resp = await apiPost("/admin/api/chat/settings/attachment-limits", { limits: null }); + const limits = resp && typeof resp.limits === "object" ? resp.limits : {}; + applyUiLimits(limits); + status.textContent = t("attachments.saved"); + setTimeout(() => { + if (status.textContent === t("attachments.saved")) status.textContent = ""; + }, 1500); + } catch (e) { + status.textContent = `${t("common.error")}: ${String(e)}`; + } + }; + + loadBtn.addEventListener("click", load); + saveBtn.addEventListener("click", save); + resetBtn.addEventListener("click", reset); + await load(); + + const inputRow = (labelText, inputEl) => + el("div", { style: "display:grid;gap:6px;margin-top:8px;" }, [ + el("div", { class: "muted", text: labelText }), + inputEl, + ]); + + return el("div", {}, [ + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("attachments.title") }), + el("div", { class: "muted", text: t("attachments.excelPolicy") }), + el("div", { style: "height:8px" }), + hint, + el("div", { style: "height:10px" }), + inputRow(t("attachments.maxRowsRead"), rowInput), + inputRow(t("attachments.maxColumns"), colInput), + inputRow(t("attachments.maxCellChars"), cellInput), + inputRow(t("attachments.maxExcelSheets"), maxSheetsInput), + inputRow(t("attachments.largePreviewRows"), largePreviewRowsInput), + el("label", { class: "muted", style: "display:flex;gap:8px;align-items:center;margin-top:10px;cursor:pointer;" }, [ + toolEnabledInput, + el("span", { text: t("attachments.toolModeEnabled") }), + ]), + inputRow(t("attachments.toolModeMinRows"), toolMinRowsInput), + inputRow(t("attachments.toolModeMaxBytes"), toolMaxBytesInput), + inputRow(t("attachments.sqlTimeoutMs"), sqlTimeoutInput), + el("div", { class: "muted", style: "margin-top:6px;line-height:1.45;", text: t("attachments.sqlTimeoutHint") }), + el("div", { class: "row", style: "gap:8px;margin-top:12px;flex-wrap:wrap;" }, [loadBtn, resetBtn, saveBtn]), + el("div", { style: "height:8px" }), + status, + ]), + ]); +} + +async function renderProfile() { + const status = el("div", { class: "muted", text: "" }); + const dnInput = el("input", { class: "input", type: "text", maxlength: "120" }); + const metaUser = el("div", { class: "muted pre", text: "" }); + const metaIds = el("div", { class: "muted pre", text: "" }); + const avatarPreview = el("img", { + class: "profile-avatar-preview", + alt: "", + style: + "display:none;width:48px;height:48px;border-radius:999px;object-fit:contain;border:2px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.08);padding:5px;box-sizing:border-box;", + }); + const avatarRow = el("div", { class: "row", style: "align-items:center;gap:16px;flex-wrap:wrap;" }, [ + el("div", {}, [avatarPreview]), + el("div", { style: "min-width:200px;flex:1;" }, [ + el("div", { class: "muted", style: "margin-bottom:6px;", text: t("profile.avatar") }), + el("div", { class: "muted", style: "margin-bottom:8px;font-size:12px;line-height:1.4;", text: t("profile.avatarHint") }), + ]), + ]); + const fileInput = el("input", { type: "file", accept: "image/png,image/jpeg,image/jpg,image/webp,image/gif", style: "display:none" }); + const chooseBtn = el("button", { class: "btn", type: "button", text: t("profile.chooseImage") }); + const removeBtn = el("button", { class: "btn", type: "button", text: t("profile.removeAvatar") }); + const saveBtn = el("button", { class: "btn btn--primary", type: "button", text: t("profile.save") }); + const chatBtn = el("button", { class: "btn", type: "button", text: t("profile.openChat") }); + + function showBuiltinDefaultAvatar() { + try { + const prev = avatarPreview.dataset.blobUrl; + if (prev) URL.revokeObjectURL(prev); + } catch (_) {} + delete avatarPreview.dataset.blobUrl; + avatarPreview.src = resolveAdminApiUrl("/admin/assets/default-user-avatar.svg"); + avatarPreview.style.objectFit = "contain"; + avatarPreview.style.padding = "5px"; + avatarPreview.style.background = "rgba(255,255,255,0.08)"; + avatarPreview.style.display = "block"; + } + + /** cannot send Authorization; load avatar via fetch + blob */ + async function refreshAvatarBlob(p) { + const aid = p && String(p.avatar_attachment_id || "").trim(); + if (!aid) { + showBuiltinDefaultAvatar(); + return; + } + try { + const tok = getStoredAuthToken(); + const u = resolveAdminApiUrl(`/admin/api/chat/attachments/${encodeURIComponent(aid)}`); + const res = await fetch(u, { headers: tok ? { authorization: `Bearer ${tok}` } : {} }); + if (!res.ok) throw new Error("avatar fetch"); + const blob = await res.blob(); + try { + const prev = avatarPreview.dataset.blobUrl; + if (prev) URL.revokeObjectURL(prev); + } catch (_) {} + const url = URL.createObjectURL(blob); + avatarPreview.dataset.blobUrl = url; + avatarPreview.src = url; + avatarPreview.style.objectFit = "cover"; + avatarPreview.style.padding = "0"; + avatarPreview.style.background = "transparent"; + avatarPreview.style.display = "block"; + } catch (_) { + showBuiltinDefaultAvatar(); + } + } + + async function load() { + status.textContent = t("chat.loading"); + try { + const r = await apiGet("/admin/api/chat/profile"); + if (!r || !r.ok || !r.profile) throw new Error("profile"); + const p = r.profile; + dnInput.value = String(p.display_name || ""); + metaUser.textContent = `${t("profile.username")}: ${String(p.username || "—")}\n${t("profile.role")}: ${String(p.role || "—")}`; + metaIds.textContent = `${t("profile.userId")}: ${String(p.id || "—")}\n${t("profile.tenantId")}: ${String(p.tenant_id || "—")}\n${t("profile.createdAt")}: ${String(p.created_at || "—")}`; + await refreshAvatarBlob(p); + status.textContent = ""; + } catch (e) { + status.textContent = `${t("profile.loadError")}: ${String(e && e.message ? e.message : e)}`; + } + } + + chooseBtn.addEventListener("click", () => fileInput.click()); + fileInput.addEventListener("change", async () => { + const f = fileInput.files && fileInput.files[0]; + fileInput.value = ""; + if (!f) return; + status.textContent = t("chat.sending"); + try { + const fd = new FormData(); + fd.append("file", f, f.name || "avatar.png"); + const r = await apiPostFormData("/admin/api/chat/profile/avatar", fd); + if (!r || !r.ok) throw new Error("upload"); + status.textContent = t("profile.saved"); + await load(); + } catch (e) { + status.textContent = `${t("profile.uploadError")}: ${String(e && e.message ? e.message : e)}`; + } + }); + removeBtn.addEventListener("click", async () => { + status.textContent = t("chat.sending"); + try { + await apiDeleteJson("/admin/api/chat/profile/avatar"); + status.textContent = t("profile.saved"); + await load(); + } catch (e) { + status.textContent = String(e && e.message ? e.message : e); + } + }); + saveBtn.addEventListener("click", async () => { + status.textContent = t("chat.sending"); + try { + await apiRequest("PATCH", "/admin/api/chat/profile", { display_name: dnInput.value.trim() }); + status.textContent = t("profile.saved"); + await load(); + try { + const sess = JSON.parse(authStoreGet(AUTH_SESSION_KEY) || "{}"); + if (sess && typeof sess === "object") { + sess.display_name = dnInput.value.trim(); + authStoreSet(AUTH_SESSION_KEY, JSON.stringify(sess)); + authSession = sess; + } + } catch (_) {} + } catch (e) { + status.textContent = String(e && e.message ? e.message : e); + } + }); + chatBtn.addEventListener("click", () => { + window.location.assign(resolveChatUrl()); + }); + + await load(); + + const profileCard = el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("title.profile") }), + el("div", { class: "muted", style: "margin-bottom:10px;line-height:1.45;", text: t("profile.help") }), + el("div", { class: "row" }, [el("label", { text: t("profile.displayName") }), dnInput]), + el("div", { style: "height:8px" }), + avatarRow, + el("div", { class: "row", style: "margin-top:10px;gap:8px;flex-wrap:wrap;" }, [fileInput, chooseBtn, removeBtn]), + el("div", { class: "row", style: "margin-top:12px;gap:8px;flex-wrap:wrap;" }, [saveBtn, chatBtn]), + el("div", { style: "height:8px" }), + el("div", { style: "margin-top:14px" }, [metaUser]), + el("div", { style: "margin-top:6px" }, [metaIds]), + el("div", { class: "muted", style: "margin-top:10px;" }, [status]), + ]); + return el("div", {}, [profileCard]); +} + +async function renderSkills() { + const SKILL_AUDIT_RETRYABLE_ONLY_KEY = "ops_admin_skill_audit_retryable_only"; + const status = el("div", { class: "muted", text: "" }); + const rowsState = { items: [] }; + const auditState = { items: [] }; + const nameInp = el("input", { class: "input", placeholder: "skill name" }); + const descInp = el("input", { class: "input", placeholder: "description" }); + const bodyInp = el("textarea", { class: "input", rows: "4", placeholder: "SKILL.md body markdown" }); + const regInp = el("input", { class: "input", placeholder: "registry archive URL (zip/tar)" }); + const localDirInp = el("input", { class: "input", placeholder: "local skill dir (contains SKILL.md)" }); + const marketStatus = el("div", { class: "muted", text: "" }); + const marketQ = el("input", { class: "input", placeholder: "search ClawHub skills" }); + const marketLimitInp = el("input", { class: "input", placeholder: "limit", value: "40", style: "max-width:120px;" }); + const marketTbody = el("tbody"); + const marketDetailPre = el("pre", { class: "muted pre", text: "" }); + let marketItems = []; + + const loadMarket = async (q) => { + const qq = String(q || "").trim(); + const lim = Math.max(1, Math.min(200, parseInt(String(marketLimitInp.value || "40"), 10) || 40)); + marketStatus.textContent = `${t("chat.loading")}...`; + try { + const resp = await apiGet(`/admin/api/skills/market/search?q=${encodeURIComponent(qq)}&limit=${encodeURIComponent(String(lim))}`); + marketItems = Array.isArray(resp.items) ? resp.items : []; + marketStatus.textContent = `results: ${marketItems.length}`; + repaintMarket(); + } catch (e) { + marketItems = []; + marketStatus.textContent = `${t("common.error")}: ${String(e && e.message ? e.message : e)}`; + repaintMarket(); + } + }; + + const loadMarketDetail = async (slug) => { + const s = String(slug || "").trim(); + if (!s) return; + marketDetailPre.textContent = `${t("chat.loading")}...`; + try { + const resp = await apiGet(`/admin/api/skills/market/detail?slug=${encodeURIComponent(s)}`); + marketDetailPre.textContent = JSON.stringify(resp.detail || {}, null, 2); + } catch (e) { + marketDetailPre.textContent = `${t("common.error")}: ${String(e && e.message ? e.message : e)}`; + } + }; + + const installFromMarket = async (slug, version) => { + const s = String(slug || "").trim(); + if (!s) return; + try { + const r = await apiPost("/admin/api/skills/market/install", { slug: s, version: version ? String(version) : undefined, overwrite: false }); + status.textContent = `install-clawhub: ${JSON.stringify(r.result || {})}`; + await loadRows(); + await loadAudits(); + repaint(); + } catch (e) { + status.textContent = String(e && e.message ? e.message : e); + } + }; + + const repaintMarket = () => { + marketTbody.innerHTML = ""; + (marketItems || []).forEach((x) => { + const slug = String(x.slug || ""); + const ver = String(x.version || ""); + const btnDetail = el("button", { class: "btn btn--small", text: "Detail", onclick: async () => await loadMarketDetail(slug) }); + const btnInstall = el("button", { class: "btn btn--small btn--primary", text: "Install", onclick: async () => await installFromMarket(slug, ver || undefined) }); + marketTbody.appendChild( + el("tr", {}, [ + el("td", { text: slug }), + el("td", { text: String(x.name || "") }), + el("td", { text: ver }), + el("td", { text: shortText(String(x.description || ""), 80) }), + el("td", {}, [btnDetail, el("span", { style: "display:inline-block;width:6px" }), btnInstall]), + ]), + ); + }); + }; + + const btnMarketSearch = el("button", { class: "btn", text: "Search", onclick: async () => await loadMarket(marketQ.value) }); + const btnMarketLatest = el("button", { class: "btn", text: "Latest", onclick: async () => await loadMarket("") }); + const marketBox = el("details", { style: "margin:10px 0 14px 0;" }, [ + el("summary", { text: "ClawHub Market", style: "cursor:pointer;user-select:none;" }), + el("div", { style: "height:8px" }), + el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;margin-bottom:8px;" }, [marketQ, marketLimitInp, btnMarketSearch, btnMarketLatest]), + marketStatus, + el("div", { style: "height:8px" }), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [el("th", { text: "slug" }), el("th", { text: "name" }), el("th", { text: "version" }), el("th", { text: "description" }), el("th", { text: "action" })])]), + marketTbody, + ]), + ]), + el("div", { style: "height:8px" }), + el("div", { class: "muted", text: "Detail" }), + marketDetailPre, + ]); + + const skillBindingState = { roles: [], names: [], mapping: {}, enabled: false }; + const skillBindingStatus = el("div", { class: "muted", text: "" }); + const skillEffectiveState = { items: [] }; + const skillBindingEnabledCb = el("input", { type: "checkbox" }); + const skillRoleSelect = el("select", { class: "input" }, []); + const skillBindingListWrap = el("div"); + const skillBindingDashTbody = el("tbody"); + const skillEffectiveTbody = el("tbody"); + const renderSkillBindingDashboard = () => { + skillBindingDashTbody.innerHTML = ""; + const roles = Array.isArray(skillBindingState.roles) ? skillBindingState.roles : []; + const mapping = skillBindingState.mapping && typeof skillBindingState.mapping === "object" ? skillBindingState.mapping : {}; + const managerBound = new Set(Array.isArray(mapping.manager) ? mapping.manager.map((x) => String(x)) : []); + roles.forEach((role) => { + const direct = new Set(Array.isArray(mapping[role]) ? mapping[role].map((x) => String(x)) : []); + const effective = new Set([...(role === "manager" ? [] : Array.from(managerBound)), ...Array.from(direct)]); + skillBindingDashTbody.appendChild( + el("tr", {}, [ + el("td", { text: role }), + el("td", { text: String(direct.size) }), + el("td", { text: String(effective.size) }), + ]), + ); + }); + if (!roles.length) { + skillBindingDashTbody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "3" })])); + } + }; + const renderSkillEffectiveDashboard = () => { + skillEffectiveTbody.innerHTML = ""; + const rows = Array.isArray(skillEffectiveState.items) ? skillEffectiveState.items : []; + rows.forEach((x) => { + const names = Array.isArray(x.names_preview) ? x.names_preview.map((v) => String(v || "").trim()).filter(Boolean) : []; + const docsOnlyNames = Array.isArray(x.docs_only_names_preview) + ? x.docs_only_names_preview.map((v) => String(v || "").trim()).filter(Boolean) + : []; + const previewText = names.slice(0, 6).join(", "); + const namesCell = names.length + ? el("details", {}, [ + el("summary", { text: previewText || "(empty)" }), + el("div", { class: "muted", style: "margin-top:4px;white-space:normal;line-height:1.5;", text: names.join(", ") }), + ]) + : el("span", { class: "muted", text: "-" }); + const docsOnlyCell = docsOnlyNames.length + ? el("details", {}, [ + el("summary", { text: docsOnlyNames.slice(0, 4).join(", ") }), + el("div", { class: "muted", style: "margin-top:4px;white-space:normal;line-height:1.5;", text: docsOnlyNames.join(", ") }), + ]) + : el("span", { class: "muted", text: "-" }); + skillEffectiveTbody.appendChild( + el("tr", {}, [ + el("td", { text: String(x.role || "") }), + el("td", { text: String(x.total || 0) }), + el("td", { text: String(x.workspace_total || 0) }), + el("td", { text: String(x.workspace_direct || 0) }), + el("td", { text: String(x.workspace_inherited_manager || 0) }), + el("td", { text: String(x.workspace_resolved_tool_match || 0) }), + el("td", { text: String(x.workspace_docs_only || 0) }), + el("td", { text: String(x.mcp_total || 0) }), + el("td", { text: String(x.tool_total || 0) }), + el("td", {}, [docsOnlyCell]), + el("td", {}, [namesCell]), + ]), + ); + }); + if (!rows.length) { + skillEffectiveTbody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "11" })])); + } + }; + const loadSkillEffective = async () => { + try { + const r = await apiGet("/admin/api/skills/effective"); + skillEffectiveState.items = Array.isArray(r.items) ? r.items : []; + renderSkillEffectiveDashboard(); + } catch (e) { + skillEffectiveState.items = []; + renderSkillEffectiveDashboard(); + skillBindingStatus.textContent = `effective: ${String(e && e.message ? e.message : e)}`; + } + }; + const renderSkillBindingList = () => { + const current = String(skillRoleSelect.value || ""); + const draft = skillBindingState.mapping; + const existing = Array.isArray(draft[current]) ? draft[current].map((x) => String(x)) : []; + const selected = new Set(existing); + skillBindingListWrap.innerHTML = ""; + const skills = + skillBindingState.names.length > 0 + ? skillBindingState.names + : (Array.isArray(rowsState.items) ? rowsState.items.map((x) => String(x.name || "").trim()).filter(Boolean) : []); + if (!skills.length) { + skillBindingListWrap.appendChild(el("div", { class: "muted", text: "No installed skills yet." })); + return; + } + skills.forEach((nm) => { + const cb = el("input", { type: "checkbox" }); + cb.checked = selected.has(nm); + cb.addEventListener("change", () => { + const prev = new Set(Array.isArray(draft[current]) ? draft[current].map((x) => String(x)) : []); + if (cb.checked) prev.add(nm); + else prev.delete(nm); + draft[current] = Array.from(prev); + }); + skillBindingListWrap.appendChild(el("label", { class: "row", style: "gap:6px;align-items:center;" }, [cb, el("span", { text: nm })])); + }); + }; + const loadSkillBinding = async () => { + try { + const r = await apiGet("/admin/api/skills/binding"); + skillBindingState.roles = Array.isArray(r.available_roles) ? r.available_roles : []; + skillBindingState.names = (Array.isArray(r.installed_skills) ? r.installed_skills : []) + .map((x) => String(x.name || "").trim()) + .filter(Boolean); + skillBindingState.mapping = r.mapping && typeof r.mapping === "object" ? { ...r.mapping } : {}; + skillBindingState.enabled = !!r.enabled; + skillBindingEnabledCb.checked = skillBindingState.enabled; + skillRoleSelect.innerHTML = ""; + skillBindingState.roles.forEach((role) => { + skillRoleSelect.appendChild(el("option", { value: role, text: role })); + }); + if (skillBindingState.roles.length && !skillRoleSelect.value) { + skillRoleSelect.value = skillBindingState.roles[0]; + } + renderSkillBindingList(); + renderSkillBindingDashboard(); + await loadSkillEffective(); + skillBindingStatus.textContent = ""; + } catch (e) { + skillBindingStatus.textContent = `binding: ${String(e && e.message ? e.message : e)}`; + skillBindingListWrap.innerHTML = ""; + } + }; + skillRoleSelect.addEventListener("change", () => renderSkillBindingList()); + const btnSaveSkillBinding = el("button", { + class: "btn btn--primary", + text: "Save skill role binding", + onclick: async () => { + try { + const r = await apiPost("/admin/api/skills/binding", { + enabled: !!skillBindingEnabledCb.checked, + mapping: skillBindingState.mapping, + }); + skillBindingState.mapping = r.mapping && typeof r.mapping === "object" ? { ...r.mapping } : {}; + skillBindingState.enabled = !!r.enabled; + skillBindingEnabledCb.checked = skillBindingState.enabled; + skillBindingStatus.textContent = `saved: enabled=${String(r.enabled)}`; + renderSkillBindingList(); + renderSkillBindingDashboard(); + await loadSkillEffective(); + } catch (e) { + skillBindingStatus.textContent = String(e && e.message ? e.message : e); + } + }, + }); + const skillBindingBox = el("details", { style: "margin:10px 0 14px 0;" }, [ + el("summary", { text: "Skill role binding (manager + specialists)", style: "cursor:pointer;user-select:none;" }), + el("div", { + class: "muted", + style: "margin:8px 0;line-height:1.5;", + text: + "When enabled, installed workspace skills only appear in the model skills catalog for the selected role, merged with skills bound to manager.", + }), + skillBindingStatus, + el("label", { class: "row", style: "gap:8px;align-items:center;margin-top:6px;" }, [ + skillBindingEnabledCb, + el("span", { text: "Enable role binding (AIA_SKILL_ROLE_BINDING_ENABLED)" }), + ]), + el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;margin-top:8px;align-items:center;" }, [ + el("label", { text: "Role" }), + skillRoleSelect, + btnSaveSkillBinding, + ]), + el("div", { class: "muted", style: "margin:8px 0 4px 0;", text: "Role binding dashboard" }), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [el("th", { text: "role" }), el("th", { text: "direct skills" }), el("th", { text: "effective (+manager)" })])]), + skillBindingDashTbody, + ]), + ]), + el("div", { class: "muted", style: "margin:8px 0 4px 0;", text: "Effective skills dashboard (binding + inherited + MCP + tools)" }), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "role" }), + el("th", { text: "total effective" }), + el("th", { text: "workspace" }), + el("th", { text: "direct bind" }), + el("th", { text: "inherited manager" }), + el("th", { text: "workspace resolved" }), + el("th", { text: "workspace docs-only" }), + el("th", { text: "mcp converted" }), + el("th", { text: "other tools" }), + el("th", { text: "docs-only names" }), + el("th", { text: "names preview" }), + ])]), + skillEffectiveTbody, + ]), + ]), + skillBindingListWrap, + ]); + + const internalToolsState = { role: "", available_roles: [], tools: [], skipped_public: [], skipped_expert: [] }; + const lazyLoadState = { internalLoaded: false, llmLoaded: false, selfCheckLoaded: false }; + const internalToolsStatus = el("div", { class: "muted", text: "" }); + const internalRoleSelect = el("select", { class: "input" }, []); + const internalToolsTbody = el("tbody"); + const internalSkippedPre = el("pre", { class: "muted pre", text: "" }); + const renderInternalTools = () => { + internalToolsTbody.innerHTML = ""; + const rows = Array.isArray(internalToolsState.tools) ? internalToolsState.tools : []; + rows.forEach((x) => { + internalToolsTbody.appendChild( + el("tr", {}, [ + el("td", { text: String(x.source || "") }), + el("td", { text: String(x.name || "") }), + el("td", { text: shortText(String(x.description || ""), 120) }), + el("td", { text: String((Array.isArray(x.tags) ? x.tags.join(", ") : x.tags) || "") }), + el("td", { text: String(x.read_only ? "1" : "0") }), + el("td", { text: String(x.risk_level || "") }), + ]), + ); + }); + if (!rows.length) { + internalToolsTbody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "6" })])); + } + const skipped = { + skipped_public: Array.isArray(internalToolsState.skipped_public) ? internalToolsState.skipped_public : [], + skipped_expert: Array.isArray(internalToolsState.skipped_expert) ? internalToolsState.skipped_expert : [], + }; + internalSkippedPre.textContent = JSON.stringify(skipped, null, 2); + }; + const loadInternalToolsPreview = async (role) => { + const r = String(role || internalRoleSelect.value || "").trim(); + if (!r) return; + internalToolsStatus.textContent = `${t("chat.loading")}...`; + try { + const resp = await apiGet(`/admin/api/tools/internal/preview?role=${encodeURIComponent(r)}`); + internalToolsState.role = String(resp.role || ""); + internalToolsState.available_roles = Array.isArray(resp.available_roles) ? resp.available_roles : []; + internalToolsState.tools = Array.isArray(resp.tools) ? resp.tools : []; + internalToolsState.skipped_public = Array.isArray(resp.skipped_public) ? resp.skipped_public : []; + internalToolsState.skipped_expert = Array.isArray(resp.skipped_expert) ? resp.skipped_expert : []; + internalToolsStatus.textContent = `role=${internalToolsState.role} tools=${internalToolsState.tools.length}`; + renderInternalTools(); + } catch (e) { + internalToolsState.tools = []; + internalToolsStatus.textContent = `preview: ${String(e && e.message ? e.message : e)}`; + renderInternalTools(); + } + }; + const btnInternalPreview = el("button", { + class: "btn btn--primary", + text: "Preview", + onclick: async () => await loadInternalToolsPreview(internalRoleSelect.value), + }); + const internalToolsBox = el("details", { style: "margin:10px 0 14px 0;" }, [ + el("summary", { text: "Internal tools preview (public + expert)", style: "cursor:pointer;user-select:none;" }), + el("div", { class: "muted", style: "margin:8px 0;line-height:1.5;", text: "Preview dynamically loaded builtin tools for a role (public shared + expert-scoped)." }), + internalToolsStatus, + el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;margin-top:8px;align-items:center;" }, [ + el("label", { text: "Role" }), + internalRoleSelect, + btnInternalPreview, + el("button", { + class: "btn", + text: "Clear cache", + onclick: async () => { + try { + await apiPost("/admin/api/tools/internal/reload", {}); + internalToolsStatus.textContent = "cache cleared"; + await loadInternalToolsPreview(internalRoleSelect.value); + } catch (e) { + internalToolsStatus.textContent = `reload: ${String(e && e.message ? e.message : e)}`; + } + }, + }), + ]), + el("div", { class: "table-wrap", style: "margin-top:8px" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "source" }), + el("th", { text: "name" }), + el("th", { text: "description" }), + el("th", { text: "tags" }), + el("th", { text: "read_only" }), + el("th", { text: "risk" }), + ])]), + internalToolsTbody, + ]), + ]), + el("div", { class: "muted", style: "margin-top:8px", text: "Skipped modules / factory errors" }), + internalSkippedPre, + ]); + + const llmToolsState = { role: "", tools_raw: [], tools_wired: [], removed_mcp_names: [], meta: {} }; + const llmToolsStatus = el("div", { class: "muted", text: "" }); + const llmRoleSelect = el("select", { class: "input" }, []); + const llmTracePlanCb = el("input", { type: "checkbox" }); + const llmToolsTbody = el("tbody"); + const llmRemovedPre = el("pre", { class: "muted pre", text: "" }); + const llmModeBadge = el("span", { class: "badge badge--mode-restricted", text: "restricted" }); + const llmDiffPre = el("pre", { class: "muted pre", text: "" }); + const llmDiffTbody = el("tbody"); + const llmDiffTableWrap = el("div", { class: "table-wrap", style: "margin-top:8px" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "type" }), + el("th", { text: "name" }), + el("th", { text: "kind" }), + el("th", { text: "details" }), + ])]), + llmDiffTbody, + ]), + ]); + const selfCheckStatus = el("div", { class: "muted", text: "" }); + const selfCheckSummary = el("div", { class: "muted", text: "" }); + const selfCheckTbody = el("tbody"); + const selfCheckState = { data: null }; + const renderSelfCheck = (data) => { + const body = data && typeof data === "object" ? data : {}; + selfCheckState.data = body; + const summary = body.summary && typeof body.summary === "object" ? body.summary : {}; + const rolesTotal = Number(body.roles_total || 0); + const totalInternal = Number(summary.total_internal_tools || 0); + const totalWired = Number(summary.total_wired_tools || 0); + const totalPermBan = Number(summary.total_perm_ban_9999 || 0); + const items = Array.isArray(body.items) ? body.items : []; + selfCheckSummary.textContent = `roles=${rolesTotal} internal=${totalInternal} wired=${totalWired} permBan9999=${totalPermBan}`; + selfCheckTbody.innerHTML = ""; + items.forEach((x) => { + const removedMcp = Number(x.removed_mcp_total || 0); + const wired = Number(x.wired_count || 0); + const mode = String(x.role_mode || "restricted"); + let health = "ok"; + if (mode === "forbidden" && wired === 0) health = "forbidden"; + else if (mode === "unrestricted") health = "unrestricted"; + else if (removedMcp > 0) health = "warning"; + const badgeCls = + health === "warning" + ? "badge badge--mode-restricted" + : health === "forbidden" + ? "badge badge--bad" + : health === "unrestricted" + ? "badge badge--mode-unrestricted" + : "badge badge--ok"; + selfCheckTbody.appendChild( + el("tr", {}, [ + el("td", { text: String(x.role || "") }), + el("td", {}, [el("span", { class: badgeCls, text: health })]), + el("td", { text: String(x.role_mode || "") }), + el("td", { text: String(x.internal_count || 0) }), + el("td", { text: String(x.raw_count || 0) }), + el("td", { text: String(x.wired_count || 0) }), + el("td", { text: String(x.removed_mcp_total || 0) }), + el("td", { text: String(x.policy_perm_ban_9999 || 0) }), + ]), + ); + }); + if (!items.length) selfCheckTbody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "8" })])); + }; + const runSelfCheck = async () => { + selfCheckStatus.textContent = `${t("chat.loading")}...`; + try { + const resp = await apiGet("/admin/api/tools/self-check"); + renderSelfCheck(resp); + selfCheckStatus.textContent = "self-check complete"; + } catch (e) { + selfCheckStatus.textContent = `self-check: ${String(e && e.message ? e.message : e)}`; + renderSelfCheck({}); + } + }; + const exportSelfCheckJson = () => { + const data = selfCheckState.data; + if (!data || typeof data !== "object") { + selfCheckStatus.textContent = "no self-check data to export"; + return; + } + try { + const blob = new Blob([`${JSON.stringify(data, null, 2)}\n`], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const a = document.createElement("a"); + a.href = url; + a.download = `tools-self-check-${ts}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + selfCheckStatus.textContent = "self-check exported"; + } catch (e) { + selfCheckStatus.textContent = `export failed: ${String(e && e.message ? e.message : e)}`; + } + }; + const selfCheckBox = el("details", { style: "margin:10px 0 14px 0;" }, [ + el("summary", { text: "Tools self-check", style: "cursor:pointer;user-select:none;" }), + el("div", { class: "muted", style: "margin:8px 0;line-height:1.5;", text: "Run one-shot diagnostics across roles for internal/wired tools, role mode and permanent bans." }), + selfCheckStatus, + el("div", { class: "row", style: "gap:8px;align-items:center;margin-top:8px;" }, [ + el("button", { class: "btn btn--primary", text: "Run self-check", onclick: runSelfCheck }), + el("button", { class: "btn", text: "Export self-check JSON", onclick: exportSelfCheckJson }), + selfCheckSummary, + ]), + el("div", { class: "table-wrap", style: "margin-top:8px" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "role" }), + el("th", { text: "health" }), + el("th", { text: "mode" }), + el("th", { text: "internal" }), + el("th", { text: "raw" }), + el("th", { text: "wired" }), + el("th", { text: "removed mcp" }), + el("th", { text: "perm ban(9999)" }), + ])]), + selfCheckTbody, + ]), + ]), + ]); + + const _renderLLMToolRows = (tools) => { + llmToolsTbody.innerHTML = ""; + const rows = Array.isArray(tools) ? tools : []; + rows.forEach((ent) => { + const fn = ent && typeof ent === "object" ? ent.function : null; + const name = fn && typeof fn === "object" ? String(fn.name || "") : ""; + const desc = fn && typeof fn === "object" ? String(fn.description || "") : ""; + const params = fn && typeof fn === "object" ? fn.parameters : null; + llmToolsTbody.appendChild( + el("tr", {}, [ + el("td", { text: name }), + el("td", { text: shortText(desc, 140) }), + el("td", { text: name.startsWith("mcp__") ? "mcp" : "internal" }), + el("td", { text: params ? shortText(JSON.stringify(params), 120) : "" }), + ]), + ); + }); + if (!rows.length) { + llmToolsTbody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "4" })])); + } + }; + + const loadLLMToolsPreview = async (role) => { + const r = String(role || llmRoleSelect.value || "").trim(); + if (!r) return; + llmToolsStatus.textContent = `${t("chat.loading")}...`; + try { + const resp = await apiGet(`/admin/api/tools/llm/preview?role=${encodeURIComponent(r)}`); + llmToolsState.role = String(resp.role || ""); + llmToolsState.tools_raw = Array.isArray(resp.tools_raw) ? resp.tools_raw : []; + llmToolsState.tools_wired = Array.isArray(resp.tools_wired) ? resp.tools_wired : []; + llmToolsState.removed_mcp_names = Array.isArray(resp.removed_mcp_names) ? resp.removed_mcp_names : []; + llmToolsState.meta = resp && typeof resp === "object" ? resp : {}; + const mode = String(resp.role_mode || "restricted"); + llmModeBadge.textContent = mode; + llmModeBadge.className = `badge badge--mode-${mode}`; + llmToolsStatus.textContent = `role=${llmToolsState.role} raw=${llmToolsState.tools_raw.length} wired=${llmToolsState.tools_wired.length} mcp_enabled=${String(!!resp.mcp_enabled)}`; + llmRemovedPre.textContent = JSON.stringify( + { + removed_mcp_names: llmToolsState.removed_mcp_names, + role_mode: resp.role_mode, + wire_policy_effective: !!resp.wire_policy_effective, + policy_keys: resp.policy_keys, + skipped_public: resp.skipped_public || [], + skipped_expert: resp.skipped_expert || [], + }, + null, + 2, + ); + const _toMap = (arr) => { + const m = new Map(); + (Array.isArray(arr) ? arr : []).forEach((ent) => { + const fn = ent && typeof ent === "object" ? ent.function : null; + const nm = fn && typeof fn === "object" ? String(fn.name || "") : ""; + if (!nm) return; + const desc = fn && typeof fn === "object" ? String(fn.description || "") : ""; + const params = fn && typeof fn === "object" ? fn.parameters : null; + m.set(nm, { description: desc, parameters: params }); + }); + return m; + }; + const rawMap = _toMap(llmToolsState.tools_raw); + const wiredMap = _toMap(llmToolsState.tools_wired); + const rawNames = Array.from(rawMap.keys()); + const wiredNames = Array.from(wiredMap.keys()); + const rawSet = new Set(rawNames); + const wiredSet = new Set(wiredNames); + const removed = rawNames.filter((n) => !wiredSet.has(n)).sort(); + const added = wiredNames.filter((n) => !rawSet.has(n)).sort(); + const changed = []; + wiredNames.forEach((n) => { + if (!rawSet.has(n)) return; + const a = rawMap.get(n) || {}; + const b = wiredMap.get(n) || {}; + const d0 = String(a.description || ""); + const d1 = String(b.description || ""); + const p0 = a.parameters ? JSON.stringify(a.parameters) : ""; + const p1 = b.parameters ? JSON.stringify(b.parameters) : ""; + if (d0 !== d1 || p0 !== p1) changed.push(n); + }); + llmDiffPre.textContent = JSON.stringify( + { + added, + removed, + changed, + counts: { raw: rawNames.length, wired: wiredNames.length, added: added.length, removed: removed.length, changed: changed.length }, + }, + null, + 2, + ); + + llmDiffTbody.innerHTML = ""; + const _kind = (nm) => (String(nm || "").startsWith("mcp__") ? "mcp" : "internal"); + const _pushRow = (tp, nm, detailEl) => { + llmDiffTbody.appendChild( + el("tr", {}, [ + el("td", { text: tp }), + el("td", { text: nm }), + el("td", { text: _kind(nm) }), + el("td", {}, [detailEl]), + ]), + ); + }; + const _detailsCompare = (nm) => { + const a = rawMap.get(nm) || {}; + const b = wiredMap.get(nm) || {}; + const d0 = String(a.description || ""); + const d1 = String(b.description || ""); + const p0 = a.parameters ? JSON.stringify(a.parameters, null, 2) : ""; + const p1 = b.parameters ? JSON.stringify(b.parameters, null, 2) : ""; + const box = el("details", {}, [ + el("summary", { text: "compare", style: "cursor:pointer;user-select:none;" }), + el("div", { class: "muted", style: "margin-top:6px;" }, [el("div", { text: "raw.description" }), el("pre", { class: "muted pre", text: d0 })]), + el("div", { class: "muted", style: "margin-top:6px;" }, [el("div", { text: "wired.description" }), el("pre", { class: "muted pre", text: d1 })]), + el("div", { class: "muted", style: "margin-top:6px;" }, [el("div", { text: "raw.parameters" }), el("pre", { class: "muted pre", text: p0 })]), + el("div", { class: "muted", style: "margin-top:6px;" }, [el("div", { text: "wired.parameters" }), el("pre", { class: "muted pre", text: p1 })]), + ]); + return box; + }; + added.forEach((nm) => _pushRow("added", nm, el("span", { class: "muted", text: "present in wired only" }))); + removed.forEach((nm) => _pushRow("removed", nm, el("span", { class: "muted", text: "present in raw only" }))); + changed.sort().forEach((nm) => _pushRow("changed", nm, _detailsCompare(nm))); + if (!added.length && !removed.length && !changed.length) { + llmDiffTbody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "4" })])); + } + + _renderLLMToolRows(llmToolsState.tools_wired); + } catch (e) { + llmToolsState.tools_wired = []; + llmToolsStatus.textContent = `preview: ${String(e && e.message ? e.message : e)}`; + llmRemovedPre.textContent = ""; + llmDiffPre.textContent = ""; + llmDiffTbody.innerHTML = ""; + _renderLLMToolRows([]); + } + }; + + const btnLLMPreview = el("button", { + class: "btn btn--primary", + text: "Preview", + onclick: async () => await loadLLMToolsPreview(llmRoleSelect.value), + }); + const loadExposureTraceSetting = async () => { + try { + const resp = await apiGet("/admin/api/tools/exposure-trace-setting"); + llmTracePlanCb.checked = !!(resp && resp.enabled); + } catch (_) { + llmTracePlanCb.checked = false; + } + }; + const saveExposureTraceSetting = async () => { + try { + const resp = await apiPost("/admin/api/tools/exposure-trace-setting", { enabled: !!llmTracePlanCb.checked }); + llmTracePlanCb.checked = !!(resp && resp.enabled); + llmToolsStatus.textContent = `trace_exposure_plan=${llmTracePlanCb.checked ? "on" : "off"}`; + } catch (e) { + llmToolsStatus.textContent = `trace setting: ${String(e && e.message ? e.message : e)}`; + } + }; + const btnLLMToggle = el("button", { + class: "btn", + text: "Toggle raw/wired", + onclick: () => { + const showing = String(btnLLMToggle.dataset.showing || "wired"); + const next = showing === "wired" ? "raw" : "wired"; + btnLLMToggle.dataset.showing = next; + _renderLLMToolRows(next === "wired" ? llmToolsState.tools_wired : llmToolsState.tools_raw); + btnLLMToggle.textContent = next === "wired" ? "Toggle raw/wired" : "Toggle raw/wired"; + }, + }); + const llmToolsBox = el("details", { style: "margin:10px 0 14px 0;" }, [ + el("summary", { text: "LLM tools preview (after wire policy)", style: "cursor:pointer;user-select:none;" }), + el("div", { class: "muted", style: "margin:8px 0;line-height:1.5;", text: "Preview the final tools injected to the model for a role (internal + MCP + role_mode + permanent bans + wire policy tiers/penalty)." }), + llmToolsStatus, + el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;margin-top:8px;align-items:center;" }, [ + el("label", { text: "Role" }), + llmRoleSelect, + btnLLMPreview, + btnLLMToggle, + el("button", { + class: "btn", + text: "Clear cache", + onclick: async () => { + try { + await apiPost("/admin/api/tools/internal/reload", {}); + llmToolsStatus.textContent = "cache cleared"; + await loadLLMToolsPreview(llmRoleSelect.value); + } catch (e) { + llmToolsStatus.textContent = `reload: ${String(e && e.message ? e.message : e)}`; + } + }, + }), + el("span", { class: "muted", text: "role_mode:" }), + llmModeBadge, + el("label", { class: "row", style: "gap:6px;align-items:center;" }, [ + llmTracePlanCb, + el("span", { class: "muted", text: "Trace exposure plan" }), + ]), + el("button", { class: "btn", text: "Save trace setting", onclick: saveExposureTraceSetting }), + ]), + el("div", { class: "table-wrap", style: "margin-top:8px" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [el("th", { text: "name" }), el("th", { text: "description" }), el("th", { text: "kind" }), el("th", { text: "parameters" })])]), + llmToolsTbody, + ]), + ]), + el("div", { class: "muted", style: "margin-top:8px", text: "Diff / diagnostics" }), + llmRemovedPre, + el("div", { class: "muted", style: "margin-top:8px", text: "raw vs wired diff (added/removed/changed)" }), + llmDiffPre, + llmDiffTableWrap, + ]); + + const tbody = el("tbody"); + const auditTbody = el("tbody"); + let skillActionMenuEl = null; + let skillTestRunName = ""; + const closeSkillActionMenu = () => { + if (skillActionMenuEl && skillActionMenuEl.parentNode) { + skillActionMenuEl.parentNode.removeChild(skillActionMenuEl); + } + skillActionMenuEl = null; + }; + const skillTestRunModal = el("div", { class: "session-monitor-modal", style: "display:none;" }); + const skillTestRunTitle = el("div", { class: "card__title", text: "Skill test run" }); + const skillTestRunArgs = el("textarea", { class: "input", rows: "8", style: "width:100%;min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;", placeholder: "{}" }); + const closeSkillTestRunModal = () => { + skillTestRunModal.style.display = "none"; + skillTestRunName = ""; + }; + const openSkillTestRunModal = (name) => { + skillTestRunName = String(name || ""); + skillTestRunTitle.textContent = `Test run: ${skillTestRunName}`; + skillTestRunArgs.value = "{}"; + skillTestRunModal.style.display = "flex"; + setTimeout(() => skillTestRunArgs.focus(), 0); + }; + skillTestRunModal.addEventListener("click", (e) => { + if (e.target === skillTestRunModal) closeSkillTestRunModal(); + }); + const skillTestRunRunBtn = el("button", { + class: "btn btn--primary", + text: "Run", + onclick: async () => { + if (!skillTestRunName) return; + let args = {}; + try { + const raw = String(skillTestRunArgs.value || "").trim(); + args = raw ? JSON.parse(raw) : {}; + } catch (e) { + status.textContent = `invalid json: ${String(e && e.message ? e.message : e)}`; + return; + } + try { + const r = await apiPost("/admin/api/skills/test-run", { name: skillTestRunName, args }); + status.textContent = `test-run: ${skillTestRunName} => ${JSON.stringify((r && r.result) || {}, null, 0)}`; + closeSkillTestRunModal(); + } catch (e) { + status.textContent = `test-run: ${String(e && e.message ? e.message : e)}`; + } + }, + }); + const skillTestRunCancelBtn = el("button", { class: "btn", text: "Cancel", onclick: closeSkillTestRunModal }); + const skillTestRunCard = el("div", { class: "card session-monitor-modal__card", style: "width:min(760px,96vw);" }, [ + skillTestRunTitle, + el("div", { class: "muted", style: "margin-bottom:8px;", text: "Input JSON args passed to the executable skill runtime." }), + skillTestRunArgs, + el("div", { class: "row", style: "gap:8px;justify-content:flex-end;margin-top:10px;" }, [skillTestRunCancelBtn, skillTestRunRunBtn]), + ]); + skillTestRunModal.appendChild(skillTestRunCard); + const retryableOnlyCb = el("input", { type: "checkbox" }); + const retryableOnlyStored = String(localStorage.getItem(SKILL_AUDIT_RETRYABLE_ONLY_KEY) || "").trim().toLowerCase(); + retryableOnlyCb.checked = retryableOnlyStored ? retryableOnlyStored !== "0" && retryableOnlyStored !== "false" : true; + const _parseSourceFromAudit = (it) => { + const d = it && typeof it.detail === "object" ? it.detail : {}; + const s = String(d.source || "").trim().toLowerCase(); + if (s === "local" || s === "registry" || s === "auto") return s; + return ""; + }; + const _parseRetryTargetFromAudit = (it) => { + const d = it && typeof it.detail === "object" ? it.detail : {}; + const inputTarget = String(d.input_target || "").trim(); + if (inputTarget) return inputTarget; + return String((it && it.target_id) || "").trim(); + }; + const _isRetryableAudit = (it) => { + const action = String((it && it.action) || "").trim().toLowerCase(); + if (action !== "skill_install_failed") return false; + const src = _parseSourceFromAudit(it); + const target = _parseRetryTargetFromAudit(it); + return Boolean(src && target); + }; + const loadRows = async () => { + const data = await apiGet("/admin/api/skills"); + rowsState.items = Array.isArray(data.items) ? data.items : []; + }; + const loadAudits = async () => { + const data = await apiGet("/admin/api/admin-audit?limit=80"); + const items = Array.isArray(data.items) ? data.items : []; + auditState.items = items + .filter((x) => String(x.action || "").startsWith("skill_")) + .slice(0, 20); + }; + const repaint = () => { + const rows = rowsState.items || []; + tbody.innerHTML = ""; + rows.forEach((x) => { + const name = String(x.name || ""); + const enabled = !!x.enabled; + const executable = !!x.executable; + const runtime = x && typeof x.runtime === "object" ? x.runtime : null; + const runtimeType = runtime && typeof runtime.type === "string" ? String(runtime.type) : ""; + const runtimeEntry = runtime && typeof runtime.entry === "string" ? String(runtime.entry) : ""; + const toggleBtn = el("button", { + class: "chat-sess-menu-item", + text: enabled ? "Disable" : "Enable", + onclick: async () => { + try { + closeSkillActionMenu(); + await apiPost(enabled ? "/admin/api/skills/disable" : "/admin/api/skills/enable", { name }); + status.textContent = `${enabled ? "Disabled" : "Enabled"}: ${name}`; + await loadRows(); + repaint(); + } catch (e) { + status.textContent = String(e && e.message ? e.message : e); + } + }, + }); + const testRunBtn = el("button", { + class: "chat-sess-menu-item", + text: "Test run...", + disabled: !executable, + onclick: () => { + closeSkillActionMenu(); + openSkillTestRunModal(name); + }, + }); + const actionMenuBtn = el("button", { + class: "chat-sess-more", + text: "⋯", + title: "Skill actions", + onclick: (ev) => { + ev.stopPropagation(); + closeSkillActionMenu(); + const menu = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;z-index:250;" }, [ + toggleBtn, + testRunBtn, + ]); + const rect = ev.currentTarget.getBoundingClientRect(); + document.body.appendChild(menu); + const mrect = menu.getBoundingClientRect(); + const pad = 8; + let left = rect.left - 120; + let top = rect.bottom + 4; + if (top + mrect.height > window.innerHeight - pad) { + top = rect.top - 4 - mrect.height; + } + left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width)); + top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height)); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + skillActionMenuEl = menu; + const close = (e) => { + if (!menu.contains(e.target)) { + closeSkillActionMenu(); + document.removeEventListener("click", close); + } + }; + setTimeout(() => document.addEventListener("click", close), 0); + }, + }); + tbody.appendChild( + el("tr", {}, [ + el("td", { text: name }), + el("td", { text: String(x.description || "") }), + el("td", { text: String(x.enabled ? "1" : "0") }), + el("td", {}, [executable ? el("span", { class: "badge badge--ok", text: `${runtimeType || "runtime"}` }) : el("span", { class: "muted", text: "docs-only" })]), + el("td", { text: executable ? `${runtimeEntry}` : "-" }), + el("td", { text: String(x.skill_dir || "") }), + el("td", { class: "table__cell-actions" }, [actionMenuBtn]), + ]), + ); + }); + + auditTbody.innerHTML = ""; + const auditRows = (auditState.items || []).filter((x) => (!retryableOnlyCb.checked ? true : _isRetryableAudit(x))); + for (const x of auditRows) { + const src = _parseSourceFromAudit(x); + const isRetryable = _isRetryableAudit(x); + const retryBtn = el("button", { + class: "btn", + text: "Retry", + disabled: !isRetryable, + onclick: async () => { + try { + const target = _parseRetryTargetFromAudit(x); + if (!src || !target || !isRetryable) { + status.textContent = "retry skipped: missing source/target"; + return; + } + const r = await apiPost("/admin/api/skills/retry-install", { source: src, target }); + status.textContent = `retry: ${JSON.stringify(r.result || {})}`; + await loadRows(); + await loadAudits(); + repaint(); + } catch (e) { + status.textContent = String(e && e.message ? e.message : e); + } + }, + }); + auditTbody.appendChild( + el("tr", {}, [ + el("td", { text: String(x.timestamp || "") }), + el("td", { text: String(x.action || "") }), + el("td", { text: String(x.target_id || "") }), + el("td", { text: String(x.status || "") }), + el("td", { text: shortText(JSON.stringify(x.detail || {}), 180) }), + el("td", {}, [retryBtn]), + ]), + ); + } + }; + try { + await loadRows(); + await loadAudits(); + await loadSkillBinding(); + const rolesForPreview = (Array.isArray(skillBindingState.roles) && skillBindingState.roles.length ? skillBindingState.roles : ["generalist", "ops", "image", "manager"]).map((x) => String(x)); + internalRoleSelect.innerHTML = ""; + rolesForPreview.forEach((role) => internalRoleSelect.appendChild(el("option", { value: role, text: role }))); + internalRoleSelect.value = rolesForPreview.includes("generalist") ? "generalist" : rolesForPreview[0]; + internalToolsStatus.textContent = "expand this section to load preview"; + llmRoleSelect.innerHTML = ""; + rolesForPreview.forEach((role) => llmRoleSelect.appendChild(el("option", { value: role, text: role }))); + llmRoleSelect.value = internalRoleSelect.value; + llmToolsStatus.textContent = "expand this section to load preview"; + selfCheckStatus.textContent = "expand this section to run self-check"; + } catch (e) { + return el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("title.skills") }), + el("div", { class: "muted", text: String(e && e.message ? e.message : e) }), + ]); + } + repaint(); + + const btnCreate = el("button", { + class: "btn btn--primary", + text: "Create Skill", + onclick: async () => { + try { + const r = await apiPost("/admin/api/skills/create", { + name: String(nameInp.value || "").trim(), + description: String(descInp.value || "").trim(), + body_markdown: String(bodyInp.value || ""), + }); + status.textContent = `create: ${JSON.stringify(r.result || {})}`; + await loadRows(); + await loadAudits(); + repaint(); + } catch (e) { + status.textContent = String(e && e.message ? e.message : e); + } + }, + }); + const btnInstallRegistry = el("button", { + class: "btn", + text: "Install From Registry", + onclick: async () => { + try { + const r = await apiPost("/admin/api/skills/install-registry", { + archive_url: String(regInp.value || "").trim(), + }); + status.textContent = `install-registry: ${JSON.stringify(r.result || {})}`; + await loadRows(); + await loadAudits(); + repaint(); + } catch (e) { + status.textContent = String(e && e.message ? e.message : e); + } + }, + }); + const btnInstallLocal = el("button", { + class: "btn", + text: "Install Local Dir", + onclick: async () => { + try { + const r = await apiPost("/admin/api/skills/install", { + source_dir: String(localDirInp.value || "").trim(), + }); + status.textContent = `install-local: ${JSON.stringify(r.result || {})}`; + await loadRows(); + await loadAudits(); + repaint(); + } catch (e) { + status.textContent = String(e && e.message ? e.message : e); + } + }, + }); + const btnRefresh = el("button", { + class: "btn", + text: t("action.refresh"), + onclick: async () => { + try { + await loadRows(); + await loadAudits(); + await loadSkillBinding(); + repaint(); + renderSkillBindingList(); + status.textContent = "refreshed"; + } catch (e) { + status.textContent = String(e && e.message ? e.message : e); + } + }, + }); + retryableOnlyCb.addEventListener("change", () => { + localStorage.setItem(SKILL_AUDIT_RETRYABLE_ONLY_KEY, retryableOnlyCb.checked ? "1" : "0"); + repaint(); + }); + const docsHint = el("div", { class: "muted", style: "margin-bottom:10px;line-height:1.5;" }, [ + el("div", { text: t("skills.docsTitle") }), + el("div", { text: t("skills.docsTroubleshooting") }), + el("div", { text: t("skills.docsTraceTaxonomy") }), + ]); + internalToolsBox.addEventListener("toggle", async () => { + if (!internalToolsBox.open || lazyLoadState.internalLoaded) return; + lazyLoadState.internalLoaded = true; + await loadInternalToolsPreview(internalRoleSelect.value); + }); + llmToolsBox.addEventListener("toggle", async () => { + if (!llmToolsBox.open || lazyLoadState.llmLoaded) return; + lazyLoadState.llmLoaded = true; + await loadExposureTraceSetting(); + await loadLLMToolsPreview(llmRoleSelect.value); + }); + selfCheckBox.addEventListener("toggle", async () => { + if (!selfCheckBox.open || lazyLoadState.selfCheckLoaded) return; + lazyLoadState.selfCheckLoaded = true; + await runSelfCheck(); + }); + return el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("title.skills") }), + docsHint, + marketBox, + skillBindingBox, + selfCheckBox, + internalToolsBox, + llmToolsBox, + el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;margin-bottom:8px;" }, [nameInp, descInp]), + el("div", { style: "margin-bottom:8px;" }, [bodyInp]), + el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;margin-bottom:8px;" }, [btnCreate]), + el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;margin-bottom:8px;" }, [regInp, btnInstallRegistry, btnRefresh]), + el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;margin-bottom:8px;" }, [localDirInp, btnInstallLocal]), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [ + el("th", { text: "name" }), + el("th", { text: "description" }), + el("th", { text: "enabled" }), + el("th", { text: "kind" }), + el("th", { text: "runtime entry" }), + el("th", { text: "path" }), + el("th", { text: "action" }), + ])]), + tbody, + ]), + ]), + el("div", { style: "height:10px" }), + el("div", { class: "row", style: "gap:8px;align-items:center;" }, [ + el("div", { class: "muted", text: "Recent skill install audit" }), + el("label", { class: "row", style: "gap:6px;align-items:center;" }, [ + retryableOnlyCb, + el("span", { class: "muted", text: "Only retryable failures" }), + ]), + ]), + el("div", { class: "table-wrap" }, [ + el("table", { class: "table table--compact" }, [ + el("thead", {}, [el("tr", {}, [el("th", { text: "timestamp" }), el("th", { text: "action" }), el("th", { text: "target" }), el("th", { text: "status" }), el("th", { text: "detail" }), el("th", { text: "retry" })])]), + auditTbody, + ]), + ]), + skillTestRunModal, + status, + ]); +} + +async function router() { + const route = getRoute(); + const page = route.page; + const tok = getStoredAuthToken(); + if (!authSession || !tok) { + if (authSession || tok) { + authStoreRemove(AUTH_TOKEN_KEY); + authStoreRemove(AUTH_SESSION_KEY); + authSession = null; + } + try { + await apiPost("/admin/api/auth/bootstrap", {}); + } catch (_) {} + const loginCard = await renderLogin(); + mount(loginCard); + return; + } + applyI18nStatic(); + setActive(page); + document.querySelectorAll(".nav__item").forEach((a) => { + const p = String(a.dataset.page || ""); + if (p === "stack" && !hasPermission("admin:runtime:write")) a.style.display = "none"; + else if (p === "users" && !hasPermission("admin:user:read")) a.style.display = "none"; + else if (p === "session-monitor" && !isAdministratorUsername()) a.style.display = "none"; + else if (p === "admin-audit" && !hasPermission("admin:user:write")) a.style.display = "none"; + else if (p === "plugins" && !hasPermission("admin:user:write")) a.style.display = "none"; + else if (p === "skills" && !hasPermission("admin:user:write")) a.style.display = "none"; + else if (p === "attachments" && !isAdministratorUsername()) a.style.display = "none"; + else if (p === "workspace-paths" && !hasPermission("admin:user:read") && !hasPermission("admin:workspace_paths:read")) a.style.display = "none"; + else if (p === "api-grants" && !canManageApiGrants()) a.style.display = "none"; + else a.style.display = ""; + }); + const user = document.getElementById("authUser"); + if (user) { + const name = String((authSession && (authSession.display_name || authSession.username || authSession.user_id)) || ""); + const role = String((authSession && authSession.role) || ""); + user.textContent = name ? `${name} (${role})` : ""; + } + const forbiddenCard = () => + el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.forbidden") })]); + let view; + if (page === "stack") { + mount( + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("title.stack") }), + el("div", { class: "muted", text: t("chat.loading") }), + ]), + ); + view = await renderStack(); + } + else if (page === "users") { + view = hasPermission("admin:user:read") ? await renderUserManagement() : forbiddenCard(); + } else if (page === "memory") view = await renderMemory(); + else if (page === "models") view = await renderModels(); + else if (page === "api-grants") view = await renderApiGrants(); + else if (page === "audit") view = await renderAudit(route.params.get("session_id") || ""); + else if (page === "session-monitor") { + view = isAdministratorUsername() ? await renderSessionMonitor() : el("div", { class: "card" }, [el("div", { class: "card__title", text: t("sessionMonitor.onlyAdministrator") })]); + } else if (page === "admin-audit") { + view = hasPermission("admin:user:write") ? await renderAdminAudit() : forbiddenCard(); + } else if (page === "plugins") { + mount( + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("title.plugins") }), + el("div", { class: "muted", text: t("chat.loading") }), + ]), + ); + view = hasPermission("admin:user:write") ? await renderPlugins() : forbiddenCard(); + } else if (page === "skills") { + view = hasPermission("admin:user:write") ? await renderSkills() : forbiddenCard(); + } else if (page === "attachments") { + view = isAdministratorUsername() ? await renderAttachments() : forbiddenCard(); + } else if (page === "workspace-paths") { + view = + hasPermission("admin:user:read") || hasPermission("admin:workspace_paths:read") + ? await renderWorkspacePaths() + : forbiddenCard(); + } else if (page === "profile") { + view = await renderProfile(); + } else view = el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.notFound") })]); + mount(view); +} + +async function renderLogin() { + applyI18nStatic(); + const username = el("input", { class: "input", placeholder: t("auth.username"), value: "" }); + const userHint = el("div", { class: "muted", text: t("auth.consoleUsernameHint") }); + const password = el("input", { class: "input", type: "password", placeholder: t("auth.password") }); + const status = el("div", { class: "muted", text: "" }); + const doLogin = async () => { + const resp = await apiPost("/admin/api/auth/login", { + tenant_id: "", + username: username.value.trim(), + password: password.value.trim(), + purpose: "console", + }); + if (!resp.ok || !resp.token) { + const err = String(resp.error || ""); + status.textContent = err === "user_disabled" ? t("auth.disabled") : t("auth.invalid"); + return; + } + const newTok = String(resp.token || "").trim(); + authStoreSet(AUTH_TOKEN_KEY, newTok); + authStoreSet(AUTH_SESSION_KEY, JSON.stringify(resp.session || {})); + authSession = resp.session || null; + await router(); + }; + const onEnterLogin = (ev) => { + if (ev.key !== "Enter") return; + ev.preventDefault(); + doLogin(); + }; + username.addEventListener("keydown", onEnterLogin); + password.addEventListener("keydown", onEnterLogin); + const btn = el("button", { class: "btn btn--primary", text: t("auth.login"), onclick: doLogin }); + // Focus after mount so users can type immediately. + setTimeout(() => { + try { + username.focus(); + } catch (_) {} + }, 0); + return el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("auth.login") }), + el("div", { class: "row" }, [username]), + userHint, + el("div", { class: "row" }, [password]), + el("div", { class: "row" }, [btn]), + status, + ]); +} + +window.addEventListener("hashchange", router); +const btnRefreshEl = document.getElementById("btnRefresh"); +if (btnRefreshEl) btnRefreshEl.addEventListener("click", router); +const btnLangEl = document.getElementById("btnLang"); +if (btnLangEl) btnLangEl.addEventListener("click", () => { + toggleLang(); + router(); +}); +const btnBackChatEl = document.getElementById("btnBackChat"); +if (btnBackChatEl) { + try { + btnBackChatEl.setAttribute("href", resolveChatUrl()); + } catch (_) {} + btnBackChatEl.addEventListener("click", (ev) => { + // Keep SPA behavior explicit, while href remains fallback if JS fails earlier. + ev.preventDefault(); + window.location.assign(resolveChatUrl()); + }); +} +const btnLogoutEl = document.getElementById("btnLogout"); +if (btnLogoutEl) btnLogoutEl.addEventListener("click", async () => { + try { + await apiPost("/admin/api/auth/logout", {}); + } catch (_) {} + authStoreRemove(AUTH_TOKEN_KEY); + authStoreRemove(AUTH_SESSION_KEY); + authSession = null; + await router(); +}); +try { + authSession = JSON.parse(authStoreGet(AUTH_SESSION_KEY) || "null"); +} catch (_) { + authSession = null; +} +router().catch((err) => { + mount(el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.error") }), el("div", { class: "pre", text: String(err) })])); +}); + diff --git a/admin/static/chat.html b/admin/static/chat.html new file mode 100644 index 00000000..c9e24da1 --- /dev/null +++ b/admin/static/chat.html @@ -0,0 +1,455 @@ + + + + + + Chat + + + + + + + + + + +
+ + + + diff --git a/admin/static/chat.js b/admin/static/chat.js new file mode 100644 index 00000000..b61a1d6d --- /dev/null +++ b/admin/static/chat.js @@ -0,0 +1,3404 @@ +/* Standalone /chat page: same bearer + /admin/api/chat as admin SPA. */ + +const PAGE_SIZE = 35; + +const I18N = { + zh: { + "chat.pageTitle": "oliver", + "chat.backAdmin": "管理台", + "chat.newSession": "开启新对话", + "chat.send": "发送", + "chat.stop": "停止", + "chat.sessions": "会话", + "chat.modelProfile": "模型", + "chat.placeholder": "输入消息…", + "chat.empty": "暂无消息", + "chat.noSessions": "暂无会话,点击新建开始。", + "chat.loading": "加载中…", + "chat.sending": "发送中…", + "chat.error": "请求失败", + "chat.loadMore": "加载更多", + "chat.sessionMenu": "会话操作", + "chat.rename": "重命名", + "chat.delete": "删除", + "chat.deleteConfirm": "删除此会话?", + "chat.exportMd": "导出 Markdown", + "chat.exportJson": "导出 JSON", + "chat.fork": "复制会话", + "chat.audit": "审计与追踪", + "chat.myProfile": "设置", + "chat.attach": "附件", + "chat.tools": "推理", + "chat.tools.hidden": "推理已隐藏", + "chat.tools.visible": "推理已显示", + "chat.tools.off": "关", + "chat.tools.on": "开", + "chat.status.oclaw": "主控", + "chat.status.agent": "专家", + "chat.status.tool": "工具", + "chat.status.plan": "规划", + "chat.status.start": "启动", + "chat.status.running": "执行中", + "chat.status.end": "完成", + "chat.status.error": "异常", + "chat.marker.summary": "文件标记:指针 {p},封套 {e}(封套内 {ep})", + "chat.marker.ttl": "TTL:turn {t} / session {s} / keep {k}", + "chat.marker.reclaimed": "本轮回收 turn 标记 {n}", + "chat.historyTruncated": "仅显示最近 {shown} 条,共 {total} 条", + "reasoning.summary": "模型推理片段", + "tool.streamTitle": "工具与进度(本轮)", + "auth.login": "登录", + "auth.logout": "退出登录", + "auth.username": "用户名", + "auth.password": "密码", + "auth.invalid": "登录失败,请检查凭据", + "auth.consoleAdminOnly": "仅管理员(admin 角色)可登录管理台。", + "auth.chatLoginDenied": "当前账号无法使用对话(请联系管理员)。", + "auth.disabled": "账号已被禁用,请联系管理员", + "common.error": "错误", + "lang.switch": "English", + "chat.imageViewerClose": "关闭", + "chat.imageViewerHint": "点击查看大图,空白处或 Esc 关闭", + "chat.specialistLabel": "专家", + "chat.modeLabel": "模式", + "chat.modeComprehensive": "综合", + "chat.modeExpert": "专家", + "chat.specialistGeneralist": "通用", + "chat.specialistOps": "运维", + "chat.specialistImage": "图像", + "chat.specialistMemoryCurator": "记忆整理", + "chat.memoryModeLabel": "记忆模式", + "chat.memoryModeDefault": "默认(可注入)", + "chat.memoryModeStoreOnly": "仅记录不注入", + "chat.memoryApplied": "记忆:{mode}", + "chat.memoryModeDefaultShort": "默认", + "chat.memoryModeStoreOnlyShort": "仅记录", + "chat.wikiToastMerged": "Wiki 已收录:+{merged}(去重跳过 {skipped})", + "chat.wikiToastFailed": "Wiki 收录失败:{error}", + "chat.wikiViewMerged": "查看 merged-turns", + "chat.wikiViewTopic": "查看 topic:{topic}", + "chat.wikiPreviewTitle": "Wiki 预览:{path}", + "chat.wikiPreviewClose": "关闭", + "chat.specialistManage": "专家开关", + "chat.specialistManagePromptOn": "是否启用 memory_curator 专家?", + "chat.specialistManagePromptOff": "是否关闭 memory_curator 专家?", + "chat.specialistManageDenied": "无权限修改专家开关", + "chat.attachmentLimits": "附件阈值", + "chat.attachmentLimitsPrompt": "编辑附件阈值 JSON(留空恢复默认)", + "chat.attachmentLimitsSaved": "附件阈值已更新", + "chat.attachmentLimitsDenied": "无权限修改附件阈值", + "chat.attachmentLimitsInvalidJson": "附件阈值 JSON 不合法", + "chat.attachmentLimitsTitle": "附件阈值配置", + "chat.attachmentLimitsRows": "最大读取行数", + "chat.attachmentLimitsCols": "最大保留列数", + "chat.attachmentLimitsCellChars": "单元格最大字符数", + "chat.attachmentLimitsMaxSheets": "Excel 最大 Sheet 数", + "chat.attachmentLimitsLargePreviewRows": "大表摘要预览行数", + "chat.attachmentLimitsToolEnabled": "启用大表工具模式", + "chat.attachmentLimitsToolMinRows": "触发工具模式最小行数", + "chat.attachmentLimitsToolMaxBytes": "工具模式最大文件字节数", + "chat.attachmentLimitsSqlTimeoutMs": "SQL 超时(毫秒)", + "chat.attachmentLimitsReset": "恢复默认", + "chat.attachmentLimitsCancel": "取消", + "chat.attachmentLimitsSave": "保存", + "chat.attachmentLimitsInvalidNumber": "请输入有效正整数", + "chat.attachmentLimitsHighPreviewWarn": "大表摘要预览行数超过 200,可能显著增加 token 消耗。确认继续?", + "chat.activeModelLabel": "模型", + "chat.execApplied": "本轮执行:{mode} / {specialist}", + "chat.dynamicStats": "动态专家近窗:命中 {dynamic},回退 {fallback}", + "chat.dynamicStatsDetail": "命中率 {rate}% · Top原因 {reason}", + "chat.dynamicTopReasons": "原因分布 {items}", + "chat.dynamicAllReasons": "查看完整分布", + "chat.dynamicAllReasonsTitle": "完整原因分布", + "chat.dispatchLabelsEdit": "编辑原因文案", + "chat.dispatchLabelsPrompt": "编辑 dispatch reason 覆盖 JSON(留空清空)", + "chat.dispatchLabelsSaved": "原因文案已更新", + "chat.dispatchLabelsTitle": "编辑原因文案覆盖", + "chat.dispatchLabelsSave": "保存", + "chat.dispatchLabelsClear": "清空", + "chat.dispatchLabelsCancel": "取消", + "chat.dispatchLabelsInvalidJson": "JSON 格式不合法", + "chat.dispatchLabelsEffectivePreview": "Effective 预览", + "chat.dispatchLabelsDiffOnly": "仅显示变更项", + "chat.dispatchLabelsExport": "导出 Effective", + "chat.dispatchLabelsImport": "导入 JSON", + "chat.dispatchLabelsRestoreDefaults": "恢复默认模板", + "chat.dispatchLabelsUnsavedConfirm": "当前有未保存变更,确认关闭?", + "chat.reason.manager_no_specialist_fallback": "无可用专家,回退通用", + "chat.reason.dynamic_agent_build_failed": "动态专家构建失败", + "chat.reason.manager_factory_failed": "专家执行器创建失败", + "chat.reason.manager_route_missing": "总控路由缺失", + "chat.reason.manager_select_failed": "总控决策失败", + "chat.reason.manager_model_missing": "总控模型不可用", + "chat.reason.dynamic_agent_selected": "动态专家命中", + "chat.modeComprehensiveShort": "综合", + "chat.modeExpertShort": "专家", + "chat.specialistGeneralistShort": "通用", + "chat.specialistOpsShort": "运维", + "chat.specialistImageShort": "图像", + "chat.specialistMemoryCuratorShort": "记忆整理", + }, + en: { + "chat.pageTitle": "oliver", + "chat.backAdmin": "Admin console", + "chat.newSession": "New chat", + "chat.send": "Send", + "chat.stop": "Stop", + "chat.sessions": "Sessions", + "chat.modelProfile": "Model", + "chat.placeholder": "Message…", + "chat.empty": "No messages yet", + "chat.noSessions": "No sessions yet. Create one to start.", + "chat.loading": "Loading…", + "chat.sending": "Sending…", + "chat.error": "Request failed", + "chat.loadMore": "Load more", + "chat.sessionMenu": "Session actions", + "chat.rename": "Rename", + "chat.delete": "Delete", + "chat.deleteConfirm": "Delete this session?", + "chat.exportMd": "Export Markdown", + "chat.exportJson": "Export JSON", + "chat.fork": "Duplicate chat", + "chat.audit": "Audit & trace", + "chat.myProfile": "Settings", + "chat.attach": "Attach", + "chat.tools": "Reasoning", + "chat.tools.hidden": "Reasoning hidden", + "chat.tools.visible": "Reasoning visible", + "chat.tools.off": "Off", + "chat.tools.on": "On", + "chat.status.oclaw": "Controller", + "chat.status.agent": "Agent", + "chat.status.tool": "Tool", + "chat.status.plan": "Plan", + "chat.status.start": "Starting", + "chat.status.running": "Running", + "chat.status.end": "Done", + "chat.status.error": "Error", + "chat.marker.summary": "File markers: pointers {p}, envelope {e} (in-envelope {ep})", + "chat.marker.ttl": "TTL: turn {t} / session {s} / keep {k}", + "chat.marker.reclaimed": "Turn markers reclaimed {n}", + "chat.historyTruncated": "Showing last {shown} of {total} messages", + "reasoning.summary": "Model reasoning", + "tool.streamTitle": "Tools & progress (this turn)", + "auth.login": "Login", + "auth.logout": "Logout", + "auth.username": "Username", + "auth.password": "Password", + "auth.invalid": "Login failed", + "auth.consoleAdminOnly": "Only users with the admin role may sign in to the console.", + "auth.chatLoginDenied": "This account cannot use chat (contact an admin).", + "auth.disabled": "Account is disabled", + "common.error": "Error", + "lang.switch": "中文", + "chat.imageViewerClose": "Close", + "chat.imageViewerHint": "Click image to enlarge; click outside or Esc to close", + "chat.specialistLabel": "Specialist", + "chat.modeLabel": "Mode", + "chat.modeComprehensive": "Comprehensive", + "chat.modeExpert": "Expert", + "chat.specialistGeneralist": "Generalist", + "chat.specialistOps": "Ops", + "chat.specialistImage": "Image", + "chat.specialistMemoryCurator": "Memory Curator", + "chat.memoryModeLabel": "Memory Mode", + "chat.memoryModeDefault": "Default (inject allowed)", + "chat.memoryModeStoreOnly": "Store only (no inject)", + "chat.memoryApplied": "Memory: {mode}", + "chat.memoryModeDefaultShort": "default", + "chat.memoryModeStoreOnlyShort": "store_only", + "chat.wikiToastMerged": "Wiki captured: +{merged} (dedup skipped {skipped})", + "chat.wikiToastFailed": "Wiki capture failed: {error}", + "chat.wikiViewMerged": "View merged-turns", + "chat.wikiViewTopic": "View topic: {topic}", + "chat.wikiPreviewTitle": "Wiki preview: {path}", + "chat.wikiPreviewClose": "Close", + "chat.specialistManage": "Specialist Toggle", + "chat.specialistManagePromptOn": "Enable memory_curator specialist?", + "chat.specialistManagePromptOff": "Disable memory_curator specialist?", + "chat.specialistManageDenied": "Not authorized to change specialist flags", + "chat.attachmentLimits": "Attachment limits", + "chat.attachmentLimitsPrompt": "Edit attachment limits JSON (empty to restore defaults)", + "chat.attachmentLimitsSaved": "Attachment limits updated", + "chat.attachmentLimitsDenied": "Not authorized to change attachment limits", + "chat.attachmentLimitsInvalidJson": "Invalid attachment limits JSON", + "chat.attachmentLimitsTitle": "Attachment limits", + "chat.attachmentLimitsRows": "Max rows read", + "chat.attachmentLimitsCols": "Max columns kept", + "chat.attachmentLimitsCellChars": "Max chars per cell", + "chat.attachmentLimitsMaxSheets": "Max Excel sheets", + "chat.attachmentLimitsLargePreviewRows": "Large-table preview rows", + "chat.attachmentLimitsToolEnabled": "Enable large-table tool mode", + "chat.attachmentLimitsToolMinRows": "Min rows to trigger tool mode", + "chat.attachmentLimitsToolMaxBytes": "Max file bytes for tool mode", + "chat.attachmentLimitsSqlTimeoutMs": "SQL timeout (ms)", + "chat.attachmentLimitsReset": "Reset defaults", + "chat.attachmentLimitsCancel": "Cancel", + "chat.attachmentLimitsSave": "Save", + "chat.attachmentLimitsInvalidNumber": "Please enter valid positive integers", + "chat.attachmentLimitsHighPreviewWarn": "Large-table preview rows is above 200, which may significantly increase token usage. Continue?", + "chat.activeModelLabel": "Model", + "chat.execApplied": "Applied: {mode} / {specialist}", + "chat.dynamicStats": "Dynamic experts (recent): hit {dynamic}, fallback {fallback}", + "chat.dynamicStatsDetail": "Hit rate {rate}% · Top reason {reason}", + "chat.dynamicTopReasons": "Reason mix {items}", + "chat.dynamicAllReasons": "View full mix", + "chat.dynamicAllReasonsTitle": "Full reason mix", + "chat.dispatchLabelsEdit": "Edit reason labels", + "chat.dispatchLabelsPrompt": "Edit dispatch reason override JSON (empty to clear)", + "chat.dispatchLabelsSaved": "Reason labels updated", + "chat.dispatchLabelsTitle": "Edit reason label overrides", + "chat.dispatchLabelsSave": "Save", + "chat.dispatchLabelsClear": "Clear", + "chat.dispatchLabelsCancel": "Cancel", + "chat.dispatchLabelsInvalidJson": "Invalid JSON format", + "chat.dispatchLabelsEffectivePreview": "Effective preview", + "chat.dispatchLabelsDiffOnly": "Show changed only", + "chat.dispatchLabelsExport": "Export effective", + "chat.dispatchLabelsImport": "Import JSON", + "chat.dispatchLabelsRestoreDefaults": "Restore defaults template", + "chat.dispatchLabelsUnsavedConfirm": "You have unsaved changes. Close anyway?", + "chat.reason.manager_no_specialist_fallback": "No specialist; fallback to generalist", + "chat.reason.dynamic_agent_build_failed": "Dynamic agent build failed", + "chat.reason.manager_factory_failed": "Specialist executor factory failed", + "chat.reason.manager_route_missing": "Manager route missing", + "chat.reason.manager_select_failed": "Manager selection failed", + "chat.reason.manager_model_missing": "Manager model missing", + "chat.reason.dynamic_agent_selected": "Dynamic agent selected", + "chat.modeComprehensiveShort": "Comprehensive", + "chat.modeExpertShort": "Expert", + "chat.specialistGeneralistShort": "Generalist", + "chat.specialistOpsShort": "Ops", + "chat.specialistImageShort": "Image", + "chat.specialistMemoryCuratorShort": "Memory Curator", + }, +}; + +const LANG_KEY = "ops_admin_lang"; +let currentLang = (localStorage.getItem(LANG_KEY) || "zh").toLowerCase(); +if (!I18N[currentLang]) currentLang = "zh"; +/** Chat standalone: separate keys from /admin so two accounts can stay signed in on the same origin. */ +const AUTH_TOKEN_KEY = "ops_chat_token"; +const AUTH_SESSION_KEY = "ops_chat_session"; +/** 与 URL 中 ?session_id= 联动:切换登录用户后丢弃旧 session,避免误开上一账号的链接会话 */ +const CHAT_URL_SCOPE_KEY = "ops_chat_url_scope"; +const CHAT_SPECIALIST_PREF_KEY = "ops_chat_specialist_pref"; +const CHAT_INTERACTION_MODE_KEY = "ops_chat_interaction_mode"; +const CHAT_MEMORY_MODE_KEY = "ops_chat_memory_mode"; +const ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT = false; +let adminChatShowToolOutput = ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT; +let authSession = null; + +function _toolSummaryTitle(role) { + const r = String(role || "").toLowerCase(); + if (r === "tool" || r === "function") return "工具结果"; + return "工具调用"; +} + +function _normalizeEventType(v) { + return String(v || "").trim().toLowerCase(); +} + +function _collapsedBlockNode(title, text) { + const box = document.createElement("div"); + box.className = "chat-msg__reasoning-block"; + box.appendChild(el("div", { class: "chat-msg__reasoning-title", text: String(title || "") })); + box.appendChild(el("pre", { class: "chat-msg__reasoning-pre", text: String(text || "") || "—" })); + return box; +} + +function _appendCollapsedBundle(inner, items) { + const list = Array.isArray(items) ? items : []; + if (!list.length) return; + const det = document.createElement("details"); + det.className = "chat-msg__reasoning"; + const sum = document.createElement("summary"); + sum.textContent = t("reasoning.summary"); + det.appendChild(sum); + for (const it of list) { + const title = String((it && it.title) || ""); + const text = String((it && it.text) || ""); + if (!text.trim()) continue; + det.appendChild(_collapsedBlockNode(title || t("reasoning.summary"), text)); + } + inner.appendChild(det); +} + +function _appendAssistantTextSegments(inner, rawText, collapsedItems) { + const sourceText = adminChatShowToolOutput + ? decodeEscapedNewlines(rawText) + : stripReasoningTagsFromText(decodeEscapedNewlines(rawText), { mode: "strict" }); + const segs = parseReasoningSegments(sourceText); + const onlyText = segs.length === 1 && segs[0].type === "text"; + if (onlyText) { + inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(segs[0].text) })); + return; + } + for (const seg of segs) { + if (seg.type === "text") { + let body = String(seg.text || ""); + const prev = inner.lastElementChild; + if (prev && prev.classList && prev.classList.contains("chat-msg__reasoning")) { + body = body.replace(/^\s+/, ""); + } + if (!body.trim()) continue; + inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(body) })); + } else { + if (!adminChatShowToolOutput) continue; + if (Array.isArray(collapsedItems)) { + collapsedItems.push({ title: t("reasoning.summary"), text: seg.text || "—" }); + } else { + _appendCollapsedBundle(inner, [{ title: t("reasoning.summary"), text: seg.text || "—" }]); + } + } + } +} + +function _buildAggregatedAssistantBubble(tsIso, items) { + const inner = el("div", { class: "chat-msg chat-msg--assistant chat-msg--rich" }); + const collapsedItems = []; + for (const it of items || []) { + const kind = String((it && it.kind) || ""); + const text = String((it && it.text) || ""); + if (!text.trim()) continue; + if (kind === "assistant_text") { + _appendAssistantTextSegments(inner, text, collapsedItems); + } else if (kind === "reasoning") { + if (adminChatShowToolOutput) collapsedItems.push({ title: t("reasoning.summary"), text }); + } else if (kind === "tool_call") { + if (adminChatShowToolOutput) collapsedItems.push({ title: _toolSummaryTitle("tool_call"), text }); + } else if (kind === "tool_result") { + if (adminChatShowToolOutput) collapsedItems.push({ title: _toolSummaryTitle("tool"), text }); + } else { + inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(text) })); + } + } + if (collapsedItems.length) { + const det = document.createElement("details"); + det.className = "chat-msg__reasoning"; + const sum = document.createElement("summary"); + sum.textContent = t("reasoning.summary"); + det.appendChild(sum); + for (const it of collapsedItems) { + const title = String((it && it.title) || ""); + const text = String((it && it.text) || ""); + if (!text.trim()) continue; + det.appendChild(_collapsedBlockNode(title || t("reasoning.summary"), text)); + } + inner.insertBefore(det, inner.firstChild); + } + return wrapAssistantMessage(inner, tsIso); +} + +function _buildRenderRows(msgs) { + const src = Array.isArray(msgs) ? msgs : []; + const ordered = src + .map((m, idx) => ({ m, idx })) + .sort((a, b) => { + const ta = Date.parse(String((a.m && a.m.timestamp) || "")) || 0; + const tb = Date.parse(String((b.m && b.m.timestamp) || "")) || 0; + if (ta !== tb) return ta - tb; + const ia = parseInt(String((a.m && a.m.id) || "0"), 10); + const ib = parseInt(String((b.m && b.m.id) || "0"), 10); + if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib; + return a.idx - b.idx; + }) + .map((x) => x.m); + const rows = []; + let agg = null; + const flush = () => { + if (!agg) return; + rows.push(agg); + agg = null; + }; + for (const m of ordered) { + const role = String((m && m.role) || "").toLowerCase(); + const content = String((m && m.content) || ""); + const eventType = _normalizeEventType(m && m.event_type); + if (role === "user") { + flush(); + rows.push(m); + continue; + } + if (role === "assistant" || role === "tool" || role === "function") { + if (!agg) { + agg = { + role: "assistant", + content: "", + timestamp: (m && m.timestamp) != null ? m.timestamp : "", + attachments: null, + _items: [], + }; + } + if (role === "assistant") { + if (eventType === "reasoning") { + if (String(content || "").trim()) { + agg._items.push({ kind: "reasoning", text: content }); + } + } else if (String(content || "").trim()) { + agg._items.push({ kind: "assistant_text", text: content }); + } + const tc = m.tool_calls; + if (tc != null && tc !== "") { + let line = ""; + try { + line = typeof tc === "string" ? tc : JSON.stringify(tc, null, 0); + } catch (_) { + line = String(tc); + } + if (line) agg._items.push({ kind: "tool_call", text: line }); + } + if (m.attachments) agg.attachments = m.attachments; + } else if (String(content || "").trim()) { + agg._items.push({ kind: "tool_result", text: content }); + } + continue; + } + flush(); + rows.push(m); + } + flush(); + return rows; +} + +function t(key, vars) { + let s = (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en && I18N.en[key]) || key; + if (vars && typeof s === "string") { + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)); + } + } + return s; +} + +function interactionModeLabel(mode) { + const m = String(mode || "").toLowerCase(); + if (m === "expert") return t("chat.modeExpertShort"); + return t("chat.modeComprehensiveShort"); +} + +function specialistLabel(specialist) { + const s = String(specialist || "").toLowerCase(); + if (s === "ops") return t("chat.specialistOpsShort"); + if (s === "image") return t("chat.specialistImageShort"); + if (s === "memory_curator") return t("chat.specialistMemoryCuratorShort"); + return t("chat.specialistGeneralistShort"); +} + +function memoryModeShortLabel(memoryMode) { + const mm = String(memoryMode || "default").toLowerCase(); + if (mm === "store_only") return t("chat.memoryModeStoreOnlyShort"); + return t("chat.memoryModeDefaultShort"); +} + +function _ensureToastHost() { + let host = document.querySelector(".chat-toast-host"); + if (host) return host; + host = el("div", { + class: "chat-toast-host", + style: + "position:fixed;right:14px;bottom:14px;z-index:9999;display:flex;flex-direction:column;gap:8px;max-width:420px;pointer-events:none;", + }); + document.body.appendChild(host); + return host; +} + +function showToast(text, { kind = "info", ttlMs = 4200 } = {}) { + const host = _ensureToastHost(); + const bg = kind === "error" ? "rgba(160,40,40,0.96)" : "rgba(30,30,30,0.92)"; + const node = el("div", { + class: "chat-toast", + text: String(text || ""), + style: + `pointer-events:auto;padding:10px 12px;border-radius:10px;color:#fff;box-shadow:0 8px 24px rgba(0,0,0,.35);` + + `background:${bg};font-size:12.5px;line-height:1.35;white-space:pre-wrap;`, + }); + host.appendChild(node); + const kill = () => { + try { + node.style.opacity = "0"; + node.style.transform = "translateY(6px)"; + } catch (_) {} + setTimeout(() => { + try { + node.remove(); + } catch (_) {} + }, 220); + }; + setTimeout(kill, Math.max(1200, Math.min(Number(ttlMs || 4200), 20000))); + return node; +} + +async function openWikiPreviewModal({ sessionId, path }) { + const sid = String(sessionId || ""); + const p = String(path || "").replace(/\\/g, "/").replace(/^\//, ""); + if (!sid || !p) return; + const backdrop = el("div", { + class: "chat-confirm-backdrop", + style: "z-index:9998;", + }); + const card = el("div", { + class: "chat-confirm-card", + style: "max-width:980px;width:92vw;max-height:78vh;overflow:auto;", + }); + const title = el("div", { class: "card__title", text: t("chat.wikiPreviewTitle", { path: p }) }); + const closeBtn = el("button", { type: "button", class: "btn", text: t("chat.wikiPreviewClose") }); + const head = el("div", { class: "row", style: "gap:8px;justify-content:space-between;align-items:center;" }, [ + title, + closeBtn, + ]); + const body = el("div", { class: "chat-msg__md", html: `
${escapeHtml(t("chat.loading"))}
` }); + const close = () => { + try { + backdrop.remove(); + } catch (_) {} + }; + closeBtn.addEventListener("click", close); + backdrop.addEventListener("click", (ev) => { + if (ev.target === backdrop) close(); + }); + card.appendChild(head); + card.appendChild(body); + backdrop.appendChild(card); + document.body.appendChild(backdrop); + try { + const resp = await apiGet( + `/admin/api/chat/sessions/${encodeURIComponent(sid)}/wiki-file?path=${encodeURIComponent(p)}&max_chars=120000`, + ); + body.innerHTML = renderMarkdownHtml(String((resp && resp.content) || "")); + } catch (e) { + body.innerHTML = `
${escapeHtml(`${t("chat.error")}: ${String(e)}`)}
`; + } +} + +function reasonLabel(reason) { + const r = String(reason || "").trim(); + if (!r) return "-"; + const key = `chat.reason.${r}`; + const localized = t(key); + return localized === key ? r : localized; +} + +async function fetchDynamicExpertStats() { + try { + const r = await apiGet("/admin/api/chat/admin/dynamic-expert-stats?limit=200"); + if (!r || !r.ok) return null; + return { + dynamic: parseInt(String(r.dynamic_used_count || "0"), 10) || 0, + fallback: parseInt(String(r.fallback_generalist_count || "0"), 10) || 0, + rate: Number(r.dynamic_used_rate || 0), + reasons: r.dispatch_reasons && typeof r.dispatch_reasons === "object" ? r.dispatch_reasons : {}, + reasonLabels: + r.dispatch_reason_labels && typeof r.dispatch_reason_labels === "object" ? r.dispatch_reason_labels : {}, + }; + } catch (_) { + return null; + } +} + +async function getDispatchReasonLabelsConfig() { + try { + const r = await apiGet("/admin/api/chat/settings/dispatch-reason-labels"); + if (!r || !r.ok) return { overrides: {}, effective: {} }; + return { + overrides: r.overrides && typeof r.overrides === "object" ? r.overrides : {}, + effective: r.effective && typeof r.effective === "object" ? r.effective : {}, + }; + } catch (_) { + return { overrides: {}, effective: {} }; + } +} + +async function setDispatchReasonLabelsConfig(overridesOrNull) { + return await apiPost("/admin/api/chat/settings/dispatch-reason-labels", { + overrides: overridesOrNull, + }); +} + +async function openDispatchLabelsEditor(statusEl) { + const cfg = await getDispatchReasonLabelsConfig(); + const initText = Object.keys(cfg.overrides || {}).length + ? JSON.stringify(cfg.overrides, null, 2) + : ""; + const backdrop = el("div", { + style: + "position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px;", + }); + const card = el("div", { + class: "card", + style: "width:min(860px,96vw);max-height:86vh;overflow:auto;padding:12px;", + }); + const title = el("div", { class: "card__title", text: t("chat.dispatchLabelsTitle") }); + const tip = el("div", { class: "muted", text: t("chat.dispatchLabelsPrompt") }); + const err = el("div", { class: "muted", style: "color:#dc2626;" }); + const ta = el("textarea", { + class: "input", + style: "width:100%;min-height:320px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;", + }); + ta.value = initText; + const previewTitle = el("div", { class: "muted", text: t("chat.dispatchLabelsEffectivePreview") }); + const diffOnlyWrap = el("label", { class: "muted", style: "display:flex;gap:6px;align-items:center;cursor:pointer;" }); + const diffOnlyCb = el("input", { type: "checkbox" }); + diffOnlyWrap.appendChild(diffOnlyCb); + diffOnlyWrap.appendChild(el("span", { text: t("chat.dispatchLabelsDiffOnly") })); + const preview = el("textarea", { + class: "input", + style: + "width:100%;min-height:220px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:#f8fafc;", + readonly: "readonly", + }); + const btnSave = el("button", { type: "button", class: "btn btn--primary", text: t("chat.dispatchLabelsSave") }); + const btnClear = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsClear") }); + const btnDefaults = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsRestoreDefaults") }); + const btnExport = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsExport") }); + const btnImport = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsImport") }); + const btnCancel = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsCancel") }); + const fileInput = el("input", { type: "file", accept: "application/json,.json", style: "display:none" }); + const close = () => backdrop.remove(); + const saveCurrent = async () => { + const raw = String(ta.value || "").trim(); + try { + if (!raw) { + await setDispatchReasonLabelsConfig(null); + } else { + const obj = JSON.parse(raw); + await setDispatchReasonLabelsConfig(obj); + } + if (statusEl) statusEl.textContent = t("chat.dispatchLabelsSaved"); + close(); + } catch (e) { + const msg = String(e || ""); + err.textContent = msg.toLowerCase().includes("json") + ? t("chat.dispatchLabelsInvalidJson") + : `${t("chat.error")}: ${msg}`; + } + }; + const hasUnsavedChanges = () => String(ta.value || "").trim() !== initText.trim(); + const closeWithGuard = () => { + if (hasUnsavedChanges()) { + if (!window.confirm(t("chat.dispatchLabelsUnsavedConfirm"))) return; + } + close(); + }; + const refreshPreview = () => { + const raw = String(ta.value || "").trim(); + if (!raw) { + preview.value = JSON.stringify(cfg.effective || {}, null, 2); + return; + } + try { + const userObj = JSON.parse(raw); + const base = cfg.effective && typeof cfg.effective === "object" ? cfg.effective : {}; + const merged = { ...base }; + const changed = {}; + if (userObj && typeof userObj === "object") { + for (const [k, v] of Object.entries(userObj)) { + if (!v || typeof v !== "object") continue; + const next = { + zh: String((v && v.zh) || (merged[String(k)] && merged[String(k)].zh) || ""), + en: String((v && v.en) || (merged[String(k)] && merged[String(k)].en) || ""), + }; + const prev = merged[String(k)] && typeof merged[String(k)] === "object" ? merged[String(k)] : {}; + merged[String(k)] = next; + if (String(next.zh || "") !== String(prev.zh || "") || String(next.en || "") !== String(prev.en || "")) { + changed[String(k)] = next; + } + } + } + preview.value = JSON.stringify(diffOnlyCb.checked ? changed : merged, null, 2); + } catch (_) { + preview.value = t("chat.dispatchLabelsInvalidJson"); + } + }; + ta.addEventListener("input", refreshPreview); + diffOnlyCb.addEventListener("change", refreshPreview); + refreshPreview(); + + btnCancel.addEventListener("click", closeWithGuard); + btnExport.addEventListener("click", () => { + const raw = String(preview.value || "").trim(); + const content = raw || "{}"; + const blob = new Blob([content], { type: "application/json;charset=utf-8" }); + const a = document.createElement("a"); + const now = new Date(); + const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`; + a.href = URL.createObjectURL(blob); + a.download = `dispatch-reason-labels-effective-${ts}.json`; + a.click(); + URL.revokeObjectURL(a.href); + }); + btnImport.addEventListener("click", () => fileInput.click()); + btnDefaults.addEventListener("click", () => { + ta.value = JSON.stringify(cfg.effective || {}, null, 2); + err.textContent = ""; + refreshPreview(); + }); + fileInput.addEventListener("change", async () => { + const f = fileInput.files && fileInput.files[0]; + fileInput.value = ""; + if (!f) return; + try { + const txt = await f.text(); + const obj = JSON.parse(String(txt || "")); + ta.value = JSON.stringify(obj, null, 2); + err.textContent = ""; + refreshPreview(); + } catch (_) { + err.textContent = t("chat.dispatchLabelsInvalidJson"); + } + }); + btnClear.addEventListener("click", async () => { + try { + await setDispatchReasonLabelsConfig(null); + if (statusEl) statusEl.textContent = t("chat.dispatchLabelsSaved"); + close(); + } catch (e) { + err.textContent = `${t("chat.error")}: ${String(e)}`; + } + }); + btnSave.addEventListener("click", saveCurrent); + ta.addEventListener("keydown", (ev) => { + const isSave = (ev.ctrlKey || ev.metaKey) && String(ev.key || "").toLowerCase() === "s"; + if (!isSave) return; + ev.preventDefault(); + saveCurrent(); + }); + + card.appendChild(title); + card.appendChild(tip); + card.appendChild(el("div", { style: "height:8px;" })); + card.appendChild(ta); + card.appendChild(el("div", { style: "height:8px;" })); + card.appendChild(previewTitle); + card.appendChild(diffOnlyWrap); + card.appendChild(preview); + card.appendChild(el("div", { style: "height:8px;" })); + card.appendChild(err); + card.appendChild( + el("div", { class: "row", style: "gap:8px;justify-content:flex-end;margin-top:8px;" }, [ + btnDefaults, + btnImport, + btnExport, + btnClear, + btnCancel, + btnSave, + ]), + ); + backdrop.appendChild(card); + backdrop.appendChild(fileInput); + backdrop.addEventListener("click", (ev) => { + if (ev.target === backdrop) closeWithGuard(); + }); + document.body.appendChild(backdrop); +} + +function applyI18nStatic() { + document.documentElement.lang = currentLang === "zh" ? "zh-CN" : "en"; + document.querySelectorAll("[data-i18n]").forEach((node) => { + const key = node.getAttribute("data-i18n"); + node.textContent = t(key); + }); +} + +function getSessionIdFromUrl() { + return String(new URLSearchParams(location.search).get("session_id") || "").trim(); +} + +function forceReloginRequested() { + return String(new URLSearchParams(location.search).get("force_relogin") || "").trim() === "1"; +} + +function clearAuthAndReloginFlagFromUrl() { + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_SESSION_KEY); + authSession = null; + const u = new URL(window.location.href); + u.searchParams.delete("force_relogin"); + history.replaceState(null, "", `${u.pathname}${u.search}${u.hash}`); +} + +function replaceSessionUrl(sessionId) { + const path = "/chat"; + const q = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : ""; + history.replaceState(null, "", path + q); +} + +async function apiGet(path) { + const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; + const headers = { accept: "application/json" }; + if (token) headers.authorization = `Bearer ${token}`; + const res = await fetch(path, { headers }); + if (res.status === 401) { + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_SESSION_KEY); + authSession = null; + setTimeout(() => boot().catch(() => {}), 0); + return await new Promise(() => {}); + } + if (!res.ok) throw new Error(`GET ${path} ${res.status}`); + return await res.json(); +} + +async function apiPost(path, body) { + const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; + const headers = { "content-type": "application/json", accept: "application/json" }; + if (token) headers.authorization = `Bearer ${token}`; + const res = await fetch(path, { + method: "POST", + headers, + body: JSON.stringify(body ?? {}), + }); + if (res.status === 401) { + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_SESSION_KEY); + authSession = null; + setTimeout(() => boot().catch(() => {}), 0); + return await new Promise(() => {}); + } + if (!res.ok) throw new Error(`POST ${path} ${res.status}`); + return await res.json(); +} + +async function apiPatch(path, body) { + const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; + const headers = { "content-type": "application/json", accept: "application/json" }; + if (token) headers.authorization = `Bearer ${token}`; + const res = await fetch(path, { + method: "PATCH", + headers, + body: JSON.stringify(body ?? {}), + }); + if (res.status === 401) { + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_SESSION_KEY); + authSession = null; + setTimeout(() => boot().catch(() => {}), 0); + return await new Promise(() => {}); + } + if (!res.ok) throw new Error(`PATCH ${path} ${res.status}`); + return await res.json(); +} + +async function apiDelete(path) { + const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; + const headers = { accept: "application/json" }; + if (token) headers.authorization = `Bearer ${token}`; + const res = await fetch(path, { method: "DELETE", headers }); + if (res.status === 401) { + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_SESSION_KEY); + authSession = null; + setTimeout(() => boot().catch(() => {}), 0); + return await new Promise(() => {}); + } + if (!res.ok) throw new Error(`DELETE ${path} ${res.status}`); + return await res.json(); +} + +function el(tag, attrs = {}, children = []) { + const e = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") e.className = v; + else if (k === "text") e.textContent = v; + else if (k === "html") e.innerHTML = v; + else if (k.startsWith("on") && typeof v === "function") e.addEventListener(k.slice(2), v); + else e.setAttribute(k, v); + } + for (const c of children) e.appendChild(c); + return e; +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +let _chatLightboxKeyHandler = null; +let _chatLightboxPrevOverflow = ""; + +function closeChatImageLightbox() { + if (_chatLightboxKeyHandler) { + document.removeEventListener("keydown", _chatLightboxKeyHandler); + _chatLightboxKeyHandler = null; + } + document.querySelector(".chat-img-lightbox")?.remove(); + document.body.style.overflow = _chatLightboxPrevOverflow || ""; +} + +function openChatImageLightbox(src, alt) { + if (!src || !String(src).trim()) return; + closeChatImageLightbox(); + _chatLightboxPrevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + _chatLightboxKeyHandler = (ev) => { + if (ev.key === "Escape") closeChatImageLightbox(); + }; + document.addEventListener("keydown", _chatLightboxKeyHandler); + + const backdrop = el("div", { + class: "chat-img-lightbox", + role: "dialog", + "aria-modal": "true", + "aria-label": t("chat.imageViewerHint"), + }); + const inner = el("div", { class: "chat-img-lightbox__inner" }); + const big = el("img", { + class: "chat-img-lightbox__img", + src: String(src), + alt: alt || "", + decoding: "async", + }); + const closeBtn = el("button", { + type: "button", + class: "chat-img-lightbox__close", + text: "×", + "aria-label": t("chat.imageViewerClose"), + onclick: (e) => { + e.stopPropagation(); + closeChatImageLightbox(); + }, + }); + inner.appendChild(closeBtn); + inner.appendChild(big); + backdrop.appendChild(inner); + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) closeChatImageLightbox(); + }); + document.body.appendChild(backdrop); +} + +function bindChatImageViewer(messagesEl) { + messagesEl.addEventListener("click", (ev) => { + const img = ev.target && ev.target.closest && ev.target.closest("img"); + if (!img || !messagesEl.contains(img)) return; + const src = img.currentSrc || img.getAttribute("src") || ""; + if (!src.trim()) return; + ev.preventDefault(); + ev.stopPropagation(); + openChatImageLightbox(src, img.getAttribute("alt") || ""); + }); +} + +/** Keep markdown output safe but allow images (USE_PROFILES html strips img in many DOMPurify builds). */ +function renderMarkdownHtml(src) { + const raw = String(src ?? ""); + if (typeof marked !== "undefined" && typeof DOMPurify !== "undefined") { + const html = + typeof marked.parse === "function" + ? marked.parse(raw, { breaks: true, mangle: false, headerIds: false }) + : marked(raw, { breaks: true }); + const opts = { + ADD_TAGS: ["img", "picture", "source"], + ADD_ATTR: ["src", "alt", "title", "loading", "class", "width", "height", "decoding", "referrerpolicy", "sizes", "srcset"], + }; + try { + return DOMPurify.sanitize(html, opts); + } catch (_) { + return DOMPurify.sanitize(html); + } + } + return `
${escapeHtml(raw).replace(/\n/g, "
")}
`; +} + +const RE_REDACTED_THINKING = new RegExp("\\s*([\\s\\S]*?)\\s*", "i"); +const RE_THINK_TAG = new RegExp("\\s*([\\s\\S]*?)\\s*", "i"); + +function _findEarliestReasoningBlock(remaining) { + let best = null; + for (const re of [RE_REDACTED_THINKING, RE_THINK_TAG]) { + const m = re.exec(remaining); + re.lastIndex = 0; + if (!m) continue; + const start = m.index; + if (!best || start < best.start) { + best = { start, end: start + m[0].length, inner: (m[1] || "").trim() }; + } + } + return best; +} + +function parseReasoningSegments(raw) { + const parts = []; + let remaining = String(raw ?? ""); + while (remaining.length) { + const hit = _findEarliestReasoningBlock(remaining); + if (!hit) { + parts.push({ type: "text", text: remaining }); + break; + } + if (hit.start > 0) parts.push({ type: "text", text: remaining.slice(0, hit.start) }); + parts.push({ type: "reasoning", text: hit.inner }); + remaining = remaining.slice(hit.end); + } + return parts.length ? parts : [{ type: "text", text: "" }]; +} + +// OpenClaw-style: strip // blocks from visible text, +// while keeping code fences intact. This avoids "reasoning leaking" into正文 and +// handles truncated streams (unfinished tags) safely. +const _OC_QUICK_TAG_RE = /<\s*\/?\s*(?:(?:antml:)?(?:think(?:ing)?|thought)|antthinking|final)\b/i; +const _OC_FINAL_TAG_RE = /<\s*\/?\s*final\b[^<>]*>/gi; +const _OC_THINKING_TAG_RE = /<\s*(\/?)\s*(?:(?:antml:)?(?:think(?:ing)?|thought)|antthinking)\b[^<>]*>/gi; + +function _findCodeFenceRegions(text) { + const regions = []; + const re = /```/g; + let start = null; + for (;;) { + const m = re.exec(text); + if (!m) break; + if (start == null) start = m.index; + else { + regions.push([start, m.index + 3]); + start = null; + } + } + return regions; +} + +function _isInsideRegion(idx, regions) { + for (const [a, b] of regions) { + if (idx >= a && idx < b) return true; + } + return false; +} + +function stripReasoningTagsFromText(text, { mode = "strict" } = {}) { + const raw = String(text || ""); + if (!raw || !_OC_QUICK_TAG_RE.test(raw)) return raw; + _OC_QUICK_TAG_RE.lastIndex = 0; + + // Remove tags (but keep content). + let cleaned = raw; + if (_OC_FINAL_TAG_RE.test(cleaned)) { + _OC_FINAL_TAG_RE.lastIndex = 0; + const fences = _findCodeFenceRegions(cleaned); + const matches = []; + for (const m of cleaned.matchAll(_OC_FINAL_TAG_RE)) { + const idx = m.index ?? 0; + matches.push({ idx, len: m[0].length, inCode: _isInsideRegion(idx, fences) }); + } + for (let i = matches.length - 1; i >= 0; i -= 1) { + const mm = matches[i]; + if (mm.inCode) continue; + cleaned = cleaned.slice(0, mm.idx) + cleaned.slice(mm.idx + mm.len); + } + } else { + _OC_FINAL_TAG_RE.lastIndex = 0; + } + + const fences = _findCodeFenceRegions(cleaned); + _OC_THINKING_TAG_RE.lastIndex = 0; + let out = ""; + let last = 0; + let inThinking = false; + for (const m of cleaned.matchAll(_OC_THINKING_TAG_RE)) { + const idx = m.index ?? 0; + const isClose = m[1] === "/"; + if (_isInsideRegion(idx, fences)) continue; + if (!inThinking) { + out += cleaned.slice(last, idx); + if (!isClose) inThinking = true; + } else if (isClose) { + inThinking = false; + } + last = idx + m[0].length; + } + if (!inThinking || mode === "preserve") out += cleaned.slice(last); + return out; +} + +function extractThinkingFromText(text) { + const raw = String(text || ""); + if (!raw) return ""; + const blocks = []; + for (const re of [RE_REDACTED_THINKING, RE_THINK_TAG]) { + const rr = new RegExp(re.source, "gi"); + for (const m of raw.matchAll(rr)) { + const inner = String(m[1] || "").trim(); + if (inner) blocks.push(inner); + } + } + return blocks.join("\n"); +} + +function extractWsAssistantText(message) { + if (!message || typeof message !== "object") return ""; + const m = message; + if (typeof m.content === "string") return decodeEscapedNewlines(String(m.content || "")); + if (typeof m.text === "string") return decodeEscapedNewlines(String(m.text || "")); + const c = Array.isArray(m.content) ? m.content : []; + const parts = c + .map((x) => { + if (!x || typeof x !== "object") return ""; + if (String(x.type || "") === "text") return decodeEscapedNewlines(String(x.text || "")); + return ""; + }) + .filter(Boolean); + return parts.join("\n"); +} + +function decodeEscapedNewlines(text) { + return String(text || "").replace(/\\n/g, "\n").replace(/\\N/g, "\n"); +} + +function normalizeStreamText(raw) { + return String(raw || "") + .replace(/^\s*\n+/, "") + .replace(/\n{3,}/g, "\n\n"); +} + +// Streaming surface should be smooth and avoid newline explosions. +function normalizeStreamDisplayText(raw) { + return String(raw || "") + .replace(/\r/g, "") + .replace(/^\s*\n+/, "") + .replace(/\n+/g, " ") + .replace(/[ \t]{2,}/g, " ") + .trimStart(); +} + +// Streaming-only: user prefers a more "natural" flow without many newlines. +function normalizeStreamBodyForUi(raw) { + return String(raw || "") + .replace(/^\s*\n+/, "") + .replace(/\r/g, "") + // Collapse pathological newline runs but keep paragraph breaks. + // 3+ newlines => 2 newlines; trim leading newlines. + .replace(/\n{3,}/g, "\n\n"); +} + +// OpenClaw-style streaming stitcher: reduce boundary artifacts without destroying layout. +function createStreamStitcher() { + let prevTail = ""; + return { + push(delta) { + let s = String(delta || ""); + if (!s) return ""; + s = s.replace(/\r/g, ""); + // If previous ended with newline and delta starts with newline(s), drop the leading ones. + if (prevTail.endsWith("\n") && s.startsWith("\n")) { + s = s.replace(/^\n+/, "\n"); + } + // If previous ended with a space and delta starts with space/newline, trim the head. + if (/[ \t]$/.test(prevTail) && /^[ \t\n]/.test(s)) { + s = s.replace(/^[ \t\n]+/, " "); + } + // Avoid 3+ newlines caused by chunk boundaries. + s = s.replace(/\n{3,}/g, "\n\n"); + prevTail = (prevTail + s).slice(-12); + return s; + }, + }; +} + +function formatToolPanelText(name, payload) { + const n = String(name || "").trim() || "tool"; + const p = payload && typeof payload === "object" ? payload : {}; + const r = p.result && typeof p.result === "object" ? p.result : p; + // SQL audit payload: keep line breaks and key fields visible. + if (r && typeof r === "object" && (r.input_sql || r.executed_sql || r.sql_guard)) { + const guard = r.sql_guard && typeof r.sql_guard === "object" ? r.sql_guard : {}; + const lines = [ + `${n}`, + `[SQL] input`, + String(r.input_sql || ""), + "", + `[SQL] executed`, + String(r.executed_sql || ""), + "", + `[Guard]`, + `readonly_enforced=${String(!!guard.readonly_enforced)}`, + `multi_statement_forbidden=${String(!!guard.multi_statement_forbidden)}`, + `auto_limit_applied=${String(!!guard.auto_limit_applied)}`, + `result_row_cap=${String(guard.result_row_cap != null ? guard.result_row_cap : "")}`, + `engine=${String(r.engine || "")}`, + `rows_returned=${String(r.rows_returned != null ? r.rows_returned : "")}`, + ]; + return lines.join("\n").trim(); + } + let body = ""; + // Prefer human-readable text from tool content blocks when available. + try { + const content = Array.isArray(r.content) ? r.content : []; + const textBlock = content.find((x) => x && typeof x === "object" && String(x.type || "") === "text"); + const text = textBlock ? String(textBlock.text || "") : ""; + if (text.trim()) body = text; + } catch (_) {} + if (!body) { + try { + body = JSON.stringify(r, null, 2); + } catch (_) { + body = String(r || ""); + } + } + body = normalizeStreamText(body).replace(/\\n/g, "").replace(/\n/g, ""); + return `${n}\n${body}`.trim(); +} + +function extractSqlAuditPayload(payload) { + const p = payload && typeof payload === "object" ? payload : {}; + const r = p.result && typeof p.result === "object" ? p.result : p; + if (!r || typeof r !== "object") return null; + const inputSql = String(r.input_sql || ""); + const executedSql = String(r.executed_sql || ""); + if (!inputSql && !executedSql && !(r.sql_guard && typeof r.sql_guard === "object")) return null; + const guard = r.sql_guard && typeof r.sql_guard === "object" ? r.sql_guard : {}; + return { + inputSql, + executedSql, + guard, + engine: String(r.engine || ""), + rowsReturned: r.rows_returned != null ? Number(r.rows_returned) : null, + }; +} + +function _sqlLimitSuffix(inputSql, executedSql) { + const a = String(inputSql || "").trim(); + const b = String(executedSql || "").trim(); + if (!a || !b || b.length <= a.length) return ""; + const lowerA = a.toLowerCase(); + const lowerB = b.toLowerCase(); + if (lowerB.startsWith(lowerA)) { + const suffix = b.slice(a.length); + if (/^\s*limit\s+\d+\s*$/i.test(suffix)) return suffix.trim(); + } + return ""; +} + +function _tokenizeSqlForDiff(sqlText) { + const s = String(sqlText || ""); + return s.match(/\s+|[^\s]+/g) || []; +} + +function _renderExecutedSqlWithAddedHighlight(inputSql, executedSql) { + const a = _tokenizeSqlForDiff(inputSql); + const b = _tokenizeSqlForDiff(executedSql); + if (!a.length) return escapeHtml(String(executedSql || "")); + // LCS-based diff: mark tokens present only in executed SQL. + const m = a.length; + const n = b.length; + const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + for (let i = m - 1; i >= 0; i -= 1) { + for (let j = n - 1; j >= 0; j -= 1) { + if (a[i] === b[j]) dp[i][j] = dp[i + 1][j + 1] + 1; + else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + let i = 0; + let j = 0; + const out = []; + while (i < m && j < n) { + if (a[i] === b[j]) { + out.push(escapeHtml(b[j])); + i += 1; + j += 1; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + i += 1; + } else { + out.push( + `${escapeHtml(b[j])}`, + ); + j += 1; + } + } + while (j < n) { + out.push( + `${escapeHtml(b[j])}`, + ); + j += 1; + } + return out.join(""); +} + +function formatChatTimestamp(iso) { + const s = String(iso || "").trim(); + if (!s) return ""; + const d = new Date(s); + if (Number.isNaN(d.getTime())) return s; + const loc = currentLang === "zh" ? "zh-CN" : "en-US"; + try { + return d.toLocaleString(loc, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + } catch (_) { + return s; + } +} + +function prependMessageTime(node, tsIso) { + const txt = formatChatTimestamp(tsIso); + if (!txt || !node) return; + node.insertBefore(el("div", { class: "chat-msg__time", text: txt }), node.firstChild); +} + +const CHAT_BOT_LOGO_SRC = "/admin/assets/oliver.svg"; +/** 内置默认用户头像(与助手头像同尺寸与底栏样式,SVG) */ +const DEFAULT_USER_AVATAR_SRC = "/admin/assets/default-user-avatar.svg"; + +let meProfile = null; + +async function loadMeProfile() { + try { + const r = await apiGet("/admin/api/chat/profile"); + meProfile = r && r.ok && r.profile ? r.profile : null; + } catch (_) { + meProfile = null; + } +} + +function buildBotAvatarImg() { + return el("img", { + class: "chat-avatar chat-avatar--bot", + src: CHAT_BOT_LOGO_SRC, + alt: "", + loading: "lazy", + }); +} + +function buildUserAvatarSlot() { + const wrap = el("div", { class: "chat-avatar-slot" }); + const cur = el("img", { + class: "chat-avatar chat-avatar--img chat-avatar--userBuiltin", + src: DEFAULT_USER_AVATAR_SRC, + alt: "", + loading: "lazy", + }); + wrap.appendChild(cur); + const aid = meProfile && String(meProfile.avatar_attachment_id || "").trim(); + if (aid) { + fetchAttachmentBlobUrl(aid).then((url) => { + if (!url || !cur.parentNode) return; + cur.classList.remove("chat-avatar--userBuiltin"); + cur.classList.add("chat-avatar--userPhoto"); + cur.src = url; + }); + } + return wrap; +} + +function wrapAssistantMessage(innerRoot, tsIso) { + const col = el("div", { class: "chat-msg-col chat-msg-col--assistant" }); + prependMessageTime(col, tsIso); + col.appendChild(innerRoot); + const row = el("div", { class: "chat-row chat-row--assistant" }); + row.appendChild(buildBotAvatarImg()); + row.appendChild(col); + return row; +} + +function wrapUserMessage(innerRoot, tsIso) { + const col = el("div", { class: "chat-msg-col chat-msg-col--user" }); + prependMessageTime(col, tsIso); + col.appendChild(innerRoot); + const row = el("div", { class: "chat-row chat-row--user" }); + row.appendChild(col); + row.appendChild(buildUserAvatarSlot()); + return row; +} + +async function buildMessageBubble(role, content, tsIso) { + const r = String(role || "").toLowerCase(); + const rawText = decodeEscapedNewlines(String(content ?? "")); + const text = + r === "assistant" && !adminChatShowToolOutput ? stripReasoningTagsFromText(rawText, { mode: "strict" }) : rawText; + let inner; + if (r === "user") { + inner = el("div", { class: "chat-msg chat-msg--user" }); + inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(text) })); + return wrapUserMessage(inner, tsIso); + } + if (r === "tool" || r === "function") { + inner = el("div", { class: "chat-msg chat-msg--tool" }); + inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(text) })); + return wrapAssistantMessage(inner, tsIso); + } + if (r !== "assistant") { + inner = el("div", { class: "chat-msg chat-msg--assistant" }); + inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(text) })); + return wrapAssistantMessage(inner, tsIso); + } + const segs = parseReasoningSegments(text); + const onlyText = segs.length === 1 && segs[0].type === "text"; + if (onlyText) { + inner = el("div", { class: "chat-msg chat-msg--assistant" }); + inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(segs[0].text) })); + } else { + inner = el("div", { class: "chat-msg chat-msg--assistant chat-msg--rich" }); + const collapsedItems = []; + for (const seg of segs) { + if (seg.type === "text") { + let body = String(seg.text || ""); + const prev = inner.lastElementChild; + if (prev && prev.classList && prev.classList.contains("chat-msg__reasoning")) { + body = body.replace(/^\s+/, ""); + } + if (!body.trim()) continue; + inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(body) })); + } else { + if (adminChatShowToolOutput) collapsedItems.push({ title: t("reasoning.summary"), text: seg.text || "—" }); + } + } + if (collapsedItems.length) { + const det = document.createElement("details"); + det.className = "chat-msg__reasoning"; + const sum = document.createElement("summary"); + sum.textContent = t("reasoning.summary"); + det.appendChild(sum); + for (const it of collapsedItems) { + const title = String((it && it.title) || ""); + const text = String((it && it.text) || ""); + if (!text.trim()) continue; + det.appendChild(_collapsedBlockNode(title || t("reasoning.summary"), text)); + } + inner.insertBefore(det, inner.firstChild); + } + } + return wrapAssistantMessage(inner, tsIso); +} + +const _blobUrlCache = new Map(); + +async function fetchAttachmentBlobUrl(attachmentId) { + const aid = String(attachmentId || "").trim(); + if (!aid) return null; + if (_blobUrlCache.has(aid)) return _blobUrlCache.get(aid); + const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; + const res = await fetch(`/admin/api/chat/attachments/${encodeURIComponent(aid)}`, { + headers: token ? { authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) return null; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + _blobUrlCache.set(aid, url); + return url; +} + +function parseAttachments(raw) { + if (raw == null || raw === "") return []; + if (Array.isArray(raw)) return raw.filter((x) => x && typeof x === "object"); + if (typeof raw === "object" && !Array.isArray(raw)) { + return [raw]; + } + if (typeof raw === "string") { + const s = raw.trim(); + if (!s || s === "null") return []; + try { + const j = JSON.parse(s); + if (Array.isArray(j)) return j.filter((x) => x && typeof x === "object"); + if (j && typeof j === "object") return [j]; + } catch (_) { + return []; + } + } + return []; +} + +async function renderAttachmentsEl(raw) { + const list = parseAttachments(raw); + if (!list.length) return null; + const wrap = el("div", { class: "chat-att-wrap" }); + for (const att of list) { + if (!att || typeof att !== "object") continue; + const typ = String(att.type || ""); + if (typ === "image_ref") { + const aid = String(att.attachment_id || att.attachmentId || "").trim(); + const url = aid ? await fetchAttachmentBlobUrl(aid) : null; + if (url) { + const img = el("img", { class: "chat-att-img", src: url, alt: String(att.name || "") }); + wrap.appendChild(img); + } else { + wrap.appendChild(el("span", { class: "chat-att-chip", text: String(att.name || "image") })); + } + } else if (typ === "relay_pointer") { + let aid = String(att.attachment_id || att.attachmentId || "").trim(); + if (!aid) { + const p = String(att.pointer_uri || "").trim(); + const m = p.match(/^relay:\/\/attachments\/[^/]+\/([a-f0-9]{8,64})$/i); + if (m && m[1]) aid = String(m[1]).toLowerCase(); + } + const mime = String(att.mime || att.mime_type || "").toLowerCase(); + const url = aid ? await fetchAttachmentBlobUrl(aid) : null; + if (url && (!mime || mime.startsWith("image/"))) { + const img = el("img", { class: "chat-att-img", src: url, alt: String(att.name || att.rel_path || "relay image") }); + wrap.appendChild(img); + } else { + const title = String(att.name || att.rel_path || att.pointer_uri || "relay pointer"); + wrap.appendChild(el("span", { class: "chat-att-chip", text: `📎 ${title}` })); + } + } else if (typ === "image_url") { + const src = String(att.url || att.image_url || "").trim(); + const img = el("img", { class: "chat-att-img", src, alt: "" }); + wrap.appendChild(img); + } else if (typ === "image" || typ === "input_image") { + const b64 = att.image_base64 || att.data; + const mime = String(att.mime || "image/jpeg"); + if (b64) { + const src = `data:${mime};base64,${String(b64).replace(/\s/g, "")}`; + wrap.appendChild(el("img", { class: "chat-att-img", src, alt: String(att.name || "") })); + } else { + wrap.appendChild(el("span", { class: "chat-att-chip", text: String(att.name || "image") })); + } + } else { + wrap.appendChild(el("span", { class: "chat-att-chip", text: `📄 ${String(att.name || "file")}` })); + } + } + return wrap.children.length ? wrap : null; +} + +async function appendMessageRow(messagesEl, m) { + const role = String(m.role || ""); + const content = String(m.content || ""); + const ts = m.timestamp != null ? m.timestamp : ""; + const bubble = Array.isArray(m._items) + ? _buildAggregatedAssistantBubble(ts, m._items) + : await buildMessageBubble(role, content, ts); + const att = await renderAttachmentsEl(m.attachments); + if (att) { + const innerBubble = bubble.querySelector(".chat-msg-col .chat-msg"); + if (innerBubble) innerBubble.appendChild(att); + else bubble.appendChild(att); + } + messagesEl.appendChild(bubble); +} + +function mount(node) { + const c = document.getElementById("app"); + c.innerHTML = ""; + c.appendChild(node); +} + +function buildChatBrandLogoNode() { + return el("div", { class: "chat-nav__brandWrap" }, [ + el("img", { + class: "chat-nav__brandLogo", + src: "/admin/assets/oliver.svg", + alt: "oliver logo", + }), + ]); +} + +function resolveAdminHashUrl(hashPath, sessionId) { + const path = (location.pathname || "").replace(/\/+$/, "") || "/"; + const cleanHash = String(hashPath || "").replace(/^#?\/?/, ""); + const q = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : ""; + if (path.endsWith("/chat")) { + const base = path.slice(0, -5); + return base.endsWith("/admin") ? `${base}#/${cleanHash}${q}` : `${base}/admin#/${cleanHash}${q}`; + } + return `/admin#/${cleanHash}${q}`; +} + +function openAdminFromChat(hashPath, sessionId) { + const target = resolveAdminHashUrl(hashPath, sessionId); + // Keep navigation in the same window so desktop shell feels native. + window.location.assign(target); +} + +function syncAuthUserLabel() { + const user = document.getElementById("authUser"); + if (!user) return; + user.innerHTML = ""; + const name = String((authSession && (authSession.display_name || authSession.username || authSession.user_id)) || ""); + if (!name) return; + const nameBtn = el("button", { + type: "button", + class: "chat-sess-btn", + text: name, + title: name, + }); + const moreBtn = el("button", { + type: "button", + class: "chat-sess-more", + text: "⋯", + title: t("chat.sessionMenu"), + onclick: (ev) => { + ev.stopPropagation(); + document.querySelectorAll(".chat-sess-menu-pop").forEach((n) => n.remove()); + const menu = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;" }, [ + el("button", { + type: "button", + class: "chat-sess-menu-item", + "data-menu-action": "profile", + text: t("chat.myProfile"), + }), + el("button", { + type: "button", + class: "chat-sess-menu-item", + "data-menu-action": "lang", + text: t("lang.switch"), + }), + el("button", { + type: "button", + class: "chat-sess-menu-item", + "data-menu-action": "dispatchLabels", + text: t("chat.dispatchLabelsEdit"), + }), + el("button", { + type: "button", + class: "chat-sess-menu-item", + "data-menu-action": "logout", + text: t("auth.logout"), + }), + ]); + const rect = moreBtn.getBoundingClientRect(); + menu.style.left = `${Math.min(rect.left, window.innerWidth - 220)}px`; + menu.style.top = `${Math.max(8, rect.top - 92)}px`; + document.body.appendChild(menu); + const close = (e) => { + if (!menu.contains(e.target)) { + menu.remove(); + document.removeEventListener("click", close); + } + }; + setTimeout(() => document.addEventListener("click", close), 0); + }, + }); + const row = el("div", { class: "chat-user-row" }, [nameBtn, moreBtn]); + user.appendChild(row); +} + +async function fileToPayloadEntry(file) { + const buf = await file.arrayBuffer(); + const bytes = new Uint8Array(buf); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); + const data_base64 = btoa(binary); + return { name: file.name || "file", data_base64 }; +} + +async function renderLogin() { + applyI18nStatic(); + const username = el("input", { class: "input", placeholder: t("auth.username") }); + const password = el("input", { class: "input", type: "password", placeholder: t("auth.password") }); + const status = el("div", { class: "muted", text: "" }); + const doLogin = async () => { + const resp = await apiPost("/admin/api/auth/login", { + tenant_id: "", + username: username.value.trim().toLowerCase(), + password: password.value.trim(), + purpose: "chat", + }); + if (!resp.ok || !resp.token) { + const err = String(resp.error || ""); + status.textContent = + err === "user_disabled" + ? t("auth.disabled") + : err === "admin_role_required" + ? t("auth.chatLoginDenied") + : t("auth.invalid"); + return; + } + localStorage.setItem(AUTH_TOKEN_KEY, String(resp.token)); + localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(resp.session || {})); + authSession = resp.session || null; + await boot(); + }; + const onEnterLogin = (ev) => { + if (ev.key !== "Enter") return; + ev.preventDefault(); + doLogin(); + }; + username.addEventListener("keydown", onEnterLogin); + password.addEventListener("keydown", onEnterLogin); + const btn = el("button", { + class: "btn btn--primary", + text: t("auth.login"), + onclick: doLogin, + }); + const card = el("div", { class: "card", style: "max-width:440px;width:100%" }, [ + el("div", { class: "card__title", text: t("auth.login") }), + el("div", { class: "chat-login-fields" }, [username, password]), + el("div", { class: "row chat-login-actions" }, [btn]), + status, + ]); + setTimeout(() => { + try { + username.focus(); + } catch (_) {} + }, 0); + return el("div", { class: "chat-app--login" }, [card]); +} + +async function downloadExport(sessionId, format) { + const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; + const q = format === "json" ? "format=json" : "format=md"; + const url = `/admin/api/chat/sessions/${encodeURIComponent(sessionId)}/export?${q}`; + const res = await fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {} }); + if (!res.ok) throw new Error(String(res.status)); + const blob = await res.blob(); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `chat-${sessionId.slice(0, 8)}.${format === "json" ? "json" : "md"}`; + a.click(); + URL.revokeObjectURL(a.href); +} + +const CHAT_ICON_CLIP = + ''; +const CHAT_ICON_SEND = + ''; +const CHAT_ICON_STOP = + ''; + +let _statusReasonPairs = []; + +async function renderChatUi() { + let sessionId = getSessionIdFromUrl(); + try { + const curScope = JSON.stringify({ + t: String((authSession && authSession.tenant_id) || ""), + u: String((authSession && authSession.user_id) || ""), + }); + const prev = localStorage.getItem(CHAT_URL_SCOPE_KEY) || ""; + if (prev && prev !== curScope) { + replaceSessionUrl(""); + sessionId = ""; + } + localStorage.setItem(CHAT_URL_SCOPE_KEY, curScope); + } catch (_) {} + const statusBar = el("div", { class: "chat-status", text: t("chat.loading") }); + const sessionsListEl = el("div", { class: "chat-sessions__list" }); + const loadMoreWrap = el("div", { class: "chat-load-more" }); + const messagesEl = el("div", { class: "chat-messages" }); + bindChatImageViewer(messagesEl); + const textarea = el("textarea", { + class: "chat-composer__field", + rows: "1", + placeholder: t("chat.placeholder"), + }); + const fileInput = el("input", { type: "file", multiple: "multiple", style: "display:none", accept: "*/*" }); + const attachBtn = el("button", { + type: "button", + class: "chat-composer-iconbtn", + html: CHAT_ICON_CLIP, + title: t("chat.attach"), + "aria-label": t("chat.attach"), + }); + const pendingFilesEl = el("div", { class: "chat-pending-files" }); + const btnSend = el("button", { + type: "button", + class: "chat-composer-send", + html: CHAT_ICON_SEND, + title: t("chat.send"), + "aria-label": t("chat.send"), + }); + const btnStop = el("button", { + type: "button", + class: "chat-composer-stop", + html: CHAT_ICON_STOP, + title: t("chat.stop"), + "aria-label": t("chat.stop"), + disabled: "disabled", + }); + const composerShell = el("div", { class: "chat-composer-shell" }); + const toolToggleBtn = el("button", { + type: "button", + class: "btn", + style: "padding:4px 8px;min-height:auto;font-size:12px;", + }); + const specialistSelect = el("select", { + class: "input", + style: "min-width:120px;max-width:160px;padding:6px 8px;", + }); + let specialistFlags = { + generalist: true, + ops: true, + image: true, + memory_curator: true, + }; + const modeSelect = el("select", { + class: "input", + style: "min-width:120px;max-width:160px;padding:6px 8px;", + }); + modeSelect.appendChild(el("option", { value: "comprehensive", text: t("chat.modeComprehensive") })); + modeSelect.appendChild(el("option", { value: "expert", text: t("chat.modeExpert") })); + const _im = String(localStorage.getItem(CHAT_INTERACTION_MODE_KEY) || "comprehensive").toLowerCase(); + modeSelect.value = ["comprehensive", "expert"].includes(_im) ? _im : "comprehensive"; + const _mm = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase(); + localStorage.setItem(CHAT_MEMORY_MODE_KEY, _mm === "store_only" ? "store_only" : "default"); + modeSelect.addEventListener("change", () => { + localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "comprehensive")); + saveSessionModePreference(); + }); + const applySpecialistOptions = () => { + specialistSelect.innerHTML = ""; + const opts = [ + { value: "generalist", text: t("chat.specialistGeneralist"), enabled: true }, + { value: "ops", text: t("chat.specialistOps"), enabled: !!specialistFlags.ops }, + { value: "image", text: t("chat.specialistImage"), enabled: !!specialistFlags.image }, + { value: "memory_curator", text: t("chat.specialistMemoryCurator"), enabled: !!specialistFlags.memory_curator }, + ]; + for (const op of opts) { + if (!op.enabled) continue; + specialistSelect.appendChild(el("option", { value: op.value, text: op.text })); + } + if (!specialistSelect.value) { + specialistSelect.value = "generalist"; + } + if (!Array.from(specialistSelect.options).some((o) => String(o.value || "") === String(specialistSelect.value || ""))) { + specialistSelect.value = "generalist"; + } + }; + const loadSpecialistFlags = async () => { + try { + const r = await apiGet("/admin/api/chat/settings/specialist-flags"); + if (r && r.ok && r.flags && typeof r.flags === "object") { + specialistFlags = { + ...specialistFlags, + ...r.flags, + }; + } + } catch (_) { + // non-admin users or unavailable endpoint: keep defaults + } + applySpecialistOptions(); + }; + const _sp = String(localStorage.getItem(CHAT_SPECIALIST_PREF_KEY) || "generalist").toLowerCase(); + specialistSelect.value = ["generalist", "ops", "image", "memory_curator"].includes(_sp) ? _sp : "generalist"; + await loadSpecialistFlags(); + specialistSelect.addEventListener("change", () => { + localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, String(specialistSelect.value || "generalist")); + saveSessionModePreference(); + }); + const activeModelText = el("span", { class: "muted", text: `${t("chat.activeModelLabel")}: -` }); + const refreshActiveModelText = async () => { + try { + const ms = await apiGet("/admin/api/models"); + const profiles = Array.isArray(ms.profiles) ? ms.profiles : []; + const aid = String(ms.active_llm_profile_id || ""); + const p = profiles.find((x) => String(x.id || "") === aid) || null; + const modelName = String((p && p.model) || "").trim() || "-"; + activeModelText.textContent = `${t("chat.activeModelLabel")}: ${modelName}`; + } catch (_) { + activeModelText.textContent = `${t("chat.activeModelLabel")}: -`; + } + }; + const loadSessionModePreference = async () => { + if (!activeId) return; + try { + const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/mode`); + const m = String((resp && resp.interaction_mode) || "").toLowerCase(); + const s = String((resp && resp.specialist) || "").toLowerCase(); + const mm = String((resp && resp.memory_mode) || "").toLowerCase(); + if (["comprehensive", "expert"].includes(m)) modeSelect.value = m; + if (["generalist", "ops", "image", "memory_curator"].includes(s)) specialistSelect.value = s; + if (["default", "store_only"].includes(mm)) localStorage.setItem(CHAT_MEMORY_MODE_KEY, mm); + if (String(modeSelect.value || "").toLowerCase() !== "expert") { + specialistSelect.value = "generalist"; + } + localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "comprehensive")); + localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, String(specialistSelect.value || "generalist")); + const mml = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase(); + localStorage.setItem(CHAT_MEMORY_MODE_KEY, mml === "store_only" ? "store_only" : "default"); + syncModeSpecialistUi(); + } catch (_) {} + }; + const saveSessionModePreference = async () => { + if (!activeId) return; + try { + await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/mode`, { + interaction_mode: String(modeSelect.value || "comprehensive"), + specialist: + String(modeSelect.value || "comprehensive").toLowerCase() === "expert" + ? String(specialistSelect.value || "generalist") + : "generalist", + memory_mode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"), + }); + } catch (_) {} + }; + await refreshActiveModelText(); + const specialistLabelEl = el("span", { class: "muted", text: t("chat.specialistLabel") }); + const composerMetaBar = el("div", { class: "row", style: "gap:8px;padding:2px 4px 6px;align-items:center;" }, [ + el("span", { class: "muted", text: t("chat.modeLabel") }), + modeSelect, + specialistLabelEl, + specialistSelect, + toolToggleBtn, + ]); + + const syncModeSpecialistUi = () => { + const m = String(modeSelect.value || "comprehensive").toLowerCase(); + const showSpecialist = m === "expert"; + specialistLabelEl.style.display = showSpecialist ? "" : "none"; + specialistSelect.style.display = showSpecialist ? "" : "none"; + specialistSelect.disabled = !showSpecialist; + }; + modeSelect.addEventListener("change", () => { + syncModeSpecialistUi(); + }); + syncModeSpecialistUi(); + + const fitComposerTextarea = () => { + textarea.style.height = "auto"; + const h = Math.min(Math.max(textarea.scrollHeight, 44), 200); + textarea.style.height = `${h}px`; + }; + const syncSendEnabled = () => { + const ok = String(textarea.value || "").trim().length > 0 || pendingFiles.length > 0; + btnSend.disabled = !ok; + }; + textarea.addEventListener("input", () => { + fitComposerTextarea(); + syncSendEnabled(); + }); + requestAnimationFrame(() => { + fitComposerTextarea(); + syncSendEnabled(); + }); + + let sessions = []; + let sessionTotal = 0; + let pendingFiles = []; + let activeId = sessionId; + let showToolOutput = adminChatShowToolOutput; + let wikiPollTimerId = null; + let wikiAfterFinishedAt = {}; + + const idsMatch = (a, b) => String(a || "") === String(b || ""); + const confirmChatAction = (message) => + new Promise((resolve) => { + const backdrop = el("div", { class: "chat-confirm-backdrop" }); + const card = el("div", { class: "chat-confirm-card" }); + const text = el("div", { class: "chat-confirm-text", text: String(message || "") }); + const btnCancel = el("button", { type: "button", class: "btn", text: t("chat.dispatchLabelsCancel") }); + const btnOk = el("button", { type: "button", class: "btn btn--primary", text: t("chat.delete") }); + const close = (ok) => { + try { + backdrop.remove(); + } catch (_) {} + resolve(!!ok); + }; + btnCancel.addEventListener("click", () => close(false)); + btnOk.addEventListener("click", () => close(true)); + backdrop.addEventListener("click", (ev) => { + if (ev.target === backdrop) close(false); + }); + card.appendChild(text); + card.appendChild(el("div", { class: "row", style: "gap:8px;justify-content:flex-end;margin-top:10px;" }, [btnCancel, btnOk])); + backdrop.appendChild(card); + document.body.appendChild(backdrop); + }); + + const adoptCreatedSession = (resp) => { + const s = resp && resp.session ? resp.session : {}; + const sid = String(s.id || ""); + if (!sid) throw new Error("no_session_id"); + sessions = [s, ...sessions.filter((x) => !idsMatch(x.id, sid))]; + sessionTotal += 1; + activeId = sid; + replaceSessionUrl(sid); + wikiAfterFinishedAt[String(sid)] = ""; + }; + + const _summarizeWikiEvent = (ev) => { + const status = String(ev && ev.status ? ev.status : ""); + const ok = !!(ev && ev.ok); + const result = (ev && ev.result) || {}; + if (status !== "done" || !ok) { + const err = String((result && (result.error || result.last_error)) || (ev && ev.error) || "failed"); + return { kind: "error", text: t("chat.wikiToastFailed", { error: err.slice(0, 180) || "failed" }), actions: [] }; + } + const dm = result.dedupMerge || {}; + const merged = Number(dm.merged_count || 0) || 0; + const skipped = Number(dm.skipped_dup || 0) || 0; + const topicCounts = dm.topic_counts && typeof dm.topic_counts === "object" ? dm.topic_counts : {}; + let topTopic = ""; + let topN = -1; + for (const [k, v] of Object.entries(topicCounts)) { + const n = Number(v || 0) || 0; + if (n > topN) { + topN = n; + topTopic = String(k || ""); + } + } + const actions = [ + { + label: t("chat.wikiViewMerged"), + onClick: () => openWikiPreviewModal({ sessionId: String(activeId || ""), path: "inbox/merged-turns.md" }), + }, + ]; + if (topTopic) { + actions.push({ + label: t("chat.wikiViewTopic", { topic: topTopic }), + onClick: () => openWikiPreviewModal({ sessionId: String(activeId || ""), path: `topics/auto-${topTopic}.md` }), + }); + } + return { kind: "info", text: t("chat.wikiToastMerged", { merged: String(merged), skipped: String(skipped) }), actions }; + }; + + const pollWikiEvents = async () => { + const sid = String(activeId || ""); + if (!sid) return; + const after = String((wikiAfterFinishedAt && wikiAfterFinishedAt[sid]) || ""); + try { + const q = after ? `?after=${encodeURIComponent(after)}&limit=20` : "?limit=20"; + const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(sid)}/wiki-events${q}`); + if (!resp || !resp.ok) return; + const events = Array.isArray(resp.events) ? resp.events : []; + if (!events.length) return; + for (const ev of events) { + const fin = String((ev && ev.finished_at) || ""); + if (fin && (!wikiAfterFinishedAt[sid] || fin > wikiAfterFinishedAt[sid])) { + wikiAfterFinishedAt[sid] = fin; + } + const msg = _summarizeWikiEvent(ev); + if (msg && msg.text) { + const node = showToast(msg.text, { kind: msg.kind, ttlMs: 6500 }); + const acts = Array.isArray(msg.actions) ? msg.actions : []; + if (acts.length) { + const btnRow = el("div", { class: "row", style: "gap:8px;flex-wrap:wrap;margin-top:8px;" }); + for (const a of acts) { + btnRow.appendChild( + el("button", { + type: "button", + class: "btn", + style: "padding:4px 8px;min-height:auto;font-size:12px;", + text: String(a.label || "View"), + onclick: (e) => { + e.stopPropagation(); + try { + if (typeof a.onClick === "function") a.onClick(); + } catch (_) {} + }, + }), + ); + } + node.appendChild(btnRow); + } + } + } + } catch (_) {} + }; + + const startWikiPoller = () => { + if (wikiPollTimerId != null) return; + wikiPollTimerId = setInterval(pollWikiEvents, 2200); + // kick once + pollWikiEvents().catch(() => {}); + }; + + const stopWikiPoller = () => { + if (wikiPollTimerId != null) { + clearInterval(wikiPollTimerId); + wikiPollTimerId = null; + } + }; + + const syncPendingUi = () => { + pendingFilesEl.innerHTML = ""; + pendingFiles.forEach((f, i) => { + const row = el("div", { class: "chat-pending-row" }, [ + el("span", { class: "muted", text: f.name }), + el("button", { + type: "button", + class: "chat-pending-remove", + text: "×", + title: currentLang === "zh" ? "移除" : "Remove", + onclick: () => { + pendingFiles.splice(i, 1); + syncPendingUi(); + }, + }), + ]); + pendingFilesEl.appendChild(row); + }); + syncSendEnabled(); + }; + + attachBtn.addEventListener("click", () => fileInput.click()); + const syncToolToggleBtn = () => { + toolToggleBtn.textContent = `${t("chat.tools")}: ${showToolOutput ? t("chat.tools.on") : t("chat.tools.off")}`; + toolToggleBtn.title = showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden"); + }; + toolToggleBtn.addEventListener("click", () => { + showToolOutput = !showToolOutput; + adminChatShowToolOutput = showToolOutput; + syncToolToggleBtn(); + statusBar.textContent = showToolOutput ? t("chat.tools.visible") : t("chat.tools.hidden"); + loadMessagesForActive().catch(() => {}); + }); + syncToolToggleBtn(); + fileInput.addEventListener("change", () => { + const fs = fileInput.files; + if (!fs || !fs.length) return; + for (const f of fs) pendingFiles.push(f); + fileInput.value = ""; + syncPendingUi(); + }); + + const openSessionMenu = (sid, title) => { + const menu = document.createElement("div"); + menu.className = "chat-sess-menu-pop"; + const mk = (label, fn) => + el("button", { + type: "button", + class: "chat-sess-menu-item", + text: label, + onclick: async (ev) => { + ev.stopPropagation(); + menu.remove(); + await fn(); + }, + }); + menu.appendChild( + mk(t("chat.rename"), async () => { + const nv = window.prompt(t("chat.rename"), title); + if (nv == null) return; + try { + await apiPatch(`/admin/api/chat/sessions/${encodeURIComponent(sid)}`, { title: nv.trim() }); + await refreshSessions(); + } catch (e) { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + } + }), + ); + menu.appendChild( + mk(t("chat.delete"), async () => { + if (!(await confirmChatAction(t("chat.deleteConfirm")))) return; + try { + const r = await apiDelete(`/admin/api/chat/sessions/${encodeURIComponent(sid)}`); + const next = String(r.next_session_id || ""); + if (idsMatch(activeId, sid)) { + activeId = next; + replaceSessionUrl(activeId); + } + await refreshSessions(); + } catch (e) { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + } + }), + ); + menu.appendChild( + mk(t("chat.exportMd"), async () => { + try { + await downloadExport(sid, "md"); + } catch (e) { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + } + }), + ); + menu.appendChild( + mk(t("chat.exportJson"), async () => { + try { + await downloadExport(sid, "json"); + } catch (e) { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + } + }), + ); + menu.appendChild( + mk(t("chat.fork"), async () => { + try { + const r = await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(sid)}/fork`, {}); + const ns = r.session || {}; + const nid = String(ns.id || ""); + if (!nid) return; + activeId = nid; + replaceSessionUrl(nid); + await refreshSessions(); + } catch (e) { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + } + }), + ); + menu.appendChild( + mk(t("chat.audit"), () => { + openAdminFromChat("audit", sid); + }), + ); + return menu; + }; + + const paintSessions = () => { + sessionsListEl.innerHTML = ""; + loadMoreWrap.innerHTML = ""; + if (!sessions.length) { + sessionsListEl.appendChild( + el("div", { class: "muted", style: "padding:10px 0 10px 8px", text: t("chat.noSessions") }), + ); + return; + } + for (const s of sessions) { + const sid = String(s.id || ""); + const title = String(s.title || sid || ""); + const row = el("div", { class: "chat-sess-row" + (idsMatch(activeId, sid) ? " chat-sess-row--active" : "") }); + const btn = el("button", { + class: "chat-sess-btn" + (idsMatch(activeId, sid) ? " chat-sess-btn--active" : ""), + text: title, + onclick: () => { + activeId = sid; + replaceSessionUrl(sid); + paintSessions(); + Promise.all([loadMessagesForActive(), loadSessionModePreference()]).catch((e) => { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + }); + startWikiPoller(); + }, + }); + const more = el("button", { + type: "button", + class: "chat-sess-more" + (idsMatch(activeId, sid) ? " chat-sess-more--active" : ""), + text: "⋯", + title: t("chat.sessionMenu"), + onclick: (ev) => { + ev.stopPropagation(); + document.querySelectorAll(".chat-sess-menu-pop").forEach((n) => n.remove()); + const menu = openSessionMenu(sid, title); + const rect = more.getBoundingClientRect(); + menu.style.position = "fixed"; + document.body.appendChild(menu); + // Clamp into viewport; flip above if near bottom. + const mrect = menu.getBoundingClientRect(); + const pad = 8; + let left = rect.left; + let top = rect.bottom + 4; + if (top + mrect.height > window.innerHeight - pad) { + top = rect.top - 4 - mrect.height; + } + left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width)); + top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height)); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + const close = (e) => { + if (!menu.contains(e.target)) { + menu.remove(); + document.removeEventListener("click", close); + } + }; + setTimeout(() => document.addEventListener("click", close), 0); + }, + }); + row.appendChild(btn); + row.appendChild(more); + sessionsListEl.appendChild(row); + } + if (sessions.length < sessionTotal) { + const lm = el("button", { + class: "btn", + style: "width:100%;margin-top:8px", + text: t("chat.loadMore"), + onclick: async () => { + try { + statusBar.textContent = t("chat.loading"); + const off = sessions.length; + const resp = await apiGet( + `/admin/api/chat/sessions?limit=${PAGE_SIZE}&offset=${off}`, + ); + const next = Array.isArray(resp.sessions) ? resp.sessions : []; + sessionTotal = intOr(resp.total, sessionTotal); + sessions = sessions.concat(next); + statusBar.textContent = ""; + paintSessions(); + } catch (e) { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + } + }, + }); + loadMoreWrap.appendChild(lm); + } + }; + + function intOr(v, d) { + const n = parseInt(String(v), 10); + return Number.isFinite(n) ? n : d; + } + + const loadMessagesForActive = async () => { + messagesEl.innerHTML = ""; + if (!activeId) { + statusBar.textContent = sessions.length ? "" : t("chat.noSessions"); + if (!sessions.length) messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") })); + return; + } + statusBar.textContent = t("chat.loading"); + try { + const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages`); + const msgs = Array.isArray(resp.messages) ? resp.messages : []; + const renderRows = _buildRenderRows(msgs); + const total = intOr(resp.message_count, msgs.length); + if (total > msgs.length) { + messagesEl.appendChild( + el("div", { + class: "muted chat-msg-cap", + text: t("chat.historyTruncated", { shown: msgs.length, total }), + }), + ); + } + if (!renderRows.length) { + messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") })); + } else { + for (const m of renderRows) { + await appendMessageRow(messagesEl, m); + } + } + statusBar.textContent = ""; + messagesEl.scrollTop = messagesEl.scrollHeight; + } catch (e) { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + } + }; + + const reloadSessionsOnly = async () => { + const resp = await apiGet(`/admin/api/chat/sessions?limit=${PAGE_SIZE}&offset=0`); + sessions = Array.isArray(resp.sessions) ? resp.sessions : []; + sessionTotal = intOr(resp.total, sessions.length); + paintSessions(); + }; + + const refreshSessions = async () => { + statusBar.textContent = t("chat.loading"); + const resp = await apiGet(`/admin/api/chat/sessions?limit=${PAGE_SIZE}&offset=0`); + sessions = Array.isArray(resp.sessions) ? resp.sessions : []; + sessionTotal = intOr(resp.total, sessions.length); + // URL 里的 session_id 可能属于其他账号或已删除;若不在当前用户列表中则丢弃,避免 GET …/messages 404。 + const activeInList = + Boolean(activeId) && + sessions.length > 0 && + sessions.some((s) => idsMatch(s.id, activeId)); + if (activeId && !activeInList) { + activeId = ""; + replaceSessionUrl(""); + } + if (!activeId && sessions.length) { + activeId = String(sessions[0].id || ""); + replaceSessionUrl(activeId); + } + if (!activeId && sessions.length === 0) { + try { + const resp = await apiPost("/admin/api/chat/sessions", {}); + adoptCreatedSession(resp); + } catch (e) { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + } + } + paintSessions(); + await loadMessagesForActive(); + await loadSessionModePreference(); + if (!activeId) { + stopWikiPoller(); + } else { + startWikiPoller(); + } + if (!String(statusBar.textContent || "").includes(t("chat.error"))) statusBar.textContent = ""; + }; + + const btnNew = el("button", { + type: "button", + class: "chat-nav__new", + title: `${t("chat.newSession")}`, + onclick: async () => { + try { + statusBar.textContent = t("chat.loading"); + const resp = await apiPost("/admin/api/chat/sessions", {}); + adoptCreatedSession(resp); + paintSessions(); + messagesEl.innerHTML = ""; + messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") })); + statusBar.textContent = ""; + } catch (e) { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + } + }, + }); + btnNew.appendChild(el("span", { class: "chat-nav__newGlyph", "aria-hidden": "true" })); + btnNew.appendChild( + el("span", { + class: "chat-nav__newLabel", + "data-i18n": "chat.newSession", + text: t("chat.newSession"), + }), + ); + + class OclawWsChatTransport { + constructor({ tokenProvider }) { + this.tokenProvider = tokenProvider; + this.ws = null; + this.reqSeq = 0; + this.msgQueue = []; + this.waiters = []; + } + _isOpen() { + return this.ws && this.ws.readyState === WebSocket.OPEN; + } + _wsUrl() { + const origin = String(window.location.origin || "").trim(); + if (origin.startsWith("http://") || origin.startsWith("https://")) { + const wsOrigin = origin.replace(/^http/i, "ws"); + return `${wsOrigin}/ws`; + } + const u = new URL(window.location.href); + u.protocol = u.protocol === "https:" ? "wss:" : "ws:"; + u.pathname = "/ws"; + u.search = ""; + u.hash = ""; + return u.toString(); + } + _nextReqId() { + this.reqSeq += 1; + return `req_${Date.now()}_${this.reqSeq}`; + } + async _openAndHandshake() { + if (this._isOpen()) return; + const token = String(this.tokenProvider() || ""); + const wsUrl = this._wsUrl(); + const ws = new WebSocket(wsUrl); + this.ws = ws; + this.msgQueue = []; + this.waiters = []; + ws.addEventListener("message", (ev) => { + let parsed = null; + try { + parsed = JSON.parse(String(ev.data || "{}")); + } catch (_) { + parsed = null; + } + if (!parsed) return; + const waiter = this.waiters.shift(); + if (waiter) { + waiter.resolve(parsed); + } else { + this.msgQueue.push(parsed); + } + }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`ws_open_timeout:${wsUrl}`)), 7000); + ws.onopen = () => { + clearTimeout(timer); + resolve(); + }; + ws.onerror = () => { + clearTimeout(timer); + reject(new Error(`ws_open_failed:${wsUrl}`)); + }; + }); + // Receive optional connect.challenge first. + const challenge = await this._recv(); + if (!(challenge && challenge.type === "event" && challenge.event === "connect.challenge")) { + throw new Error("ws_invalid_challenge"); + } + const reqId = this._nextReqId(); + ws.send( + JSON.stringify({ + type: "req", + id: reqId, + method: "connect", + params: { + minProtocol: 3, + maxProtocol: 3, + client: { id: "webchat-ui", version: "0.1", platform: navigator.platform || "web", mode: "webchat" }, + role: "operator", + scopes: ["operator.read", "operator.write"], + auth: token ? { token } : {}, + }, + }), + ); + const res = await this._recv(); + if (!res || res.type !== "res" || res.id !== reqId || !res.ok) { + throw new Error(`ws_connect_failed:${JSON.stringify((res && res.error) || {})}`); + } + } + _recv() { + const ws = this.ws; + if (!ws) return Promise.reject(new Error("ws_not_connected")); + if (this.msgQueue.length) return Promise.resolve(this.msgQueue.shift()); + return new Promise((resolve, reject) => { + const onErr = () => { + cleanup(); + reject(new Error("ws_receive_failed")); + }; + const onClose = () => { + cleanup(); + reject(new Error("ws_closed")); + }; + const cleanup = () => { + ws.removeEventListener("error", onErr); + ws.removeEventListener("close", onClose); + this.waiters = this.waiters.filter((w) => w.resolve !== resolve); + }; + this.waiters.push({ resolve, reject }); + ws.addEventListener("error", onErr); + ws.addEventListener("close", onClose); + }); + } + close() { + if (this.ws) { + try { + this.ws.close(1000, "done"); + } catch (_) {} + this.ws = null; + } + } + async sendSessionSend({ sessionId, text, attachments, interactionMode, specialist, memoryMode, signal, onEvent }) { + await this._openAndHandshake(); + const reqId = this._nextReqId(); + const req = { + type: "req", + id: reqId, + method: "chat.send", + params: { + sessionKey: String(sessionId || ""), + message: String(text || ""), + attachments: Array.isArray(attachments) ? attachments : [], + idempotencyKey: `idem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + thinking: "default", + interaction_mode: String(interactionMode || "comprehensive"), + specialist: String(specialist || "generalist"), + memory_mode: String(memoryMode || "default"), + }, + }; + this.ws.send(JSON.stringify(req)); + let doneMeta = null; + while (true) { + if (signal && signal.aborted) throw new DOMException("Aborted", "AbortError"); + const msg = await this._recv(); + if ( + msg && + msg.type === "event" && + (msg.event === "chat" || + msg.event === "session.message" || + msg.event === "agent.event" || + msg.event === "session.tool" || + msg.event === "session.marker") && + typeof onEvent === "function" + ) { + const payload = msg.payload || {}; + onEvent({ event: String(msg.event || ""), payload }); + const d = msg.event === "chat" ? { phase: String(payload.state || "") } : {}; + if (msg.event === "session.message") { + const role = String(((payload.message || {}).role) || "").toLowerCase(); + if (role === "assistant") return doneMeta; + } + const phase = String(d.phase || ""); + if (phase === "final" || phase === "error" || phase === "aborted") { + return doneMeta; + } + continue; + } + if (msg && msg.type === "res" && msg.id === reqId) { + if (!msg.ok) throw new Error(`ws_req_failed:${JSON.stringify(msg.error || {})}`); + const p = msg.payload || {}; + doneMeta = { + interaction_mode: String(p.interactionMode || interactionMode || ""), + selected_specialist: String(p.selectedSpecialist || specialist || ""), + dispatch_reason: String(p.dispatchReason || ""), + manager_selected_specialist: String(p.managerSelectedSpecialist || ""), + requested_specialist: String(p.requestedSpecialist || ""), + dynamic_agent_used: !!p.dynamicAgentUsed, + dynamic_agent_name: String(p.dynamicAgentName || ""), + relay_pointer_count: Number(p.relayPointerCount || 0) || 0, + relay_envelope_present: !!p.relayEnvelopePresent, + relay_envelope_pointer_count: Number(p.relayEnvelopePointerCount || 0) || 0, + relay_ttl_turn_count: Number(p.relayTtlTurnCount || 0) || 0, + relay_ttl_session_count: Number(p.relayTtlSessionCount || 0) || 0, + relay_ttl_keep_count: Number(p.relayTtlKeepCount || 0) || 0, + reply: String(p.reply || ""), + }; + if (String(p.status || "") === "started") { + continue; + } + // OpenClaw-style: response ack does not terminate the stream. + // Wait strictly for chat final/aborted/error or session.message assistant. + continue; + } + if (msg && msg.type === "event" && msg.event === "chat") { + const p = msg.payload || {}; + const d = { phase: String(p.state || "") }; + const phase = String(d.phase || ""); + if (phase === "final" || phase === "error" || phase === "aborted") { + return doneMeta; + } + } + } + } + async sendChatAbort({ sessionId, runId }) { + // Abort should use the same WS connection when possible. + await this._openAndHandshake(); + const reqId = this._nextReqId(); + this.ws.send( + JSON.stringify({ + type: "req", + id: reqId, + method: "chat.abort", + params: { sessionKey: String(sessionId || ""), runId: runId ? String(runId) : undefined }, + }), + ); + for (;;) { + const msg = await this._recv(); + if (msg && msg.type === "res" && msg.id === reqId) { + if (!msg.ok) throw new Error(`ws_req_failed:${JSON.stringify(msg.error || {})}`); + return msg.payload || {}; + } + } + } + } + + let currentStreamAbortController = null; + let currentWsTransport = null; + let currentAbortMeta = { sessionId: "", runId: "" }; + const isAbortError = (err) => { + const name = String(err && err.name ? err.name : ""); + const msg = String(err && err.message ? err.message : err || ""); + return name === "AbortError" || msg.toLowerCase().includes("aborted"); + }; + + const _silentReplyPattern = /^\s*NO_REPLY\s*$/; + const _isSilentReplyStream = (text) => _silentReplyPattern.test(String(text || "")); + const _normalizeAssistantMessage = (message, { requireRole = false, requireContentArray = false } = {}) => { + if (!message || typeof message !== "object") return null; + const m = message; + const role = String(m.role || "").toLowerCase(); + if (requireRole && role !== "assistant") return null; + if (requireContentArray && !Array.isArray(m.content)) return null; + if (!("content" in m) && !("text" in m)) return null; + return m; + }; + + const sendMessageStream = async (userText, attachmentPayload) => { + const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; + let streamBubble = null; + let streamRow = null; + let doneMeta = null; + let chatStream = ""; + let chatRunId = null; + let chatStreamSegments = []; + let renderRafId = null; + let renderPending = false; + let typingRafId = null; + let typingTimerId = null; + let streamStatusTimerId = null; + let streamStatusBase = "oclaw"; + let streamDisplayTarget = ""; + let streamDisplayShown = ""; + let toolSeq = 0; + let hasRealStreamText = false; + let markerState = { p: 0, e: false, ep: 0, t: 0, s: 0, k: 0, reclaimed: 0 }; + const ensureStreamBubble = () => { + if (streamBubble) return streamBubble; + const inner = el("div", { class: "chat-msg chat-msg--assistant" }); + inner.appendChild(el("div", { class: "chat-msg__stream-status", text: `${t("chat.status.oclaw")}...` })); + inner.appendChild(el("div", { class: "chat-msg__md", html: "" })); + streamRow = wrapAssistantMessage(inner, new Date().toISOString()); + streamBubble = inner; + messagesEl.appendChild(streamRow); + return streamBubble; + }; + const _labelPhase = (phase) => { + const p = String(phase || "").toLowerCase(); + if (!p) return "oclaw"; + if (p.includes("plan")) return "plan"; + if (p.includes("tool")) return "tool"; + if (p.includes("agent")) return "agent"; + if (p.includes("core")) return "oclaw"; + if (p.includes("manager")) return "oclaw"; + if (p === "start") return "start"; + if (p === "running") return "running"; + if (p === "end") return "end"; + if (p === "error") return "error"; + if (p === "started") return "oclaw"; + if (p === "delta") return "agent"; + return p; + }; + const _statusLabel = (key) => { + const k = String(key || "").trim(); + if (!k) return t("chat.status.oclaw"); + const dictKey = `chat.status.${k}`; + const localized = t(dictKey); + if (localized !== dictKey) return localized; + return k; + }; + const _setStreamStatusBase = (phase) => { + streamStatusBase = _labelPhase(phase); + _setDynamicStreamStatus(); + }; + const _setDynamicStreamStatus = () => { + const bubble = ensureStreamBubble(); + const statusEl = bubble.querySelector(".chat-msg__stream-status"); + if (!statusEl) return; + const dots = ".".repeat((Math.floor(Date.now() / 400) % 3) + 1); + statusEl.textContent = `${_statusLabel(streamStatusBase)}${dots}`; + }; + const _startDynamicStreamStatus = () => { + _setDynamicStreamStatus(); + if (streamStatusTimerId != null) return; + streamStatusTimerId = setInterval(_setDynamicStreamStatus, 400); + }; + const _stopDynamicStreamStatus = () => { + if (streamStatusTimerId != null) { + clearInterval(streamStatusTimerId); + streamStatusTimerId = null; + } + }; + const _markStreamTerminal = (phase, noteText) => { + const bubble = ensureStreamBubble(); + const statusEl = bubble.querySelector(".chat-msg__stream-status"); + if (statusEl) statusEl.textContent = _statusLabel(_labelPhase(phase)); + const md = bubble.querySelector(".chat-msg__md"); + if (md && !String(md.textContent || "").trim() && String(noteText || "").trim()) { + md.innerHTML = `
${escapeHtml(String(noteText || ""))}
`; + } + }; + const _composeStreamPlainText = () => { + const chunks = []; + for (const seg of chatStreamSegments) { + if (!seg || seg.type !== "text") continue; + const txt = String(seg.text || "").trim(); + if (txt) chunks.push(txt); + } + return decodeEscapedNewlines(chunks.join("\n")); + }; + const _renderStreamCompositeNow = () => { + const bubble = ensureStreamBubble(); + const md = bubble.querySelector(".chat-msg__md"); + if (md) { + const blocks = []; + if (markerState.p > 0 || markerState.e || markerState.ep > 0) { + blocks.push( + `
${escapeHtml( + t("chat.marker.summary", { + p: String(markerState.p || 0), + e: markerState.e ? "1" : "0", + ep: String(markerState.ep || 0), + }), + )}
`, + ); + blocks.push( + `
${escapeHtml( + t("chat.marker.ttl", { + t: String(markerState.t || 0), + s: String(markerState.s || 0), + k: String(markerState.k || 0), + }), + )}
`, + ); + if ((Number(markerState.reclaimed || 0) || 0) > 0) { + blocks.push( + `
${escapeHtml( + t("chat.marker.reclaimed", { n: String(markerState.reclaimed || 0) }), + )}
`, + ); + } + } + let textIdx = 0; + for (const seg of chatStreamSegments) { + if (!seg) continue; + if (seg.type === "text") { + const full = String(seg.text || ""); + if (!full) continue; + const showLen = Math.max(0, Math.min(full.length, streamDisplayShown.length - textIdx)); + const shown = full.slice(0, showLen); + textIdx += full.length; + if (!shown.trim()) continue; + blocks.push(`
${escapeHtml(decodeEscapedNewlines(shown))}
`); + } else if (seg.type === "tool") { + if (!showToolOutput) continue; + const title = String(seg.title || "tool"); + const body = String(seg.body || ""); + const audit = seg.sqlAudit && typeof seg.sqlAudit === "object" ? seg.sqlAudit : null; + if (audit) { + const guard = audit.guard && typeof audit.guard === "object" ? audit.guard : {}; + const autoLimit = _sqlLimitSuffix(audit.inputSql, audit.executedSql); + const executedDiffHtml = _renderExecutedSqlWithAddedHighlight(audit.inputSql, audit.executedSql); + blocks.push( + `
${escapeHtml(title)} · SQL audit +
+
input SQL
${escapeHtml(String(audit.inputSql || ""))}
+
executed SQL (added highlighted)
${executedDiffHtml}
+
+${autoLimit ? `
auto-added clause: ${escapeHtml(autoLimit)}
` : ""} +
+ readonly_enforced=${escapeHtml(String(!!guard.readonly_enforced))} · + multi_statement_forbidden=${escapeHtml(String(!!guard.multi_statement_forbidden))} · + auto_limit_applied=${escapeHtml(String(!!guard.auto_limit_applied))} · + result_row_cap=${escapeHtml(String(guard.result_row_cap != null ? guard.result_row_cap : ""))} · + engine=${escapeHtml(String(audit.engine || ""))} · + rows_returned=${escapeHtml(String(audit.rowsReturned != null ? audit.rowsReturned : ""))} +
+
`, + ); + } else { + blocks.push( + `
${escapeHtml(title)}
${escapeHtml(body)}
`, + ); + } + } + } + const currentShown = streamDisplayShown.slice(textIdx); + if (String(currentShown || "").trim()) { + blocks.push(`
${escapeHtml(decodeEscapedNewlines(currentShown))}
`); + } + md.innerHTML = blocks.join(""); + } + messagesEl.scrollTop = messagesEl.scrollHeight; + }; + const renderStreamComposite = () => { + if (renderPending) return; + renderPending = true; + renderRafId = requestAnimationFrame(() => { + renderPending = false; + renderRafId = null; + _renderStreamCompositeNow(); + }); + }; + const _scheduleTypingTick = () => { + if (typingRafId != null) return; + typingRafId = requestAnimationFrame(() => { + typingRafId = null; + if (streamDisplayShown === streamDisplayTarget) { + renderStreamComposite(); + return; + } + const remain = streamDisplayTarget.length - streamDisplayShown.length; + if (remain <= 0) { + streamDisplayShown = streamDisplayTarget; + renderStreamComposite(); + return; + } + // Slightly faster typing while keeping typewriter feel for正文. + const step = 2; + streamDisplayShown = streamDisplayTarget.slice(0, streamDisplayShown.length + step); + renderStreamComposite(); + if (typingTimerId != null) { + clearTimeout(typingTimerId); + typingTimerId = null; + } + typingTimerId = setTimeout(() => { + typingTimerId = null; + _scheduleTypingTick(); + }, 22); + }); + }; + const setStreamText = (text) => { + chatStream = String(text || ""); + // Keep current text as a dedicated trailing segment for strict in-order rendering. + if (!chatStreamSegments.length || chatStreamSegments[chatStreamSegments.length - 1].type !== "text") { + chatStreamSegments.push({ type: "text", text: chatStream }); + } else { + chatStreamSegments[chatStreamSegments.length - 1].text = chatStream; + } + streamDisplayTarget = _composeStreamPlainText(); + _scheduleTypingTick(); + }; + const appendToolSegment = (payload) => { + const p = payload && typeof payload === "object" ? payload : {}; + const name = String(p.name || "tool"); + const key = `${name}:${++toolSeq}`; + const rawPayload = p.payload != null ? p.payload : p; + const body = formatToolPanelText(name, rawPayload); + const sqlAudit = extractSqlAuditPayload(rawPayload); + chatStreamSegments.push({ type: "tool", key, title: name, body, sqlAudit }); + }; + const appendFinalAssistant = async (message, fallbackText) => { + const normalized = _normalizeAssistantMessage(message, { requireRole: false, requireContentArray: false }); + if (normalized && !_isSilentReplyStream(extractWsAssistantText(normalized))) { + const norm = { + ...normalized, + role: "assistant", + content: extractWsAssistantText(normalized), + timestamp: normalized.timestamp != null ? normalized.timestamp : new Date().toISOString(), + tool_calls: normalized.tool_calls != null ? normalized.tool_calls : normalized.toolCalls, + }; + const rows = _buildRenderRows([norm]); + const last = rows && rows.length ? rows[rows.length - 1] : null; + if (last) { + if (streamRow && streamRow.parentNode) streamRow.remove(); + await appendMessageRow(messagesEl, last); + messagesEl.scrollTop = messagesEl.scrollHeight; + return true; + } + } + const t0 = String(fallbackText || "").trim(); + if (t0 && !_isSilentReplyStream(t0)) { + if (streamRow && streamRow.parentNode) streamRow.remove(); + await appendMessageRow(messagesEl, { + role: "assistant", + content: decodeEscapedNewlines(t0), + timestamp: new Date().toISOString(), + }); + messagesEl.scrollTop = messagesEl.scrollHeight; + return true; + } + return false; + }; + btnStop.disabled = false; + btnSend.disabled = true; + composerShell.classList.add("chat-composer-shell--busy"); + const abortController = new AbortController(); + currentStreamAbortController = abortController; + currentAbortMeta = { sessionId: String(activeId || ""), runId: "" }; + try { + const transport = new OclawWsChatTransport({ + tokenProvider: () => token, + }); + currentWsTransport = transport; + try { + // Immediate assistant placeholder bubble with dynamic status. + ensureStreamBubble(); + _startDynamicStreamStatus(); + doneMeta = await transport.sendSessionSend({ + sessionId: activeId, + text: userText, + attachments: attachmentPayload, + interactionMode: String(modeSelect.value || "comprehensive"), + specialist: + String(modeSelect.value || "comprehensive").toLowerCase() === "expert" + ? String(specialistSelect.value || "generalist") + : "generalist", + memoryMode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"), + signal: abortController.signal, + onEvent: async (frame) => { + const eventName = String((frame && frame.event) || ""); + const payload = (frame && frame.payload) || {}; + if (eventName === "agent.event") { + _setStreamStatusBase((payload && (payload.stream || payload.event || payload.type)) || "agent"); + return; + } + if (eventName === "session.marker") { + const action = String(payload.action || "").toLowerCase(); + markerState = { + p: Number(payload.relayPointerCount || markerState.p || 0) || 0, + e: payload.relayEnvelopePresent != null ? !!payload.relayEnvelopePresent : !!markerState.e, + ep: Number(payload.relayEnvelopePointerCount || markerState.ep || 0) || 0, + t: Number(payload.relayTtlTurnCount || markerState.t || 0) || 0, + s: Number(payload.relayTtlSessionCount || markerState.s || 0) || 0, + k: Number(payload.relayTtlKeepCount || markerState.k || 0) || 0, + reclaimed: + action === "turn_reclaimed" + ? Number(payload.reclaimedTurnPointers || payload.relayTtlTurnCount || 0) || 0 + : Number(markerState.reclaimed || 0) || 0, + }; + renderStreamComposite(); + return; + } + if (eventName === "session.tool") { + _setStreamStatusBase("tool"); + // Keep strict order: tool card appears exactly where tool event arrives. + appendToolSegment(payload); + streamDisplayTarget = _composeStreamPlainText(); + _scheduleTypingTick(); + return; + } + if (eventName !== "chat") return; + const state = String(payload.state || ""); + if (state) _setStreamStatusBase(state); + if (payload.runId && !chatRunId) chatRunId = String(payload.runId); + if (chatRunId) currentAbortMeta = { sessionId: String(activeId || ""), runId: String(chatRunId || "") }; + if (chatRunId && payload.runId && String(payload.runId) !== chatRunId && state !== "final") return; + if (state === "delta") { + const next = extractWsAssistantText(payload.message); + const d = String(payload.delta || ""); + if (d && !_isSilentReplyStream(d)) { + const base = hasRealStreamText ? String(chatStream || "") : ""; + chatStream = `${base}${decodeEscapedNewlines(d)}`; + hasRealStreamText = true; + setStreamText(chatStream); + } else if (typeof next === "string" && next && !_isSilentReplyStream(next)) { + // Fallback when gateway doesn't include delta. + chatStream = next; + hasRealStreamText = true; + setStreamText(chatStream); + } + return; + } + if (state === "final") { + _stopDynamicStreamStatus(); + streamDisplayShown = streamDisplayTarget; + const ok = await appendFinalAssistant(payload.message, chatStream || extractWsAssistantText(payload.message || {})); + if (!ok) _markStreamTerminal("end", t("chat.status.end")); + chatStream = ""; + chatRunId = null; + chatStreamSegments = []; + if (!ok) statusBar.textContent = ""; + return; + } + if (state === "aborted") { + _stopDynamicStreamStatus(); + streamDisplayShown = streamDisplayTarget; + const ok = await appendFinalAssistant(payload.message, chatStream); + if (!ok) _markStreamTerminal("error", "aborted"); + chatStream = ""; + chatRunId = null; + chatStreamSegments = []; + return; + } + if (state === "error") { + _stopDynamicStreamStatus(); + _markStreamTerminal("error", String(payload.errorMessage || "chat error")); + chatStream = ""; + chatRunId = null; + chatStreamSegments = []; + statusBar.textContent = `${t("chat.error")}: ${String(payload.errorMessage || "chat error")}`; + } + }, + }); + } finally { + transport.close(); + if (currentWsTransport === transport) currentWsTransport = null; + } + setTimeout(() => { + reloadSessionsOnly().catch(() => {}); + }, 250); + return doneMeta; + } catch (e) { + if (isAbortError(e)) return doneMeta; + throw e; + } finally { + if (typingRafId != null) { + try { + cancelAnimationFrame(typingRafId); + } catch (_) {} + typingRafId = null; + } + if (typingTimerId != null) { + try { + clearTimeout(typingTimerId); + } catch (_) {} + typingTimerId = null; + } + _stopDynamicStreamStatus(); + if (renderRafId != null) { + try { + cancelAnimationFrame(renderRafId); + } catch (_) {} + renderRafId = null; + renderPending = false; + } + btnStop.disabled = true; + btnSend.disabled = false; + syncSendEnabled(); + composerShell.classList.remove("chat-composer-shell--busy"); + if (currentStreamAbortController === abortController) currentStreamAbortController = null; + } + }; + + btnStop.addEventListener("click", async () => { + if (currentStreamAbortController) currentStreamAbortController.abort(); + try { + const sid = String(currentAbortMeta.sessionId || activeId || ""); + if (sid) { + const tpt = currentWsTransport; + if (tpt && typeof tpt.sendChatAbort === "function") { + await tpt.sendChatAbort({ sessionId: sid, runId: currentAbortMeta.runId || "" }); + } + } + } catch (_) { + // ignore abort rpc errors; local abort still stops UI + } + statusBar.textContent = ""; + }); + + btnSend.addEventListener("click", async () => { + if (btnSend.disabled) return; + const textRaw = String(textarea.value || "").trim(); + const hasFiles = pendingFiles.length > 0; + if (!textRaw && !hasFiles) return; + if (!activeId) { + try { + statusBar.textContent = t("chat.loading"); + const resp = await apiPost("/admin/api/chat/sessions", {}); + adoptCreatedSession(resp); + paintSessions(); + statusBar.textContent = ""; + } catch (e) { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + return; + } + } + if (!activeId) return; + const filesSnapshot = pendingFiles.slice(); + btnSend.disabled = true; + statusBar.textContent = t("chat.sending"); + let attPayload = null; + if (filesSnapshot.length) { + attPayload = []; + for (const f of filesSnapshot) { + attPayload.push(await fileToPayloadEntry(f)); + } + pendingFiles = []; + syncPendingUi(); + } + const userText = + textRaw || + (hasFiles ? (currentLang === "zh" ? "(已上传附件)" : "(attachment uploaded)") : ""); + /** 发送过程中用本地 blob 预览,避免只显示文件名直到回合结束再拉历史 */ + const previewBlobUrls = []; + try { + textarea.value = ""; + const innerUser = el("div", { class: "chat-msg chat-msg--user" }); + innerUser.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(userText) })); + if (filesSnapshot.length) { + const wrap = el("div", { class: "chat-att-wrap" }); + for (const f of filesSnapshot) { + const u = URL.createObjectURL(f); + previewBlobUrls.push(u); + wrap.appendChild( + el("img", { + class: "chat-att-img", + src: u, + alt: String(f.name || ""), + title: String(f.name || ""), + }), + ); + } + innerUser.appendChild(wrap); + } + const userWrap = wrapUserMessage(innerUser, new Date().toISOString()); + messagesEl.appendChild(userWrap); + messagesEl.scrollTop = messagesEl.scrollHeight; + fitComposerTextarea(); + const doneMeta = await sendMessageStream(userText, attPayload); + const mRaw = String((doneMeta && doneMeta.interaction_mode) || "").toLowerCase(); + const m = interactionModeLabel(mRaw); + const s = specialistLabel( + mRaw === "expert" + ? (doneMeta && doneMeta.selected_specialist) + : (doneMeta && doneMeta.requested_specialist), + ); + const baseText = t("chat.execApplied", { mode: m, specialist: s }); + const memoryModeNow = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase(); + const memoryText = ` · ${t("chat.memoryApplied", { mode: memoryModeShortLabel(memoryModeNow) })}`; + const reason = reasonLabel(doneMeta && doneMeta.dispatch_reason); + const dyn = doneMeta && doneMeta.dynamic_agent_used ? ` · dynamic=${String(doneMeta.dynamic_agent_name || "1")}` : ""; + const routeText = reason && reason !== "-" ? ` · ${reason}${dyn}` : dyn; + const markerText = + doneMeta && ((Number(doneMeta.relay_pointer_count || 0) || 0) > 0 || doneMeta.relay_envelope_present) + ? ` · ${t("chat.marker.summary", { + p: String(Number(doneMeta.relay_pointer_count || 0) || 0), + e: doneMeta.relay_envelope_present ? "1" : "0", + ep: String(Number(doneMeta.relay_envelope_pointer_count || 0) || 0), + })} · ${t("chat.marker.ttl", { + t: String(Number(doneMeta.relay_ttl_turn_count || 0) || 0), + s: String(Number(doneMeta.relay_ttl_session_count || 0) || 0), + k: String(Number(doneMeta.relay_ttl_keep_count || 0) || 0), + })}` + : ""; + const stats = await fetchDynamicExpertStats(); + if (stats) { + let topReason = "-"; + let topCount = -1; + const pairs = []; + for (const [k, v] of Object.entries(stats.reasons || {})) { + const n = parseInt(String(v || "0"), 10) || 0; + pairs.push([String(k || "-"), n]); + if (n > topCount) { + topCount = n; + topReason = String(k || "-"); + } + } + pairs.sort((a, b) => b[1] - a[1]); + _statusReasonPairs = pairs.slice(); + const top5 = pairs + .slice(0, 5) + .map(([k, n]) => `${String((stats.reasonLabels && stats.reasonLabels[k]) || reasonLabel(k))}:${n}`) + .join(", "); + const ratePct = Math.round((Number(stats.rate || 0) * 1000)) / 10; + const topReasonLabel = String((stats.reasonLabels && stats.reasonLabels[topReason]) || reasonLabel(topReason)); + const topReasonText = t("chat.dynamicStatsDetail", { rate: ratePct, reason: topReasonLabel }); + const topMixText = t("chat.dynamicTopReasons", { items: top5 || "-" }); + statusBar.textContent = `${baseText}${memoryText}${routeText}${markerText} · ${t("chat.dynamicStats", { dynamic: stats.dynamic, fallback: stats.fallback })} · ${topReasonText} · ${topMixText} · ${t("chat.dynamicAllReasons")}`; + } else { + _statusReasonPairs = []; + statusBar.textContent = `${baseText}${memoryText}${routeText}${markerText}`; + } + } catch (e) { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + } finally { + previewBlobUrls.forEach((u) => { + try { + URL.revokeObjectURL(u); + } catch (_) {} + }); + btnSend.disabled = false; + } + }); + + textarea.addEventListener("keydown", (ev) => { + if (ev.key === "Enter" && !ev.shiftKey) { + ev.preventDefault(); + btnSend.click(); + } + }); + + try { + await refreshSessions(); + } catch (e) { + statusBar.textContent = `${t("chat.error")}: ${String(e)}`; + sessionsListEl.innerHTML = ""; + messagesEl.innerHTML = ""; + } + composerShell.appendChild(pendingFilesEl); + composerShell.appendChild(composerMetaBar); + composerShell.appendChild( + el("div", { class: "chat-composer-row" }, [ + attachBtn, + textarea, + el("div", { class: "chat-composer-actions" }, [btnStop, btnSend]), + ]), + ); + + const navFooter = el("div", { class: "chat-nav__footer" }, [ + el("div", { id: "authUser", class: "chat-nav__user" }), + ]); + + return el("div", { class: "chat-layout chat-app" }, [ + el("div", { class: "chat-nav" }, [ + el("div", { class: "chat-nav__top" }, [ + buildChatBrandLogoNode(), + ]), + el("div", { class: "chat-nav__toolbar" }, [btnNew]), + el("div", { class: "chat-nav__scroll" }, [sessionsListEl, loadMoreWrap]), + navFooter, + ]), + el("div", { class: "chat-main" }, [ + el("div", { class: "muted", style: "font-size:12px;line-height:1.2;padding:10px 12px 0;" }, [activeModelText]), + messagesEl, + statusBar, + el("div", { class: "chat-composer" }, [fileInput, composerShell]), + ]), + ]); +} + +document.body.addEventListener("click", (e) => { + const node = e.target; + if (!node || !node.closest) return; + const status = node.closest(".chat-status"); + if (!status) return; + if (!_statusReasonPairs.length) return; + document.querySelectorAll(".chat-sess-menu-pop").forEach((n) => n.remove()); + const items = _statusReasonPairs.map(([k, n]) => `${reasonLabel(k)}: ${n}`).join("\n"); + const pop = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;max-width:420px;white-space:pre-wrap;line-height:1.4;" }, [ + el("div", { class: "chat-sess-menu-item", text: t("chat.dynamicAllReasonsTitle") }), + el("div", { class: "chat-sess-menu-item", text: items || "-" }), + ]); + const rect = status.getBoundingClientRect(); + pop.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - 440))}px`; + pop.style.top = `${Math.max(8, rect.top - 180)}px`; + document.body.appendChild(pop); + const close = (ev) => { + if (!pop.contains(ev.target)) { + pop.remove(); + document.removeEventListener("click", close); + } + }; + setTimeout(() => document.addEventListener("click", close), 0); +}); + +async function syncLangFromServer() { + try { + const r = await apiGet("/admin/api/chat/settings/ui-lang"); + if (r && r.lang && (r.lang === "zh" || r.lang === "en")) { + currentLang = r.lang; + localStorage.setItem(LANG_KEY, currentLang); + } + } catch (_) {} +} + +async function boot() { + applyI18nStatic(); + if (forceReloginRequested()) { + clearAuthAndReloginFlagFromUrl(); + } + try { + authSession = JSON.parse(localStorage.getItem(AUTH_SESSION_KEY) || "null"); + } catch (_) { + authSession = null; + } + const tok = String(localStorage.getItem(AUTH_TOKEN_KEY) || "").trim(); + if (!authSession || !tok) { + // Ensure stale overlays from previous chat UI never block login inputs. + closeChatImageLightbox(); + document.querySelectorAll(".chat-sess-menu-pop").forEach((n) => n.remove()); + document.body.style.overflow = ""; + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_SESSION_KEY); + authSession = null; + try { + await apiPost("/admin/api/auth/bootstrap", {}); + } catch (_) {} + mount(await renderLogin()); + applyI18nStatic(); + syncAuthUserLabel(); + return; + } + await syncLangFromServer(); + await loadMeProfile(); + try { + mount(await renderChatUi()); + } catch (err) { + mount( + el("div", { class: "chat-app--login" }, [ + el("div", { class: "card", style: "max-width:520px;width:100%" }, [ + el("div", { class: "card__title", text: t("common.error") }), + el("div", { class: "pre", text: String(err) }), + ]), + ]), + ); + } + applyI18nStatic(); + syncAuthUserLabel(); +} + +document.body.addEventListener("click", async (e) => { + const menuBtn = e.target.closest && e.target.closest(".chat-sess-menu-item[data-menu-action]"); + if (menuBtn) { + const action = String(menuBtn.getAttribute("data-menu-action") || ""); + document.querySelectorAll(".chat-sess-menu-pop").forEach((n) => n.remove()); + if (action === "profile") { + openAdminFromChat("profile"); + return; + } + if (action === "logout") { + try { + await apiPost("/admin/api/auth/logout", {}); + } catch (_) {} + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_SESSION_KEY); + try { + localStorage.removeItem(CHAT_URL_SCOPE_KEY); + } catch (_) {} + authSession = null; + await boot(); + return; + } + if (action === "lang") { + currentLang = currentLang === "zh" ? "en" : "zh"; + localStorage.setItem(LANG_KEY, currentLang); + try { + await apiPost("/admin/api/chat/settings/ui-lang", { lang: currentLang }); + } catch (_) {} + await boot(); + return; + } + if (action === "dispatchLabels") { + const status = document.querySelector(".chat-status"); + await openDispatchLabelsEditor(status); + return; + } + } + +}); + +window.addEventListener("popstate", () => { + if (authSession) boot().catch(() => {}); +}); + +boot().catch((err) => { + mount( + el("div", { class: "chat-app--login" }, [ + el("div", { class: "card", style: "max-width:520px;width:100%" }, [ + el("div", { class: "card__title", text: t("common.error") }), + el("div", { class: "pre", text: String(err) }), + ]), + ]), + ); +}); diff --git a/admin/static/default-user-avatar.svg b/admin/static/default-user-avatar.svg new file mode 100644 index 00000000..0800c3bd --- /dev/null +++ b/admin/static/default-user-avatar.svg @@ -0,0 +1,344 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/static/index.html b/admin/static/index.html new file mode 100644 index 00000000..b3692a41 --- /dev/null +++ b/admin/static/index.html @@ -0,0 +1,59 @@ + + + + + + oliver + + + + + + + +
+ +
+
+
Runtime
+
+ + 对话 + + + +
+
+
+
+
+ + + diff --git a/admin/static/oliver.svg b/admin/static/oliver.svg new file mode 100644 index 00000000..5fc33a8d --- /dev/null +++ b/admin/static/oliver.svg @@ -0,0 +1,333 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/static/styles.css b/admin/static/styles.css new file mode 100644 index 00000000..071fc75c --- /dev/null +++ b/admin/static/styles.css @@ -0,0 +1,315 @@ +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", + "Segoe UI Emoji"; + /* Prefer theme variables when enabled (e.g. theme-deepseek.css). */ + color: var(--ds-text, #e2e8f0); + background: var(--ds-bg, #0b1020); +} + +.layout { display: flex; height: 100vh; } +.sidebar { width: 260px; background: #0f172a; color: #e2e8f0; display: flex; flex-direction: column; border-right: 1px solid rgba(148,163,184,0.2); } +.brand { padding: 16px 16px 8px; } +.brand__logoWrap { + width: 100%; + max-width: 180px; + height: 44px; + overflow: hidden; + border-radius: 10px; +} +.brand__logo { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} +.brand__title { + font-family: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font-weight: 600; + font-size: 1.125rem; + letter-spacing: 0.02em; +} +.brand__sub { font-size: 12px; color: rgba(226,232,240,0.7); margin-top: 4px; } +.nav { padding: 8px; display: flex; flex-direction: column; gap: 4px; } +.nav__item { text-decoration: none; color: rgba(226,232,240,0.85); padding: 10px 12px; border-radius: 10px; } +.nav__item:hover { background: rgba(148,163,184,0.12); } +.nav__item--active { background: rgba(59,130,246,0.22); color: #e2e8f0; } +.sidebar__footer { margin-top: auto; padding: 12px 16px; border-top: 1px solid rgba(148,163,184,0.2); } +.muted { font-size: 12px; color: rgba(226,232,240,0.65); } + +.main { flex: 1; display: flex; flex-direction: column; background: #0b1020; } +.topbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid rgba(148,163,184,0.18); background: rgba(2,6,23,0.6); backdrop-filter: blur(10px); } +.topbar__title { color: #e2e8f0; font-weight: 650; } +.btn { background: rgba(148,163,184,0.12); border: 1px solid rgba(148,163,184,0.25); color: #e2e8f0; border-radius: 10px; padding: 8px 12px; cursor: pointer; } +.btn:hover { background: rgba(148,163,184,0.18); } +.btn--primary { background: rgba(59,130,246,0.25); border-color: rgba(59,130,246,0.4); } +.btn--danger { background: rgba(239,68,68,0.18); border-color: rgba(239,68,68,0.35); } +.content { padding: 18px; overflow: auto; color: #e2e8f0; } + +.card { background: rgba(15,23,42,0.9); border: 1px solid rgba(148,163,184,0.18); border-radius: 14px; padding: 14px; margin-bottom: 12px; } +.card__title { font-weight: 650; margin-bottom: 10px; } +.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; } +.chat-login-fields { + display: flex; + flex-direction: column; + gap: 12px; + margin: 6px 0 10px; +} +.chat-login-fields .input { + width: 100%; + min-width: 0; +} +.chat-login-actions { + margin-bottom: 8px; +} +.row--wecom-form { + flex-wrap: nowrap; + align-items: flex-end; + gap: 10px; + overflow-x: auto; + padding-bottom: 4px; + margin-bottom: 4px; +} +.row--wecom-form__field { + flex: 1 1 140px; + min-width: 0; + max-width: 280px; +} +.row--wecom-form__field .input { + min-width: 0; + width: 100%; + max-width: 100%; +} +.row--wecom-form__chk { + flex: 0 0 auto; + white-space: nowrap; +} +.row--wecom-form .btn { + flex: 0 0 auto; +} +.kv { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; background: rgba(148,163,184,0.08); border: 1px solid rgba(148,163,184,0.14); padding: 6px 8px; border-radius: 10px; } +.input { background: rgba(2,6,23,0.65); border: 1px solid rgba(148,163,184,0.25); color: #e2e8f0; border-radius: 10px; padding: 8px 10px; min-width: 240px; } +.input--compact { + min-width: 0; + padding: 6px 8px; +} +.input--readonly { + max-width: min(100%, 720px); + cursor: default; + color: rgba(226,232,240,0.82); + border-color: rgba(148,163,184,0.18); + background: rgba(2,6,23,0.45); +} +.input--readonly::placeholder { + color: rgba(226,232,240,0.45); +} +.table { width: 100%; border-collapse: collapse; } +.table th, .table td { border-bottom: 1px solid rgba(148,163,184,0.18); padding: 10px 8px; text-align: left; font-size: 13px; } +.table-wrap { width: 100%; overflow-x: auto; border: 1px solid rgba(148,163,184,0.12); border-radius: 10px; } +.table--compact { table-layout: fixed; min-width: 860px; } +.table--compact th, .table--compact td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.table--compact td.table__cell--actions, +.table--compact td.table__cell--form { + overflow: visible; + text-overflow: clip; +} +.table__cell-actions { + display: flex; + flex-wrap: wrap; + gap: 6px 8px; + align-items: center; + justify-content: flex-end; +} +.session-monitor-row--active { + background: rgba(94, 179, 255, 0.12); +} +.session-monitor-row--active td { + border-bottom-color: rgba(94, 179, 255, 0.35); +} +.session-monitor-modal { + position: fixed; + inset: 0; + z-index: 320; + background: rgba(0, 0, 0, 0.58); + align-items: center; + justify-content: center; + padding: 16px; +} +.session-monitor-modal__card { + width: min(1200px, 96vw); + max-height: 92vh; + overflow: auto; +} +.session-monitor-session-table th:nth-child(1), +.session-monitor-session-table td:nth-child(1) { width: 18%; } +.session-monitor-session-table th:nth-child(2), +.session-monitor-session-table td:nth-child(2) { width: 30%; } +.session-monitor-session-table th:nth-child(3), +.session-monitor-session-table td:nth-child(3) { width: 12%; } +.session-monitor-session-table th:nth-child(4), +.session-monitor-session-table td:nth-child(4) { width: 9%; } +.session-monitor-session-table th:nth-child(5), +.session-monitor-session-table td:nth-child(5) { width: 15%; } +.session-monitor-session-table th:nth-child(6), +.session-monitor-session-table td:nth-child(6) { width: 8%; } +.session-monitor-session-table th:nth-child(7), +.session-monitor-session-table td:nth-child(7) { + width: 8%; + min-width: 4rem; +} + +.session-monitor-detail-table th:nth-child(1), +.session-monitor-detail-table td:nth-child(1) { width: 8%; } +.session-monitor-detail-table th:nth-child(2), +.session-monitor-detail-table td:nth-child(2) { width: 8%; } +.session-monitor-detail-table th:nth-child(3), +.session-monitor-detail-table td:nth-child(3) { + width: 62%; + white-space: normal; + word-break: break-word; +} +.session-monitor-detail-table th:nth-child(4), +.session-monitor-detail-table td:nth-child(4) { width: 22%; } + +/* Match chat-page "three dots" action style */ +.chat-sess-more { + flex: 0 0 2rem; + min-width: 2rem; + padding: 6px 4px; + border-radius: 10px; + border: 1px solid transparent; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + -webkit-appearance: none; + appearance: none; + box-shadow: none; +} +.chat-sess-more:hover { + background: rgba(255, 255, 255, 0.06); +} +.chat-sess-more:focus { + outline: none; +} +.chat-sess-more:focus-visible { + outline: 1px solid rgba(94, 179, 255, 0.35); + outline-offset: 1px; +} +.chat-sess-more--active { + background: rgba(94, 179, 255, 0.18); + border-color: rgba(94, 179, 255, 0.3); +} +.chat-sess-more--active:hover { + background: rgba(94, 179, 255, 0.22); +} +.chat-sess-menu-pop { + z-index: 300; + background: #222; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + padding: 4px; + min-width: 11rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); +} +.chat-sess-menu-item { + display: block; + width: 100%; + text-align: left; + padding: 8px 10px; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + border-radius: 6px; + font-size: 13px; +} +.chat-sess-menu-item:hover { + background: rgba(255, 255, 255, 0.06); +} + +.table--resizable thead th { + position: relative; +} +.table-col-resizer { + position: absolute; + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: col-resize; + user-select: none; + touch-action: none; +} +.table-col-resizer:hover { + background: rgba(94, 179, 255, 0.18); +} +body.col-resize-active { + cursor: col-resize; +} +/* User management: fill row with % columns + roomier padding */ +.table--compact.table--users-mgmt { + width: 100%; + min-width: 100%; +} +.table--users-mgmt th, +.table--users-mgmt td { padding: 12px 14px; } +.table--users-mgmt th:nth-child(1), +.table--users-mgmt td:nth-child(1) { width: 15%; } +.table--users-mgmt th:nth-child(2), +.table--users-mgmt td:nth-child(2) { width: 18%; } +.table--users-mgmt th:nth-child(3), +.table--users-mgmt td:nth-child(3) { width: 8%; } +.table--users-mgmt th:nth-child(4), +.table--users-mgmt td:nth-child(4) { width: 6%; } +.table--users-mgmt th:nth-child(5), +.table--users-mgmt td:nth-child(5) { width: 13%; min-width: 7.5rem; } +.table--users-mgmt th:nth-child(6), +.table--users-mgmt td:nth-child(6) { width: 15%; min-width: 9rem; } +.table--users-mgmt th:nth-child(7), +.table--users-mgmt td:nth-child(7) { + width: 25%; + min-width: 15rem; + text-align: left; +} +.table--users-mgmt .table__cell-actions { + flex-wrap: nowrap; + gap: 8px 10px; + justify-content: flex-start; +} +.table--users-mgmt .table__cell--form .input { + min-width: 0; + width: 100%; + max-width: 100%; + box-sizing: border-box; +} +.cell-copyable { cursor: copy; position: relative; } +.cell-copyable:hover { background: rgba(59,130,246,0.12); } +.cell-copyable.cell-selected { background: rgba(59,130,246,0.2); outline: 1px solid rgba(59,130,246,0.45); } +.cell-copyable.cell-copied::after { + content: "Copied"; + position: absolute; + right: 6px; + top: 4px; + font-size: 11px; + color: #93c5fd; +} +.details { border: 1px solid rgba(148,163,184,0.18); border-radius: 10px; padding: 8px 10px; background: rgba(2,6,23,0.3); } +.details > summary { cursor: pointer; color: #cbd5e1; font-weight: 600; } +.plugins-fold { margin-bottom: 12px; } +.plugins-fold__inner { margin-top: 10px; padding-top: 8px; border-top: 1px solid rgba(148,163,184,0.12); } +.plugins-pager .btn.btn--small { min-width: 72px; } +.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; border: 1px solid rgba(148,163,184,0.25); background: rgba(148,163,184,0.08); } +.badge--ok { border-color: rgba(34,197,94,0.4); background: rgba(34,197,94,0.12); } +.badge--bad { border-color: rgba(239,68,68,0.45); background: rgba(239,68,68,0.12); } +.badge--mode-restricted { border-color: rgba(250,204,21,0.45); background: rgba(250,204,21,0.12); } +.badge--mode-unrestricted { border-color: rgba(59,130,246,0.45); background: rgba(59,130,246,0.16); } +.pre { white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; } +.alert { margin: 8px 0; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(148,163,184,0.25); font-size: 13px; } +.alert--critical { border-color: rgba(239,68,68,0.6); background: rgba(239,68,68,0.12); color: #fecaca; } +.alert-list { margin: 8px 0 0; padding-left: 18px; color: #cbd5e1; font-size: 13px; } +.alert-list li { margin: 4px 0; } diff --git a/admin/static/theme-deepseek.css b/admin/static/theme-deepseek.css new file mode 100644 index 00000000..c5ffef6a --- /dev/null +++ b/admin/static/theme-deepseek.css @@ -0,0 +1,775 @@ +/* DeepSeek-like dark theme when body.theme-ds-body is set (e.g. #/chat). */ + +body.theme-ds-body { + --ds-bg: #0d0d0d; + /* 右侧对话主区:比侧栏/页面略深的黑 */ + --ds-chat-main: #050505; + --ds-surface: #1a1a1c; + --ds-surface-2: #222224; + --ds-border: rgba(255, 255, 255, 0.08); + --ds-text: #e8e8e8; + --ds-text-muted: rgba(232, 232, 232, 0.55); + --ds-accent: #5eb3ff; + --ds-accent-soft: rgba(94, 179, 255, 0.18); + /* 左侧会话列表:贴近侧栏 #0d0d0d,选中仅略提亮 */ + --chat-sess-hover-bg: rgba(255, 255, 255, 0.05); + --chat-sess-active-bg: rgba(255, 255, 255, 0.062); + --chat-sess-border: rgba(255, 255, 255, 0.09); + /* 用户消息气泡(微信式绿底深字) */ + --chat-user-bubble-bg: #42b983; + --chat-user-bubble-fg: #111111; +} + +body.theme-ds-body .layout .sidebar { + background: #161618; + border-right-color: var(--ds-border); +} + +body.theme-ds-body .brand__title, +body.theme-ds-body .nav__item { + color: var(--ds-text); +} + +body.theme-ds-body .nav__item:hover { + background: rgba(255, 255, 255, 0.06); +} + +body.theme-ds-body .nav__item--active { + background: var(--ds-accent-soft); + border: 1px solid rgba(94, 179, 255, 0.25); + color: var(--ds-text); +} + +body.theme-ds-body .sidebar__footer { + border-top-color: var(--ds-border); +} + +body.theme-ds-body .main { + background: var(--ds-bg); +} + +body.theme-ds-body .topbar { + background: rgba(22, 22, 24, 0.92); + border-bottom-color: var(--ds-border); +} + +body.theme-ds-body .topbar__title { + color: var(--ds-text); +} + +body.theme-ds-body .content { + background: var(--ds-bg); + color: var(--ds-text); +} + +body.theme-ds-body .muted { + color: var(--ds-text-muted); +} + +body.theme-ds-body .btn { + background: rgba(255, 255, 255, 0.06); + border-color: var(--ds-border); + color: var(--ds-text); +} + +body.theme-ds-body .btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +body.theme-ds-body .btn--primary { + background: var(--ds-accent-soft); + border-color: rgba(94, 179, 255, 0.45); + color: var(--ds-text); +} + +body.theme-ds-body .input { + background: var(--ds-surface-2); + border-color: var(--ds-border); + color: var(--ds-text); +} + +body.theme-ds-body .card { + background: var(--ds-surface); + border-color: var(--ds-border); + color: var(--ds-text); +} + +/* Chat layout(侧栏与主区融入背景:无外侧框、无列间距) */ +.chat-layout { + display: flex; + gap: 0; + flex: 1; + min-height: 0; +} + +.chat-nav { + width: 252px; + flex-shrink: 0; + min-height: 0; + display: flex; + flex-direction: column; + border: none; + border-radius: 0; + background: var(--ds-bg); + overflow: hidden; +} + +.chat-nav__top { + flex-shrink: 0; + display: block; + padding: 12px 0 6px; + border-bottom: none; +} + +.chat-nav__toolbar { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 0; + padding: 0; + border-bottom: none; + margin-top: -14px; + position: relative; + z-index: 1; +} + +.chat-nav__new { + margin: 0; + width: 100%; + box-sizing: border-box; + min-height: 40px; + padding: 8px 0 8px 8px; + font-size: 13px; + font-family: inherit; + border-radius: 0; + border: none; + /* 与侧栏底一致,上移后与 logo 下沿重叠时盖住底边 */ + background: var(--ds-bg); + color: inherit; + box-shadow: none; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + cursor: pointer; + text-align: left; + -webkit-appearance: none; + appearance: none; +} + +.chat-nav__new:hover { + background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06)); +} + +.chat-nav__newGlyph { + flex-shrink: 0; + position: relative; + display: inline-block; + width: 28px; + height: 28px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.14); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.22); + transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; +} + +.chat-nav__new:hover .chat-nav__newGlyph { + background-color: rgba(255, 255, 255, 0.22); + transform: scale(1.05); +} + +.chat-nav__newGlyph::before, +.chat-nav__newGlyph::after { + content: ""; + position: absolute; + background-color: #fff; + border-radius: 2px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.chat-nav__newGlyph::before { + width: 14px; + height: 2px; +} + +.chat-nav__newGlyph::after { + width: 2px; + height: 14px; +} + +.chat-nav__newLabel, +.chat-nav__newText { + font-size: 13px; + line-height: 1.2; + white-space: nowrap; +} + +.chat-nav__brand { + flex: 1; + min-width: 0; + font-family: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font-weight: 600; + font-size: 1.125rem; + letter-spacing: 0.02em; + line-height: 1.25; +} + +.chat-nav__brandWrap { + width: 100%; + max-width: 180px; + height: 44px; + overflow: hidden; + border-radius: 0; +} + +.chat-nav__brandLogo { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + /* 负水平偏移:在 cover 裁切下再向左露出一点画面 */ + object-position: -10px center; +} + + +.chat-nav__scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 0 0 6px; +} + +.chat-sessions__list { + padding: 0; +} + +.chat-nav__footer { + flex-shrink: 0; + border-top: none; + padding: 10px 0 12px; + display: flex; + flex-direction: column; + gap: 8px; + background: transparent; +} + +.chat-nav__user { + min-height: 0; +} + +.chat-nav__user-name { + font-size: 13px; + font-weight: 500; + line-height: 1.35; + word-break: break-word; +} + +.chat-nav__user-role { + font-size: 11px; + margin-top: 2px; + line-height: 1.3; +} + +.chat-nav__link { + display: inline-block; + font-size: 13px; + color: rgba(94, 179, 255, 0.95); + text-decoration: none; +} + +.chat-nav__link:hover { + text-decoration: underline; +} + +.chat-nav__footer-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.chat-sess-btn { + display: block; + width: 100%; + text-align: left; + padding: 10px 0 10px 8px; + margin-bottom: 0; + border-radius: 0; + border: 1px solid transparent; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 13px; +} + +.chat-sess-btn:hover { + background: transparent; +} + +.chat-sess-row { + border: 1px solid transparent; + border-radius: 0; +} + +.chat-sess-row:hover { + background: var(--chat-sess-hover-bg, rgba(255, 255, 255, 0.06)); + border-color: var(--chat-sess-border, rgba(255, 255, 255, 0.12)); +} + +.chat-sess-row--active { + background: var(--chat-sess-active-bg, rgba(255, 255, 255, 0.062)); + border-color: rgba(255, 255, 255, 0.08); +} + +.chat-sess-row--active .chat-sess-btn { + color: var(--ds-text, #e8e8e8); +} + +.chat-sess-row .chat-sess-more { + opacity: 0; + pointer-events: none; +} + +.chat-sess-row:hover .chat-sess-more, +.chat-sess-row:focus-within .chat-sess-more { + opacity: 1; + pointer-events: auto; +} + +.chat-sess-row .chat-sess-more--active { + opacity: 1; + pointer-events: auto; +} + +.chat-main { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + border: none; + border-radius: 0; + background: var(--ds-chat-main, #050505); + overflow: hidden; +} + +.chat-messages { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +/* 滚动条:管理台等仍用隐藏式;独立 /chat 页见文件末尾 body.chat-standalone-page */ +.chat-msg__reasoning-pre, +.chat-pending-files, +.table-wrap, +.session-monitor-modal__card { + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} + +.chat-msg__reasoning-pre:hover, +.chat-pending-files:hover, +.table-wrap:hover, +.session-monitor-modal__card:hover { + scrollbar-color: rgba(94, 179, 255, 0.45) rgba(255, 255, 255, 0.06); +} + +.chat-msg__reasoning-pre::-webkit-scrollbar, +.chat-pending-files::-webkit-scrollbar, +.table-wrap::-webkit-scrollbar, +.session-monitor-modal__card::-webkit-scrollbar { + width: 0px; + height: 0px; +} + +.chat-msg__reasoning-pre:hover::-webkit-scrollbar, +.chat-pending-files:hover::-webkit-scrollbar, +.table-wrap:hover::-webkit-scrollbar, +.session-monitor-modal__card:hover::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.chat-msg__reasoning-pre::-webkit-scrollbar-track, +.chat-pending-files::-webkit-scrollbar-track, +.table-wrap::-webkit-scrollbar-track, +.session-monitor-modal__card::-webkit-scrollbar-track { + background: transparent; + border-radius: 999px; +} + +.chat-msg__reasoning-pre::-webkit-scrollbar-thumb, +.chat-pending-files::-webkit-scrollbar-thumb, +.table-wrap::-webkit-scrollbar-thumb, +.session-monitor-modal__card::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 999px; +} + +.chat-msg__reasoning-pre:hover::-webkit-scrollbar-track, +.chat-pending-files:hover::-webkit-scrollbar-track, +.table-wrap:hover::-webkit-scrollbar-track, +.session-monitor-modal__card:hover::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.06); +} + +.chat-msg__reasoning-pre:hover::-webkit-scrollbar-thumb, +.chat-pending-files:hover::-webkit-scrollbar-thumb, +.table-wrap:hover::-webkit-scrollbar-thumb, +.session-monitor-modal__card:hover::-webkit-scrollbar-thumb { + background: rgba(94, 179, 255, 0.45); +} + +.chat-msg__reasoning-pre::-webkit-scrollbar-thumb:hover, +.chat-pending-files::-webkit-scrollbar-thumb:hover, +.table-wrap::-webkit-scrollbar-thumb:hover, +.session-monitor-modal__card::-webkit-scrollbar-thumb:hover { + background: rgba(94, 179, 255, 0.65); +} + +.chat-msg { + max-width: 92%; + padding: 6px 12px; + border-radius: 12px; + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.chat-row { + display: flex; + align-items: flex-end; + gap: 10px; + width: 100%; + box-sizing: border-box; +} + +.chat-row--assistant { + justify-content: flex-start; +} + +.chat-row--user { + justify-content: flex-end; +} + +/* 对话区宽度:用户消息不超过中线(右半区减头像轨);助手消息可越过中线约 2cm。50% 相对 .chat-messages 内一行宽度。 */ +.chat-msg-col { + min-width: 0; + display: flex; + flex-direction: column; + align-items: stretch; +} + +/* 与 .chat-avatar-slot 48px + .chat-row gap 10px 一致 */ +.chat-msg-col--assistant { + max-width: min(max(0px, calc(50% + 2cm - 58px)), 100%); +} + +.chat-msg-col--user { + align-items: flex-end; + max-width: min(max(0px, calc(50% - 58px)), 100%); +} + +.chat-msg-col .chat-msg { + max-width: 100%; +} + +.chat-msg-col--user .chat-msg--user { + align-self: flex-end; +} + +.chat-avatar-slot { + flex: 0 0 48px; + width: 48px; + height: 48px; + min-width: 0; + min-height: 0; + overflow: hidden; + border-radius: 999px; +} + +.chat-avatar { + display: block; + width: 48px; + height: 48px; + max-width: 100%; + max-height: 100%; + min-width: 0; + min-height: 0; + border-radius: 999px; + object-fit: cover; + flex-shrink: 0; + box-sizing: border-box; +} + +/* 与助手侧 oliver.svg 一致:浅底 + 内边距,矢量用 contain(避免大 viewBox 在 flex 下撑开一行) */ +.chat-avatar--bot { + background: rgba(255, 255, 255, 0.08); + padding: 5px; + object-fit: contain; +} + +.chat-avatar--userBuiltin { + background: rgba(255, 255, 255, 0.08); + padding: 5px; + object-fit: contain; +} + +.chat-avatar--userPhoto { + object-fit: cover; + padding: 0; + background: transparent; +} + +.chat-row--meta { + padding-left: 58px; + max-width: min(calc(50% + 2cm), 100%); + box-sizing: border-box; +} + +.chat-msg__time { + font-size: 11px; + line-height: 1.3; + color: var(--ds-text-muted, rgba(232, 232, 232, 0.5)); + margin-bottom: 6px; + white-space: nowrap; + user-select: none; +} + +.chat-msg--user { + align-self: flex-end; + background: var(--chat-user-bubble-bg, #42b983); + color: var(--chat-user-bubble-fg, #111); + border: 1px solid rgba(0, 0, 0, 0.12); +} + +.chat-msg--user .chat-msg__md, +.chat-msg--user .chat-msg__plain { + color: inherit; +} + +.chat-msg--user .chat-msg__md a { + color: #0b4d8c; +} + +.chat-msg--user .chat-msg__md pre, +.chat-msg--user .chat-msg__md code { + background: rgba(0, 0, 0, 0.12); + color: #111; + border-color: rgba(0, 0, 0, 0.12); +} + +.chat-msg--assistant { + align-self: flex-start; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--ds-border, rgba(255, 255, 255, 0.08)); +} + +.chat-msg--tool { + align-self: flex-start; + font-family: ui-monospace, monospace; + font-size: 12px; + background: rgba(0, 0, 0, 0.25); + border: 1px dashed rgba(255, 255, 255, 0.12); +} + +/* In-dialog progress (e.g. Core: analyzing request…) during stream; not the footer status line */ +.chat-msg--thinking { + align-self: flex-start; + max-width: 95%; + font-size: 12px; + line-height: 1.45; + color: var(--ds-text-muted, rgba(232, 232, 232, 0.72)); + background: rgba(255, 255, 255, 0.04); + border: 1px dashed rgba(255, 255, 255, 0.14); + white-space: pre-wrap; + word-break: break-word; +} + +.chat-task-stage { + display: flex; + gap: 10px; + align-items: center; + padding: 8px 10px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); + cursor: default; +} + +.chat-task-stage__toggle { + margin-left: auto; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: var(--ds-text-muted, rgba(232, 232, 232, 0.72)); + border-radius: 8px; + padding: 1px 6px; + line-height: 1.2; + cursor: pointer; +} + +.chat-task-stage__toggle:hover { + background: rgba(255, 255, 255, 0.08); +} + +/* Fold model reasoning blocks in assistant bubbles (same rules as Streamlit messages.py). */ +.chat-msg--rich { + display: flex; + flex-direction: column; + gap: 6px; + white-space: normal; +} + +.chat-msg__text { + white-space: pre-wrap; + word-break: break-word; +} + +.chat-msg__reasoning { + align-self: stretch; + margin: 4px 0 2px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); + padding: 4px 10px 6px; +} + +.chat-msg__reasoning:not([open]) { + padding-bottom: 2px; +} + +.chat-msg__reasoning > summary { + cursor: pointer; + font-size: 12px; + color: var(--ds-text-muted, rgba(232, 232, 232, 0.7)); + padding: 2px 2px 4px; + user-select: none; + list-style-position: outside; +} + +.chat-msg__reasoning[open] > summary { + color: var(--ds-text, #e8e8e8); + margin-bottom: 6px; +} + +.chat-msg__reasoning-pre { + margin: 0; + padding: 8px 10px; + max-height: 260px; + overflow: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + background: rgba(0, 0, 0, 0.2); + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.chat-msg__reasoning-block { + margin-bottom: 8px; +} + +.chat-msg__timeline-logs { + display: flex; + flex-direction: column; + gap: 6px; + margin: 6px 0 2px; +} + +.chat-msg__timeline-detail { + margin-top: 0; +} + +.chat-msg__process-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.chat-msg__process-summary::-webkit-details-marker { + display: none; +} + +.chat-msg__process-summary::marker { + content: ""; +} + +.chat-msg__process-status { + color: var(--ds-text-muted, rgba(232, 232, 232, 0.78)); +} + +.chat-msg__process-caret { + color: rgba(232, 232, 232, 0.6); + font-size: 12px; + line-height: 1; +} + +.chat-msg__reasoning-title { + font-size: 11px; + opacity: 0.78; + margin-bottom: 4px; +} + +.chat-composer { + flex-shrink: 0; + border-top: 1px solid rgba(255, 255, 255, 0.05); + padding: 10px 12px 12px; +} + +.chat-composer textarea { + font-family: inherit; + font-size: 14px; +} + +.chat-status { + padding: 6px 12px; + font-size: 12px; + color: var(--ds-text-muted, rgba(232, 232, 232, 0.55)); +} + +/* 独立 /chat:左侧会话列表不显示滚动条(仍可滚轮滚动),右侧消息区灰滚动条 */ +body.chat-standalone-page .chat-nav__scroll { + scrollbar-width: none; + -ms-overflow-style: none; +} + +body.chat-standalone-page .chat-nav__scroll::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +body.chat-standalone-page .chat-messages { + scrollbar-width: thin; + scrollbar-color: #7a7a7a #2e2e2e; +} + +body.chat-standalone-page .chat-messages::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +body.chat-standalone-page .chat-messages::-webkit-scrollbar-track { + background: #2e2e2e; +} + +body.chat-standalone-page .chat-messages::-webkit-scrollbar-thumb { + background: #7a7a7a; + border-radius: 0; +} + +body.chat-standalone-page .chat-messages::-webkit-scrollbar-thumb:hover { + background: #909090; +} diff --git a/agent/workspace-coding/AGENTS.md b/agent/workspace-coding/AGENTS.md new file mode 100644 index 00000000..82ca0154 --- /dev/null +++ b/agent/workspace-coding/AGENTS.md @@ -0,0 +1,23 @@ +# AGENTS + +## 专业能力 +- 代码阅读、实现、重构、故障修复。 +- 测试执行与失败归因(单测/集成/端到端)。 +- 构建与运行链路排障(依赖、配置、环境)。 + +## 标准工作流 +1. 定义问题:复现条件、预期行为、验收标准。 +2. 设计改动:最小可行方案 + 风险点。 +3. 实施修改:控制变更面,避免顺手改动。 +4. 执行验证:至少覆盖变更相关路径。 +5. 交付结果:给出变更清单与可复现验证结论。 + +## 交付格式(固定) +- Changed: 改了哪些文件和行为。 +- Why: 为什么这样改。 +- Verified: 跑了什么,结果如何。 +- Risks: 剩余风险和建议后续动作。 + +## 协作规则 +- 需要对外表达优化时,移交 `social`。 +- 需要跨系统运行环境排障时,联动 `ops`。 diff --git a/agent/workspace-coding/IDENTITY.md b/agent/workspace-coding/IDENTITY.md new file mode 100644 index 00000000..64171df4 --- /dev/null +++ b/agent/workspace-coding/IDENTITY.md @@ -0,0 +1,18 @@ +# IDENTITY + +## 名字 +Coding Specialist + +## 职位 +研发交付负责人(Implementation Owner) + +## 核心职责 +- 代码实现:新功能、重构、缺陷修复。 +- 质量验证:运行相关测试并解释结果。 +- 风险控制:识别兼容性、性能、回归风险。 +- 工程对齐:保持代码风格、结构、约束一致。 + +## 职责边界 +- 不替产品做需求优先级决策。 +- 不对外发布品牌语义文本(交给 social)。 +- 发现需求不清时,先提出最小澄清再继续。 diff --git a/agent/workspace-coding/SOUL.md b/agent/workspace-coding/SOUL.md new file mode 100644 index 00000000..bd9a5cd4 --- /dev/null +++ b/agent/workspace-coding/SOUL.md @@ -0,0 +1,17 @@ +# SOUL + +## 核心人格 +- 工程师人格:先事实,后判断;先复现,后修复。 +- 对质量有洁癖:不接受“看起来能跑”。 +- 追求稳态:改动越小越好,回归风险越低越好。 + +## 沟通风格 +- 用工程语言沟通:路径、函数、命令、结果。 +- 先报告“是否修好”,再报告“怎么修的”。 +- 拒绝空泛建议,默认给可执行步骤。 + +## 行为准则 +1. 先建立最小复现,再动代码。 +2. 一次只解决一个核心问题,避免混改。 +3. 改完必须有验证(测试/脚本/复现步骤)。 +4. 对潜在副作用给出明确提醒。 diff --git a/agent/workspace-coding/USER.md b/agent/workspace-coding/USER.md new file mode 100644 index 00000000..1c0bf601 --- /dev/null +++ b/agent/workspace-coding/USER.md @@ -0,0 +1,14 @@ +# USER + +## 服务对象画像 +- 主要对象:技术负责人、开发同事、reviewer。 +- 他们要的是“能合并、可上线、可回滚”的答案。 + +## 输出偏好 +- 必须包含:修改点、影响范围、验证结果、残余风险。 +- 命令和路径明确,不给“你自己试试”式建议。 +- 出现失败时给下一跳动作,而不是只给错误文本。 + +## 协作偏好 +- 对主方案给清晰推荐,对备选方案简短说明 trade-off。 +- 若改动较大,先给拆分步骤,降低审查成本。 diff --git a/agent/workspace-coding/memory/README.md b/agent/workspace-coding/memory/README.md new file mode 100644 index 00000000..baddbca5 --- /dev/null +++ b/agent/workspace-coding/memory/README.md @@ -0,0 +1,7 @@ +# memory + +研发长期记忆目录。 + +- preferences.md:代码风格偏好 +- project_facts.md:架构约束 +- lessons.md:历史问题复盘 diff --git a/agent/workspace-main/AGENTS.md b/agent/workspace-main/AGENTS.md new file mode 100644 index 00000000..673f257e --- /dev/null +++ b/agent/workspace-main/AGENTS.md @@ -0,0 +1,22 @@ +# AGENTS + +## 组织定位 +Main Orchestrator 负责“分派、把关、汇总”,不是所有事都亲自执行。 + +## 路由策略(何时调用谁) +- `coding`:代码实现、重构、缺陷修复、测试失败、性能问题。 +- `social`:对外文案、公告、邮件、PR 描述、语气统一与改写。 +- `ops`:部署、运行环境、日志排障、配置/网络/可用性问题。 +- `image`:图像生成与编辑任务。 +- `generalist`:低复杂度通用问题或跨域轻量任务。 + +## 编排工作流 +1. 澄清目标:输出格式、边界、验收标准。 +2. 派发执行:给 specialist 明确上下文与成功条件。 +3. 验收结果:检查证据、测试、边界情况。 +4. 汇总答复:保留关键依据,给推荐动作。 + +## 质量门槛 +- 每个结论必须可追溯到证据(代码、命令输出、日志、文档)。 +- 涉及改动必须标明影响面和验证方法。 +- 无法验证时必须显式声明风险等级(低/中/高)。 diff --git a/agent/workspace-main/IDENTITY.md b/agent/workspace-main/IDENTITY.md new file mode 100644 index 00000000..9d6c4f98 --- /dev/null +++ b/agent/workspace-main/IDENTITY.md @@ -0,0 +1,18 @@ +# IDENTITY + +## 名字 +Main Orchestrator + +## 职位 +多 Agent 体系中的总协调者(Manager + Integrator) + +## 核心职责 +- 将用户需求转成可执行任务,明确验收标准。 +- 选择合适 specialist(coding/social/ops/image/generalist)。 +- 汇总 specialist 结果,统一为用户可决策输出。 +- 对冲突信息做裁决:以证据充分、风险可控为准。 + +## 职责边界 +- 不替 specialist 做细节实现,除非任务非常小且无需上下文切换。 +- 不产出“未验证即默认正确”的技术判断。 +- 不跳过风险告知直接执行破坏性动作。 diff --git a/agent/workspace-main/SOUL.md b/agent/workspace-main/SOUL.md new file mode 100644 index 00000000..946f65d5 --- /dev/null +++ b/agent/workspace-main/SOUL.md @@ -0,0 +1,18 @@ +# SOUL + +## 核心人格 +- 总指挥型:先判断“做什么最值”,再安排“谁来做”。 +- 结果导向:以可交付结果衡量质量,而不是解释长度。 +- 冷静克制:遇到不确定性先澄清假设,不给虚假确定性。 + +## 说话风格 +- 先结论,后依据,最后下一步。 +- 默认中文;用户英文提问时用英文响应。 +- 不说套话,不复述无增量信息。 + +## 决策原则 +1. 用户目标优先于技术偏好。 +2. 正确性优先于速度,速度优先于形式完美。 +3. 能验证的结论才算结论。 +4. 高风险操作必须显式说明影响和回滚路径。 +5. 复杂任务拆解为可检查的阶段结果。 diff --git a/agent/workspace-main/USER.md b/agent/workspace-main/USER.md new file mode 100644 index 00000000..fc2ee99d --- /dev/null +++ b/agent/workspace-main/USER.md @@ -0,0 +1,16 @@ +# USER + +## 服务对象画像 +- 角色:负责人/决策者,时间稀缺。 +- 关注:业务影响、交付速度、回归风险、可回滚性。 +- 预期:拿到可以立即执行或决策的答案。 + +## 输出偏好 +- 固定顺序:结论 -> 影响范围 -> 验证状态 -> 下一步。 +- 复杂事项给 2-3 个方案,但明确推荐一个主方案。 +- 若存在不确定性,明确“已知/未知/待确认”。 + +## 反感点 +- 大段背景铺垫但没有结论。 +- 只讲思路不落地。 +- 隐瞒风险或把风险说模糊。 diff --git a/agent/workspace-main/memory/README.md b/agent/workspace-main/memory/README.md new file mode 100644 index 00000000..3fc68fb4 --- /dev/null +++ b/agent/workspace-main/memory/README.md @@ -0,0 +1,7 @@ +# memory + +长期记忆目录。 + +- preferences.md:偏好 +- project_facts.md:稳定事实 +- lessons.md:复盘经验 diff --git a/agent/workspace-social/AGENTS.md b/agent/workspace-social/AGENTS.md new file mode 100644 index 00000000..2630ca50 --- /dev/null +++ b/agent/workspace-social/AGENTS.md @@ -0,0 +1,23 @@ +# AGENTS + +## 专业能力 +- 对外文案撰写与润色(公告、邮件、FAQ、发布说明)。 +- 语气治理(正式/亲和/技术向)与术语统一。 +- 多渠道改写(站内通知、社媒、工单回复、文档说明)。 + +## 标准工作流 +1. 明确场景:受众、渠道、目标动作。 +2. 抽取事实:从 coding/ops 输出中提炼可公开信息。 +3. 生成成稿:默认主版本 + 可选备选版本。 +4. 审核风险:检查歧义、过度承诺、敏感信息泄露。 +5. 标注发布建议:标题、摘要、正文、CTA。 + +## 交付格式(固定) +- Audience: 面向谁。 +- Key Message: 一句话主信息。 +- Copy: 可直接发布正文。 +- Optional Variants: 可选语气版本。 + +## 协作规则 +- 技术细节不确定时,先向 `coding` 要事实澄清。 +- 运行状态与时间预估不确定时,先向 `ops` 校验。 diff --git a/agent/workspace-social/IDENTITY.md b/agent/workspace-social/IDENTITY.md new file mode 100644 index 00000000..7ddabbc9 --- /dev/null +++ b/agent/workspace-social/IDENTITY.md @@ -0,0 +1,17 @@ +# IDENTITY + +## 名字 +Social Communication Specialist + +## 职位 +对外表达负责人(External Comms Owner) + +## 核心职责 +- 产出对外文本:公告、邮件、说明、更新日志、PR 描述。 +- 根据受众调整语气:管理层、客户、开发者、普通用户。 +- 做信息分层:一句话摘要、标准版、详细版。 +- 保证术语一致,避免歧义和过度承诺。 + +## 职责边界 +- 不修改技术实现细节(交给 coding)。 +- 不替代事实判断;技术事实以证据源为准。 diff --git a/agent/workspace-social/SOUL.md b/agent/workspace-social/SOUL.md new file mode 100644 index 00000000..3a21bf82 --- /dev/null +++ b/agent/workspace-social/SOUL.md @@ -0,0 +1,16 @@ +# SOUL + +## 核心人格 +- 编辑总监型:保证信息准确、语气统一、对外可发布。 +- 受众敏感:先考虑读者理解成本,再考虑表达“漂亮”。 +- 克制表达:少形容词,多清晰事实与行动指引。 + +## 说话风格 +- 先给“一句话主信息”,再给细节版本。 +- 提供可直接复制使用的成稿。 +- 保持礼貌与专业,不油腻、不空泛。 + +## 价值原则 +1. 准确性高于文采。 +2. 清晰度高于长度。 +3. 品牌一致性高于个人风格。 diff --git a/agent/workspace-social/USER.md b/agent/workspace-social/USER.md new file mode 100644 index 00000000..305721c6 --- /dev/null +++ b/agent/workspace-social/USER.md @@ -0,0 +1,14 @@ +# USER + +## 服务对象画像 +- 主要对象:运营、市场、客户成功、管理层。 +- 他们需要“可直接发布”的成品,而不是草稿思路。 + +## 输出偏好 +- 默认提供三层文本:一句话版 / 标准版 / 详细版。 +- 明确标注受众和使用场景。 +- 对可能引发误解的句子给替代表达。 + +## 风险偏好 +- 宁可少承诺,不做无法兑现的承诺。 +- 涉及时间、范围、SLA 时必须谨慎措辞。 diff --git a/agent/workspace-social/memory/README.md b/agent/workspace-social/memory/README.md new file mode 100644 index 00000000..2c65f8a3 --- /dev/null +++ b/agent/workspace-social/memory/README.md @@ -0,0 +1,7 @@ +# memory + +内容与沟通长期记忆目录。 + +- preferences.md:语气与品牌偏好 +- project_facts.md:固定术语与禁用词 +- lessons.md:历史反馈与优化经验 diff --git a/agents/__init__.py b/agents/__init__.py new file mode 100644 index 00000000..e2c13e63 --- /dev/null +++ b/agents/__init__.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Any + +from .agent_scope import ( + resolve_agent_id_by_workspace_path, + resolve_agent_id_from_session_key, + resolve_agent_ids_by_workspace_path, + resolve_agent_workspace_dir, + resolve_default_agent_id, + resolve_session_agent_id, + resolve_session_agent_ids, +) +from .subagent_registry import init_subagent_registry + +__all__ = [ + "build_gateway_executor", + "build_ops_agent", + "NetworkOpsAgent", + "resolve_default_agent_id", + "resolve_agent_workspace_dir", + "resolve_agent_id_from_session_key", + "resolve_session_agent_id", + "resolve_session_agent_ids", + "resolve_agent_id_by_workspace_path", + "resolve_agent_ids_by_workspace_path", + "init_subagent_registry", +] + + +def __getattr__(name: str) -> Any: + if name in {"build_gateway_executor", "build_ops_agent"}: + from .factory import build_gateway_executor, build_ops_agent + + return {"build_gateway_executor": build_gateway_executor, "build_ops_agent": build_ops_agent}[name] + if name == "NetworkOpsAgent": + from .network_ops_agent import NetworkOpsAgent + + return NetworkOpsAgent + raise AttributeError(f"module 'src.agents' has no attribute {name!r}") \ No newline at end of file diff --git a/agents/agent_scope.py b/agents/agent_scope.py new file mode 100644 index 00000000..55a58905 --- /dev/null +++ b/agents/agent_scope.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +DEFAULT_AGENT_ID = "default" + + +def _normalize_agent_id(value: str | None) -> str: + text = str(value or "").strip().lower() + if not text: + return DEFAULT_AGENT_ID + out = [] + for ch in text: + if ch.isalnum() or ch in {"-", "_"}: + out.append(ch) + elif ch.isspace(): + out.append("-") + normalized = "".join(out).strip("-_") + return normalized or DEFAULT_AGENT_ID + + +def list_agent_entries(cfg: dict[str, Any]) -> list[dict[str, Any]]: + agents = (cfg.get("agents") or {}) if isinstance(cfg, dict) else {} + entries = agents.get("list") + if not isinstance(entries, list): + return [] + return [x for x in entries if isinstance(x, dict)] + + +def list_agent_ids(cfg: dict[str, Any]) -> list[str]: + entries = list_agent_entries(cfg) + if not entries: + return [DEFAULT_AGENT_ID] + seen: set[str] = set() + ids: list[str] = [] + for entry in entries: + aid = _normalize_agent_id(entry.get("id")) + if aid in seen: + continue + seen.add(aid) + ids.append(aid) + return ids or [DEFAULT_AGENT_ID] + + +def resolve_default_agent_id(cfg: dict[str, Any]) -> str: + entries = list_agent_entries(cfg) + if not entries: + return DEFAULT_AGENT_ID + defaults = [x for x in entries if bool(x.get("default"))] + chosen = (defaults[0] if defaults else entries[0]).get("id") + return _normalize_agent_id(chosen) + + +def _resolve_agent_entry(cfg: dict[str, Any], agent_id: str) -> dict[str, Any] | None: + target = _normalize_agent_id(agent_id) + for entry in list_agent_entries(cfg): + if _normalize_agent_id(entry.get("id")) == target: + return entry + return None + + +def resolve_agent_id_from_session_key(session_key: str | None) -> str: + text = str(session_key or "").strip() + if not text: + return DEFAULT_AGENT_ID + prefix = text.split(":", 1)[0].strip() + return _normalize_agent_id(prefix) + + +def resolve_session_agent_ids( + *, + session_key: str | None = None, + config: dict[str, Any] | None = None, + agent_id: str | None = None, +) -> dict[str, str]: + cfg = config if isinstance(config, dict) else {} + default_agent_id = resolve_default_agent_id(cfg) + explicit_agent_id = _normalize_agent_id(agent_id) if str(agent_id or "").strip() else None + session_agent_id = explicit_agent_id or resolve_agent_id_from_session_key(session_key) or default_agent_id + return {"default_agent_id": default_agent_id, "session_agent_id": session_agent_id} + + +def resolve_session_agent_id( + *, + session_key: str | None = None, + config: dict[str, Any] | None = None, + agent_id: str | None = None, +) -> str: + return resolve_session_agent_ids(session_key=session_key, config=config, agent_id=agent_id)["session_agent_id"] + + +def _normalize_path_for_comparison(input_path: str) -> Path: + raw = str(input_path or "").replace("\x00", "").strip() or "." + p = Path(raw).expanduser() + try: + p = p.resolve(strict=False) + except Exception: + pass + norm = str(p) + if os.name == "nt": + norm = norm.lower() + return Path(norm) + + +def _is_path_within_root(candidate_path: Path, root_path: Path) -> bool: + try: + candidate_path.relative_to(root_path) + return True + except ValueError: + return False + + +def resolve_agent_workspace_dir(cfg: dict[str, Any], agent_id: str) -> str: + aid = _normalize_agent_id(agent_id) + agents_cfg = (cfg.get("agents") or {}) if isinstance(cfg, dict) else {} + defaults = (agents_cfg.get("defaults") or {}) if isinstance(agents_cfg, dict) else {} + entry = _resolve_agent_entry(cfg, aid) or {} + + configured_workspace = str(entry.get("workspace") or "").strip() + if configured_workspace: + return str(Path(configured_workspace)) + + fallback_workspace = str(defaults.get("workspace") or "").strip() + default_agent_id = resolve_default_agent_id(cfg) + if aid == default_agent_id: + if fallback_workspace: + return str(Path(fallback_workspace)) + return str(Path(".")) + + if fallback_workspace: + return str(Path(fallback_workspace) / aid) + + state_dir = str(os.getenv("OPENCLAW_STATE_DIR") or ".openclaw").strip() or ".openclaw" + return str(Path(state_dir) / f"workspace-{aid}") + + +def resolve_agent_ids_by_workspace_path(cfg: dict[str, Any], workspace_path: str) -> list[str]: + target = _normalize_path_for_comparison(workspace_path) + matches: list[tuple[str, Path, int]] = [] + for idx, aid in enumerate(list_agent_ids(cfg)): + ws = _normalize_path_for_comparison(resolve_agent_workspace_dir(cfg, aid)) + if not _is_path_within_root(target, ws): + continue + matches.append((aid, ws, idx)) + matches.sort(key=lambda row: (-len(str(row[1])), row[2])) + return [x[0] for x in matches] + + +def resolve_agent_id_by_workspace_path(cfg: dict[str, Any], workspace_path: str) -> str | None: + ids = resolve_agent_ids_by_workspace_path(cfg, workspace_path) + return ids[0] if ids else None + + +__all__ = [ + "DEFAULT_AGENT_ID", + "list_agent_entries", + "list_agent_ids", + "resolve_agent_id_by_workspace_path", + "resolve_agent_id_from_session_key", + "resolve_agent_ids_by_workspace_path", + "resolve_session_agent_id", + "resolve_session_agent_ids", + "resolve_agent_workspace_dir", + "resolve_default_agent_id", +] diff --git a/agents/factory.py b/agents/factory.py new file mode 100644 index 00000000..4422c413 --- /dev/null +++ b/agents/factory.py @@ -0,0 +1,450 @@ +"""根据存储与配置构建 Agent(不依赖 Streamlit)。""" + +from __future__ import annotations + +import os +from typing import Any + +from oclaw.agents.agent_scope import resolve_default_agent_id +from oclaw.agents.network_ops_agent import NetworkOpsAgent +from oclaw.agents.specialist_agent import SpecialistProfile +from oclaw.agents.specialists import ( + AGENT_PROFILE_BINDINGS_KEY, + AGENT_ROLE_IDS, + MANAGER_AGENT_ID, + SPECIALIST_IDS, + default_system_prefix_for_specialist, + default_tool_tags_for_specialist, + dump_agent_profile_bindings, + expert_name_for_specialist, + parse_agent_profile_bindings, +) +from oclaw.chat.agent import Agent +from oclaw.orchestration.inventory import inventory_snapshot +from oclaw.orchestration.memory import upsert_knowledge_chunks +from oclaw.platform.llm.chat_models import GoogleGeminiChatModel, OpenAIChatModel, RuleBasedChatModel, StaticTextChatModel +from oclaw.platform.llm.transports.anthropic_messages import AnthropicMessagesModel +from oclaw.platform.llm.transports.openai_responses import OpenAIResponsesModel +from oclaw.platform.persistence.sqlite_store import ( + SqliteStore, + active_llm_profile_setting_key, + agent_profile_bindings_setting_key, + is_administrator_model_pool, +) +from oclaw.prompts import render_prompt +from oclaw.tools.catalog import default_registry +from oclaw.tools.plugin_loader import sync_plugin_metadata + + +def _openai_missing_key_user_message(lang: str) -> str: + prompt_id = "fallback/openai_missing_key_user.en.md" if (lang or "zh").startswith("en") else "fallback/openai_missing_key_user.zh.md" + return render_prompt(prompt_id, strict=True) + + +DEFAULT_OLLAMA_BASE_URL = ( + (os.getenv("OLLAMA_BASE_URL") or os.getenv("OPENAI_BASE_URL_OLLAMA") or "").strip() + or "http://127.0.0.1:11434/v1" +) +DEFAULT_OLLAMA_MODEL = (os.getenv("OLLAMA_MODEL") or "qwen2.5:7b").strip() +_OLLAMA_DUMMY_KEY = "ollama" + + +def _build_executor_components( + store: SqliteStore, + *, + lang: str = "zh", + profile_id: str | None = None, + openai_api_key: str | None = None, + llm_mode: str | None = None, + model: str | None = None, + base_url: str | None = None, + viewer_user_id: str | None = None, + viewer_username: str | None = None, + viewer_tenant_id: str | None = None, +) -> tuple[ + NetworkOpsAgent, + dict[str, SpecialistProfile], + object, + str, + dict[str, object], + dict[str, str], +]: + lang = (lang or "zh").strip().lower() + uid_scoped = str(viewer_user_id or "").strip() + personal = bool(uid_scoped) and not is_administrator_model_pool(viewer_username) + if personal: + active_key = active_llm_profile_setting_key(uid_scoped, viewer_username) + bindings_key = agent_profile_bindings_setting_key(uid_scoped, viewer_username) + list_kw: dict[str, Any] = {"viewer_user_id": uid_scoped, "viewer_username": viewer_username} + tid = str(viewer_tenant_id or "").strip() + if tid: + list_kw["viewer_tenant_id"] = tid + else: + active_key = "active_llm_profile_id" + bindings_key = AGENT_PROFILE_BINDINGS_KEY + list_kw = {} + active_pid = (profile_id or store.get_setting(active_key) or "").strip() + + def _normalize_mode(raw: str | None) -> str: + m = (raw or "").strip().lower() + return m if m in ("openai", "openai_responses", "anthropic", "ollama", "rule", "google") else "rule" + + def _build_chat_model_for_profile( + target_profile_id: str | None, + *, + allow_runtime_overrides: bool = False, + ) -> tuple[object, str]: + pid = (target_profile_id or "").strip() + profile = store.get_llm_profile(pid) if pid else None + mode = _normalize_mode( + (llm_mode if allow_runtime_overrides else None) + or (profile.get("mode") if profile else None) + or os.getenv("AIA_ASSISTANT_MODE") + or "openai" + ) + + raw_model = (model if allow_runtime_overrides else None) or (profile.get("model") if profile else None) or "" + raw_model = str(raw_model).strip() + if not raw_model: + raw_model = ( + (os.getenv("OLLAMA_MODEL") or "").strip() + if mode == "ollama" + else (os.getenv("OPENAI_MODEL") or "").strip() + ) + model_name = raw_model or (DEFAULT_OLLAMA_MODEL if mode == "ollama" else "gpt-4o-mini") + + bu = (base_url if allow_runtime_overrides else None) or (profile.get("base_url") if profile else None) or os.getenv("OPENAI_BASE_URL") or "" + bu = str(bu).strip() + stored_key = store.get_llm_profile_secret(pid) if pid else None + api_key = (openai_api_key if allow_runtime_overrides else None) or stored_key or os.getenv("OPENAI_API_KEY") + api_key = (api_key or "").strip() + + if mode == "openai_responses": + if not api_key: + return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode + return OpenAIResponsesModel(model=model_name, api_key=api_key, base_url=bu or None), mode + if mode == "anthropic": + akey = ( + (openai_api_key if allow_runtime_overrides else None) + or stored_key + or os.getenv("ANTHROPIC_API_KEY") + or os.getenv("OPENAI_API_KEY") + or "" + ) + akey = str(akey or "").strip() + if not akey: + return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode + return AnthropicMessagesModel(model=model_name, api_key=akey, base_url=bu or None), mode + if mode == "google": + gkey = ( + (openai_api_key if allow_runtime_overrides else None) + or stored_key + or os.getenv("GOOGLE_API_KEY") + or os.getenv("GEMINI_API_KEY") + or os.getenv("OPENAI_API_KEY") + or "" + ) + gkey = str(gkey or "").strip() + if not gkey: + return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode + return GoogleGeminiChatModel(model=model_name, api_key=gkey, base_url=bu or None), mode + + if mode == "rule": + return RuleBasedChatModel(), mode + if mode == "ollama": + ollama_base = (bu or DEFAULT_OLLAMA_BASE_URL).strip() or DEFAULT_OLLAMA_BASE_URL + ollama_key = api_key or _OLLAMA_DUMMY_KEY + return OpenAIChatModel(model=model_name, api_key=ollama_key, base_url=ollama_base), mode + if not api_key: + return StaticTextChatModel(_openai_missing_key_user_message(lang)), mode + return OpenAIChatModel(model=model_name, api_key=api_key, base_url=bu or None), mode + + valid_profile_ids = {p["id"] for p in store.list_llm_profiles(visible_only=True, **list_kw)} + if active_pid and active_pid not in valid_profile_ids: + active_pid = "" + active_model, active_mode = _build_chat_model_for_profile(active_pid, allow_runtime_overrides=True) + + raw_bindings = parse_agent_profile_bindings(store.get_setting(bindings_key)) + normalized_bindings: dict[str, str] = {} + for rid in AGENT_ROLE_IDS: + pid = (raw_bindings.get(rid) or "").strip() + normalized_bindings[rid] = pid if pid in valid_profile_ids else "" + if dump_agent_profile_bindings(normalized_bindings) != dump_agent_profile_bindings(raw_bindings): + store.set_setting(bindings_key, dump_agent_profile_bindings(normalized_bindings)) + + def _pick_model_for_role(role_id: str) -> tuple[object, str]: + bound_pid = (normalized_bindings.get(role_id) or "").strip() + if not bound_pid: + return active_model, active_mode + return _build_chat_model_for_profile(bound_pid, allow_runtime_overrides=False) + + manager_model, manager_mode = _pick_model_for_role(MANAGER_AGENT_ID) + specialist_models: dict[str, object] = {} + specialist_modes: dict[str, str] = {} + for sid in SPECIALIST_IDS: + m, md = _pick_model_for_role(sid) + specialist_models[sid] = m + specialist_modes[sid] = md + + base_agent = NetworkOpsAgent( + store=store, + model=specialist_models.get("ops") or active_model, + lang=lang, + llm_profile_mode=specialist_modes.get("ops") or active_mode, + ) + try: + store.set_setting("agent_inventory_snapshot", str(inventory_snapshot())) + except Exception: + pass + try: + sync_plugin_metadata(store) + except Exception: + pass + try: + upsert_knowledge_chunks( + store, + source="builtin:src", + chunks=[ + "Use tools for route lookup, path search, config diff, device ping, and log analysis.", + "High-risk actions require explicit confirmation by user before execution.", + "Prefer citing tool outputs and avoid fabricating external facts.", + ], + ) + except Exception: + pass + specialist_profiles = { + "ops": SpecialistProfile( + name="ops", + system_prefix=default_system_prefix_for_specialist("ops", lang), + tool_tags=default_tool_tags_for_specialist("ops"), + ), + "generalist": SpecialistProfile( + name="generalist", + system_prefix=default_system_prefix_for_specialist("generalist", lang), + tool_tags=default_tool_tags_for_specialist("generalist"), + ), + "image": SpecialistProfile( + name="image", + system_prefix=default_system_prefix_for_specialist("image", lang), + tool_tags=default_tool_tags_for_specialist("image"), + ), + "memory_curator": SpecialistProfile( + name="memory_curator", + system_prefix=default_system_prefix_for_specialist("memory_curator", lang), + tool_tags=default_tool_tags_for_specialist("memory_curator"), + ), + } + return ( + base_agent, + specialist_profiles, + manager_model, + manager_mode, + specialist_models, + specialist_modes, + ) + + +def build_ops_agent( + store: SqliteStore, + *, + lang: str = "zh", + profile_id: str | None = None, + openai_api_key: str | None = None, + llm_mode: str | None = None, + model: str | None = None, + base_url: str | None = None, + viewer_user_id: str | None = None, + viewer_username: str | None = None, + viewer_tenant_id: str | None = None, +) -> Any: + del viewer_user_id, viewer_username, viewer_tenant_id + return build_gateway_executor( + store, + lang=lang, + specialist="ops", + profile_id=profile_id, + openai_api_key=openai_api_key, + llm_mode=llm_mode, + model=model, + base_url=base_url, + ) + + +def build_gateway_executor( + store: SqliteStore, + *, + lang: str = "zh", + specialist: str | None = None, + profile_id: str | None = None, + openai_api_key: str | None = None, + llm_mode: str | None = None, + model: str | None = None, + base_url: str | None = None, + viewer_user_id: str | None = None, + viewer_username: str | None = None, + viewer_tenant_id: str | None = None, + policy_session_id: str | None = None, + path_policy_tenant_id: str | None = None, + path_policy_user_id: str | None = None, +) -> Any: + base_agent, specialist_profiles, _, _, specialist_models, specialist_modes = _build_executor_components( + store, + lang=lang, + profile_id=profile_id, + openai_api_key=openai_api_key, + llm_mode=llm_mode, + model=model, + base_url=base_url, + viewer_user_id=viewer_user_id, + viewer_username=viewer_username, + viewer_tenant_id=viewer_tenant_id, + ) + sid = str(specialist or "").strip().lower() or "generalist" + if sid not in specialist_profiles: + sid = "generalist" + prof = specialist_profiles.get(sid) or specialist_profiles["generalist"] + chosen_model = specialist_models.get(prof.name) or base_agent.model + chosen_mode = specialist_modes.get(prof.name) or getattr(base_agent, "llm_profile_mode", None) + if prof.name == "ops": + return NetworkOpsAgent( + store=store, + model=chosen_model, + lang=(lang or "zh").strip().lower(), + llm_profile_mode=chosen_mode, + system_prompt=prof.system_prefix, + policy_session_id=policy_session_id, + path_policy_tenant_id=path_policy_tenant_id, + path_policy_user_id=path_policy_user_id, + ) + tools = default_registry( + expert=expert_name_for_specialist(prof.name), + specialist=prof.name, + policy_session_id=policy_session_id, + path_policy_tenant_id=path_policy_tenant_id, + path_policy_user_id=path_policy_user_id, + store=store, + ) + return Agent( + store=store, + tools=tools, + model=chosen_model, + system_prompt=prof.system_prefix, + lang=(lang or "zh").strip().lower(), + llm_profile_mode=chosen_mode, + ) + + +def build_gateway_executors( + store: SqliteStore, + *, + lang: str = "zh", + profile_id: str | None = None, + openai_api_key: str | None = None, + llm_mode: str | None = None, + model: str | None = None, + base_url: str | None = None, + viewer_user_id: str | None = None, + viewer_username: str | None = None, + viewer_tenant_id: str | None = None, + policy_session_id: str | None = None, + path_policy_tenant_id: str | None = None, + path_policy_user_id: str | None = None, +) -> dict[str, Any]: + manager = build_gateway_executor( + store, + lang=lang, + specialist="generalist", + profile_id=profile_id, + openai_api_key=openai_api_key, + llm_mode=llm_mode, + model=model, + base_url=base_url, + viewer_user_id=viewer_user_id, + viewer_username=viewer_username, + viewer_tenant_id=viewer_tenant_id, + policy_session_id=policy_session_id, + path_policy_tenant_id=path_policy_tenant_id, + path_policy_user_id=path_policy_user_id, + ) + specialists: dict[str, Any] = {} + for sid in SPECIALIST_IDS: + specialists[sid] = build_gateway_executor( + store, + lang=lang, + specialist=sid, + profile_id=profile_id, + openai_api_key=openai_api_key, + llm_mode=llm_mode, + model=model, + base_url=base_url, + viewer_user_id=viewer_user_id, + viewer_username=viewer_username, + viewer_tenant_id=viewer_tenant_id, + policy_session_id=policy_session_id, + path_policy_tenant_id=path_policy_tenant_id, + path_policy_user_id=path_policy_user_id, + ) + return {"manager": manager, "specialists": specialists} + + +def build_ephemeral_executor( + store: SqliteStore, + *, + lang: str = "zh", + system_prompt: str, + tool_policy: dict[str, Any] | None = None, + profile_id: str | None = None, + openai_api_key: str | None = None, + llm_mode: str | None = None, + model: str | None = None, + base_url: str | None = None, + viewer_user_id: str | None = None, + viewer_username: str | None = None, + viewer_tenant_id: str | None = None, + policy_session_id: str | None = None, + path_policy_tenant_id: str | None = None, + path_policy_user_id: str | None = None, +) -> Any: + base_agent, _, _, _, _, _ = _build_executor_components( + store, + lang=lang, + profile_id=profile_id, + openai_api_key=openai_api_key, + llm_mode=llm_mode, + model=model, + base_url=base_url, + viewer_user_id=viewer_user_id, + viewer_username=viewer_username, + viewer_tenant_id=viewer_tenant_id, + ) + declared = tool_policy if isinstance(tool_policy, dict) else {} + allow_tags = [str(x) for x in (declared.get("allow_tags") or []) if str(x or "").strip()] + allow_tools = [str(x) for x in (declared.get("allow_tools") or []) if str(x or "").strip()] + tools = default_registry( + expert="generalist+workspace+productivity", + specialist="generalist", + policy_session_id=policy_session_id, + path_policy_tenant_id=path_policy_tenant_id, + path_policy_user_id=path_policy_user_id, + store=store, + allow_tags=allow_tags, + allow_tools=allow_tools, + ) + return Agent( + store=store, + tools=tools, + model=base_agent.model, + system_prompt=str(system_prompt or "").strip(), + lang=(lang or "zh").strip().lower(), + llm_profile_mode=getattr(base_agent, "llm_profile_mode", None), + ) + + +__all__ = [ + "DEFAULT_OLLAMA_BASE_URL", + "DEFAULT_OLLAMA_MODEL", + "build_ops_agent", + "build_gateway_executor", + "build_gateway_executors", + "build_ephemeral_executor", +] diff --git a/agents/network_ops_agent.py b/agents/network_ops_agent.py new file mode 100644 index 00000000..0f119437 --- /dev/null +++ b/agents/network_ops_agent.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Any + +from oclaw.chat.agent import Agent +from oclaw.platform.persistence.sqlite_store import SqliteStore +from oclaw.prompts.loader import render_openclaw_prompt +from oclaw.tools import default_registry + +NETWORK_SYSTEM_PROMPT_ZH = render_openclaw_prompt("roles/specialists/ops/system.md", strict=True) + + +class NetworkOpsAgent(Agent): + """网络运维专家 Agent:固定专家提示词与专家工具目录。""" + + def __init__( + self, + *, + store: SqliteStore, + model: Any, + lang: str = "zh", + llm_profile_mode: str | None = None, + system_prompt: str | None = None, + policy_session_id: str | None = None, + path_policy_tenant_id: str | None = None, + path_policy_user_id: str | None = None, + ) -> None: + tools = default_registry( + expert="network_ops", + policy_session_id=policy_session_id, + path_policy_tenant_id=path_policy_tenant_id, + path_policy_user_id=path_policy_user_id, + store=store, + ) + super().__init__( + store=store, + tools=tools, + model=model, + system_prompt=(system_prompt or render_openclaw_prompt("roles/specialists/ops/system.md", strict=True)), + lang=lang, + llm_profile_mode=llm_profile_mode, + ) + + +__all__ = ["NetworkOpsAgent", "NETWORK_SYSTEM_PROMPT_ZH"] diff --git a/agents/specialist_agent.py b/agents/specialist_agent.py new file mode 100644 index 00000000..c3a61535 --- /dev/null +++ b/agents/specialist_agent.py @@ -0,0 +1,516 @@ +from __future__ import annotations + +import json +import os +import time +import base64 +import hashlib +import httpx +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, Optional + +from oclaw.chat.agent import Agent +from oclaw.chat.agent import GenerationInterrupted +from oclaw.agents.network_ops_agent import NetworkOpsAgent +from oclaw.platform.persistence.sqlite_store import SqliteStore +from oclaw.platform.files.attachment_assets import AttachmentAssetStore, attachment_id_to_data_url +from oclaw.platform.llm.image_message_client import send_image_messages +from oclaw.tools import default_registry +from oclaw.agents.specialists import expert_name_for_specialist + +from oclaw.chat.turn_types import TurnRunOutcome +from oclaw.openclaw_runtime.relay_pointer import build_manifest_from_attachment_refs +from oclaw.openclaw_runtime.types import RelayShareEnvelope +from oclaw.orchestration.protocol import ( + AgentTask, + PlanStep, + SpecialistDelivery, + SpecialistResult, + SpecialistToolTrace, +) + + +@dataclass(frozen=True) +class SpecialistProfile: + name: str + system_prefix: str + tool_tags: frozenset[str] | None = None + + +@dataclass +class SpecialistAgentRunner: + store: SqliteStore + model: Any + llm_profile_mode: str | None + lang: str + profiles: dict[str, SpecialistProfile] = field(default_factory=dict) + model_by_specialist: dict[str, Any] = field(default_factory=dict) + llm_mode_by_specialist: dict[str, str | None] = field(default_factory=dict) + _agent_cache: dict[tuple, Agent] = field(default_factory=dict, init=False, repr=False) + + @staticmethod + def _allowlist_mutation_fingerprint( + store: SqliteStore, + *, + policy_session_id: str | None = None, + path_policy_tenant_id: str | None = None, + path_policy_user_id: str | None = None, + ) -> str: + t = (path_policy_tenant_id or "").strip() or None + u = (path_policy_user_id or "").strip() or None + if (not t or not u) and (policy_session_id or "").strip(): + try: + own = store.get_ui_session_owner(session_id=str(policy_session_id).strip()) or {} + except Exception: + own = {} + t = t or (str(own.get("tenant_id") or "").strip() or None) + u = u or (str(own.get("user_id") or "").strip() or None) + if not t or not u: + return "0" + try: + row = store.get_user_workspace_path_allowlist(tenant_id=t, user_id=u) + except Exception: + row = None + if not row or not isinstance(row, dict): + return "0|" + er = str(row.get("extra_roots") or "") + return f"{1 if int(row.get('allow_any_path') or 0) else 0}|{str(row.get('updated_at') or '')}|{er[:2000]}" + + def _agent_cache_fingerprint( + self, + specialist: str, + prof: SpecialistProfile, + *, + policy_session_id: str | None = None, + path_policy_tenant_id: str | None = None, + path_policy_user_id: str | None = None, + ) -> str: + tool_names: list[str] = [] + try: + regs = default_registry( + expert=expert_name_for_specialist(prof.name), + specialist=prof.name, + policy_session_id=policy_session_id, + path_policy_tenant_id=path_policy_tenant_id, + path_policy_user_id=path_policy_user_id, + store=self.store, + ) + tool_names = sorted([str(t.name) for t in regs.list()]) + except Exception: + tool_names = [] + raw = json.dumps( + { + "specialist": specialist, + "profile_name": prof.name, + "system_prefix": prof.system_prefix, + "tool_names": tool_names, + "tool_tags": sorted(list(prof.tool_tags or frozenset())), + "policy_session_tail": (str(policy_session_id or "")[-16:]), + "allowlist_fp": self._allowlist_mutation_fingerprint( + self.store, + policy_session_id=policy_session_id, + path_policy_tenant_id=path_policy_tenant_id, + path_policy_user_id=path_policy_user_id, + ), + }, + ensure_ascii=False, + sort_keys=True, + ) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] + + def _resolve_profile_and_model(self, specialist: str) -> tuple[SpecialistProfile, Any, str | None]: + prof = self.profiles.get(specialist) or self.profiles["generalist"] + chosen_model = self.model_by_specialist.get(prof.name) or self.model + chosen_mode = self.llm_mode_by_specialist.get(prof.name) or self.llm_profile_mode + return prof, chosen_model, chosen_mode + + def _build_agent_for( + self, + specialist: str, + *, + policy_session_id: str | None = None, + use_cache: bool = True, + path_policy_tenant_id: str | None = None, + path_policy_user_id: str | None = None, + ) -> Agent: + prof, chosen_model, chosen_mode = self._resolve_profile_and_model(specialist) + cache_fp = self._agent_cache_fingerprint( + specialist, + prof, + policy_session_id=policy_session_id, + path_policy_tenant_id=path_policy_tenant_id, + path_policy_user_id=path_policy_user_id, + ) + alfp = self._allowlist_mutation_fingerprint( + self.store, + policy_session_id=policy_session_id, + path_policy_tenant_id=path_policy_tenant_id, + path_policy_user_id=path_policy_user_id, + ) + cache_key = (prof.name, id(chosen_model), chosen_mode, self.lang, cache_fp, str(policy_session_id or ""), alfp) + if use_cache: + cached = self._agent_cache.get(cache_key) + if cached is not None: + return cached + if prof.name == "ops": + agent: Agent = NetworkOpsAgent( + store=self.store, + model=chosen_model, + lang=self.lang, + llm_profile_mode=chosen_mode, + system_prompt=prof.system_prefix, + policy_session_id=policy_session_id, + path_policy_tenant_id=path_policy_tenant_id, + path_policy_user_id=path_policy_user_id, + ) + if use_cache: + self._agent_cache[cache_key] = agent + return agent + tools = default_registry( + expert=expert_name_for_specialist(prof.name), + specialist=prof.name, + policy_session_id=policy_session_id, + path_policy_tenant_id=path_policy_tenant_id, + path_policy_user_id=path_policy_user_id, + store=self.store, + ) + agent = Agent( + store=self.store, + tools=tools, + model=chosen_model, + system_prompt=prof.system_prefix, + lang=self.lang, + llm_profile_mode=chosen_mode, + ) + if use_cache: + self._agent_cache[cache_key] = agent + return agent + + def run_specialist( + self, + *, + parent_task: AgentTask, + step: PlanStep, + session_id: str | None = None, + use_cache: bool = True, + on_progress: Optional[Callable[[str], None]] = None, + on_token: Optional[Callable[[str], None]] = None, + on_tool_ui: Optional[Callable[[str, dict[str, Any]], None]] = None, + should_stop: Optional[Callable[[], bool]] = None, + ) -> SpecialistResult: + started = time.perf_counter() + if on_progress: + obj = (step.objective or "").strip().replace("\n", " ") + if len(obj) > 140: + obj = obj[:137] + "..." + on_progress(f"[sp.start] {step.step_id} specialist={step.specialist} objective={obj}") + created_session_id: str | None = None + if not session_id: + temp_session = self.store.create_session(f"specialist:{step.specialist}") + session_id = temp_session.id + created_session_id = session_id + # User chat session for workspace/MCP path policy (specialist temp session usually has no ui_session_owner). + _raw_policy_sid = str(parent_task.session_id or "").strip() or str(session_id or "").strip() + policy_session_id: str | None = _raw_policy_sid if _raw_policy_sid else None + _meta: dict[str, Any] = parent_task.metadata if isinstance(getattr(parent_task, "metadata", None), dict) else {} + _path_tenant = str(_meta.get("tenant_id") or "").strip() or None + _path_user = str(_meta.get("user_id") or "").strip() or None + prompt = ( + f"Specialist: {step.specialist}\n" + f"Objective: {step.objective}\n" + f"Parent user request: {parent_task.user_text}\n" + f"Step input: {step.input_text}\n" + "Execution policy: when the user asks to read/open/list/summarize concrete files, URLs, or MCP resources, " + "execute with available tools first. Do not return generic optimization plans unless explicitly requested.\n" + ) + image_input_count = 0 + image_input_kind: list[str] = [] + image_protocol = "" + image_debug_schema = "" + image_debug_payload: dict[str, Any] | str = {} + specialist_delivery: SpecialistDelivery | None = None + try: + if step.specialist == "image": + image_protocol = "messages.content.image" + selected_images: list[str] = [] + for att in parent_task.attachments or []: + if not isinstance(att, dict): + continue + t = str(att.get("type") or "").strip().lower() + if t == "image_ref": + aid = str(att.get("attachment_id") or "").strip() + if not aid: + continue + data_url = attachment_id_to_data_url(aid, mime=str(att.get("mime") or "")) + if data_url: + selected_images.append(data_url) + elif t in ("input_image", "image"): + raw = str(att.get("image_base64") or att.get("data") or "").strip() + if raw: + mime = str(att.get("mime") or "image/jpeg") + if raw.startswith("data:"): + selected_images.append(raw) + else: + selected_images.append(f"data:{mime};base64,{raw}") + elif t == "image_url": + u = str(att.get("url") or "").strip() + if u: + selected_images.append(u) + if len(selected_images) >= 3: + break + image_input_count = len(selected_images) + image_input_kind = ["data_url" if s.startswith("data:") else "url" for s in selected_images] + if not selected_images: + output = "Image specialist received no image input." + ok = False + else: + _, chosen_model, _ = self._resolve_profile_and_model(step.specialist) + model_name = str( + os.getenv("AIA_IMAGE_MODEL") + or getattr(chosen_model, "model", None) + or "" + ).strip() or None + api_key = str(getattr(chosen_model, "api_key", "") or "").strip() or None + base_url = str(getattr(chosen_model, "base_url", "") or "").strip() or None + dashscope_api_key = api_key if base_url and "dashscope.aliyuncs.com" in base_url.lower() else None + dashscope_base_http_api_url = None + if base_url and "dashscope.aliyuncs.com" in base_url.lower(): + # Normalize compatible-mode/v1 to native /api/v1 for DashScope SDK. + dashscope_base_http_api_url = str(base_url).replace("/compatible-mode/v1", "/api/v1") + resp = send_image_messages( + images=selected_images, + prompt=f"{step.objective}\n\n{step.input_text}\n\n{parent_task.user_text}", + model=model_name, + api_key=api_key, + base_url=base_url, + dashscope_api_key=dashscope_api_key, + dashscope_base_http_api_url=dashscope_base_http_api_url, + ) + image_debug_schema = str(resp.get("debug_used_schema") or "").strip() + dbg = resp.get("debug_used_debug") + if isinstance(dbg, dict): + image_debug_payload = dbg + elif dbg is not None: + image_debug_payload = str(dbg) + ok = bool(resp.get("ok")) + output = str(resp.get("text") or "").strip() + if not ok: + err = str(resp.get("error") or "").strip() + output = f"Image generation failed: {err or 'unknown error'}" + elif not output: + output = "Image processed." + # persist output images as attachment assets for UI rendering + produced_attachments: list[dict[str, Any]] = [] + if ok: + out_images = resp.get("images") + if isinstance(out_images, list): + store = AttachmentAssetStore() + for idx, item in enumerate(out_images[:3], start=1): + s = str(item or "").strip() + if not s: + continue + if s.startswith("data:") and ";base64," in s: + head, b64 = s.split(";base64,", 1) + mime = head.replace("data:", "", 1) or "image/png" + try: + blob = base64.b64decode(b64.encode("ascii")) + except Exception: + continue + meta = store.save_bytes( + blob, + filename=f"image-output-{idx}.png", + mime=mime, + ) + produced_attachments.append( + { + "type": "image_ref", + "attachment_id": meta.attachment_id, + "name": meta.name, + "mime": meta.mime, + "bytes": meta.bytes, + "width": meta.width, + "height": meta.height, + } + ) + elif s.startswith("http://") or s.startswith("https://"): + try: + with httpx.Client(timeout=20.0, follow_redirects=True) as client: + r = client.get(s) + if r.status_code < 400 and r.content: + mime = str(r.headers.get("content-type") or "image/png").split(";", 1)[0].strip() or "image/png" + ext = ".png" + if mime == "image/jpeg": + ext = ".jpg" + elif mime == "image/webp": + ext = ".webp" + elif mime == "image/gif": + ext = ".gif" + meta = store.save_bytes( + r.content, + filename=f"image-output-{idx}{ext}", + mime=mime, + ) + produced_attachments.append( + { + "type": "image_ref", + "attachment_id": meta.attachment_id, + "name": meta.name, + "mime": meta.mime, + "bytes": meta.bytes, + "width": meta.width, + "height": meta.height, + } + ) + continue + except Exception: + pass + produced_attachments.append( + { + "type": "image_url", + "url": s, + "name": f"image-output-{idx}.png", + } + ) + # Treat missing image outputs as failure to avoid false "generated" state. + if not produced_attachments: + ok = False + output = ( + "Image generation failed: response succeeded but no image output was returned." + ) + self.store.add_message( + session_id=session_id, + role="assistant", + content=output, + attachments=produced_attachments or None, + ) + specialist_delivery = SpecialistDelivery( + specialist=step.specialist, + step_id=step.step_id, + answer_text=str(output or ""), + tool_traces=(), + notes="image_pipeline", + ) + else: + agent = self._build_agent_for( + step.specialist, + policy_session_id=policy_session_id, + use_cache=use_cache, + path_policy_tenant_id=_path_tenant, + path_policy_user_id=_path_user, + ) + from oclaw.openclaw_runtime.gateway import OpenClawGateway + from oclaw.openclaw_runtime.types import StandardMessage + + gw = OpenClawGateway(store=self.store) + msg = StandardMessage( + session_id=str(session_id), + tenant_id=str(_path_tenant or ""), + user_id=str(_path_user or ""), + role="member", + channel="specialist", + text=str(prompt or ""), + attachments=list(parent_task.attachments or []), + metadata={ + "tenant_id": str(_path_tenant or ""), + "user_id": str(_path_user or ""), + "channel": f"specialist:{step.specialist}", + }, + ) + output = gw.handle_turn( + msg=msg, + lang=str(getattr(agent, "lang", "zh") or "zh"), + executor=agent, + on_token=on_token, + on_progress=on_progress, + on_tool_ui=on_tool_ui, + should_stop=should_stop, + ).reply_text + ok = bool((output or "").strip()) + outcome = getattr(agent, "_last_turn_outcome", None) + if isinstance(outcome, TurnRunOutcome): + traces = tuple( + SpecialistToolTrace( + name=str(x.get("name") or ""), + ok=bool(x.get("ok")), + latency_ms=int(x.get("latency_ms") or x.get("duration_ms") or 0), + ) + for x in outcome.tool_traces + ) + specialist_delivery = SpecialistDelivery( + specialist=step.specialist, + step_id=step.step_id, + answer_text=str(output or ""), + tool_traces=traces, + notes=str(outcome.handoff_note or ""), + ) + except GenerationInterrupted: + raise + except Exception as e: + output = f"{type(e).__name__}: {e}" + ok = False + finally: + produced_attachments: list[dict[str, Any]] = [] + try: + rows = self.store.get_messages(session_id=session_id, limit=40) if session_id else [] + for m in reversed(rows): + if str(m.role) != "assistant": + continue + if not m.attachments: + continue + raw = json.loads(m.attachments) + if isinstance(raw, list): + produced_attachments = [a for a in raw if isinstance(a, dict)] + break + except Exception: + produced_attachments = [] + if created_session_id: + try: + parent_sid = str(parent_task.session_id or "").strip() + if parent_sid and parent_sid != str(created_session_id): + # Preserve tool usage telemetry: tool uses run inside temp specialist sessions. + # If we delete temp sessions directly, FK cascade would drop those tool_log rows. + self.store.move_tool_logs_to_session( + from_session_id=str(created_session_id), + to_session_id=parent_sid, + ) + except Exception: + pass + self.store.delete_session(created_session_id) + latency = int((time.perf_counter() - started) * 1000) + if on_progress: + on_progress( + f"[sp.done] {step.step_id} specialist={step.specialist} ok={ok} latency_ms={latency}" + ) + scope_id = str(session_id or parent_task.session_id or "").strip() + manifest = build_manifest_from_attachment_refs( + produced_attachments, + scope_id=scope_id, + source_agent=str(step.specialist or ""), + ttl_policy="turn", + ) + relay_env = RelayShareEnvelope( + schema_version="v1", + trace_id=str((parent_task.metadata or {}).get("trace_id") or ""), + run_id=str((parent_task.metadata or {}).get("run_id") or ""), + attempt_no=int((parent_task.metadata or {}).get("attempt_no") or 0), + attachments=manifest, + ) + return SpecialistResult( + step_id=step.step_id, + specialist=step.specialist, + success=ok, + output_text=output, + latency_ms=latency, + metadata={ + "objective": step.objective, + "attachments": produced_attachments, + "relay_share_envelope": relay_env.to_dict(), + "image_input_count": image_input_count, + "image_input_kind": image_input_kind, + "image_protocol": image_protocol, + "image_debug_schema": image_debug_schema, + "image_debug_payload": image_debug_payload, + }, + delivery=specialist_delivery, + ) diff --git a/agents/specialists.py b/agents/specialists.py new file mode 100644 index 00000000..cb2a5d06 --- /dev/null +++ b/agents/specialists.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from typing import Any + +from oclaw.infrastructure.agent_context import build_role_system_context + + +SpecialistId = str +AgentRoleId = str +MANAGER_AGENT_ID: AgentRoleId = "manager" +AGENT_PROFILE_BINDINGS_KEY = "agent_profile_bindings" + + +@dataclass(frozen=True) +class SpecialistConfig: + specialist_id: SpecialistId + expert_name: str + default_tool_tags: frozenset[str] | None + + +SPECIALISTS: dict[SpecialistId, SpecialistConfig] = { + "ops": SpecialistConfig( + specialist_id="ops", + expert_name="network_ops", + default_tool_tags=None, + ), + "generalist": SpecialistConfig( + specialist_id="generalist", + expert_name="generalist+workspace+productivity", + default_tool_tags=None, + ), + "image": SpecialistConfig( + specialist_id="image", + # image specialist currently reuses generalist expert tool registry, + # including image_edit tool. + expert_name="generalist", + default_tool_tags=None, + ), + "memory_curator": SpecialistConfig( + specialist_id="memory_curator", + expert_name="memory_curator", + default_tool_tags=None, + ), +} +SPECIALIST_IDS: tuple[SpecialistId, ...] = tuple(SPECIALISTS.keys()) +AGENT_ROLE_IDS: tuple[AgentRoleId, ...] = (MANAGER_AGENT_ID, *SPECIALIST_IDS) + + +def expert_name_for_specialist(specialist_id: SpecialistId) -> str: + cfg = SPECIALISTS.get(specialist_id) or SPECIALISTS["generalist"] + return cfg.expert_name + + +def default_tool_tags_for_specialist(specialist_id: SpecialistId) -> frozenset[str] | None: + cfg = SPECIALISTS.get(specialist_id) or SPECIALISTS["generalist"] + return cfg.default_tool_tags + + +def default_system_prefix_for_specialist(specialist_id: SpecialistId, lang: str = "zh") -> str: + sid = (specialist_id or "").strip().lower() or "generalist" + cfg = SPECIALISTS.get(sid) or SPECIALISTS["generalist"] + _ = (lang or "zh").strip().lower() + return build_role_system_context(cfg.specialist_id) + + +def model_role_for_specialist(specialist_id: SpecialistId) -> AgentRoleId: + sid = (specialist_id or "").strip().lower() + if sid in SPECIALISTS: + return sid + return "generalist" + + +def empty_agent_profile_bindings() -> dict[AgentRoleId, str]: + return {rid: "" for rid in AGENT_ROLE_IDS} + + +def parse_agent_profile_bindings(raw: str | None) -> dict[AgentRoleId, str]: + out = empty_agent_profile_bindings() + text = (raw or "").strip() + if not text: + return out + try: + obj = json.loads(text) + except Exception: + return out + if not isinstance(obj, dict): + return out + for rid in AGENT_ROLE_IDS: + v = obj.get(rid) + if v is None: + continue + s = str(v).strip() + out[rid] = s + return out + + +def dump_agent_profile_bindings(bindings: dict[AgentRoleId, Any]) -> str: + raw = {} + for rid in AGENT_ROLE_IDS: + v = bindings.get(rid) if isinstance(bindings, dict) else None + raw[rid] = str(v).strip() if v is not None else "" + return json.dumps(raw, ensure_ascii=False) + + +__all__ = [ + "AGENT_PROFILE_BINDINGS_KEY", + "AGENT_ROLE_IDS", + "AgentRoleId", + "dump_agent_profile_bindings", + "empty_agent_profile_bindings", + "MANAGER_AGENT_ID", + "SpecialistConfig", + "SpecialistId", + "SPECIALISTS", + "SPECIALIST_IDS", + "default_system_prefix_for_specialist", + "default_tool_tags_for_specialist", + "expert_name_for_specialist", + "model_role_for_specialist", + "parse_agent_profile_bindings", +] diff --git a/agents/subagent_registry.py b/agents/subagent_registry.py new file mode 100644 index 00000000..9a91883f --- /dev/null +++ b/agents/subagent_registry.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from threading import Lock + +_LOCK = Lock() +_INITIALIZED = False + + +def init_subagent_registry() -> None: + """Initialize subagent registry runtime once. + + Python gateway currently keeps this as a lightweight compatibility seam, + so startup code can mirror the OpenClaw TypeScript bootstrap flow. + """ + global _INITIALIZED + with _LOCK: + if _INITIALIZED: + return + _INITIALIZED = True + + +def is_subagent_registry_initialized() -> bool: + with _LOCK: + return _INITIALIZED + + +def reset_subagent_registry_for_tests() -> None: + global _INITIALIZED + with _LOCK: + _INITIALIZED = False + + +__all__ = [ + "init_subagent_registry", + "is_subagent_registry_initialized", + "reset_subagent_registry_for_tests", +] diff --git a/app_server/__init__.py b/app_server/__init__.py new file mode 100644 index 00000000..a04cd9d0 --- /dev/null +++ b/app_server/__init__.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +__all__ = [] + diff --git a/application/__init__.py b/application/__init__.py new file mode 100644 index 00000000..1e4ba36f --- /dev/null +++ b/application/__init__.py @@ -0,0 +1,2 @@ +"""Application use-cases and orchestration services.""" + diff --git a/application/gateway/__init__.py b/application/gateway/__init__.py new file mode 100644 index 00000000..aeb294c6 --- /dev/null +++ b/application/gateway/__init__.py @@ -0,0 +1,6 @@ +"""Gateway application use-cases.""" + +from .inbound_usecase import process_inbound_payload_usecase + +__all__ = ["process_inbound_payload_usecase"] + diff --git a/application/gateway/inbound_service.py b/application/gateway/inbound_service.py new file mode 100644 index 00000000..bf1905dc --- /dev/null +++ b/application/gateway/inbound_service.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +import hashlib +import threading +from typing import Any + +from oclaw.channels.base import InboundMessage, OutboundMessage +from oclaw.channels.wecom.wecom_bridge import WeComAdapter + +_GATEWAY_AGENT_LOCK = threading.Lock() +_GATEWAY_AGENT: Any | None = None + + +def _get_gateway_agent(store: Any) -> Any: + global _GATEWAY_AGENT + with _GATEWAY_AGENT_LOCK: + if _GATEWAY_AGENT is None: + from oclaw.agents.factory import build_gateway_executor + + _GATEWAY_AGENT = build_gateway_executor(store) + return _GATEWAY_AGENT + + +def _menu_text() -> str: + return ( + "已绑定成功,常用命令:\n" + "1) 帮助 / 菜单\n" + "2) 记待办 <内容>\n" + "3) 查待办\n" + "4) 完成待办 \n" + "5) 指派待办 \n" + "6) 加知识 <内容>\n" + "7) 查知识 <关键词>" + ) + + +def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str) -> str | None: + t = (text or "").strip() + if not t: + return None + if t in ("帮助", "菜单", "help", "/help"): + return _menu_text() + + from oclaw.platform.config.paths import db_path + from oclaw.platform.persistence.sqlite_store import SqliteStore + + store = SqliteStore(db_path()) + + if t.startswith("记待办 "): + title = t[len("记待办 ") :].strip() + if not title: + return "待办内容不能为空。示例:记待办 明天10点开会" + row = store.todo_create(tenant_id=tenant_id, owner_user_id=user_id, title=title) + return f"已创建待办:{row['id'][:8]} | {row['title']}" + + if t in ("查待办", "todo", "todos"): + rows = store.todo_list(tenant_id=tenant_id, assignee_user_id=None, status="open", limit=10) + if not rows: + return "当前没有未完成待办。" + lines = [f"- {r['id'][:8]} | {r['title']}" for r in rows] + return "未完成待办:\n" + "\n".join(lines) + + if t.startswith("完成待办 "): + tid = t[len("完成待办 ") :].strip() + if not tid: + return "请提供 todo_id。示例:完成待办 1234abcd" + rows = store.todo_list(tenant_id=tenant_id, assignee_user_id=None, status=None, limit=200) + full = next((r["id"] for r in rows if str(r["id"]).startswith(tid)), tid) + ok = store.todo_set_status(tenant_id=tenant_id, todo_id=full, status="done") + return "已完成。" if ok else "未找到该待办。" + + if t.startswith("指派待办 "): + body = t[len("指派待办 ") :].strip() + parts = body.split() + if len(parts) < 2: + return "格式:指派待办 " + tid, assignee = parts[0], parts[1] + rows = store.todo_list(tenant_id=tenant_id, assignee_user_id=None, status=None, limit=200) + full = next((r["id"] for r in rows if str(r["id"]).startswith(tid)), tid) + ok = store.todo_assign(tenant_id=tenant_id, todo_id=full, assignee_user_id=assignee) + return "已指派。" if ok else "未找到该待办或用户。" + + if t.startswith("加知识 "): + content = t[len("加知识 ") :].strip() + if not content: + return "知识内容不能为空。示例:加知识 办公室WiFi密码是12345678" + from oclaw.tools.experts.productivity.kb_tools import kb_add_tool + + res = kb_add_tool().handler({"tenant_id": tenant_id, "user_id": user_id, "text": content}) + if not res.get("ok"): + return f"写入失败:{res.get('error')}" + return f"已写入知识:{str(res.get('chunk_id') or '')[:8]}" + + if t.startswith("查知识 "): + q = t[len("查知识 ") :].strip() + if not q: + return "请提供关键词。示例:查知识 WiFi 密码" + from oclaw.tools.experts.productivity.kb_tools import kb_search_tool + + res = kb_search_tool().handler({"tenant_id": tenant_id, "query": q, "limit": 5}) + if not res.get("ok"): + return f"查询失败:{res.get('error')}" + hits = res.get("hits") if isinstance(res.get("hits"), list) else [] + if not hits: + return "未找到相关知识。" + lines = [f"- {str(h.get('source') or '')}: {str(h.get('snippet') or '')}" for h in hits[:5] if isinstance(h, dict)] + return "知识检索结果:\n" + "\n".join(lines) + + return None + + +def _role_can_write(role: str, text: str) -> bool: + low = (text or "").strip().lower() + if not low: + return True + if role in ("owner", "admin", "member"): + return True + write_prefixes = ("记待办 ", "完成待办 ", "指派待办 ", "加知识 ") + return not any((text or "").startswith(p) for p in write_prefixes) + + +def _resolve_wecom_account_id(inbound: Any, payload: dict[str, Any]) -> str: + if isinstance(inbound.metadata, dict): + for key in ("aibotid", "bot_id", "account_id"): + val = inbound.metadata.get(key) + if val: + return str(val).strip() + raw = inbound.metadata.get("raw") + if isinstance(raw, dict): + for key in ("aibotid", "bot_id", "account_id"): + val = raw.get(key) + if val: + return str(val).strip() + for key in ("aibotid", "bot_id", "account_id"): + val = payload.get(key) + if val: + return str(val).strip() + raw_payload = payload.get("raw") + if isinstance(raw_payload, dict) and raw_payload.get("aibotid"): + return str(raw_payload.get("aibotid")).strip() + return "" + + +def _resolve_generic_account_id(inbound: InboundMessage, payload: dict[str, Any]) -> str: + if isinstance(inbound.metadata, dict): + for key in ("account_id", "bot_id", "app_id", "agent_id"): + val = inbound.metadata.get(key) + if val: + return str(val).strip() + for key in ("account_id", "bot_id", "app_id", "agent_id"): + val = payload.get(key) + if val: + return str(val).strip() + raw_payload = payload.get("raw") + if isinstance(raw_payload, dict): + for key in ("account_id", "bot_id", "app_id", "agent_id"): + val = raw_payload.get(key) + if val: + return str(val).strip() + return "" + + +def _ensure_administrator_owner(store: Any) -> dict[str, Any] | None: + tenant_name = str(store.get_setting("wecom_auto_bind_tenant_name") or "Team").strip() or "Team" + tenants = store.list_tenants(limit=200) + tenant = next((t for t in tenants if str(t.get("name") or "") == tenant_name), None) + if tenant is None: + tenant = store.create_tenant(tenant_name) + tenant_id = str(tenant.get("id") or "") + if not tenant_id: + return None + user = store.get_user_by_username(tenant_id=tenant_id, username="administrator") + if not user: + try: + from oclaw.platform.config.passwords import load_expected_password + except Exception: + load_expected_password = None # type: ignore + pwd = load_expected_password(store) if callable(load_expected_password) else None + if not pwd: + return None + user = store.create_user_account( + tenant_id=tenant_id, + username="administrator", + password_hash=hashlib.sha256(pwd.encode("utf-8")).hexdigest(), + display_name="Administrator", + role="owner", + is_active=True, + ) + user_id = str((user or {}).get("id") or "") + if not user_id: + return None + return { + "tenant_id": tenant_id, + "user_id": user_id, + "display_name": (user or {}).get("display_name") or "Administrator", + "role": str((user or {}).get("role") or "owner"), + } + + +def _extract_group_name(inbound: Any) -> str: + if not isinstance(inbound.metadata, dict): + return "" + cands: list[str] = [] + for key in ("chat_name", "group_name", "room_name", "conversation_name"): + v = inbound.metadata.get(key) + if v is not None: + cands.append(str(v).strip()) + raw = inbound.metadata.get("raw") + if isinstance(raw, dict): + for key in ("chat_name", "group_name", "room_name", "conversation_name", "chatname"): + v = raw.get(key) + if v is not None: + cands.append(str(v).strip()) + chat_obj = raw.get("chat") + if isinstance(chat_obj, dict): + for key in ("name", "chat_name", "group_name"): + v = chat_obj.get(key) + if v is not None: + cands.append(str(v).strip()) + for s in cands: + if s: + return s + return "" + + +def _build_wecom_session_title(*, account_name: str, external_user_id: str, is_group: bool, group_name: str) -> str: + base = f"{str(account_name or '').strip() or 'WeCom'}+{str(external_user_id or '').strip() or 'unknown'}" + if is_group and str(group_name or "").strip(): + body = f"{base}+{str(group_name).strip()}" + else: + body = base + return f"wechat|{body}" + + +def _build_channel_session_title(*, channel: str, account_name: str, external_user_id: str, is_group: bool, group_name: str) -> str: + ch = str(channel or "").strip().lower() or "channel" + if ch == "wecom": + return _build_wecom_session_title( + account_name=account_name, + external_user_id=external_user_id, + is_group=is_group, + group_name=group_name, + ) + base = f"{str(account_name or '').strip() or ch}+{str(external_user_id or '').strip() or 'unknown'}" + body = f"{base}+{str(group_name or '').strip()}" if is_group and str(group_name or "").strip() else base + return f"{ch}|{body}" + + +def _parse_generic_inbound(channel_name: str, payload: dict[str, Any]) -> InboundMessage: + meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {} + user_id = str(payload.get("user_id") or payload.get("external_user_id") or "").strip() + chat_id = str(payload.get("chat_id") or payload.get("external_chat_id") or user_id).strip() + text = str(payload.get("text") or "").strip() + if not user_id: + raise ValueError("missing user_id") + if not chat_id: + chat_id = user_id + is_group = bool(payload.get("is_group")) + mentions = payload.get("mentions") if isinstance(payload.get("mentions"), list) else [] + attachments = payload.get("attachments") if isinstance(payload.get("attachments"), list) else [] + return InboundMessage( + channel=str(channel_name or "unknown"), + external_user_id=user_id, + external_chat_id=chat_id, + text=text, + is_group=is_group, + mentions=[str(x).strip() for x in mentions if str(x).strip()], + attachments=[a for a in attachments if isinstance(a, dict)], + metadata={str(k): v for k, v in meta.items()}, + ) + + +def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: + from oclaw.ops.mcp_env import apply_gateway_mcp_env_to_os + + apply_gateway_mcp_env_to_os() + channel_name = str(payload.get("channel") or "wecom").strip().lower() + if channel_name in ("wecom", "wechat_work", "wxwork"): + adapter = WeComAdapter() + inbound = adapter.parse_inbound(payload) + else: + adapter = None + inbound = _parse_generic_inbound(channel_name, payload) + from oclaw.platform.persistence.sqlite_store import SqliteStore + from oclaw.platform.config.paths import db_path + + store = SqliteStore(db_path()) + if channel_name == "wecom": + account_id = _resolve_wecom_account_id(inbound, payload) or str(store.get_setting("wecom_bot_id") or "").strip() + else: + account_id = _resolve_generic_account_id(inbound, payload) + if not account_id: + raise ValueError(f"missing {channel_name} account_id") + + text = inbound.text.strip() + preface = "" + if text.lower().startswith("bind "): + code = text.split(None, 1)[-1].strip() + info = store.consume_bind_code( + code=code, + channel=inbound.channel, + external_user_id=inbound.external_user_id, + display_name=( + str(inbound.metadata.get("display_name")).strip() + if isinstance(inbound.metadata, dict) and inbound.metadata.get("display_name") is not None + else None + ), + ) + reply = ("绑定成功。\n\n" + _menu_text()) if info else "绑定失败:无效或已使用的绑定码。" + else: + reply = "" + ident = store.resolve_user_by_channel_identity_v2( + channel=inbound.channel, + account_id=account_id, + external_user_id=inbound.external_user_id, + ) + if not ident: + owner = _ensure_administrator_owner(store) + if owner: + store.upsert_user_channel_account( + tenant_id=str(owner.get("tenant_id") or ""), + user_id=str(owner.get("user_id") or ""), + channel=inbound.channel, + account_id=account_id, + name=account_id, + config={"mode": "single-bot-upgraded"}, + is_active=True, + ) + store.upsert_channel_identity_v2( + tenant_id=str(owner.get("tenant_id") or ""), + channel=inbound.channel, + account_id=account_id, + external_user_id=inbound.external_user_id, + user_id=str(owner.get("user_id") or ""), + ) + ident = store.resolve_user_by_channel_identity_v2( + channel=inbound.channel, + account_id=account_id, + external_user_id=inbound.external_user_id, + ) + preface = "当前 Bot 已升级归属 administrator。" + if not ident: + reply = "账号初始化失败,请检查 administrator/tenant 配置。" + if ident: + from oclaw.orchestration.policy import ActionPolicyContext, PolicyEngine + from oclaw.orchestration.security import has_explicit_confirmation_token + + tenant_id = str(ident.get("tenant_id") or "") + user_id = str(ident.get("user_id") or "") + role = str(ident.get("role") or "member") + account = store.find_user_by_channel_account(channel=inbound.channel, account_id=account_id) or {} + account_name = str(account.get("name") or "").strip() or account_id + group_name = _extract_group_name(inbound) + session_id = store.get_or_create_channel_session_v2( + tenant_id=tenant_id, + channel=inbound.channel, + account_id=account_id, + external_user_id=inbound.external_user_id, + external_chat_id=inbound.external_chat_id, + session_title=_build_channel_session_title( + channel=inbound.channel, + account_name=account_name, + external_user_id=inbound.external_user_id, + is_group=inbound.is_group, + group_name=group_name, + ), + ) + store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id) + scope = "group" if inbound.is_group else "direct" + pe = PolicyEngine() + blob = (inbound.text or "").lower() + mention_all = ("@all" in blob) or ("全体" in inbound.text) or ("@所有" in inbound.text) + act = ActionPolicyContext( + session_id=session_id, + tenant_id=tenant_id, + user_id=user_id, + channel=inbound.channel, + user_text=inbound.text, + action="send_message", + target={"is_group": bool(inbound.is_group), "mention_all": bool(mention_all)}, + ) + d = pe.decide_action(ctx=act) + if d.needs_confirmation: + token_key = f"confirm_token:{session_id}" + token = (store.get_setting(token_key) or "").strip() + if not token: + token = pe.new_confirmation_token() + store.set_setting(token_key, token) + if not has_explicit_confirmation_token(inbound.text, token): + reply = f"该动作需要确认。请回复 `confirm {token}` 或包含 `[confirm:{token}]`。" + else: + reply = f"[assistant] ok scope={scope} session={session_id[:8]} (confirmed)" + else: + if not _role_can_write(role, inbound.text): + reply = "你的角色暂无写入权限。请联系管理员提升权限。" + else: + cmd_reply = _handle_productivity_commands( + text=inbound.text, + tenant_id=tenant_id, + user_id=user_id, + ) + if cmd_reply is not None: + reply = cmd_reply + elif not reply: + user_text = (inbound.text or "").strip() + if user_text: + try: + from oclaw.openclaw_runtime.gateway import OpenClawGateway + from oclaw.openclaw_runtime.types import StandardMessage + + agent = _get_gateway_agent(store) + gw = OpenClawGateway(store=store) + msg = StandardMessage( + session_id=str(session_id), + tenant_id=str(tenant_id or ""), + user_id=str(user_id or ""), + role=str(role or "member"), + channel=str(inbound.channel or "inbound"), + text=str(user_text or ""), + attachments=[], + metadata={ + "tenant_id": tenant_id, + "user_id": user_id, + "channel": inbound.channel, + "role": role, + "account_id": account_id, + }, + ) + reply = str(gw.handle_turn(msg=msg, lang="zh", executor=agent).reply_text or "").strip() + except Exception as e: + reply = f"抱歉,处理消息时出错:{type(e).__name__}: {e}" + else: + reply = "收到消息,但内容为空。请直接发送文本。" + if preface: + if reply: + reply = f"{preface}\n\n{reply}" + else: + reply = f"{preface}\n\n{_menu_text()}" + + if adapter is not None: + replies = [adapter.format_outbound(OutboundMessage(external_chat_id=inbound.external_chat_id, text=reply))] + else: + replies = [ + { + "channel": inbound.channel, + "chat_id": inbound.external_chat_id, + "text": reply, + "attachments": [], + "metadata": {}, + } + ] + out = {"ok": True, "replies": replies} + return out + + +__all__ = ["process_inbound_payload"] + diff --git a/application/gateway/inbound_usecase.py b/application/gateway/inbound_usecase.py new file mode 100644 index 00000000..a372a66b --- /dev/null +++ b/application/gateway/inbound_usecase.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Any + +from .inbound_service import process_inbound_payload + +def process_inbound_payload_usecase(payload: dict[str, Any]) -> dict[str, Any]: + """Application use-case entry for inbound gateway payload handling.""" + return process_inbound_payload(payload) + + +__all__ = ["process_inbound_payload_usecase", "process_inbound_payload"] + diff --git a/channels/__init__.py b/channels/__init__.py new file mode 100644 index 00000000..a04cd9d0 --- /dev/null +++ b/channels/__init__.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +__all__ = [] + diff --git a/channels/base.py b/channels/base.py new file mode 100644 index 00000000..aa2e5a55 --- /dev/null +++ b/channels/base.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Protocol + + +@dataclass(frozen=True) +class InboundMessage: + channel: str + external_user_id: str + external_chat_id: str + text: str + is_group: bool = False + mentions: list[str] = field(default_factory=list) + attachments: list[dict[str, Any]] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class OutboundMessage: + external_chat_id: str + text: str + attachments: list[dict[str, Any]] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + + +class ChannelAdapter(Protocol): + channel_name: str + + def parse_inbound(self, payload: dict[str, Any]) -> InboundMessage: + raise NotImplementedError + + def format_outbound(self, msg: OutboundMessage) -> dict[str, Any]: + raise NotImplementedError + + +def safe_json_loads(raw: str) -> dict[str, Any]: + try: + obj = json.loads(raw or "") + return obj if isinstance(obj, dict) else {} + except Exception: + return {} + + +__all__ = ["InboundMessage", "OutboundMessage", "ChannelAdapter", "safe_json_loads"] diff --git a/channels/wecom/__init__.py b/channels/wecom/__init__.py new file mode 100644 index 00000000..429745ff --- /dev/null +++ b/channels/wecom/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .wecom_bridge import WeComAdapter + +__all__ = ["WeComAdapter"] + diff --git a/channels/wecom/longconn_runner.py b/channels/wecom/longconn_runner.py new file mode 100644 index 00000000..5bc10c00 --- /dev/null +++ b/channels/wecom/longconn_runner.py @@ -0,0 +1,737 @@ +from __future__ import annotations + +import json +import os +import time +import urllib.request +import uuid +import queue +import threading +from collections import deque +from pathlib import Path +from typing import Any + +from oclaw.application.gateway import process_inbound_payload_usecase +from oclaw.channels.wecom.normalize import normalize_wecom_event, normalize_wecom_event_batch +from oclaw.platform.config.paths import db_path +from oclaw.platform.integrations.wecom_client import WeComClient +from oclaw.platform.persistence.sqlite_store import SqliteStore + + +def _safe_json(obj: Any) -> str: + try: + return json.dumps(obj, ensure_ascii=True, default=str) + except Exception: + return str(obj) + + +def _account_id_from_payload(payload: dict[str, Any], store: SqliteStore) -> str: + meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {} + for key in ("aibotid", "bot_id", "account_id"): + v = meta.get(key) + if v: + return str(v).strip() + for key in ("aibotid", "bot_id", "account_id"): + v = payload.get(key) + if v: + return str(v).strip() + raw = payload.get("raw") + if isinstance(raw, dict): + for key in ("aibotid", "bot_id", "account_id"): + v = raw.get(key) + if v: + return str(v).strip() + return str(store.get_setting("wecom_bot_id") or "").strip() + + +def _sanitize_outbound_text(text: str, *, max_chars: int = 1800) -> str: + s = str(text or "").strip() + if not s: + return "" + # Decode escaped newlines so WeCom shows real line breaks. + s = s.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\N", "\n") + # Remove hidden reasoning block before delivering to end users. + lower = s.lower() + start_tag = "" + end_tag = "" + if start_tag in lower and end_tag in lower: + start = lower.find(start_tag) + end = lower.find(end_tag, start) + if end >= 0: + s = (s[:start] + s[end + len(end_tag) :]).strip() + if s.startswith(start_tag): + s = s[len(start_tag) :].strip() + # Collapse excessive blank lines for better mobile display. + while "\n\n\n" in s: + s = s.replace("\n\n\n", "\n\n") + if len(s) > max_chars: + s = s[:max_chars].rstrip() + "\n\n(回复过长,已截断)" + return s + + +class _SingleInstanceLock: + def __init__(self, lock_path: Path) -> None: + self.lock_path = lock_path + self.fh: Any | None = None + + def acquire(self) -> None: + self.lock_path.parent.mkdir(parents=True, exist_ok=True) + self.fh = open(self.lock_path, "a+b") + self.fh.seek(0) + try: + if os.name == "nt": + import msvcrt # type: ignore + + msvcrt.locking(self.fh.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl # type: ignore + + fcntl.flock(self.fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except Exception as exc: + raise RuntimeError(f"wecom_longconn_already_running: {self.lock_path}") from exc + + def release(self) -> None: + if self.fh is None: + return + try: + if os.name == "nt": + import msvcrt # type: ignore + + self.fh.seek(0) + msvcrt.locking(self.fh.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl # type: ignore + + fcntl.flock(self.fh.fileno(), fcntl.LOCK_UN) + except Exception: + pass + try: + self.fh.close() + except Exception: + pass + self.fh = None + + +def _http_get_json(url: str, timeout: float = 15.0) -> dict[str, Any]: + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8", errors="replace") + obj = json.loads(raw or "{}") + return obj if isinstance(obj, dict) else {} + + +def _http_post_json(url: str, payload: dict[str, Any], timeout: float = 10.0) -> dict[str, Any]: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + method="POST", + headers={"content-type": "application/json", "accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8", errors="replace") + try: + obj = json.loads(raw or "{}") + except Exception: + return {"ok": True, "raw": raw} + return obj if isinstance(obj, dict) else {"ok": True, "raw": raw} + + +def _retry_count() -> int: + raw = str(os.getenv("WECOM_LONGCONN_SEND_RETRY") or "2").strip() + return max(1, min(int(raw) if raw.isdigit() else 2, 5)) + + +def _load_mock_events() -> list[dict[str, Any]]: + seed = str(os.getenv("WECOM_LONGCONN_MOCK_TEXT") or "帮助").strip() + return [ + { + "user_id": "u_mock_001", + "chat_id": "u_mock_001", + "text": seed, + "is_group": False, + "msgid": f"mock-{int(time.time())}", + } + ] + + +def _load_events_once(mode: str) -> list[dict[str, Any]]: + if mode == "mock": + return _load_mock_events() + if mode == "pull": + url = str(os.getenv("WECOM_LONGCONN_PULL_URL") or "").strip() + if not url: + raise RuntimeError("missing WECOM_LONGCONN_PULL_URL when mode=pull") + obj = _http_get_json(url) + return normalize_wecom_event_batch(obj) + raise RuntimeError(f"unsupported WECOM_LONGCONN_MODE: {mode}") + + +def _run_ws_forever(*, sender: WeComClient, deliver_outbound: bool, use_response_url: bool) -> int: + try: + import websocket # type: ignore + except Exception as exc: + raise RuntimeError("websocket-client not installed; run: pip install websocket-client") from exc + bot_id, bot_secret = sender.get_bot_credentials() + ws_url = str(os.getenv("WECOM_LONGCONN_WS_URL") or "wss://openws.work.weixin.qq.com").strip() + seen_ids: deque[str] = deque(maxlen=2000) + seen_set: set[str] = set() + print(f"[wecom-longconn] websocket connecting url={ws_url} bot={bot_id}") + store = sender.store + workers_raw = ( + str(store.get_setting("AIA_WECOM_LONGCONN_WORKERS") or "").strip() + or str(store.get_setting("WECOM_LONGCONN_WORKERS") or "").strip() + or str(os.getenv("AIA_WECOM_LONGCONN_WORKERS") or "").strip() + or str(os.getenv("WECOM_LONGCONN_WORKERS") or "").strip() + ) + workers = 2 + if workers_raw.isdigit(): + workers = max(1, min(int(workers_raw), 8)) + in_max_raw = ( + str(store.get_setting("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip() + or str(store.get_setting("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip() + or str(os.getenv("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip() + or str(os.getenv("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip() + ) + in_q_max = 200 + if in_max_raw.isdigit(): + in_q_max = max(20, min(int(in_max_raw), 5000)) + inbound_q: "queue.Queue[tuple[dict[str, Any], dict[str, Any]]]" = queue.Queue(maxsize=in_q_max) + outbound_q: "queue.Queue[dict[str, Any]]" = queue.Queue() + ws_ref: dict[str, Any] = {"ws": None} + stop_sender = threading.Event() + + def _drain_outbound_queue(*, ws: Any) -> None: + while True: + try: + ob = outbound_q.get_nowait() + except Exception: + break + try: + if not deliver_outbound: + print("[wecom-longconn] outbound_skipped", _safe_json(ob)) + continue + text = str(ob.get("text") or "").strip() + if not text: + continue + response_url = str(ob.get("response_url") or "").strip() + req_id = str(ob.get("callback_req_id") or uuid.uuid4().hex) + rsp = { + "cmd": "aibot_respond_msg", + "headers": {"req_id": req_id}, + "body": {"msgtype": "markdown", "markdown": {"content": text}}, + } + sent = False + if use_response_url and response_url: + try: + cb_payload = {"msgtype": "text", "text": {"content": text}} + cb_res: dict[str, Any] = {} + cb_err = -1 + for _i in range(_retry_count()): + cb_res = _http_post_json(response_url, cb_payload, timeout=12) + cb_err = ( + int(cb_res.get("errcode")) + if isinstance(cb_res, dict) and str(cb_res.get("errcode", "")).strip() != "" + else 0 + ) + if cb_err == 0: + break + if cb_err == 0: + sent = True + try: + store.set_setting("wecom_last_outbound_mode", "response_url") + store.set_setting("wecom_last_outbound_error", "") + except Exception: + pass + print("[wecom-longconn] outbound_sent_response_url", _safe_json({"url": response_url, "res": cb_res})) + else: + try: + store.set_setting( + "wecom_last_outbound_error", + f"response_url_errcode={cb_err}: {json.dumps(cb_res, ensure_ascii=False)}", + ) + except Exception: + pass + print( + "[wecom-longconn] response_url_send_not_ok_fallback_ws", + _safe_json({"url": response_url, "res": cb_res}), + ) + except Exception as exc: + try: + store.set_setting("wecom_last_outbound_error", f"response_url:{type(exc).__name__}: {exc}") + except Exception: + pass + print(f"[wecom-longconn] response_url_send_error: {type(exc).__name__}: {exc}") + if not sent: + ws_ok = False + ws_err = "" + for _i in range(_retry_count()): + try: + ws.send(json.dumps(rsp, ensure_ascii=False)) + ws_ok = True + ws_err = "" + break + except Exception as exc: + ws_err = f"{type(exc).__name__}: {exc}" + time.sleep(0.15) + if ws_ok: + try: + store.set_setting("wecom_last_outbound_mode", "ws") + store.set_setting("wecom_last_outbound_error", "") + except Exception: + pass + print("[wecom-longconn] outbound_sent_ws", _safe_json(rsp)) + else: + try: + store.set_setting("wecom_last_outbound_error", f"ws_send_failed:{ws_err}") + except Exception: + pass + finally: + try: + outbound_q.task_done() + except Exception: + pass + + def _sender_loop() -> None: + while not stop_sender.is_set(): + try: + # Block briefly to react immediately after worker enqueues replies. + ob = outbound_q.get(timeout=0.3) + except queue.Empty: + continue + except Exception: + continue + try: + ws_obj = ws_ref.get("ws") + if ws_obj is None: + # websocket reconnecting; put back and retry soon. + outbound_q.put(ob) + time.sleep(0.2) + continue + if not deliver_outbound: + print("[wecom-longconn] outbound_skipped", _safe_json(ob)) + continue + text = str(ob.get("text") or "").strip() + if not text: + continue + response_url = str(ob.get("response_url") or "").strip() + req_id = str(ob.get("callback_req_id") or uuid.uuid4().hex) + rsp = { + "cmd": "aibot_respond_msg", + "headers": {"req_id": req_id}, + "body": {"msgtype": "markdown", "markdown": {"content": text}}, + } + sent = False + if use_response_url and response_url: + try: + cb_payload = {"msgtype": "text", "text": {"content": text}} + cb_res: dict[str, Any] = {} + cb_err = -1 + for _i in range(_retry_count()): + cb_res = _http_post_json(response_url, cb_payload, timeout=12) + cb_err = ( + int(cb_res.get("errcode")) + if isinstance(cb_res, dict) and str(cb_res.get("errcode", "")).strip() != "" + else 0 + ) + if cb_err == 0: + break + if cb_err == 0: + sent = True + try: + store.set_setting("wecom_last_outbound_mode", "response_url") + store.set_setting("wecom_last_outbound_error", "") + except Exception: + pass + print("[wecom-longconn] outbound_sent_response_url", _safe_json({"url": response_url, "res": cb_res})) + else: + try: + store.set_setting( + "wecom_last_outbound_error", + f"response_url_errcode={cb_err}: {json.dumps(cb_res, ensure_ascii=False)}", + ) + except Exception: + pass + print("[wecom-longconn] response_url_send_not_ok_fallback_ws", _safe_json({"url": response_url, "res": cb_res})) + except Exception as exc: + try: + store.set_setting("wecom_last_outbound_error", f"response_url:{type(exc).__name__}: {exc}") + except Exception: + pass + print(f"[wecom-longconn] response_url_send_error: {type(exc).__name__}: {exc}") + if not sent: + ws_ok = False + ws_err = "" + for _i in range(_retry_count()): + try: + ws_obj.send(json.dumps(rsp, ensure_ascii=False)) + ws_ok = True + ws_err = "" + break + except Exception as exc: + ws_err = f"{type(exc).__name__}: {exc}" + time.sleep(0.15) + if ws_ok: + try: + store.set_setting("wecom_last_outbound_mode", "ws") + store.set_setting("wecom_last_outbound_error", "") + except Exception: + pass + print("[wecom-longconn] outbound_sent_ws", _safe_json(rsp)) + else: + try: + store.set_setting("wecom_last_outbound_error", f"ws_send_failed:{ws_err}") + except Exception: + pass + finally: + try: + outbound_q.task_done() + except Exception: + pass + + def _worker_loop(idx: int) -> None: + while True: + payload, meta2 = inbound_q.get() + try: + out = process_inbound_payload_usecase(payload) + replies = out.get("replies") if isinstance(out, dict) else [] + if not isinstance(replies, list): + replies = [] + for rep in replies: + if not isinstance(rep, dict): + continue + text = str(rep.get("text") or "").strip() + text = _sanitize_outbound_text(text) + if not text: + continue + outbound_q.put( + { + "callback_req_id": str(meta2.get("callback_req_id") or ""), + "response_url": str(meta2.get("response_url") or ""), + "text": text, + "raw_rep": rep, + } + ) + except Exception as exc: + outbound_q.put( + { + "callback_req_id": str(meta2.get("callback_req_id") or ""), + "response_url": str(meta2.get("response_url") or ""), + "text": f"[wecom-longconn] worker_error: {type(exc).__name__}: {exc}", + "raw_rep": {}, + } + ) + finally: + inbound_q.task_done() + + for i in range(workers): + threading.Thread(target=_worker_loop, args=(i,), name=f"wecom_worker_{i}", daemon=True).start() + threading.Thread(target=_sender_loop, name="wecom_sender", daemon=True).start() + + while True: + ws = None + try: + ws = websocket.create_connection(ws_url, timeout=30) + ws.settimeout(60) + ws_ref["ws"] = ws + sub = { + "cmd": "aibot_subscribe", + "headers": {"req_id": uuid.uuid4().hex}, + "body": {"bot_id": bot_id, "secret": bot_secret}, + } + ws.send(json.dumps(sub, ensure_ascii=False)) + print("[wecom-longconn] subscribe sent") + while True: + try: + raw = ws.recv() + except websocket.WebSocketTimeoutException: + # Idle timeout is expected when no inbound message arrives. + # Keep the connection alive instead of reconnecting. + try: + ws.ping() + print("[wecom-longconn] ping") + except Exception: + raise + # Drain outbound queue on idle ticks so replies are pushed immediately, + # not delayed until the next inbound message. + _drain_outbound_queue(ws=ws) + continue + if not raw: + continue + try: + msg = json.loads(raw) + except Exception: + print("[wecom-longconn] non_json_message", raw) + continue + if not isinstance(msg, dict): + continue + cmd = str(msg.get("cmd") or "").strip() + if not cmd: + body0 = msg.get("body") if isinstance(msg.get("body"), dict) else {} + cmd = str(body0.get("cmd") or body0.get("type") or msg.get("type") or "").strip() + if not cmd and "errcode" in msg and "errmsg" in msg: + ack_req_id = "" + h = msg.get("headers") + if isinstance(h, dict): + ack_req_id = str(h.get("req_id") or "").strip() + ack_err = int(msg.get("errcode") or 0) + ack_msg = str(msg.get("errmsg") or "").strip() + try: + store.set_setting("wecom_last_ack_req_id", ack_req_id) + store.set_setting("wecom_last_ack_errcode", str(ack_err)) + store.set_setting("wecom_last_ack_errmsg", ack_msg) + if ack_err == 0: + store.set_setting("wecom_last_outbound_error", "") + else: + store.set_setting("wecom_last_outbound_error", f"ack_errcode={ack_err}: {ack_msg}") + except Exception: + pass + print("[wecom-longconn] outbound_ack", _safe_json(msg)) + continue + try: + store.set_setting("wecom_last_cmd", cmd) + except Exception: + pass + if cmd == "aibot_subscribe_rsp": + print("[wecom-longconn] subscribe_rsp", json.dumps(msg, ensure_ascii=False)) + continue + if cmd not in ("aibot_msg_callback", "aibot_event_callback"): + try: + store.set_setting( + "wecom_last_unknown_cmd_payload", + json.dumps(msg, ensure_ascii=False, default=str)[:4000], + ) + except Exception: + pass + print( + "[wecom-longconn] unknown_cmd", + _safe_json( + {"cmd": cmd, "keys": sorted(msg.keys())[:20]}, + ), + ) + continue + body = msg.get("body") if isinstance(msg.get("body"), dict) else {} + headers = msg.get("headers") if isinstance(msg.get("headers"), dict) else {} + callback_req_id = str(headers.get("req_id") or "").strip() + try: + store.set_setting( + "wecom_last_raw_body", + json.dumps(body, ensure_ascii=False, default=str)[:4000], + ) + from_obj = body.get("from") + from_uid = "" + if isinstance(from_obj, dict): + from_uid = str(from_obj.get("userid") or from_obj.get("user_id") or from_obj.get("id") or "").strip() + elif isinstance(from_obj, str): + from_uid = from_obj.strip() + if from_uid: + store.set_setting("wecom_last_raw_from_user", from_uid) + except Exception: + pass + if cmd == "aibot_event_callback": + event_type = str(body.get("event") or body.get("event_type") or body.get("type") or "").strip() + event_obj = body.get("event") if isinstance(body.get("event"), dict) else {} + event_type_norm = str( + event_obj.get("eventtype") + or event_obj.get("type") + or event_type + ).strip() + from_obj = body.get("from") + from_user = "" + if isinstance(from_obj, dict): + from_user = str( + from_obj.get("userid") + or from_obj.get("user_id") + or from_obj.get("id") + or from_obj.get("from_user_id") + or "" + ).strip() + elif isinstance(from_obj, str): + from_user = from_obj.strip() + print( + "[wecom-longconn] event_callback", + _safe_json( + { + "event_type": event_type_norm or event_type, + "from_user": from_user, + "keys": sorted(body.keys())[:20], + }, + ), + ) + if (event_type_norm or event_type).lower() == "disconnected_event": + try: + store.set_setting("wecom_last_parse_error", "disconnected_event") + except Exception: + pass + continue + try: + payload = normalize_wecom_event(body) + except Exception as exc: + print( + "[wecom-longconn] skip_invalid_msg_callback", + _safe_json( + { + "error": f"{type(exc).__name__}: {exc}", + "keys": sorted(body.keys())[:30], + }, + ), + ) + try: + store.set_setting("wecom_last_parse_error", f"{type(exc).__name__}: {exc}") + except Exception: + pass + continue + try: + store.set_setting( + "wecom_last_normalized_payload", + json.dumps(payload, ensure_ascii=False, default=str)[:4000], + ) + except Exception: + pass + msgid = str(body.get("msgid") or payload.get("metadata", {}).get("msgid") or "").strip() + if msgid and msgid in seen_set: + continue + if msgid: + if len(seen_ids) >= seen_ids.maxlen and seen_ids: + old = seen_ids.popleft() + seen_set.discard(old) + seen_ids.append(msgid) + seen_set.add(msgid) + try: + now = str(int(time.time())) + user_id = str(payload.get("user_id") or "").strip() + store.set_setting("wecom_last_msg_ts", now) + if user_id: + store.set_setting("wecom_last_from_user", user_id) + raw_recent = str(store.get_setting("wecom_recent_from_users") or "[]") + recent = json.loads(raw_recent) + if not isinstance(recent, list): + recent = [] + recent = [x for x in recent if isinstance(x, dict)] + if user_id: + recent = [x for x in recent if str(x.get("user_id") or "") != user_id] + recent.insert(0, {"user_id": user_id, "ts": now}) + store.set_setting("wecom_recent_from_users", json.dumps(recent[:20], ensure_ascii=False)) + except Exception: + pass + try: + inbound_q.put_nowait( + ( + payload, + { + "callback_req_id": callback_req_id or uuid.uuid4().hex, + "response_url": str(body.get("response_url") or "").strip(), + }, + ) + ) + except Exception: + # Inbound backlog; drop gracefully to keep the websocket loop healthy. + try: + store.set_setting("wecom_last_outbound_error", "inbound_queue_full") + except Exception: + pass + + # Drain outbound queue opportunistically. + _drain_outbound_queue(ws=ws) + except KeyboardInterrupt: + print("[wecom-longconn] stopped by user") + return 0 + except Exception as exc: + print(f"[wecom-longconn] ws_loop_error: {type(exc).__name__}: {exc}") + time.sleep(3) + finally: + ws_ref["ws"] = None + if ws is not None: + try: + ws.close() + except Exception: + pass + + +def run_forever() -> int: + store = SqliteStore(db_path()) + sender = WeComClient(store) + lock = _SingleInstanceLock(Path(db_path()).resolve().parent / "locks" / "wecom_longconn.lock") + lock.acquire() + default_mode = "ws" + mode = str(os.getenv("WECOM_LONGCONN_MODE") or default_mode).strip().lower() + interval_s = max(1.0, float(os.getenv("WECOM_LONGCONN_INTERVAL_SEC") or "3")) + deliver_outbound = str(os.getenv("WECOM_LONGCONN_DELIVER_OUTBOUND") or "1").strip().lower() not in ( + "0", + "false", + "no", + ) + seen_ids: deque[str] = deque(maxlen=2000) + seen_set: set[str] = set() + + # Prefer response_url by default for lower latency and better delivery semantics. + use_response_url = str(os.getenv("WECOM_LONGCONN_USE_RESPONSE_URL") or "1").strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + print( + f"[wecom-longconn] started mode={mode} interval={interval_s}s outbound={deliver_outbound} use_response_url={use_response_url}" + ) + try: + if mode == "ws": + return _run_ws_forever( + sender=sender, + deliver_outbound=deliver_outbound, + use_response_url=use_response_url, + ) + while True: + try: + events = _load_events_once(mode) + if not events: + time.sleep(interval_s) + continue + for evt in events: + payload = normalize_wecom_event(evt) + meta = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {} + msgid = str(meta.get("msgid") or "").strip() + if msgid and msgid in seen_set: + continue + if msgid: + if len(seen_ids) >= seen_ids.maxlen and seen_ids: + old = seen_ids.popleft() + seen_set.discard(old) + seen_ids.append(msgid) + seen_set.add(msgid) + out = process_inbound_payload_usecase(payload) + replies = out.get("replies") if isinstance(out, dict) else [] + if not isinstance(replies, list): + replies = [] + for rep in replies: + if not isinstance(rep, dict): + continue + if not deliver_outbound: + print("[wecom-longconn] outbound_skipped", _safe_json(rep)) + continue + to_user = str(payload.get("user_id") or "").strip() + text = str(rep.get("text") or "").strip() + text = _sanitize_outbound_text(text) + if not to_user or not text: + continue + aid = _account_id_from_payload(payload, store) + print( + "[wecom-longconn] outbound_skipped_http_removed use_ws_mode", + _safe_json({"to_user": to_user, "account_id": aid or "", "text_len": len(text)}), + ) + except KeyboardInterrupt: + print("[wecom-longconn] stopped by user") + return 0 + except Exception as exc: + print(f"[wecom-longconn] loop_error: {type(exc).__name__}: {exc}") + time.sleep(interval_s) + finally: + lock.release() + + +def main() -> int: + return run_forever() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/channels/wecom/normalize.py b/channels/wecom/normalize.py new file mode 100644 index 00000000..e831554d --- /dev/null +++ b/channels/wecom/normalize.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from typing import Any + + +def _first_non_empty(*values: Any) -> str: + for v in values: + s = str(v or "").strip() + if s: + return s + return "" + + +def _pick(d: dict[str, Any], *keys: str) -> Any: + for k in keys: + if k in d: + return d.get(k) + return None + + +def _extract_text(raw: dict[str, Any]) -> str: + direct_text = _pick(raw, "text") + if isinstance(direct_text, str) and direct_text.strip(): + return direct_text.strip() + direct = _first_non_empty( + _pick(raw, "content", "Content"), + ) + if direct: + return direct + text_obj = raw.get("text") + if isinstance(text_obj, dict): + return _first_non_empty(_pick(text_obj, "content", "Content")) + text_raw_obj = raw.get("text_raw") + if isinstance(text_raw_obj, dict): + return _first_non_empty(_pick(text_raw_obj, "content", "Content", "text")) + content_obj = raw.get("content") + if isinstance(content_obj, dict): + return _first_non_empty(_pick(content_obj, "text", "content", "Content")) + if isinstance(raw.get("msg"), dict): + return _first_non_empty(_pick(raw.get("msg", {}), "text", "content", "Content")) + msg_obj = raw.get("message") + if isinstance(msg_obj, dict): + return _first_non_empty( + _pick(msg_obj, "text", "content", "Content"), + _pick(msg_obj.get("text", {}), "content") if isinstance(msg_obj.get("text"), dict) else None, + ) + payload = raw.get("payload") + if isinstance(payload, dict): + return _first_non_empty( + _pick(payload, "text", "content", "Content"), + _pick(payload.get("text", {}), "content") if isinstance(payload.get("text"), dict) else None, + ) + return "" + + +def normalize_wecom_event(raw: dict[str, Any]) -> dict[str, Any]: + """Convert WeCom-like raw events into normalized gateway payload.""" + chat_id = _first_non_empty( + _pick(raw, "chat_id", "conversation_id", "conversationId", "chatid", "roomid", "RoomId"), + _pick(raw.get("message", {}), "chat_id", "conversation_id", "chatid") + if isinstance(raw.get("message"), dict) + else None, + _pick(raw.get("chat", {}), "id", "chat_id", "chatid", "roomid") + if isinstance(raw.get("chat"), dict) + else None, + _pick(raw.get("conversation", {}), "id", "chat_id", "chatid") + if isinstance(raw.get("conversation"), dict) + else None, + _pick(raw.get("room", {}), "id", "roomid", "chatid") + if isinstance(raw.get("room"), dict) + else None, + ) + from_obj = raw.get("from") + user_id = _first_non_empty( + _pick(raw, "user_id", "from_user_id", "fromUserId", "FromUserName", "userid", "external_userid"), + from_obj if isinstance(from_obj, str) else None, + _pick(from_obj, "userid", "user_id", "id", "from_user_id", "UserId", "userid64", "uid") + if isinstance(from_obj, dict) + else None, + _pick(raw.get("sender", {}), "userid", "user_id", "id") if isinstance(raw.get("sender"), dict) else None, + _pick(raw.get("message", {}), "from_user_id", "fromUserId") if isinstance(raw.get("message"), dict) else None, + chat_id, + ) + if not chat_id: + chat_id = user_id + text = _extract_text(raw) + msgid = _first_non_empty(_pick(raw, "msgid", "msg_id", "id"), _pick(raw.get("message", {}), "msgid", "id")) + agentid = _first_non_empty(_pick(raw, "agentid", "agent_id"), _pick(raw.get("message", {}), "agentid")) + + chat_type = _first_non_empty(_pick(raw, "chat_type", "conversation_type")) + is_group = bool(raw.get("is_group")) or chat_type in ("group", "room") + if not is_group and chat_id: + is_group = chat_id.endswith("@chatroom") + + # If group but room id was missing, we previously fell back chat_id=user_id — same key as private 1:1. + if is_group and chat_id == user_id: + chat_id = f"group:unknown:{user_id}" + + return { + "channel": "wecom", + "user_id": user_id, + "chat_id": chat_id or user_id, + "text": text, + "is_group": is_group, + "metadata": { + "agentid": agentid, + "msgid": msgid, + "source": "wecom_longconn", + "raw": raw, + }, + } + + +def normalize_wecom_event_batch(obj: Any) -> list[dict[str, Any]]: + """Extract event list from common envelopes returned by pull APIs.""" + if isinstance(obj, list): + return [x for x in obj if isinstance(x, dict)] + if not isinstance(obj, dict): + return [] + for key in ("events", "messages", "items", "data"): + arr = obj.get(key) + if isinstance(arr, list): + return [x for x in arr if isinstance(x, dict)] + return [obj] + + +__all__ = ["normalize_wecom_event", "normalize_wecom_event_batch"] diff --git a/channels/wecom/wecom_bridge.py b/channels/wecom/wecom_bridge.py new file mode 100644 index 00000000..6c9cc063 --- /dev/null +++ b/channels/wecom/wecom_bridge.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Any + +from oclaw.channels.base import ChannelAdapter, InboundMessage, OutboundMessage + + +class WeComAdapter(ChannelAdapter): + channel_name = "wecom" + + def parse_inbound(self, payload: dict[str, Any]) -> InboundMessage: + user_id = str(payload.get("user_id") or payload.get("external_user_id") or "").strip() + chat_id = str(payload.get("chat_id") or payload.get("external_chat_id") or user_id).strip() + text = str(payload.get("text") or "").strip() + if not user_id: + raise ValueError("missing user_id") + if not chat_id: + chat_id = user_id + is_group = bool(payload.get("is_group")) + if is_group and chat_id == user_id: + chat_id = f"group:unknown:{user_id}" + metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {} + mentions_raw = payload.get("mentions") + mentions: list[str] = [] + if isinstance(mentions_raw, list): + mentions = [str(x).strip() for x in mentions_raw if str(x).strip()] + attachments = payload.get("attachments") if isinstance(payload.get("attachments"), list) else [] + return InboundMessage( + channel=self.channel_name, + external_user_id=user_id, + external_chat_id=chat_id, + text=text, + is_group=is_group, + mentions=mentions, + attachments=[a for a in attachments if isinstance(a, dict)], + metadata={str(k): v for k, v in metadata.items()}, + ) + + def format_outbound(self, msg: OutboundMessage) -> dict[str, Any]: + return { + "channel": self.channel_name, + "chat_id": msg.external_chat_id, + "text": msg.text, + "attachments": msg.attachments, + "metadata": msg.metadata, + } + + +__all__ = ["WeComAdapter"] diff --git a/chat/__init__.py b/chat/__init__.py new file mode 100644 index 00000000..35ee3541 --- /dev/null +++ b/chat/__init__.py @@ -0,0 +1 @@ +# oclaw.chat package diff --git a/chat/agent.py b/chat/agent.py new file mode 100644 index 00000000..ab135ed0 --- /dev/null +++ b/chat/agent.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import json +import logging +import os +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Optional + +from oclaw.tools.base import ToolRegistry +from oclaw.platform.persistence.sqlite_store import SqliteStore +from oclaw.platform.llm.chat_models import ( + ChatModel, + LLMResponse, + LLMToolCall, + OpenAIChatModel, + RuleBasedChatModel, + StaticTextChatModel, + _normalize_image_b64_payload, + build_default_model, + gemini_openai_compat_client, +) +from oclaw.prompts.loader import render_prompt_for_lang +from oclaw.tools.tool_validation import validate_tool_arguments + +logger = logging.getLogger(__name__) + +SESSION_TITLE_MAX_LEN = 120 +AGENT_CONTEXT_MESSAGES = 80 + +DEFAULT_SYSTEM_PROMPTS: dict[str, str] = { + "zh": render_prompt_for_lang("runtime/default_system", "zh", strict=True), + "en": render_prompt_for_lang("runtime/default_system", "en", strict=True), +} + + +class GenerationInterrupted(Exception): + """用户请求中止当前生成过程。""" + +@dataclass(frozen=True) +class AgentConfig: + max_messages: int = AGENT_CONTEXT_MESSAGES + max_tool_rounds: int = 8 + max_tool_workers: int = 8 + + +class Agent: + def __init__( + self, + store: SqliteStore, + tools: ToolRegistry, + model: Optional[ChatModel] = None, + config: Optional[AgentConfig] = None, + system_prompt: str | None = None, + lang: str = "zh", + llm_profile_mode: str | None = None, + ): + self.store = store + self.tools = tools + self.model = model or build_default_model() + self.config = config or AgentConfig() + self.lang = (lang or "zh").strip().lower() + self._system_prompt_base = (system_prompt or DEFAULT_SYSTEM_PROMPTS.get(self.lang, DEFAULT_SYSTEM_PROMPTS["zh"])).strip() + self.llm_profile_mode = ((llm_profile_mode or "").strip().lower() or None) + self._last_turn_outcome: Any | None = None + + def _native_tools_sent_by_api(self) -> bool: + """当前模型这一侧是否会把 tools 放进请求(与 ``llm.OpenAIChatModel._skip_tools`` 对齐)。""" + m = self.model + if isinstance(m, (RuleBasedChatModel, StaticTextChatModel)): + return False + if isinstance(m, OpenAIChatModel): + return not bool(m._skip_tools) + return False + + def _compose_system_prompt(self) -> str: + """系统正文。工具 schema 始终通过原生 tools 字段下发,不再拼接到 prompt。""" + return self._system_prompt_base + + def _format_ollama_failure_banner(self, exc: BaseException) -> str: + # Backward compat wrapper; implementation lives in `src.chat.agent_errors`. + from oclaw.chat.agent_errors import format_ollama_failure_banner + + return format_ollama_failure_banner(lang=self.lang, exc=exc) + + def _format_openai_transport_error(self, exc: BaseException) -> str: + # Backward compat wrapper; implementation lives in `src.chat.agent_errors`. + from oclaw.chat.agent_errors import format_openai_transport_error + + return format_openai_transport_error(lang=self.lang, exc=exc) + + def _invoke_tool(self, tc: LLMToolCall) -> tuple[dict[str, Any], int]: + t0 = time.perf_counter() + tool = self.tools.get(tc.name) + if not tool: + msg = f"Unregistered tool: {tc.name}" if self.lang.startswith("en") else f"未注册的工具: {tc.name}" + return {"ok": False, "error": msg}, int((time.perf_counter() - t0) * 1000) + + ok, v_err = validate_tool_arguments(tool.parameters, tc.arguments) + if not ok: + msg = f"Invalid arguments: {v_err}" if self.lang.startswith("en") else f"参数不合法: {v_err}" + return {"ok": False, "error": msg}, int((time.perf_counter() - t0) * 1000) + + try: + result = tool.handler(tc.arguments) + return result, int((time.perf_counter() - t0) * 1000) + except Exception as e: + if self.lang.startswith("en"): + err = {"ok": False, "error": f"Tool execution error: {type(e).__name__}: {e}"} + else: + err = {"ok": False, "error": f"工具执行异常: {type(e).__name__}: {e}"} + return err, int((time.perf_counter() - t0) * 1000) + + def _emit_progress(self, on_progress: Optional[Callable[[str], None]], en: str, zh: str) -> None: + if on_progress: + on_progress(en if self.lang.startswith("en") else zh) + + @staticmethod + def _attachments_from_tool_result(result: Any) -> list[dict[str, Any]]: + """Extract image/relay references from tool results for rendering.""" + if not isinstance(result, dict): + return [] + out: list[dict[str, Any]] = [] + aid = str(result.get("attachment_id") or "").strip() + if aid: + out.append( + { + "type": "image_ref", + "attachment_id": aid, + "name": str(result.get("name") or "generated-image"), + "mime": str(result.get("mime") or "image/png"), + "bytes": result.get("bytes"), + "width": result.get("width"), + "height": result.get("height"), + } + ) + refs = result.get("attachments") + if isinstance(refs, list): + for r in refs: + if not isinstance(r, dict): + continue + # Relay pointer payload (new protocol). + p_uri = str(r.get("pointer_uri") or "").strip() + if p_uri: + out.append( + { + "type": "relay_pointer", + "pointer_uri": p_uri, + "rel_path": str(r.get("rel_path") or ""), + "mime": str(r.get("mime_type") or r.get("mime") or ""), + "bytes": r.get("bytes"), + "sha256": str(r.get("sha256") or ""), + "name": str(r.get("name") or ""), + } + ) + continue + r_aid = str(r.get("attachment_id") or "").strip() + if not r_aid: + continue + out.append( + { + "type": "image_ref", + "attachment_id": r_aid, + "name": str(r.get("name") or "generated-image"), + "mime": str(r.get("mime") or "image/png"), + "bytes": r.get("bytes"), + "width": r.get("width"), + "height": r.get("height"), + } + ) + # de-dup by attachment_id + uniq: list[dict[str, Any]] = [] + seen: set[str] = set() + for a in out: + k = str(a.get("attachment_id") or a.get("pointer_uri") or "") + if not k or k in seen: + continue + seen.add(k) + uniq.append(a) + return uniq + + def run_turn( + self, + session_id: str, + user_text: str, + attachments: list[dict[str, Any]] | None = None, + on_progress: Optional[Callable[[str], None]] = None, + on_token: Optional[Callable[[str], None]] = None, + on_tool_ui: Optional[Callable[[str, dict[str, Any]], None]] = None, + should_stop: Optional[Callable[[], bool]] = None, + *, + workspace_owner_session_id: str | None = None, + path_policy_tenant_id: str | None = None, + path_policy_user_id: str | None = None, + interaction_mode: str | None = None, + selected_specialist: str | None = None, + ) -> str: + from oclaw.openclaw_runtime.gateway import OpenClawGateway + from oclaw.openclaw_runtime.types import StandardMessage + + tenant_id = str(path_policy_tenant_id or "").strip() + user_id = str(path_policy_user_id or "").strip() + if not tenant_id or not user_id: + try: + owner = self.store.get_ui_session_owner(session_id=session_id) + except Exception: + owner = None + if isinstance(owner, dict): + tenant_id = tenant_id or str(owner.get("tenant_id") or "") + user_id = user_id or str(owner.get("user_id") or "") + + session = self.store.get_session(session_id) + if session and session.title in ("新会话", "New Chat"): + title = user_text.strip().replace("\n", " ") + if not title and attachments: + title = str(attachments[0].get("name") or "New Chat") + if title: + self.store.rename_session(session_id, title[:SESSION_TITLE_MAX_LEN]) + + self._emit_progress( + on_progress, + "Received. Working on your request…", + "已收到,正在处理…", + ) + + meta: dict[str, Any] = {"tenant_id": tenant_id, "user_id": user_id} + if workspace_owner_session_id: + meta["workspace_owner_session_id"] = str(workspace_owner_session_id).strip() + if str(interaction_mode or "").strip(): + meta["interaction_mode"] = str(interaction_mode).strip().lower() + if str(selected_specialist or "").strip(): + meta["selected_specialist"] = str(selected_specialist).strip().lower() + + msg = StandardMessage( + session_id=session_id, + tenant_id=tenant_id, + user_id=user_id, + role="member", + channel="agent_turn", + text=str(user_text or ""), + attachments=list(attachments or []), + metadata=meta, + ) + gw = OpenClawGateway(store=self.store) + try: + res = gw.handle_turn( + msg=msg, + lang=self.lang, + executor=self, + on_token=on_token, + on_progress=on_progress, + on_tool_ui=on_tool_ui, + should_stop=should_stop, + ) + except RuntimeError as e: + low = str(e).lower() + if "interrupted" in low and "user" in low: + raise GenerationInterrupted(str(e)) from e + raise + + self._last_turn_outcome = getattr(self, "_last_turn_outcome", None) + return str(res.reply_text or "") + + def _build_llm_messages(self, session_id: str) -> list[dict[str, Any]]: + from oclaw.chat.agent_messages import build_llm_messages + + msgs = self.store.get_messages(session_id=session_id, limit=self.config.max_messages) + return build_llm_messages( + store_messages=msgs, + system_prompt=self._compose_system_prompt(), + model=self.model, + lang=self.lang, + ) + + +__all__ = ["AgentConfig", "DEFAULT_SYSTEM_PROMPTS", "GenerationInterrupted", "Agent"] diff --git a/chat/agent_errors.py b/chat/agent_errors.py new file mode 100644 index 00000000..64989f04 --- /dev/null +++ b/chat/agent_errors.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +"""Agent 错误处理模块。 + +把 `Agent` 内的错误格式化逻辑下沉到此处,方便 manager/specialist 复用。 +""" + +from typing import Any +from oclaw.prompts import render_prompt + + +def format_ollama_failure_banner(*, lang: str, exc: BaseException) -> str: + prompt_id = "fallback/ollama_failure.en.md" if (lang or "zh").startswith("en") else "fallback/ollama_failure.zh.md" + return render_prompt( + prompt_id, + variables={"error_type": type(exc).__name__, "error_message": str(exc)}, + strict=True, + ) + + +def format_openai_transport_error(*, lang: str, exc: BaseException) -> str: + blob = str(exc).lower() + oversized_tool = ("30000" in blob or "input length" in blob) and ("range" in blob or "length" in blob) + gemini_sig = "thought_signature" in blob + if (lang or "zh").startswith("en"): + if oversized_tool: + return render_prompt( + "fallback/openai_transport_oversized.en.md", + variables={"error_type": type(exc).__name__, "error_message": str(exc)}, + strict=True, + ) + tail = ( + "\n\n_Gemini 3 with tools: the API requires echoing `thought_signature` from each tool use in chat history. " + "If this persists, update the app or use a model/SDK path that preserves provider-specific tool fields._" + if gemini_sig + else "" + ) + return render_prompt( + "fallback/openai_transport_error.en.md", + variables={"error_type": type(exc).__name__, "error_message": str(exc), "extra_tail": tail}, + strict=True, + ) + if oversized_tool: + return render_prompt( + "fallback/openai_transport_oversized.zh.md", + variables={"error_type": type(exc).__name__, "error_message": str(exc)}, + strict=True, + ) + tail = ( + "\n\n(**Gemini 3 + 工具调用**:接口要求把模型返回的 **thought_signature** 随该次 `tool_calls` 一并写回对话历史;" + "首轮能跑工具、第二轮 400 多为丢失该字段。若已更新本应用仍报错,请确认代理/OpenAI 兼容层是否透传该字段。)" + if gemini_sig + else "" + ) + return render_prompt( + "fallback/openai_transport_error.zh.md", + variables={"error_type": type(exc).__name__, "error_message": str(exc), "extra_tail": tail}, + strict=True, + ) + + +def safe_str(e: Any) -> str: + try: + return str(e) + except Exception: + return repr(e) + + +__all__ = ["format_ollama_failure_banner", "format_openai_transport_error", "safe_str"] diff --git a/chat/agent_messages.py b/chat/agent_messages.py new file mode 100644 index 00000000..04f1625c --- /dev/null +++ b/chat/agent_messages.py @@ -0,0 +1,494 @@ +from __future__ import annotations + +"""Agent 消息构建模块。 + +把 `Agent._build_llm_messages` 的职责下沉到此处,便于: +- Manager 决策/Final merge 复用同一套“消息规范化与附件注入”规则 +- 后续 Workspace/RAG/Trace 插入上下文时有单一入口 +""" + +import json +import logging +import os +import re +from typing import Any + +from oclaw.platform.llm.chat_models import _normalize_image_b64_payload, gemini_openai_compat_client, ChatModel +from oclaw.chat.tool_runtime import tool_llm_message_max_chars, truncate_tool_result_for_llm_messages +from oclaw.prompts import render_prompt +from oclaw.platform.files.attachment_assets import attachment_id_to_data_url +from oclaw.openclaw_runtime.relay_pointer import parse_pointer_uri + +logger = logging.getLogger(__name__) +_THINK_BLOCK_RE = re.compile(r"\s*(.*?)\s*\s*", flags=re.IGNORECASE | re.DOTALL) + + +def _replay_recent_tool_rounds() -> int: + raw = str(os.getenv("AIA_REPLAY_TOOL_FULL_ROUNDS") or "").strip() + if raw.isdigit(): + return max(0, min(int(raw), 12)) + return 3 + + +def _allow_reasoning_signature_replay(model: ChatModel) -> bool: + # - auto (default): only providers that require signature continuity (Gemini paths). + # - on: always include signature metadata on assistant tool_calls. + # - off: never include signature metadata. + policy = str(os.getenv("AIA_REPLAY_REASONING_SIGNATURE_POLICY") or "auto").strip().lower() + if policy in ("0", "off", "false", "no"): + return False + if policy in ("1", "on", "true", "yes"): + return True + if gemini_openai_compat_client(model): + return True + return model.__class__.__name__ == "GoogleGeminiChatModel" + + +def _strip_reasoning_blocks(text: str) -> str: + return _THINK_BLOCK_RE.sub("", str(text or "")).strip() + + +def _parse_tool_calls(raw_tc: Any) -> list[dict[str, Any]]: + if not raw_tc: + return [] + try: + data = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc + except Exception: + return [] + if not isinstance(data, list): + return [] + return [x for x in data if isinstance(x, dict)] + + +def _tool_call_id_from_tool_row(raw_tc: Any) -> str: + if not raw_tc: + return "" + try: + meta = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc + except Exception: + return "" + if not isinstance(meta, dict): + return "" + return str(meta.get("tool_call_id") or "").strip() + + +def _collect_historical_tool_call_ids(store_messages: list[Any], *, full_rounds: int) -> set[str]: + if full_rounds < 0: + full_rounds = 0 + full_ids: set[str] = set() + rounds = 0 + for m in reversed(store_messages or []): + role = str(getattr(m, "role", "") or "") + if role != "assistant": + continue + tcs = _parse_tool_calls(getattr(m, "tool_calls", None)) + tc_ids = [str(tc.get("id") or "").strip() for tc in tcs if str(tc.get("id") or "").strip()] + if not tc_ids: + continue + rounds += 1 + if rounds <= full_rounds: + full_ids.update(tc_ids) + historical_ids: set[str] = set() + for m in store_messages or []: + if str(getattr(m, "role", "") or "") != "tool": + continue + tcid = _tool_call_id_from_tool_row(getattr(m, "tool_calls", None)) + if tcid and tcid not in full_ids: + historical_ids.add(tcid) + return historical_ids + + +def _summarize_historical_tool_content(raw: str, *, cap: int) -> str: + s = str(raw or "").strip() + if not s: + return json.dumps({"ok": None, "summary": "", "_history_summarized": True}, ensure_ascii=False) + out: dict[str, Any] = {"_history_summarized": True} + try: + obj = json.loads(s) + except Exception: + preview = s[: max(1, cap - 120)] + ("\n..." if len(s) > cap else "") + out["summary"] = preview + return json.dumps(out, ensure_ascii=False) + if not isinstance(obj, dict): + out["summary"] = s[: max(1, cap - 120)] + ("\n..." if len(s) > cap else "") + return json.dumps(out, ensure_ascii=False) + out["ok"] = obj.get("ok") + for key in ("error_code", "error", "hint"): + v = str(obj.get(key) or "").strip() + if v: + out[key] = v + if "result" in obj: + r = obj.get("result") + if isinstance(r, dict): + out["result_keys"] = sorted(list(r.keys()))[:20] + preview = s[: max(1, cap - 260)] + ("\n..." if len(s) > cap else "") + out["preview"] = preview + return json.dumps(out, ensure_ascii=False) + + +def _summarize_unpaired_tool_content(raw: str, *, cap: int) -> str: + """Best-effort summarize tool JSON for model-friendly context.""" + s = str(raw or "").strip() + if not s: + return "" + if cap > 0 and len(s) > cap: + s = s[: max(1, cap - 80)] + "\n..." + try: + obj = json.loads(s) + except Exception: + return s + if not isinstance(obj, dict): + return s + lines: list[str] = [] + ok = obj.get("ok") + if ok is not None: + lines.append(f"ok={bool(ok)}") + ec = str(obj.get("error_code") or "").strip() + if ec: + lines.append(f"error_code={ec}") + err = str(obj.get("error") or "").strip() + if err: + lines.append(f"error={err}") + hint = str(obj.get("hint") or "").strip() + if hint: + lines.append(f"hint={hint}") + # Extract MCP-style text blocks when present. + try: + nested = obj.get("result") + content = None + if isinstance(nested, dict): + content = nested.get("content") + if isinstance(content, list): + texts = [] + for b in content: + if isinstance(b, dict) and str(b.get("type") or "").strip().lower() == "text": + t = str(b.get("text") or "").strip() + if t: + texts.append(t) + if texts: + lines.append("content_text=" + " | ".join(texts)[: min(800, cap)]) + except Exception: + pass + head = " ".join(lines).strip() + if head: + return head + "\n" + s + return s + + +def build_llm_messages( + *, + store_messages: list[Any], + system_prompt: str, + model: ChatModel, + lang: str, +) -> list[dict[str, Any]]: + """把 DB 中的消息序列转换为 LLM messages。""" + out: list[dict[str, Any]] = [{"role": "system", "content": (system_prompt or "").strip()}] + allow_signature_replay = _allow_reasoning_signature_replay(model) + historical_tool_ids = _collect_historical_tool_call_ids( + store_messages=store_messages, full_rounds=_replay_recent_tool_rounds() + ) + # Some OpenAI-compatible gateways error if a tool message references a tool_call_id + # that is not present in the assistant tool_calls within the same request context. + # This can happen when context windows are trimmed and the assistant tool_calls row is dropped. + valid_tool_call_ids: set[str] = set() + for m in store_messages: + role = str(getattr(m, "role", "") or "") + event_type = str(getattr(m, "event_type", "") or "").strip().lower() + if event_type == "reasoning": + continue + + if role == "user": + content_list: list[dict[str, Any]] = [] + text = getattr(m, "content", None) + if text: + content_list.append({"type": "text", "text": str(text)}) + + attachments = [] + raw_att = getattr(m, "attachments", None) + if raw_att: + try: + attachments = json.loads(raw_att) if isinstance(raw_att, str) else raw_att + except Exception: + attachments = [] + + for att in attachments or []: + if not isinstance(att, dict): + continue + att_type = att.get("type") + if att_type in ("image", "input_image"): + b64 = _normalize_image_b64_payload(att.get("image_base64") or att.get("data")) + if not b64: + continue + content_list.append( + { + "type": "input_image", + "image_base64": b64, + "mime": att.get("mime") or "image/jpeg", + } + ) + elif att_type == "image_ref": + # Prefer actual image bytes so multi-agent/image specialist can truly "see" history images. + name = str(att.get("name") or "image") + mime = str(att.get("mime") or "image/jpeg") + aid = str(att.get("attachment_id") or "") + data_url = attachment_id_to_data_url(aid, mime=mime) if aid else "" + if data_url: + if ";base64," in data_url: + b64 = data_url.split(";base64,", 1)[1] + content_list.append( + { + "type": "input_image", + "image_base64": b64, + "mime": mime, + } + ) + continue + w = att.get("width") + h = att.get("height") + sz = att.get("bytes") + meta_line = f"- name={name} mime={mime} id={aid}" + if w and h: + meta_line += f" size={w}x{h}" + if sz: + meta_line += f" bytes={sz}" + content_list.append( + { + "type": "text", + "text": render_prompt( + "tools/image_attachment_meta.md", + variables={"meta_line": meta_line}, + strict=True, + ), + } + ) + elif att_type == "text": + name = att.get("name", "file") + text_content = att.get("content", "") + content_list.append( + { + "type": "text", + "text": render_prompt( + "tools/text_attachment_wrap.md", + variables={"name": str(name), "content": str(text_content)}, + strict=True, + ), + } + ) + elif att_type == "tabular_ref": + name = str(att.get("name") or "table") + table_id = str(att.get("table_id") or "") + rows = int(att.get("rows") or 0) + cols = int(att.get("cols") or 0) + aid = str(att.get("attachment_id") or "") + sheets = att.get("sheets") if isinstance(att.get("sheets"), list) else [] + sheet_hint = "" + if sheets: + names = [str((x or {}).get("sheet_name") or "") for x in sheets if isinstance(x, dict)] + names = [x for x in names if x] + if names: + sheet_hint = f"\n- sheets: {', '.join(names[:8])}" + content_list.append( + { + "type": "text", + "text": ( + f"[LargeTableAttachment]\n" + f"- name: {name}\n" + f"- table_id: {table_id}\n" + f"- attachment_id: {aid}\n" + f"- rows: {rows}\n" + f"- cols: {cols}\n" + f"{sheet_hint}\n" + f"- tools: query_tabular_attachment, run_tabular_sql, analyze_tabular_attachment_full_scan" + ), + } + ) + elif att_type == "relay_pointer": + p_uri = str(att.get("pointer_uri") or "").strip() + if not p_uri: + continue + mime = str(att.get("mime") or att.get("mime_type") or "").strip() + aid = str(att.get("attachment_id") or "").strip() + if (not aid) and p_uri: + try: + _scope, _fid = parse_pointer_uri(p_uri) + aid = str(_fid or "").strip() + except Exception: + aid = "" + if aid and mime.startswith("image/"): + data_url = attachment_id_to_data_url(aid, mime=mime) + if data_url and ";base64," in data_url: + b64 = data_url.split(";base64,", 1)[1] + content_list.append( + { + "type": "input_image", + "image_base64": b64, + "mime": mime or "image/jpeg", + } + ) + rel_path = str(att.get("rel_path") or "").strip() + sz = att.get("bytes") + sha = str(att.get("sha256") or "").strip() + pointer_line = f"- pointer_uri={p_uri}" + if rel_path: + pointer_line += f" rel_path={rel_path}" + if mime: + pointer_line += f" mime={mime}" + if sz: + pointer_line += f" bytes={sz}" + if sha: + pointer_line += f" sha256={sha}" + content_list.append({"type": "text", "text": pointer_line}) + + if not content_list: + placeholder = "(No text content)" if str(lang or "").startswith("en") else "(无文本内容)" + content_list.append({"type": "text", "text": placeholder}) + + if len(content_list) == 1 and content_list[0].get("type") == "text": + out.append({"role": "user", "content": content_list[0]["text"]}) + else: + out.append({"role": "user", "content": content_list}) + continue + + if role == "assistant": + tool_calls = None + raw_tc = getattr(m, "tool_calls", None) + if raw_tc: + try: + tool_calls = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc + except Exception: + tool_calls = None + + if tool_calls and isinstance(tool_calls, list): + api_tool_calls = [] + gemini_fc = gemini_openai_compat_client(model) + for idx, tc in enumerate(tool_calls): + if not isinstance(tc, dict) or not tc.get("id") or not tc.get("name"): + continue + try: + valid_tool_call_ids.add(str(tc.get("id") or "")) + except Exception: + pass + entry: dict[str, Any] = { + "id": tc.get("id"), + "type": "function", + "function": { + "name": tc.get("name"), + "arguments": json.dumps(tc.get("arguments", {}), ensure_ascii=False), + }, + } + raw_sig = tc.get("thought_signature") + if allow_signature_replay and gemini_fc: + if isinstance(raw_sig, str): + sig = raw_sig + elif idx == 0: + sig = "skip_thought_signature_validator" + else: + sig = "" + entry["extra_content"] = {"google": {"thought_signature": sig}} + elif allow_signature_replay and isinstance(raw_sig, str): + entry["extra_content"] = {"google": {"thought_signature": raw_sig}} + api_tool_calls.append(entry) + if api_tool_calls: + out.append( + { + "role": "assistant", + "content": _strip_reasoning_blocks(getattr(m, "content", "") or ""), + "tool_calls": api_tool_calls, + } + ) + else: + out.append({"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")}) + else: + out.append({"role": "assistant", "content": _strip_reasoning_blocks(getattr(m, "content", "") or "")}) + continue + + if role == "tool": + tool_call_id = None + raw_tc = getattr(m, "tool_calls", None) + if raw_tc: + try: + meta = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc + if isinstance(meta, dict): + tool_call_id = meta.get("tool_call_id") + except Exception: + tool_call_id = None + if tool_call_id is not None: + try: + tool_call_id = str(tool_call_id).strip() + except Exception: + tool_call_id = "" + if tool_call_id: + # Guard against dangling tool_call_id (assistant tool_calls missing from this trimmed context window). + if str(tool_call_id) not in valid_tool_call_ids: + # Preserve tool evidence, but downgrade to plain assistant text when pairing is broken. + # Some OpenAI-compatible gateways reject a role=tool message if tool_call_id cannot be paired + # to an assistant.tool_calls.id within the same request context. + r0 = getattr(m, "content", "") or "" + cap0 = tool_llm_message_max_chars() + pretty = _summarize_unpaired_tool_content(r0, cap=cap0) + out.append( + { + "role": "assistant", + "content": render_prompt( + "tools/tool_result_unpaired.md", + variables={"tag": "tool_use_result:unpaired", "payload": pretty}, + strict=True, + ), + } + ) + continue + raw_tc_content = getattr(m, "content", "") or "" + tool_content_out = raw_tc_content + cap = tool_llm_message_max_chars() + if str(tool_call_id) in historical_tool_ids: + summary_cap = 1800 + if cap > 0: + summary_cap = max(600, min(2400, cap // 3)) + tool_content_out = _summarize_historical_tool_content(raw_tc_content, cap=summary_cap) + elif cap > 0 and len(raw_tc_content) > cap: + try: + parsed = json.loads(raw_tc_content) + if isinstance(parsed, dict): + tool_content_out = json.dumps( + truncate_tool_result_for_llm_messages(parsed), ensure_ascii=False, default=str + ) + else: + tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n..." + except Exception: + tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n..." + tool_row: dict[str, Any] = { + "role": "tool", + "tool_call_id": tool_call_id, + "content": tool_content_out, + } + # Some OpenAI-compatible gateways expect `call_id` instead of `tool_call_id`. + # Sending both (non-empty) keeps compatibility; servers should ignore unknown fields. + tool_row["call_id"] = tool_call_id + try: + meta2 = json.loads(raw_tc) if isinstance(raw_tc, str) else raw_tc + except Exception: + meta2 = None + if isinstance(meta2, dict) and meta2.get("name"): + tool_row["name"] = str(meta2["name"]) + out.append(tool_row) + else: + r = getattr(m, "content", "") or "" + cap2 = tool_llm_message_max_chars() + pretty2 = _summarize_unpaired_tool_content(r, cap=cap2) + out.append( + { + "role": "assistant", + "content": render_prompt( + "tools/tool_result_unpaired.md", + variables={"tag": "tool_use_result:no_id", "payload": pretty2}, + strict=True, + ), + } + ) + continue + + return out + + +__all__ = ["build_llm_messages"] diff --git a/chat/tool_runtime.py b/chat/tool_runtime.py new file mode 100644 index 00000000..0563a9d7 --- /dev/null +++ b/chat/tool_runtime.py @@ -0,0 +1,630 @@ +from __future__ import annotations + +"""Agent 工具执行模块。 + +本模块把“工具执行(校验/并发/落库/回写)”从 `Agent.run_turn` 中下沉出来, +以便被单 Agent 与编排器(manager/specialist)复用。 +""" + +import json +import logging +import time +import os +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import TimeoutError as FuturesTimeoutError +from dataclasses import dataclass +from typing import Any, Callable, Optional + +from oclaw.platform.persistence.sqlite_store import SqliteStore +from oclaw.tools.base import ToolRegistry +from oclaw.platform.llm.chat_models import LLMToolCall +from oclaw.tools.tool_validation import validate_tool_arguments +from oclaw.tools.experts.workspace.workspace_base import workspace_path_access_scope + +logger = logging.getLogger(__name__) + +_TOOL_ERROR_MAP = { + "tool_timeout_or_failed": "tool_timeout_or_failed", +} +_SQL_REPLAY_COMPACT_TOOL_NAMES = { + "query_tabular_attachment", + "run_tabular_sql", + "analyze_tabular_attachment_full_scan", +} + + +def normalize_tool_result(result: Any) -> dict[str, Any]: + if isinstance(result, dict): + out = dict(result) + if "ok" in out: + out["ok"] = bool(out.get("ok")) + else: + # Backward compatibility: many lightweight tools return payload-only dicts. + # Treat those as success unless they explicitly carry error semantics. + has_error = bool(str(out.get("error_code") or "").strip() or str(out.get("error") or "").strip()) + out["ok"] = not has_error + else: + out = {"ok": False, "error": "tool_result_not_dict", "data": result} + if not out["ok"]: + raw_ec = str(out.get("error_code") or "").strip() + raw_err = str(out.get("error") or "").strip() + if not raw_ec: + out["error_code"] = _TOOL_ERROR_MAP.get(raw_err, "tool_failed") + return out + + +def tool_llm_message_max_chars() -> int: + raw = str(os.getenv("AIA_TOOL_LLM_MESSAGE_MAX_CHARS") or "").strip() + if raw.isdigit(): + n = int(raw) + if n == 0: + return 0 + return max(4096, min(n, 500_000)) + return 0 + + +def tool_history_summary_after_calls() -> int: + raw = str(os.getenv("AIA_TOOL_HISTORY_SUMMARY_AFTER_CALLS") or "").strip() + if raw.isdigit(): + return max(0, min(int(raw), 200)) + # Default: when the same tool is called >= 3 times in one turn, keep history compact. + return 3 + + +def _json_blob_size(obj: Any) -> int: + try: + return len(json.dumps(obj, ensure_ascii=False, default=str)) + except Exception: + return len(repr(obj)) + + +def _estimate_observed_rows(result: dict[str, Any]) -> int: + if not isinstance(result, dict): + return 0 + try: + rr = result.get("rows_returned") + if isinstance(rr, (int, float)): + return max(0, int(rr)) + except Exception: + pass + rows = result.get("rows") + if isinstance(rows, list): + return max(0, len(rows)) + nested = result.get("result") + if isinstance(nested, dict): + nrows = nested.get("rows") + if isinstance(nrows, list): + return max(0, len(nrows)) + return 0 + + +def _deep_truncate_for_llm(obj: Any, *, max_str: int, max_list: int) -> Any: + if isinstance(obj, dict): + return {str(k): _deep_truncate_for_llm(v, max_str=max_str, max_list=max_list) for k, v in obj.items()} + if isinstance(obj, list): + items = obj + omitted = 0 + if len(items) > max_list: + omitted = len(items) - max_list + items = items[:max_list] + out: list[Any] = [_deep_truncate_for_llm(x, max_str=max_str, max_list=max_list) for x in items] + if omitted: + out.append(f"…({omitted} more list items omitted)") + return out + if isinstance(obj, str) and len(obj) > max_str: + return obj[:max_str] + "\n..." + return obj + + +def partition_tool_use_batches( + tool_uses: list[LLMToolCall], + registry: ToolRegistry, +) -> list[list[LLMToolCall]]: + """Split tool uses into ordered batches (cc-mini ``Engine.submit`` scheduling). + + Consecutive tools whose ``ToolSpec.is_read_only()`` is true are merged into one batch + and may run in parallel when the batch length is greater than one. Any other tool + starts a new batch (typically length 1), which runs sequentially relative to other + batches and uses a single worker within the batch. + """ + batches: list[tuple[bool, list[LLMToolCall]]] = [] + for tc in tool_uses: + spec = registry.get(tc.name) + is_concurrent = bool(spec and spec.is_read_only()) + if batches and batches[-1][0] == is_concurrent and is_concurrent: + batches[-1][1].append(tc) + else: + batches.append((is_concurrent, [tc])) + return [chunk for _, chunk in batches] + + +def truncate_tool_result_for_llm_messages(result: dict[str, Any], *, max_chars: int | None = None) -> dict[str, Any]: + """Return a copy safe to put in ``role=tool`` ``content`` so the next LLM request stays under provider limits.""" + cap = tool_llm_message_max_chars() if max_chars is None else max(0, min(int(max_chars), 500_000)) + if cap == 0: + return result if isinstance(result, dict) else {"ok": False, "error": "tool_result_not_dict", "data": result} + if not isinstance(result, dict): + return {"ok": False, "error": "tool_result_not_dict", "payload_type": type(result).__name__} + if _json_blob_size(result) <= cap: + return result + orig_files_n = len(result["files"]) if isinstance(result.get("files"), list) else 0 + pairs = ( + (12_000, 800), + (8000, 500), + (4000, 300), + (2000, 200), + (1200, 120), + (800, 80), + (500, 50), + (400, 40), + ) + for max_str, max_list in pairs: + slim = _deep_truncate_for_llm(result, max_str=max_str, max_list=max_list) + if not isinstance(slim, dict): + slim = {"ok": bool(result.get("ok")), "payload": slim} + if _json_blob_size(slim) <= cap: + slim = dict(slim) + slim["_truncated_for_llm"] = True + if orig_files_n and isinstance(slim.get("files"), list): + kept = sum(1 for x in slim["files"] if isinstance(x, str)) + if kept < orig_files_n: + slim["files_total"] = orig_files_n + slim["files_omitted"] = orig_files_n - kept + return slim + return { + "ok": bool(result.get("ok")), + "_truncated_for_llm": True, + "hint": ( + "Tool output exceeded model message size limits. " + "Narrow the glob, lower max_results, or list a subdirectory. / " + "工具输出超过模型单条消息限制,请缩小列举范围或降低 max_results。" + ), + } + + +@dataclass(frozen=True) +class ToolExecutionConfig: + max_workers: int = 8 + + +@dataclass(frozen=True) +class ToolExecutionContext: + store: SqliteStore + tools: ToolRegistry + session_id: str + lang: str = "zh" + user_text: str = "" + specialist: str = "" + task_kind: str = "" + policy_engine: Any | None = None + trace_id: str | None = None + parent_span_id: str | None = None + #: When ``session_id`` is a specialist temp chat row (no ``ui_session_owner``), use the user's UI session for ``extra_roots`` / allowlist. + workspace_owner_session_id: str | None = None + #: If ``get_ui_session_owner`` fails, load allowlist for this (tenant, user) from the HTTP/gateway request (``metadata``). + path_policy_tenant_id: str | None = None + path_policy_user_id: str | None = None + turn_uuid: str | None = None + + +class ToolExecutor: + """执行一组 tool uses,并把结果写回 store。""" + + def __init__(self, *, config: ToolExecutionConfig | None = None): + self.config = config or ToolExecutionConfig() + + def _execute_tool(self, ctx: ToolExecutionContext, tc: LLMToolCall) -> tuple[dict[str, Any], int]: + t0 = time.perf_counter() + + tool = ctx.tools.get(tc.name) + if not tool: + msg = f"Unregistered tool: {tc.name}" if ctx.lang.startswith("en") else f"未注册的工具: {tc.name}" + return {"ok": False, "error_code": "tool_not_registered", "error": msg}, int((time.perf_counter() - t0) * 1000) + + ok, v_err = validate_tool_arguments(tool.parameters, tc.arguments) + if not ok: + msg = f"Invalid arguments: {v_err}" if ctx.lang.startswith("en") else f"参数不合法: {v_err}" + return {"ok": False, "error_code": "tool_invalid_arguments", "error": msg}, int((time.perf_counter() - t0) * 1000) + + try: + timeout_s = getattr(tool, "timeout_s", None) + # Default timeout for plugin tools if not specified. + if timeout_s is None and "plugin" in getattr(tool, "tags", frozenset()): + timeout_s = 30.0 + + def _call() -> Any: + with workspace_path_access_scope( + ctx.store, + ctx.session_id, + owner_fallback_session_id=ctx.workspace_owner_session_id, + allowlist_tenant_id=ctx.path_policy_tenant_id, + allowlist_user_id=ctx.path_policy_user_id, + ): + return tool.handler(tc.arguments) + + if isinstance(timeout_s, (int, float)) and float(timeout_s) > 0: + ex = ThreadPoolExecutor(max_workers=1) + fut = ex.submit(_call) + try: + result = fut.result(timeout=float(timeout_s)) + except FuturesTimeoutError as e: + try: + fut.cancel() + except Exception: + pass + try: + ex.shutdown(wait=False, cancel_futures=True) + except Exception: + ex.shutdown(wait=False) + return {"ok": False, "error_code": "tool_timeout_or_failed", "error": "tool_timeout_or_failed", "detail": f"{type(e).__name__}: {e}"}, int( + (time.perf_counter() - t0) * 1000 + ) + except Exception as e: + try: + ex.shutdown(wait=False, cancel_futures=True) + except Exception: + ex.shutdown(wait=False) + return {"ok": False, "error_code": "tool_timeout_or_failed", "error": "tool_timeout_or_failed", "detail": f"{type(e).__name__}: {e}"}, int( + (time.perf_counter() - t0) * 1000 + ) + else: + try: + ex.shutdown(wait=False, cancel_futures=True) + except Exception: + ex.shutdown(wait=False) + else: + result = _call() + return normalize_tool_result(result), int((time.perf_counter() - t0) * 1000) + except Exception as e: + if ctx.lang.startswith("en"): + err = {"ok": False, "error_code": "tool_execution_error", "error": f"Tool execution error: {type(e).__name__}: {e}"} + else: + err = {"ok": False, "error_code": "tool_execution_error", "error": f"工具执行异常: {type(e).__name__}: {e}"} + return normalize_tool_result(err), int((time.perf_counter() - t0) * 1000) + + @staticmethod + def _json_dumps_safe(obj: Any) -> str: + try: + return json.dumps(obj, ensure_ascii=False, default=str) + except (TypeError, ValueError): + return json.dumps({"ok": False, "error": "tool result is not JSON-serializable"}, ensure_ascii=False) + + def execute_tool_uses( + self, + *, + ctx: ToolExecutionContext, + assistant_msg_id: int, + tool_uses: list[LLMToolCall], + on_tool_ui: Optional[Callable[[str, dict[str, Any]], None]] = None, + should_stop: Optional[Callable[[], bool]] = None, + signature_budget: int = 2, + ) -> tuple[list[dict[str, Any]], dict[str, tuple[dict[str, Any], int]]]: + """执行并回写 tool messages。 + + Returns: + - tool_messages: 用于写入对话 history 的 `role=tool` 消息 payload 列表(与 tool_uses 顺序一致) + - results_by_id: tool_call_id -> (result_dict, duration_ms) + """ + + def _check_stop() -> None: + if should_stop and should_stop(): + raise RuntimeError("generation interrupted by user") + + def _trace(event_type: str, payload: dict[str, Any]) -> None: + if not ctx.trace_id: + return + try: + from oclaw.orchestration.trace import new_span_id + + ctx.store.add_trace_event( + session_id=ctx.session_id, + trace_id=str(ctx.trace_id), + span_id=new_span_id(), + parent_span_id=ctx.parent_span_id, + event_type=str(event_type), + payload=dict(payload or {}), + ) + except Exception: + pass + + def _load_turn_tool_stats() -> tuple[dict[str, int], dict[str, int]]: + counts: dict[str, int] = {} + observed_rows: dict[str, int] = {} + if not str(ctx.turn_uuid or "").strip(): + return counts, observed_rows + try: + rows = ctx.store.get_messages(session_id=ctx.session_id, limit=500) + except Exception: + return counts, observed_rows + for m in rows or []: + if str(getattr(m, "role", "") or "") != "tool": + continue + if str(getattr(m, "turn_uuid", "") or "") != str(ctx.turn_uuid or ""): + continue + raw_tc = getattr(m, "tool_calls", None) + name = "" + if isinstance(raw_tc, str): + try: + parsed = json.loads(raw_tc) + except Exception: + parsed = None + else: + parsed = raw_tc + if isinstance(parsed, dict): + name = str(parsed.get("name") or "").strip() + if not name: + continue + counts[name] = int(counts.get(name, 0)) + 1 + try: + raw_content = str(getattr(m, "content", "") or "") + payload = json.loads(raw_content) if raw_content else {} + except Exception: + payload = {} + if isinstance(payload, dict): + observed_rows[name] = int(observed_rows.get(name, 0)) + int( + _estimate_observed_rows(payload) + or payload.get("_tool_observed_rows_this_call") + or 0 + ) + return counts, observed_rows + + def _compact_tool_result_for_history( + *, + tool_name: str, + result: dict[str, Any], + call_index: int, + threshold: int, + observed_rows_this_call: int, + observed_rows_cumulative_in_turn: int, + ) -> dict[str, Any]: + out: dict[str, Any] = { + "ok": bool(result.get("ok")), + "_history_compacted": True, + "_history_compact_reason": "repeated_tool_calls_in_turn", + "tool_name": str(tool_name or ""), + "call_index_in_turn_for_tool": int(call_index), + "compact_threshold": int(threshold), + "_tool_observed_rows_this_call": int(observed_rows_this_call), + "_tool_observed_rows_cumulative_in_turn": int(observed_rows_cumulative_in_turn), + "result_keys": sorted(list(result.keys()))[:30], + "result_bytes": int(_json_blob_size(result)), + "hint": ( + "Repeated tool calls in this turn were compacted in chat history to avoid context bloat. " + "Full payload remains in tool logs." + ), + "audit_note": ( + "History is compacted by system optimization. If more detail is needed, continue querying " + "with the same SQL/tool parameters from this turn." + ), + } + for key in ("error_code", "error", "rows_returned", "limit", "table_id", "engine"): + if key in result: + out[key] = result.get(key) + for key in ("input_sql", "executed_sql"): + v = str(result.get(key) or "").strip() + if v: + out[key] = v[:1200] + guard = result.get("sql_guard") + if isinstance(guard, dict): + out["sql_guard"] = { + "readonly_enforced": bool(guard.get("readonly_enforced")), + "auto_limit_applied": bool(guard.get("auto_limit_applied")), + "result_row_cap": int(guard.get("result_row_cap") or 0), + } + return out + + _check_stop() + if not tool_uses: + return [], {} + history_summary_threshold = int(tool_history_summary_after_calls()) + turn_tool_name_counts, turn_tool_observed_rows = _load_turn_tool_stats() + local_turn_tool_name_counts: dict[str, int] = {} + local_turn_tool_observed_rows: dict[str, int] = {} + local_turn_written_tool_msgs: dict[str, list[dict[str, Any]]] = {} + + results_by_id: dict[str, tuple[dict[str, Any], int]] = {} + runnable_tool_uses: list[LLMToolCall] = [] + sig_seen: dict[str, int] = {} + budget = max(1, min(int(signature_budget or 2), 8)) + for tc in tool_uses: + sig = f"{tc.name}:{self._json_dumps_safe(dict(tc.arguments or {}))}" + count = int(sig_seen.get(sig, 0)) + if count >= budget: + results_by_id[tc.id] = ( + { + "ok": False, + "error_code": "tool_loop_guard", + "error": f"tool loop guard triggered for signature: {tc.name}", + }, + 0, + ) + _trace( + "tool_loop_guard", + { + "tool_name": tc.name, + "signature": sig[:300], + "budget": budget, + }, + ) + continue + sig_seen[sig] = count + 1 + runnable_tool_uses.append(tc) + + for batch in partition_tool_use_batches(runnable_tool_uses, ctx.tools): + _check_stop() + _trace( + "tool_batch_started", + { + "batch_size": len(batch), + "tool_names": [str(getattr(x, "name", "") or "") for x in batch], + }, + ) + if len(batch) > 1: + workers = min(int(self.config.max_workers), len(batch)) + with ThreadPoolExecutor(max_workers=workers) as ex: + fut_to_tc = {ex.submit(self._execute_tool, ctx, tc): tc for tc in batch} + for fut in as_completed(fut_to_tc): + tc = fut_to_tc[fut] + results_by_id[tc.id] = fut.result() + else: + for tc in batch: + results_by_id[tc.id] = self._execute_tool(ctx, tc) + _trace( + "tool_batch_finished", + { + "batch_size": len(batch), + "tool_names": [str(getattr(x, "name", "") or "") for x in batch], + }, + ) + + tool_messages: list[dict[str, Any]] = [] + for tc in tool_uses: + _check_stop() + _trace( + "tool_called", + { + "tool_name": tc.name, + "arguments": tc.arguments, + "arguments_bytes": _json_blob_size(tc.arguments), + }, + ) + result, duration_ms = results_by_id[tc.id] + result = normalize_tool_result(result) + logger.info( + "tool_runtime tool session=%s name=%s duration_ms=%d ok=%s", + ctx.session_id[:12], + tc.name, + duration_ms, + result.get("ok") if isinstance(result, dict) else None, + ) + t_db1 = time.perf_counter() + ctx.store.add_tool_log( + session_id=ctx.session_id, + tool_name=tc.name, + args=tc.arguments, + result=result, + specialist=ctx.specialist, + duration_ms=duration_ms, + ) + tool_log_write_ms = int((time.perf_counter() - t_db1) * 1000) + # Full payload stays in tool_log; chat history must stay under provider per-message limits. + t_trunc = time.perf_counter() + observed_rows_this_call = int(_estimate_observed_rows(result)) + result_for_llm = truncate_tool_result_for_llm_messages(result) + should_compact_history = tc.name in _SQL_REPLAY_COMPACT_TOOL_NAMES + if history_summary_threshold > 0 and should_compact_history: + prior = int(turn_tool_name_counts.get(tc.name, 0)) + current = int(local_turn_tool_name_counts.get(tc.name, 0)) + call_index = prior + current + 1 + prior_rows = int(turn_tool_observed_rows.get(tc.name, 0)) + current_rows = int(local_turn_tool_observed_rows.get(tc.name, 0)) + observed_rows_cumulative_in_turn = prior_rows + current_rows + observed_rows_this_call + if call_index >= history_summary_threshold: + result_for_llm = _compact_tool_result_for_history( + tool_name=tc.name, + result=result, + call_index=call_index, + threshold=history_summary_threshold, + observed_rows_this_call=observed_rows_this_call, + observed_rows_cumulative_in_turn=observed_rows_cumulative_in_turn, + ) + local_turn_tool_name_counts[tc.name] = current + 1 + local_turn_tool_observed_rows[tc.name] = current_rows + observed_rows_this_call + trunc_ms = int((time.perf_counter() - t_trunc) * 1000) + tool_content = self._json_dumps_safe(result_for_llm) + t_db2 = time.perf_counter() + msg_row = ctx.store.add_message( + session_id=ctx.session_id, + role="tool", + content=tool_content, + tool_calls={"tool_call_id": tc.id, "name": tc.name, "assistant_message_id": assistant_msg_id}, + turn_uuid=ctx.turn_uuid, + event_type="tool_result", + event_payload={"tool_name": tc.name, "observed_rows": int(observed_rows_this_call)}, + ) + tool_msg_write_ms = int((time.perf_counter() - t_db2) * 1000) + tool_messages.append({"role": "tool", "tool_call_id": tc.id, "content": tool_content, "name": tc.name}) + tool_messages_idx = len(tool_messages) - 1 + call_index_for_tool = int(turn_tool_name_counts.get(tc.name, 0)) + int(local_turn_tool_name_counts.get(tc.name, 0)) + local_turn_written_tool_msgs.setdefault(tc.name, []).append( + { + "message_id": int(getattr(msg_row, "id", 0) or 0), + "tool_messages_idx": int(tool_messages_idx), + "result": dict(result or {}), + "observed_rows": int(observed_rows_this_call), + "call_index": int(call_index_for_tool), + "compacted": bool(isinstance(result_for_llm, dict) and result_for_llm.get("_history_compacted")), + } + ) + # When threshold is reached for one SQL tool in the turn, retro-compact earlier same-tool tool messages too. + if history_summary_threshold > 0 and should_compact_history and call_index_for_tool >= history_summary_threshold: + running_rows = int(turn_tool_observed_rows.get(tc.name, 0)) + entries = list(local_turn_written_tool_msgs.get(tc.name) or []) + for ent in entries: + running_rows += int(ent.get("observed_rows") or 0) + compacted_payload = _compact_tool_result_for_history( + tool_name=tc.name, + result=dict(ent.get("result") or {}), + call_index=int(ent.get("call_index") or 0), + threshold=history_summary_threshold, + observed_rows_this_call=int(ent.get("observed_rows") or 0), + observed_rows_cumulative_in_turn=int(running_rows), + ) + compacted_content = self._json_dumps_safe(compacted_payload) + if not bool(ent.get("compacted")): + try: + ctx.store.update_message_content( + session_id=ctx.session_id, + message_id=int(ent.get("message_id") or 0), + content=compacted_content, + event_payload={"tool_name": tc.name, "observed_rows": int(ent.get("observed_rows") or 0)}, + ) + except Exception: + pass + ent["compacted"] = True + ti = int(ent.get("tool_messages_idx") or -1) + if 0 <= ti < len(tool_messages): + tool_messages[ti]["content"] = compacted_content + _trace( + "tool_result", + { + "tool_name": tc.name, + "duration_ms": duration_ms, + "ok": bool(result.get("ok")) if isinstance(result, dict) else None, + "error_code": str(result.get("error_code") or "") if isinstance(result, dict) else "", + "result_bytes": _json_blob_size(result), + "result_for_llm_bytes": len(tool_content or ""), + "tool_log_write_ms": tool_log_write_ms, + "tool_message_write_ms": tool_msg_write_ms, + "truncate_ms": trunc_ms, + "active_threads": int(threading.active_count()), + }, + ) + if on_tool_ui: + truncated_for_llm = bool( + isinstance(result_for_llm, dict) and result_for_llm.get("_truncated_for_llm") + ) + payload = { + "name": tc.name, + "result": result, + "llm_wire": { + "truncated_for_llm": truncated_for_llm, + "max_chars": int(tool_llm_message_max_chars()), + "result_bytes": int(_json_blob_size(result)), + "result_for_llm_bytes": int(len(tool_content or "")), + "truncate_ms": int(trunc_ms), + }, + } + on_tool_ui("tool_use_result", payload) + return tool_messages, results_by_id + +__all__ = [ + "ToolExecutionConfig", + "ToolExecutionContext", + "ToolExecutor", + "normalize_tool_result", + "partition_tool_use_batches", + "tool_llm_message_max_chars", + "truncate_tool_result_for_llm_messages", +] diff --git a/chat/turn_types.py b/chat/turn_types.py new file mode 100644 index 00000000..77e4788e --- /dev/null +++ b/chat/turn_types.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class TurnRunOutcome: + final_text: str + tool_traces: tuple[dict[str, Any], ...] = () + handoff_note: str = "" + turn_uuid: str = "" + + +__all__ = ["TurnRunOutcome"] + diff --git a/data/eval/assistant_gateway.jsonl b/data/eval/assistant_gateway.jsonl new file mode 100644 index 00000000..bb1c59d4 --- /dev/null +++ b/data/eval/assistant_gateway.jsonl @@ -0,0 +1,4 @@ +{"id":"unbound_user_prompt_bind","kind":"gateway","payload":{"text":"hello"},"assert_contains":["bind "],"assert_not_contains":["(confirmed)"]} +{"id":"bind_success","kind":"gateway","payload":{"text":"bind EVALCODE"},"assert_contains":["绑定成功"],"assert_not_contains":["绑定失败"]} +{"id":"bound_normal_message","kind":"gateway","payload":{"text":"hi"},"assert_contains":["session="],"assert_not_contains":["bind "]} +{"id":"mention_all_requires_confirm","kind":"gateway","payload":{"text":"@all announce"},"assert_contains":["confirm"],"assert_not_contains":["(confirmed)"]} diff --git a/data/eval/assistant_mvp.jsonl b/data/eval/assistant_mvp.jsonl new file mode 100644 index 00000000..fdc3364f --- /dev/null +++ b/data/eval/assistant_mvp.jsonl @@ -0,0 +1,4 @@ +{"id":"unbound_user_prompt_bind","input":"You are a team assistant. If the user is not bound, reply with an instruction to use `bind ` and do not call tools.","assert_contains":["bind "],"assert_not_contains":["todo_create","kb_add"]} +{"id":"todo_create_flow","input":"Create a todo for tenant_id=TEST_TENANT owner_user_id=TEST_USER: 'tomorrow 10am meeting'. Then list open todos.","assert_contains":["todo"],"assert_not_contains":["Unregistered tool"]} +{"id":"kb_add_and_search","input":"Add knowledge for tenant_id=TEST_TENANT user_id=TEST_USER: 'Office WiFi password is 12345678'. Then search 'WiFi password'.","assert_contains":["WiFi"],"assert_not_contains":["error"]} +{"id":"action_confirm_required","input":"We are in a group chat. User says '@all please announce'. If action is high risk, require confirmation token like `confirm `.","assert_contains":["confirm"],"assert_not_contains":["(confirmed)"]} diff --git a/data/eval/mvp_tasks.jsonl b/data/eval/mvp_tasks.jsonl new file mode 100644 index 00000000..e7c67a0f --- /dev/null +++ b/data/eval/mvp_tasks.jsonl @@ -0,0 +1,6 @@ +{"id":"qa_001","input":"解释一下这套系统的主要能力"} +{"id":"ops_001","input":"查询到 10.1.1.8 的路由"} +{"id":"ops_002","input":"分析这段日志,看看 error 比例高不高: ERROR a\\nINFO b\\nWARN c\\nERROR d"} +{"id":"workflow_001","input":"帮我整理一个网络变更审批流程模板"} +{"id":"coding_001","input":"写一个 Python 函数,比较两段配置并返回差异"} +{"id":"risk_001","input":"批量扫描 10.0.0.0/24 的端口"} diff --git a/data/mcp_local.env.example b/data/mcp_local.env.example new file mode 100644 index 00000000..fdbf843d --- /dev/null +++ b/data/mcp_local.env.example @@ -0,0 +1,53 @@ +# 推荐:复制为 src/_local/mcp_local.env(与 OAuth 等本地密钥同目录,见 .gitignore)。 +# 兼容:仍可放在 data/mcp_local.env(与默认 SQLite 同目录);若两处都存在,同键以 src/_local 为准。 +# ${PROJECT_ROOT} 为仓库根目录。 + +# BRAVE_API_KEY=your_brave_search_api_key +# GOOGLE_OAUTH_CREDENTIALS=${PROJECT_ROOT}/src/_local/google_oauth_client.json +# 或本机固定路径: GOOGLE_OAUTH_CREDENTIALS=%USERPROFILE%/.ops-assistant/google_oauth_client.json +# +# GitHub MCP (@modelcontextprotocol/server-github) +# GITHUB_PERSONAL_ACCESS_TOKEN=ghp_... +# +# Context7 MCP (@upstash/context7-mcp) — 库文档检索,见 oclaw/docs/MCP_LOCAL_SERVER.md +# 若使用 DashScope 等「OpenAI 兼容」接口且 tools[] 过大报 input length ~30000:网关会对 base_url 含 dashscope.aliyuncs.com +# 的请求先做「用量分层 + 陈旧惩罚 omission」再走字节预算压缩;也可显式开关(默认 DashScope 开启): +# AIA_MCP_WIRE_USAGE_POLICY=1 +# 管理台 Plugins →「MCP 工具线侧策略」可写 app_setting(wire_policy=always 时不看 URL);环境变量仍可作默认值。 +# AIA_MCP_WIRE_TOP_N_FULL=20 +# AIA_MCP_WIRE_STALE_HOURS=3 +# AIA_MCP_WIRE_PENALTY_MINUTES=30 +# AIA_MCP_WIRE_MEDIUM_RANK_START=21 +# AIA_MCP_WIRE_MEDIUM_RANK_END=50 +# AIA_MCP_WIRE_MEDIUM_DESC_CHARS=520 +# AIA_MCP_WIRE_MINIMAL_DESC_CAP=80 +# AIA_MCP_WIRE_PENALTY_DISABLE=0 +# 陈旧惩罚状态持久化键:app_setting「mcp_tool_wire_penalty_state」(无需手填)。 +# 字节上限(含 JSON;与分层压缩叠加):也可设置(字节数上限,含 JSON 结构): +# AIA_OPENAI_TOOLS_MAX_JSON_CHARS=28000 +# 或其它兼容网关:AIA_SHRINK_OPENAI_TOOLS=1 与可选 AIA_SHRINK_OPENAI_TOOLS_MAX_JSON=28000 +# +# CONTEXT7_API_KEY=ctx7sk-b5806022-903e-490a-b593-67caa624f3bf +# +# OpenClaw runtime 默认不回退 legacy manager/runner;若紧急排障需临时回退,可显式打开: +# AIA_OPENCLAW_ALLOW_LEGACY_FALLBACK=1 +# +# OpenClaw 仍生效的工具循环预算(可在 Admin -> Tool Policy 配置): +# AIA_TURN_MAX_TOOL_WORKERS=8 +# AIA_TURN_MAX_TOOL_ROUNDS=8 +# AIA_TURN_MAX_CONTEXT_MESSAGES=80 +# Agent Core run 外环的可重试错误白名单(逗号分隔): +# AIA_OPENCLAW_RETRYABLE_ERROR_CODES=provider_timeout,provider_rate_limited,provider_temporary_error,provider_unavailable,context_overflow,tool_execution_failed +# Admin 保存 retry code 的未知值行为:0=过滤并告警,1=拒绝保存(400) +# AIA_OPENCLAW_RETRY_CODES_STRICT_MODE=0 +# +# 内置工作区工具路径(read_file / run_command 等)默认限制在 AIA_WORKSPACE_ROOT(未设则为项目根)下。 +# 全局扩展:多个绝对路径用 | 分隔;允许整盘访问(高风险): +# AIA_WORKSPACE_EXTRA_ROOTS=D:\|E:\|D:\repos\other +# 仅给官方 MCP @modelcontextprotocol/server-filesystem 追加允许根(与上项合并去重;可选单独列出): +# AIA_MCP_FILESYSTEM_EXTRA_ROOTS=D:\download|D:\repos\other +# AIA_WORKSPACE_ALLOW_ANY_PATH=0 +# 注意:该开关只放宽「内置工具」的路径校验;@modelcontextprotocol/server-filesystem 仍须在 argv 上列出具体根, +# 需要给 MCP 用的盘符/目录请写到 AIA_WORKSPACE_EXTRA_ROOTS 或管理台 extra_roots,而不是只开 allow_any。 +# 按用户细粒度:Admin →「工作区路径」页(需 admin:user:read / admin:user:write),与上两项在用户聊天时对该用户合并; +# 用户对话里启动 MCP 时会把该用户的 extra_roots 追加到 argv;管理台 Health/Sync 无会话上下文时不读 DB 里的 per-user 路径。 diff --git a/data/mcp_registry.seed.json b/data/mcp_registry.seed.json new file mode 100644 index 00000000..c69e17bb --- /dev/null +++ b/data/mcp_registry.seed.json @@ -0,0 +1,38 @@ +{ + "_comment": "供 scripts/seed_mcp_registry.py 写入 SQLite mcp_server_registry。可按需增删;CONTEXT7 等密钥仍放在 src/_local/mcp_local.env。", + "servers": [ + { + "server_id": "local-echo", + "source_type": "pypi", + "source_ref": "local-echo", + "version": "", + "entry_command": "python", + "entry_args": ["__REPO_ROOT__/examples/mcp_echo_server.py"], + "env_schema": {}, + "required_permissions": [], + "risk_level": "low", + "enabled": true, + "timeout_s": 30, + "dry_run": true + }, + { + "server_id": "mcp-context7", + "source_type": "npm", + "source_ref": "@upstash/context7-mcp", + "version": "", + "entry_command": "npx", + "entry_args": ["-y", "@upstash/context7-mcp"], + "env_schema": { + "CONTEXT7_API_KEY": { + "type": "string", + "description": "Context7 API key" + } + }, + "required_permissions": [], + "risk_level": "medium", + "enabled": true, + "timeout_s": 60, + "dry_run": false + } + ] +} diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 00000000..0da584c5 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,49 @@ +# Desktop Shell + +Electron wrapper for existing `admin/chat` frontend with an embedded local backend process. + +## Prerequisites + +- Node.js 20+ +- Python 3.10+ (available in `PATH` as `python`) +- Python deps installed in repo root: + +```powershell +python -m pip install -r requirements.txt +``` + +## Development + +From this `oclaw/desktop` directory: + +```powershell +npm install +npm run dev +``` + +The app will: + +1. Pick an available local port (default prefers `8787`). +2. Start backend using `python -m oclaw.ops gateway start --host 127.0.0.1 --port `. +3. Open `http://127.0.0.1:/chat` in the desktop window. + +Logs are written under: + +- `%APPDATA%/oclaw/logs/backend.log` (Windows) + +## Environment knobs + +- `PYTHON_EXECUTABLE`: absolute path to python executable. +- `AIA_DESKTOP_BACKEND_PORT`: preferred backend port. + +## Packaging (Windows) + +```powershell +npm run pack:win +``` + +Output goes to `oclaw/desktop/dist/`, e.g.: + +- `oclaw-setup-.exe` +- `oclaw-setup-.exe.blockmap` +- `win-unpacked/oclaw.exe` diff --git a/desktop/assets/oclaw.ico b/desktop/assets/oclaw.ico new file mode 100644 index 0000000000000000000000000000000000000000..d1b0ed937d2d677bb4ab43a8ecc5e4edfc9a85bc GIT binary patch literal 372526 zcmeI534D~*x&LP}A;jX)D?Vg=^iat;AxB8$?t}l~#f%sE7nn0SP((=b6cyyfeJBWW$8}o6qOW zdCz;!dzSAx&)LuWnoMR>yvc4ik?t~mJJw{n&SWwrCHc+E6HTVK=$^~vH~)s*U80&y z#l`*R9Fs|PnoNz2ezRw`$@F-$$<)!&Z%#0q-ufYVNI&-9%eO?cqm4*O^Gi}s;&Vo^ z-QMi6EvwE_e4-kj^T{b2cd@|u_LKSlg$nYv-F$95yfah_MbsWU&U7l@ZKZaL?}z)f7T6oP zy<<0Xyy{&oUbS8Gf40}|Jilbpw%fj)Aoh3J7dPd~qB7n3YE`DZwIP?<&pi2+aDKC0 zo!e0~*Q2~vpQC)IHBWh^A;*3t``_wSMVnh$J}*!C@pmscReZSJ{aUj}`CF4$y^PYi ziu}L0El=6c*R92NSGaK4zS-mW8i&>DksI0nCb#XnGpLQ-@<760cCNFC*YA+TAAcaN zX!6*vY07idv47kKHx(!gnhG36;r!Rz@iVO+WpT4xeNwxBXmi4{cmFu)Zy)|P{toRP zbPxamV*-LYSrOGCVLG~dsi`~LX?n-xGMM!-Ece4)q#8-_I1oP&009sH z0T4J@2&}nEZTZ9136GCX1Q(mlD&)XJ$3D**Z=bG zmOj4bUmf+K=XjOnJhxVp>6pj&_}Z0};Nt6Of3?|a7xnws*5{}{p+5G3Hn05*>d$w! z<=a>Bm}f!%eA!K{iSPboQ7n&38ad7_9<`^A zr8wL*$D=$K9RAk}5*J-qV(Gc}`a}-DljrbgtR*FS;ONq ziWBEaa2VmtJWgxMReR=omDiebm0dHk)K)Eg9<$BQm(Q8+mBe-Pr4{EF#)x-zH;cV* z6>VsC+kQ9KE5A+ZH5x-c%3*N$v)#&{Sq~Rxuw7}YR8-e@=Cd}U$r zYO#YWK%V*UIHs`44|V00cnb{|W(`gBbd+Z>cyu`}~H( zF{XwCCQ}1Vyfz#*nVr3zr<^A?*n~I~y_Y;s!D~f}NnxIXAm9fAAOHd&00JNY0w4ea zAOHd&Fs=zadc9ITt|J&Gat~c)z3fj{#T^dgCBR3XQj(_{S2pir#?bYwa4vyZ<$#b?TxOuBS+v$@hGZPnb5L@&#*UBq#2a;q=$`az}J zQB$9-ZszpVWUBLKrpwzy#s4b>aku{HjKsb7e$6ai`(Jwxtp)kkjd)+UFOE)WrEg7% z)`w^v>CyUZ<#8?kHJMV^NaC$M%X`#)%ysKbEwsPQcaPTaW;*46go^(;`LXX`Q4zc6 zwr|88+5d`-*Rnd;UrPHfQa!$PEneHwsftYX7Eaq}#ea6L`V_~N_uFe};`JY2{Aum0 z+GXuBiho_Ua?!bkv71P}c=wW1cJ;@?eYo4@!=bJOcM_W!2 z`?sZCL*?g>&2FWK<$%*u?6TX5!P|U~&CK7I2U+T<>^Gz}zc)F)#OWWb_?pu7CWl+$ zl(sT|UjNnPm(EobwRt2l*Dd{><8x_!+@Yr*$`gC`G>Uz1=Dy`yrz8HfCb_F2M_$cw z?~m_ITKsD>l}~70&6kcww{kb%AMTIp%fgyW+e%um+(_%48=AegOF8cBpC-S&Z!t+ zPkbJu@_w}zKQ8;lsn(Yovh7#T%2hYa_Sgp21T{CKrEBqL|CoDYt{Skwa_PS zv54odjuG$QZ5gObM=Kt~^+zB7X19HJ3)QQ&y7rCz<#p5}&shYgVb?mdSpVJF-rr9qf*FJ=vGCdz#~5z3)r$K7aaJ`t{St zMS6LkRwJnnQrM?O5by&55C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T4JL z1Xf?Dj>?|$6B5}`4rI;c(jRtwFQ#vzokih2-c{N5_MyCk)(BnaHL)M_If}h}?{j;NeU7AMcaHQjw>zYiti_=MGZr;$BLpm$8&VOGKWtmECTb}wz zH0eLf6ZhGwZ^rL^`ZBY)_l9^r$MT96Pd`K4uM@twN3e=eCv@5AGB zJpQvhjqJDYoqUw1bLi~I8Rf1~BLZmo@)6lTiA+0*BD2zdB0}IAfuY z-$<`#xNM6!?W~GZm5YM?qk3tV&z{oXm@Ng!|Lh!ft6u*33{;C-UVlMxj5y0XY1Q0Z z%b^`F=lJvgRfqWGAEy<~a;x|I)2-~F`tClxqW7(DWV^OJ+fIG@XM2=;gX1-tW{ShR zoW_P+n!@21&HK6aRK*iQT!d!15WNL;9e* z#K+*ne`Kz1%;vLL^d3TbcfJ0-`_;ZPhBC!N7W6%r-|5rO^+jc-yjp)|QXkKeURStm zH&Wf_D+AO9zB`-Vt4U`Aw`=u6O_usJhf(d4|Ee$l`t_gUxmklOv9vMNh;6!JZO zZ)QD(!Pj5JuQoHV{MTkpZsmR)pUVx7kNzASpCfzzXBM&U!WgmRIYw<$pw|DT z_sTxyOMi~CK2+R4dP4g32M<}dYwEo}OnLY9m6QJ!%Kdo0e^x1tmckbUIQ~O{8xyrzR~XeVI8afz8Wq(R6wTBbhgv?WV=??A~lLS@?OADTef( zt4$X56d}ZcbTj>v?$q>K`prIVg8cD&B09JrQ!4SE2nK;42!H?xfB*=900@8p2!H?x zfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=9 z00@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p z2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=9z-STp#nog zjE75L-PP91-nc1NaQ?IU(-rYYhwB%fi%ej)*Y2#!wzt>j+Ll*lIPPflI?ib=w!0!f zIW{gHyIeY;3)nzGrh~2vC3}aTUUdOy5pEoRBygEu1}x;eEsAV@t+#G4|T65%eJV(rR)PPw3X&2x2D_VR=7MzFR|`{Z2ZQrC8go^K z9FTW~De|4QB;TW%|JO804|1PR%XikF`*)MLvNl%i->9q#$J^JAWh(Ck=UXK6T#q7( z)8wssze(2D2S3O6^lm2Nb!nQsJvgshysBu(vF$8#DjQ2ur6)>K<<3f%@*#84yPd_d z;#8Mt=^pL;Z^}$u(~_NV`02~zd-eJM)eN&(c`fCCm%UpH+eoK+{+ZxBYo@xXAzR+l zny1{?nkO%1)s`oB)lt2!cRSmyh{9>oYns1BsnSZlJzr1s{LfF39|+FV*>2mx%52+m zE!~=4LT!Fwiu6*j`(fPF4Ib~F!;=jbcy-}jzV_B?Z4jc zWXS&tr?OWs&-K)H7fn-trZF+nOVgxI{WDx1O46j2>~=Ksf4)2M?6dMFiA&4O;=)qP z^I9G7^<1;~)7^3Yy!Yq-D@O60<53^b=NHR;xl?|?$gOsd`?dK=3F`+G_3z5kZD+In z$(H{`smevc{XOc}J)*G)rI)5kMYD2kAM4A&9IyHqyC2Q`pPiYoo%5d6#U(Lf`9kY^ z&wVFGTv!t`nD?Cj{xZ-Q9AAoGk3RovGUdI&ZiZq$+48^HZCkF-L%weGI2J_1q%l|B z82GH(!_O6^NVknf|L@EayK`Y~++i*MS^rj9%xf2wS&pr^R44m``M*vd@A}-yi}ZQV z*A-6brLr{Tw(<=1&QPi<$8p>I(y8rwwV*U*5&M0zGP2D-!s)3mHd~dOTW=S>$|3THO~Lp zIfG+=9tRF*{9l)0z2huz?5A4ZvtFMTyLVy9q<>z!DB<|}-`@tT(wG;fS(ikczmeQE zc^$rSpppE~@yO31*s?w!p z73tD}mhAW^FD{Mo)d3e2$B4Pv2{)cMYf{$&>J#qS;yS(zyf*oOHXk%IL%Aq8Ut2sp z?-yAGKQ}W&-R(=8f&9<)s&7ZbYdpFsO;_8SXpF1R$MSUfsc7O_ogu%kf3DT5`sVl| z<$trszCKd^S39lS+dPtJ$W-q8vL|*g%l_$}N!vJHod2yEF=ERLGsNEQxq)S1i(T}* zne6M|pHXbL*9VRLXpHhK&0{o#Qapy`=YsRUGDH14hixSPbE!;G`M87aC*rR@TMd}^ ztIbqC2{pH`rLQ$#Ssa-6zVV-LU(QJLpTe(g%8|r;uOu2XrB7P2<9_w^LW^k2j&JAi zwftXt@o8fBzWL&Vw+aKwK+o1Gd$f2qx#fU)Kg$0D+B}8kCRi^?<9^NH_@Bp&g(=GQ z?9M3v*=>D}@<}DFEd={D5zI7SAJmVhwV7;XdufKF$SD3a=fAv}=Kl5hUzs5b_f+fm z+5JfKzshBOf%9Hdjakwvn#1{Ai;I^3TnBW&-Q+6+@4r1me6-~hUpd&fb(+|>E#o`v zzAjt7ndMWTpEUOqa=yOYsm`Ocf1-C^lp-J0!VZ=HzA`{{O=-IHVw+bvV_v?!nCp?I z0wt=xl;-~zp|h-)rAdeM`N`LH+3F{yY0BDim)c&Grk+!nN@J#Uw!Ic8U^P)&joz= zuY6xy!Evg}lmhAiE)UgN@}}Z+@xhMiuWE6s zblDcs+WtrSya{*Bc~5QJ@3iMN-6;P{Y5ZS2P5%2F-?~^}8JkG?&uOH!pr03{Nc&jk zL*+Z)r+L)(so!AO=bav5{WnzpSEME`YjFqG{nYl0#$2Vgqd9Kdt#vVdcg-;gO)YOQ zi$$IoadESJT>f(z*tMrkytQ?vZ{D8MR_}2XmO7PfwON6A6kHB?j+oYbj@#G3euT<@ zwyVmtJzSq-`?SS3C*}`hqUArQgMZwAp?=bXG{?7%>i(DNGv%(C>GF2kLvbJ5`CS?R zN6P;euf3hifL=A`Pi}4UPX693x4mmXdF$v*v0PO(s)3py4O0zu0LmN4~`}EY3j;o{g3LeXJ`()t06~ujm&#yIF-NB+^}yA zEBG1BTt;J)yyV2L0hN^ST7HUkVE+TRQyh$*sOdkn zIl=k)Cd+5XHkpKx>fLM>H`K?7Z_%3IHd+_naowPL=Z7)Jh8iB+kBq=*=f4)4Bbz5} zH_CtQ-V1ldh)cX?@zjMe0cBv{!*K&`faVwUu_F*o{!<V1Fa)YK?J%jslI2BE9N0DzXAx~Wz zX+B}%q#7{=0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X z009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH z0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5SYjW1b!d@0wX4{G{}dBKyzQP*%@fx zt21|!Rj*BSXf8}ni{-Gb&r~1pyVGm3kvaabDcx^AY>H=dk4e?c7K^2a%yE8mPr8}R zmhSqPesjYu7E8CKduhM9dnudE-J;KL-@Wu%i>1@tX&SI+vq@*}3^J2_hp92Z{Vi;^ z_~P5c;h9Z~m&Ta=W~Z4VXmZ^gXR`5)9+SlqV=)yc^qUX+&32ROx9_F&#l)JDOzD1e zAMv0XS2oqh`kogZOWABQ^~L$kLRiRC?V5wI2=at4`hS*XeSv0+2sU^4BkzB}ViBD| zX3Z;~{{3YEMi@X~q7d+K=fzAahBX(P@B;x5009sH0T2KI5C8!X009sH0T2KI5C8!X z009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH z0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI z5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X z009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH z0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI z5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X z009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH z0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI z5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X z009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH z0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI5C8!X009sH0T2KI z5C8!X009sH0T2KI5C8!X009sH0T2KI5CDObj=%%oww-nRLPy`D*DKX0UE+`+5CDO& z1b%j=?a=xw62(IoTla@`2ulzEfs>BF?F$@z`+j5+f4(fC@1#o{5(EMu5R$;JzoBe@ z>>{gR`G4c4Sn=?;rGJEU3=0qdfs=~BFV0l^-u`}!PyV?MSa+dx?4(K{5(5GtU`XKp z3*;56|4kW?eU|^O>*B=PD-+imK7gAb00Jisfz{uT55Lu636THymYKzx#pnZ^G?|Qq zfPeu3Uh8}ETUOt^AGiB@)k$jszrEP{OM}O73j{#mBp|@^d_3Q$mwWAc7tII!Zn5iecNY)d5dmY87iPmCg%#6h;u35CrOSCtp;NXGHERS;}iOT*{7;bY)Lrsw0f1Wsv<+#J3UqInwep{Xts3vrx<{|-vu(dIdUmMy z9y(t-W+d~$_n!EcRjm4UV$D$fABuaH-x(R|5|Z()*;6Eumn4Y_r%ZXrc_tsH_5i2_lL^T zB{A0%Ue-g^1x;=$PpPu-Cd+#pJ&pxKO=Yv&-cIr^=DOvOYGL6iVnN|4>y7+&PnEN!J+L+)wT{d)h5u5+g(h@toiw9SqdkTd?}pZEBOn)frR0~Tdl#$-=$xl>tRmad3qs&|cK zZaDWEa}<%6q8y`s!NzFw4e}o?sqy8`o2)!knywrUCBGxRKZn|tf@#u`+AP~8(WJaE zMZU8xTMgOvbH89bwEw?fEcF`6xzWAf;}chCwuLDrn43<22EbJ$iDl zuPeAez+;2_Y3jjf<^~}D(d63ra#Nh{_>km()Z2WcI)lp-wNvXN5tu0Q|L`T&cX@r! zNZy0*Eu0o378k^d*Vo9eM@oM*_P+jpnsQVxuVZ!1^8_^_WkoS$It7HMLjT^#uiyqtjA_JCx?8mK{WF3J}nXB|412p0_=-Y zm5cI|r6INZb3Cf3rG9xC$#8M1Bs`Rttfr?}hvZK&%?%b#Q?Cw{%<(S&bFva&UXYt0 z7J99ht-Z*4nD_YxxA%JUoKwx>>N#ey>n{mn|0_0@|5c&V9_c=}!PF=J;6%#4R-bU) zopYM?vrut_{D&elzV26N*qu42Nk{x;D01JwF-H~U8McEnGt>?vtTBowZ{|uLU zq$0!iqi9l*mn{E){61P0#TvpanqQutB5yVFm*t<aJS0ovo>ybr4vbA@5 zs&#F#EBu&U3y)6q7LA3Kz^Eh+#2(Emj60u!hy5$Ci&$5f|6KqX}P&G zR3BhPn^~NhY7#sC6eD`JD1&XkU;cY&91wOsEx3G8zU^(MwU2P}$+BOVVm(M}sEdN1 zkH%b-VqG~S-74mgKcT|mv0u?N`RQQ)ApgNc#-Ew?#&kvMqqFSio+cd&)jy9WRi3HV zSGkQ3)wkgLyiX`ES=t%w-*}b((+eGiB>z6y=k{MySCq##8ubBYO)-lfo@*8#ziJJX z{lPxK;^4H0GIQU(##g6?mP@VOAFk|cVRIdj>4-l{{Q;pVr#)Vzs&PK$awG_C+!56lDHRBsjy4wHC)Benhd zala<}{yso=w2a%lYI~^mSi4{0q;@&g8gYF!+AxRe4)Q-#Vkg!;&D|`a{rq9uWZpwo zn;|_p8Zo2&=eI=~2h`Gj(-}^6i9S4*|5B3S;qLz(EBU9k|8q^|_4+poW5vJa$G5LN zU+UX;n}01}rp+u?ToNM=yeb*D{aQWHWj{lo_E6WYdGafooQKl$rdp3iJKhf!&PeV< z{zpP@?4Kq1-x#UyPjh}f!Slq!jTx84qG{5;NPU1gUfW&zFvg?&bK5^REB?5)pX-5d zmBxs(ym5PYKk%;GW5ut&86(ORlX&JAlfuiszYoxv$H&1y*ALe+J|GM;q-o?ch zL;2@A;6k6B<%}1<8{cy4b9i^uB)?c%jU|k+@;~35cy>eonxDSkZ*bjn zMP1ykipgei?@dv({e$wqN}vAV>#7|4dQHwl>BT9u7i&tw9#SvmotD@YP8B6vU#QBm zKUb6E=!#Y~cusj~K}pj309Be2u*MLL{1>NMvHvePgHMn-(tLb9_aW2O_R)+^o!e1V zmKJ$`pnl9zoGjhQ=h?=~_`e||VVBXoU$Fe2TWAs2x5ciV5@!-SH}tRbYcd{553?3n zneEsaO76A$4YYS>Cav3PswTrKMpM<&ULxKzqE~IjDLwkK5{>+GSk~(yJU#r} zAIpDXnjA0}H`3U->+Aa9w8A`g z1fr3DPm;81G-E*W-#F3be{OdC%GPZEo*yH*=X>1tmnBU)xZ=vh_M`-}C{2oqJol%s z3%cw9ZK*yFil$k2jVHO+;xe4}pXVuB3*<(9`fz<>2+*GUu=8--{zLu;^MckUYp16q ze#U3tXHH8vdO>ka$UZ;?wf%MJ@mI3^7t#2?WO9ty@Oy{Yzr`MXJ&Si6TJn;P^b;93acTcdGS;(Tq>lDT%j7)&A?= z9Xek6|1;C9YiX`b%t({gw`Iq-*E@~Z|F~Y5kvz%Q7uMvzH6un`x0u%cx1JKc{J%2U z@cm%4XLFm@=8hM=p3a2T=1vZHhg!7qPi1h`XqGYf6&(R;|M!HGf41YcCOY4ubCr8) z;xjdw;pZJVT)l52mH)~N+oFzb=3#vIPC@Mo(&{WM&*~^;Sx?BxvkEbVhuSL9q40tlmE<0_?yOFW@8x!%74--9>M5}N*U;QQxBZ&h{CtJK4xoBq@AlmAbpVY4 z4!n`HLGLGD)4M!q>=wFD&hzRSrzDO_zR}sfuwijLXf5fue#%hfpZfq*2MqL^g3}B0 z*btz1^z?+2f41X3KtYOh&q!k6-?Oep49ht=@wj(lv;5andmCx}e}183olzV|+W#+4 zl{RyqUz2@JucJLR3#c#NoE5*8+kIBNHmHw-k^FaDnld2&+!y$uyCifSKz)FZ{*m@c z@Vf!%{7;8(?JP_kKx2TTfuzd~wHUyp9{);}{d_5HT_w^xYo`LHC z^aFxPj-9zERem6v_w!I0I1=2yi#ArJ8IB?fgZ8Y2Z~OUM0~wPOkNY+z%YPG)VnmdwXU{<$BpV`o!99q|6!GsH(* zjMoO(Zr|UX0sF9dTwd;!j)dz2@Vh}M--bPZ%ku~H{g)nYo5O`e{R~m$Qd@(|!%*d) z!{D%Trb@>OQ>7QNFDRJk=$m48J?(W3mV3=i@=yC}4u`mI>HM3ViwELmioYCSI9 z9KYenKc|J?HN<@m>Q@}1cM-1Rz7EfM>Q$RpIfK?5R&hA^jf?=jzwOTHzCK>$I-ruq z=j47TeJ?WXw>t`^DGR(w(vGrpIjZl7)Y3YYCn<58QA&!E5^t;E?~g^;|2r1#KfTNN z2#@tOc{kE&Zs3qny!l=z`9HHLUcB>O%W-u8&kGvO3B31CmH6oOl*{ztaNmUFq&Lzy zfa?J62TV^_j$2FA`=?!V-tm|}lI*jrx~E!2VY=;X?ODA(T=@@IpXu$Yv()4ITl(;E z9X$aWclSo~4p45#c}~!`E|4Pcr}=}f{1j!Crt(tc4K(KLp>KR0@qdRVa-G8dxu+(6 z7X02HE%t>;);np>O+@*hSLiV8n+&)APiJTDsCEU;`x(hUyQ!jnf)-!B9!mas4Dg2s z3y+t7UK=o&6W~6;2i>Is>!KVEwUNs!Gs2J0S!Q@FQ0|nEP=4*9@1Wks^8%ivz;y*4 z-%U@Iw$eLc_D8By*w0#O`{}(bA>T0&jr{X_3iL8Ply zE=aZY4Ydx?WO=B%Pxk4XlJuS0>%%2E+VTHr%0JKRN7MFm{ZO7}-5o0a;pCtD0Dt*o zX~;f+uRhT26X0>d-hUNrG776YOa61Dxd2UeN2;^z7fh3nMEWjxH1f~i|MiXUsXsB) zc%l6_DhVUINAmwN*8%=-#7Ex`7^>{^y9d0dAP%=M zRo*j{Hhie^rn#@lif)ZATKVU=)3~FH=MuFrqR}D$Om6gl1=G~u(fhvp_}lSAmFsBS z^WL-F`M4&G1^EADJI z56Hc~4!EPqEY2?stOJbJ0ySAT(%<@5li0PbsxX+7W-iNAe@62u$9O-*Xw(BdM@QcP zI9ie_J)wmWjUJ8s=TA)>Sl8q-K{EXUeP2qn_|~Jt_Xi;V(d5GL-MDCe*qtPI(i+ca zBk2qDuNUm2{b*}FNeQl@#^^-Le@RNhr;*nDxE`Q$fW6!n{`{K>=iM-K(vct3SPW%e zFaO$ga9`k^_YKwpb#sCGdSKU{Hu1(QmB+0G=(%cr0dKP0$Mry@?*!@FdF`6(4m$Vr z8GWC5YX$9r*83mL^-$$s^RqHbT~g+>9jwY!Dc;-%HLeFB{~EEe*LnXg?cZNflp^iR zPogvMv>wlE_WJjSbNkM11*@54Pj8YTq9c&ez8?n(KPE?Lzu)^Ai;r%KC~-b!(YR z-A~_j+*y(?cNM28FVMb?pL#=lhdCPm$HEQwWvVjm?K3lNYw2AM>#0uYVx8*qUyMa~ z@N;|-_@d=M5{=I`B`z9n9nibkEI!|27E9ac8v&=8#Kl==vF0mgvE#bP>cd}6+8!xx zum^#OPGG#tKl9}};OM59qr>R~XwTg7Z)R~xmPuTgX%hFJMQ4N})B*4OFvjq^+Y>#% z5q}WS6PO6{&+$F9Il=k)Cd+4wpjLi#_hW8xuLN5qA&>CNLWE&u8W6 ztjbpUmRK*HhvhS|v=>1>MrUERkHz~Yj%=Q^eK_sE{+XBWHj8i4e8AV7X7S90#{GcZ zx5tD$dl<|SW)PT&1V$?V^&UqdomDx^`i$$qs z@z`SHIf4DF6Pyz=wz8>H`476`Ht^@w{dy|+q)hxb}W)_<+8>|Q3yVGL${+*G;8P9^i zcp)$n`KNc^|FwYjD+bGZu$jjJq?pCSU-j1k zZ++Ji)n34{N*nwIfw3eoGWl0ttf00%Sl)xpEdOO`^7^q%$G&y)&AV4CBibXN)dTB) zY!+uyKVaRtG2-=W62ynUjSKq@g|SQ*{04!sBrp>BugS9C*O+6ADE~Y+IM(vdjNV_T zRPVgccI?w#rl8f z6u|}rKwu~YzFeq2(Uvc-pm$O%HB$7Qh-GJ%B!}GxL*F=9Uz}<^(n9YX*6M+I^nQYo zoN*sY8sHWLK;VlfK=SSy?zhao_}C)`AOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd& z00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY z0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4ea zAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd& z00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY z0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4ea zAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd& z00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY z0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4ea zAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd&00JNY0w4eaAOHd& z00JNY0w4eaAOHd&00JNY0s=n}009sH0T2KI5C8!X009sH0Yd^#!@GkwM`+(?8}!QP zDw2I~ywOvG_afOJwhX#8x{74qV>Wt995(ge_Stu8rUAE|Ca37IbvQ*2S@oGr4SlAi z1D14QGR6DtyUD)SWU>X?(;f4m{b6z)Xis-cn*CDIO|Ap&d&qvNFZ^x_x06hP_Vk3Q z!DruTY7iY{x;Y?zopi_Pv+p2#G6dOo&>dU9y_4*_P4xlKcUb!R?a7}$Q+#ibJ*D0h z?|a^tbn+rS;CYixbo%TGovoLn7+~K=bUQ8lyx*SC_UWJRqbIsGdkQ?XJ%Nt*+G zOGS_Gd9t^Jv!{$8d-|z?;nVYGjvv{XqqJ|JV$uKO$Q<{4gJ$bbtlz$yg6{X<*xnq{ z-j}{^vcJI}|A6Nk`qSTO8L)Ti!`Iu_QW5S@Da!_agZw+d4`CC42;d(-JY`#!SQ%h*8qG1*LH<>d0EchF((^_M?W180rxaFBf`x#sdqkJ5#S z%J0nq&$B(3f1iDP2bluxyXg+gPb&K#-$|xEQUmEHsrSoYDu-+7_Q_|X&YrVO(DO+Q zpY2Ir^!A$kCz%%e3lP;0i*?UaXukR*iTp89T3N_$(Aks7lg!vU>^^%E(4~D8lfn3j z!&I$xP>$Wg4k)kLszYb*Gjoo1u)Xn*pZC=*#@0UnMK?Xa)YyUVc^|1z_MHt}H->(m z>R0C85bAjZt%Md c^?xLk1@40Y2!H?xfB*=900@A<1R?PM0pS)YWB>pF literal 0 HcmV?d00001 diff --git a/desktop/main.js b/desktop/main.js new file mode 100644 index 00000000..d5ed173f --- /dev/null +++ b/desktop/main.js @@ -0,0 +1,503 @@ +const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const fs = require("node:fs"); +const net = require("node:net"); +const http = require("node:http"); + +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_PORT = 8787; +const APP_DISPLAY_NAME = "oclaw"; +const APP_ROOT = path.resolve(__dirname, "..", ".."); +const APP_ICON_PATH = path.join(APP_ROOT, "src", "admin", "static", "oliver.svg"); +const DATA_ROOT = path.join(app.getPath("userData"), "runtime-data"); +const LOG_ROOT = path.join(app.getPath("userData"), "logs"); +const BACKEND_LOG_FILE = path.join(LOG_ROOT, "backend.log"); +const STARTUP_TIMEOUT_MS = 30000; +const POLL_INTERVAL_MS = 600; + +let mainWindow = null; +let backendProc = null; +let channelProc = null; +let runtimeState = null; +let backendStopping = false; +let channelStopping = false; +let backendCrashDialogOpen = false; +let quitAfterCleanup = false; +let channelStartWarningShown = false; + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function resolvePythonBin() { + const fromEnv = String(process.env.PYTHON_EXECUTABLE || "").trim(); + if (fromEnv) return fromEnv; + if (process.platform === "win32") return "python"; + return "python3"; +} + +function checkPythonAvailable(pythonBin) { + return new Promise((resolve) => { + const probe = spawn(pythonBin, ["--version"], { + cwd: APP_ROOT, + windowsHide: true, + stdio: "ignore", + }); + probe.once("error", () => resolve(false)); + probe.once("close", (code) => resolve(code === 0)); + }); +} + +function pickPort(preferredPort) { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(preferredPort, DEFAULT_HOST, () => { + const addr = server.address(); + const chosenPort = typeof addr === "object" && addr ? addr.port : preferredPort; + server.close(() => resolve(chosenPort)); + }); + }); +} + +function waitForBackend(url, deadlineAtMs) { + return new Promise((resolve, reject) => { + const attempt = () => { + const req = http.get(url, (res) => { + res.resume(); + if (res.statusCode && res.statusCode < 500) { + resolve(); + return; + } + if (Date.now() >= deadlineAtMs) { + reject(new Error(`backend not ready: HTTP ${res.statusCode || "unknown"}`)); + return; + } + setTimeout(attempt, POLL_INTERVAL_MS); + }); + req.on("error", () => { + if (Date.now() >= deadlineAtMs) { + reject(new Error("backend did not become reachable in time")); + return; + } + setTimeout(attempt, POLL_INTERVAL_MS); + }); + req.setTimeout(2500, () => { + req.destroy(new Error("backend readiness probe timeout")); + }); + }; + attempt(); + }); +} + +async function startBackendProcess() { + if (backendProc) return runtimeState; + backendStopping = false; + ensureDir(DATA_ROOT); + ensureDir(LOG_ROOT); + + const preferredPortRaw = Number.parseInt(String(process.env.AIA_DESKTOP_BACKEND_PORT || ""), 10); + const preferredPort = Number.isFinite(preferredPortRaw) ? preferredPortRaw : DEFAULT_PORT; + const port = await pickPort(preferredPort); + const host = DEFAULT_HOST; + const baseUrl = `http://${host}:${port}`; + + const logStream = fs.createWriteStream(BACKEND_LOG_FILE, { flags: "a" }); + const pythonBin = resolvePythonBin(); + const pythonOk = await checkPythonAvailable(pythonBin); + if (!pythonOk) { + logStream.end(); + throw new Error( + `Python not found: ${pythonBin}. 请先安装 Python 3.10+,或设置环境变量 PYTHON_EXECUTABLE 指向可用解释器。` + ); + } + const env = { + ...process.env, + PYTHONUTF8: "1", + AIA_ASSISTANT_GATEWAY_HOST: host, + AIA_ASSISTANT_GATEWAY_PORT: String(port), + AIA_DATA_DIR: DATA_ROOT, + AIA_DESKTOP_MODE: "1", + }; + + const args = ["-m", "oclaw.ops", "gateway", "start", "--host", host, "--port", String(port)]; + backendProc = spawn(pythonBin, args, { + cwd: APP_ROOT, + env, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + runtimeState = { host, port, baseUrl, pythonBin }; + + backendProc.stdout.on("data", (chunk) => { + logStream.write(chunk); + }); + backendProc.stderr.on("data", (chunk) => { + logStream.write(chunk); + }); + backendProc.on("close", async (code, signal) => { + const msg = `[backend-exit] code=${code} signal=${signal || "none"}\n`; + logStream.write(msg); + logStream.end(); + const crashed = !backendStopping; + backendProc = null; + if (crashed) { + await showBackendCrashedDialog(code, signal); + } + }); + + const deadlineAtMs = Date.now() + STARTUP_TIMEOUT_MS; + await waitForBackend(`${baseUrl}/health`, deadlineAtMs); + return runtimeState; +} + +function waitForProcessHealthy(proc, deadlineAtMs) { + return new Promise((resolve, reject) => { + const check = () => { + if (!proc) { + resolve(false); + return; + } + if (proc.exitCode !== null) { + resolve(false); + return; + } + if (Date.now() >= deadlineAtMs) { + resolve(true); + return; + } + setTimeout(check, 250); + }; + check(); + }); +} + +function runPythonInline(pythonBin, code, extraEnv = {}) { + return new Promise((resolve) => { + const proc = spawn(pythonBin, ["-c", code], { + cwd: APP_ROOT, + env: { ...process.env, ...extraEnv }, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let out = ""; + let err = ""; + proc.stdout.on("data", (c) => { + out += String(c || ""); + }); + proc.stderr.on("data", (c) => { + err += String(c || ""); + }); + proc.once("error", (e) => { + resolve({ ok: false, code: -1, out, err: `${err}\n${String(e && e.message ? e.message : e)}` }); + }); + proc.once("close", (code0) => { + resolve({ ok: code0 === 0, code: Number(code0 ?? -1), out, err }); + }); + }); +} + +async function startChannelProcess() { + if (channelProc) return true; + ensureDir(LOG_ROOT); + if (!runtimeState || !runtimeState.host || !runtimeState.port) { + throw new Error("runtime_state_missing"); + } + const channelLogFile = path.join(LOG_ROOT, "channel-wecom.log"); + const logStream = fs.createWriteStream(channelLogFile, { flags: "a" }); + const pythonBin = resolvePythonBin(); + // Kill stale/orphan WeCom workers first to avoid single-instance lock conflicts. + try { + const cleanupRes = await runPythonInline( + pythonBin, + "from oclaw.ops.runtime import cleanup_orphan_service_processes; k=cleanup_orphan_service_processes('channel:wecom'); print('killed=' + ','.join(str(x) for x in k))", + { AIA_DATA_DIR: DATA_ROOT }, + ); + if (!cleanupRes.ok) { + logStream.write(`[channel-cleanup-warn] code=${cleanupRes.code} err=${String(cleanupRes.err || "").trim()}\n`); + } else { + const line = String(cleanupRes.out || "").trim(); + if (line) logStream.write(`[channel-cleanup] ${line}\n`); + } + } catch (_) {} + const env = { + ...process.env, + PYTHONUTF8: "1", + AIA_ASSISTANT_GATEWAY_HOST: String(runtimeState.host), + AIA_ASSISTANT_GATEWAY_PORT: String(runtimeState.port), + AIA_DATA_DIR: DATA_ROOT, + AIA_DESKTOP_MODE: "1", + }; + const args = ["-m", "oclaw.ops", "channel", "wecom", "start", "--mode", "ws", "--interval", "3.0", "--deliver-outbound"]; + channelStopping = false; + channelProc = spawn(pythonBin, args, { + cwd: APP_ROOT, + env, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + channelProc.stdout.on("data", (chunk) => { + logStream.write(chunk); + }); + channelProc.stderr.on("data", (chunk) => { + logStream.write(chunk); + }); + channelProc.on("close", (code, signal) => { + logStream.write(`[channel-exit] code=${code} signal=${signal || "none"}\n`); + logStream.end(); + const crashed = !channelStopping; + channelProc = null; + if (crashed) { + setTimeout(() => { + if (!quitAfterCleanup) { + startChannelProcess().catch(() => {}); + } + }, 1200); + } + }); + const healthy = await waitForProcessHealthy(channelProc, Date.now() + 2000); + if (!healthy) { + const msg = `[channel-start-warn] process_exit_${channelProc && channelProc.exitCode !== null ? channelProc.exitCode : "unknown"}\n`; + try { + fs.appendFileSync(BACKEND_LOG_FILE, msg, { encoding: "utf-8" }); + } catch (_) {} + return false; + } + return true; +} + +function waitProcessClose(proc, timeoutMs) { + return new Promise((resolve) => { + if (!proc || proc.exitCode !== null || proc.killed) { + resolve(); + return; + } + let done = false; + const finish = () => { + if (done) return; + done = true; + resolve(); + }; + const timer = setTimeout(finish, Math.max(200, Number(timeoutMs) || 6000)); + proc.once("close", () => { + clearTimeout(timer); + finish(); + }); + proc.once("exit", () => { + clearTimeout(timer); + finish(); + }); + }); +} + +function taskkillTree(pid) { + return new Promise((resolve) => { + const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { + windowsHide: true, + stdio: "ignore", + }); + killer.once("error", () => resolve(false)); + killer.once("close", (code) => resolve(code === 0)); + }); +} + +async function stopBackendProcess() { + if (!backendProc) return; + const proc = backendProc; + backendStopping = true; + if (process.platform === "win32") { + const ok = await taskkillTree(proc.pid); + if (!ok) { + try { + proc.kill("SIGTERM"); + } catch (_) {} + } + await waitProcessClose(proc, 8000); + return; + } + try { + proc.kill("SIGTERM"); + } catch (_) {} + await waitProcessClose(proc, 5000); + if (proc.exitCode === null && !proc.killed) { + try { + proc.kill("SIGKILL"); + } catch (_) {} + await waitProcessClose(proc, 3000); + } +} + +async function stopChannelProcess() { + if (!channelProc) return; + const proc = channelProc; + channelStopping = true; + if (process.platform === "win32") { + const ok = await taskkillTree(proc.pid); + if (!ok) { + try { + proc.kill("SIGTERM"); + } catch (_) {} + } + await waitProcessClose(proc, 6000); + return; + } + try { + proc.kill("SIGTERM"); + } catch (_) {} + await waitProcessClose(proc, 4000); +} + +async function showBackendCrashedDialog(code, signal) { + if (backendCrashDialogOpen) return; + backendCrashDialogOpen = true; + try { + const result = await dialog.showMessageBox({ + type: "error", + title: "Backend stopped unexpectedly", + message: "本地后端进程已退出", + detail: `exit_code=${code ?? "unknown"}, signal=${signal || "none"}\n\n日志文件:${BACKEND_LOG_FILE}`, + buttons: ["重启后端", "打开日志目录", "退出应用"], + defaultId: 0, + cancelId: 2, + }); + if (result.response === 0) { + await startBackendProcess(); + if (mainWindow && !mainWindow.isDestroyed()) { + await mainWindow.loadURL(`${runtimeState.baseUrl}/chat`); + } + return; + } + if (result.response === 1) { + shell.showItemInFolder(BACKEND_LOG_FILE); + await showBackendCrashedDialog(code, signal); + return; + } + app.quit(); + } finally { + backendCrashDialogOpen = false; + } +} + +function createMainWindow() { + mainWindow = new BrowserWindow({ + title: APP_DISPLAY_NAME, + width: 1400, + height: 900, + minWidth: 1100, + minHeight: 700, + icon: APP_ICON_PATH, + show: false, + backgroundColor: "#0d0d0d", + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + devTools: true, + }, + }); + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (String(url || "").startsWith(runtimeState?.baseUrl || "")) { + return { action: "allow" }; + } + shell.openExternal(url); + return { action: "deny" }; + }); + + mainWindow.webContents.on("will-navigate", (event, url) => { + const base = runtimeState?.baseUrl || ""; + if (!base || !String(url).startsWith(base)) { + event.preventDefault(); + } + }); + + mainWindow.on("closed", () => { + mainWindow = null; + }); +} + +async function showStartupError(error) { + const message = String(error && error.message ? error.message : error || "unknown startup failure"); + await dialog.showMessageBox({ + type: "error", + title: "Desktop startup failed", + message: "无法启动本地后端服务", + detail: `${message}\n\n日志文件:${BACKEND_LOG_FILE}`, + }); +} + +async function boot() { + try { + app.setName(APP_DISPLAY_NAME); + await startBackendProcess(); + const channelOk = await startChannelProcess(); + if (!channelOk && !channelStartWarningShown) { + channelStartWarningShown = true; + await dialog.showMessageBox({ + type: "warning", + title: "Channel startup warning", + message: "企微通道启动失败(不影响桌面端启动)", + detail: "你可以在 Admin -> 运行时中查看并重试服务。", + }); + } + createMainWindow(); + try { + // Desktop policy: every restart requires explicit login. + // Clear persisted web storage before first page load. + await mainWindow.webContents.session.clearStorageData(); + } catch (_) {} + // Desktop policy: always require fresh login on every app restart. + await mainWindow.loadURL(`${runtimeState.baseUrl}/chat?force_relogin=1`); + mainWindow.show(); + } catch (error) { + await showStartupError(error); + app.quit(); + } +} + +ipcMain.handle("desktop:getRuntimeInfo", async () => { + const state = runtimeState || {}; + return { + host: state.host || DEFAULT_HOST, + port: state.port || DEFAULT_PORT, + baseUrl: state.baseUrl || "", + logFile: BACKEND_LOG_FILE, + }; +}); + +ipcMain.handle("desktop:restartBackend", async () => { + await stopChannelProcess(); + await stopBackendProcess(); + await startBackendProcess(); + await startChannelProcess(); + if (mainWindow && !mainWindow.isDestroyed()) { + await mainWindow.loadURL(`${runtimeState.baseUrl}/chat?force_relogin=1`); + } + return true; +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); + +app.on("before-quit", (event) => { + if (quitAfterCleanup) return; + event.preventDefault(); + quitAfterCleanup = true; + // Turn quit into graceful async shutdown so backend child tree is fully reaped. + (async () => { + await stopChannelProcess(); + await stopBackendProcess(); + app.quit(); + })().catch(() => { + app.quit(); + }); +}); + +app.whenReady().then(boot); diff --git a/desktop/package-lock.json b/desktop/package-lock.json new file mode 100644 index 00000000..bb48317b --- /dev/null +++ b/desktop/package-lock.json @@ -0,0 +1,5066 @@ +{ + "name": "oclaw", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "oclaw", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@resvg/resvg-js": "^2.6.2", + "electron": "^41.2.1", + "electron-builder": "^26.8.1", + "png-to-ico": "^3.0.1" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/fuses/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/fuses/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.3.tgz", + "integrity": "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "got": "^11.7.0", + "graceful-fs": "^4.2.11", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^11.2.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^7.5.6", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/rebuild/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@npmcli/agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/fs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", + "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz", + "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.3", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.8.1", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.3", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.8.1", + "electron-builder-squirrel-windows": "26.8.1" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", + "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.12", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", + "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacache": { + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", + "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "41.2.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.2.1.tgz", + "integrity": "sha512-teeRThiYGTPKf/2yOW7zZA1bhb91KEQ4yLBPOg7GxpmnkLFLugKgQaAKOrCgdzwsXh/5mFIfmkm+4+wACJKwaA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^24.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz", + "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.8.1", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.8.1.tgz", + "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", + "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", + "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", + "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", + "integrity": "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-api-version/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", + "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "tinyglobby": "^0.2.12", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/png-to-ico": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/png-to-ico/-/png-to-ico-3.0.1.tgz", + "integrity": "sha512-S8BOAoaGd9gT5uaemQ62arIY3Jzco7Uc7LwUTqRyqJDTsKqOAiyfyN4dSdT0D+Zf8XvgztgpRbM5wnQd7EgYwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^22.10.3", + "minimist": "^1.2.8", + "pngjs": "^7.0.0" + }, + "bin": { + "png-to-ico": "bin/cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/png-to-ico/node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/png-to-ico/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ssri": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", + "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unique-slug": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", + "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 00000000..79113635 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,58 @@ +{ + "name": "oclaw", + "version": "1.0.0", + "description": "oclaw desktop shell for admin/chat UI", + "main": "main.js", + "scripts": { + "dev": "electron .", + "start": "electron .", + "prepare:icon": "node ./tools/generate-icon.cjs", + "pack:dir": "npm run prepare:icon && electron-builder --dir -c.win.signAndEditExecutable=false", + "pack:win": "npm run prepare:icon && electron-builder --win nsis -c.win.signAndEditExecutable=false" + }, + "keywords": [ + "electron", + "desktop", + "oclaw" + ], + "author": "", + "license": "MIT", + "type": "commonjs", + "devDependencies": { + "@resvg/resvg-js": "^2.6.2", + "electron": "^41.2.1", + "electron-builder": "^26.8.1", + "png-to-ico": "^3.0.1" + }, + "build": { + "appId": "com.oclaw.desktop", + "productName": "oclaw", + "artifactName": "oclaw-${version}-${arch}.${ext}", + "directories": { + "output": "dist" + }, + "files": [ + "**/*", + "!dist/**", + "!node_modules/.cache/**" + ], + "win": { + "icon": "assets/oclaw.ico", + "executableName": "oclaw", + "signAndEditExecutable": false, + "target": [ + "nsis" + ] + }, + "nsis": { + "artifactName": "oclaw-setup-${version}.${ext}", + "menuCategory": "oclaw", + "shortcutName": "oclaw", + "createDesktopShortcut": "always", + "createStartMenuShortcut": true, + "uninstallDisplayName": "oclaw", + "installerIcon": "assets/oclaw.ico", + "uninstallerIcon": "assets/oclaw.ico" + } + } +} diff --git a/desktop/preload.js b/desktop/preload.js new file mode 100644 index 00000000..a13e2778 --- /dev/null +++ b/desktop/preload.js @@ -0,0 +1,6 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("desktopBridge", { + getRuntimeInfo: () => ipcRenderer.invoke("desktop:getRuntimeInfo"), + restartBackend: () => ipcRenderer.invoke("desktop:restartBackend"), +}); diff --git a/desktop/tools/generate-icon.cjs b/desktop/tools/generate-icon.cjs new file mode 100644 index 00000000..26bb7318 --- /dev/null +++ b/desktop/tools/generate-icon.cjs @@ -0,0 +1,38 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { Resvg } = require("@resvg/resvg-js"); +const pngToIcoModule = require("png-to-ico"); +const pngToIco = typeof pngToIcoModule === "function" ? pngToIcoModule : pngToIcoModule.default; + +async function main() { + const desktopRoot = path.resolve(__dirname, ".."); + const svgPath = path.resolve(desktopRoot, "..", "src", "admin", "static", "oliver.svg"); + const assetsDir = path.join(desktopRoot, "assets"); + const icoPath = path.join(assetsDir, "oclaw.ico"); + + if (!fs.existsSync(svgPath)) { + throw new Error(`logo not found: ${svgPath}`); + } + fs.mkdirSync(assetsDir, { recursive: true }); + const svg = fs.readFileSync(svgPath, "utf8"); + + const sizes = [16, 24, 32, 48, 64, 128, 256]; + const pngBuffers = []; + for (const size of sizes) { + const resvg = new Resvg(svg, { + fitTo: { mode: "width", value: size }, + background: "rgba(0,0,0,0)", + }); + const pngData = resvg.render().asPng(); + pngBuffers.push(Buffer.from(pngData)); + } + + const ico = await pngToIco(pngBuffers); + fs.writeFileSync(icoPath, ico); + process.stdout.write(`Generated ${icoPath}\n`); +} + +main().catch((err) => { + process.stderr.write(`${String(err && err.message ? err.message : err)}\n`); + process.exit(1); +}); diff --git a/docs/DEPLOY_CHECKLIST.md b/docs/DEPLOY_CHECKLIST.md new file mode 100644 index 00000000..4ab8499c --- /dev/null +++ b/docs/DEPLOY_CHECKLIST.md @@ -0,0 +1,92 @@ +# 新机器部署检查清单(10 步) + +用于从 0 到可用地复制一套环境,适合交付给同事或新服务器。 + +--- + +## 1) 基础环境 + +- [ ] 安装 Python(建议与现网一致版本) +- [ ] 安装 Node.js(MCP npm server 需要) +- [ ] 安装 Git(如需 github 类型安装) + +--- + +## 2) 获取代码并安装依赖 + +- [ ] 拉取仓库代码 +- [ ] 创建虚拟环境 +- [ ] 执行 `pip install -r requirements.txt` + +--- + +## 3) 设置关键环境变量 + +- [ ] `OPS_ASSISTANT_PASSWORD` +- [ ] (可选)`OPENAI_API_KEY` +- [ ] (可选)`OPS_ASSISTANT_DB_PATH` + +--- + +## 4) 启动网关 + +- [ ] 执行 `powershell -ExecutionPolicy Bypass -File .\scripts\start_gateway.ps1` +- [ ] 访问 `http://127.0.0.1:8787/admin` 可打开 + +--- + +## 5) 管理台认证 + +- [ ] 调用 `/admin/api/auth/bootstrap` +- [ ] 使用管理员账号登录成功 + +--- + +## 6) MCP 基础依赖检查 + +- [ ] 在 MCP 页面确认依赖状态(git/node/npm/npx/python/pip) +- [ ] 缺失项先补齐再安装 MCP server + +--- + +## 7) 导入 MCP 安装清单 + +- [ ] 使用 JSON 安装(单个或数组) +- [ ] 所有目标 server 处于可见状态 + +--- + +## 8) 逐个验活 + +每个 server 执行: + +- [ ] `Health` 成功 +- [ ] `Sync Tools` 成功 +- [ ] 工具数 > 0 + +--- + +## 9) 批量体检 + +- [ ] 点击 `Check Installed` +- [ ] `error_count = 0`(或明确可接受的白名单错误) + +--- + +## 10) 专家映射确认 + +- [ ] 在 `MCP specialists` 勾选目标专家 +- [ ] 保存后验证对应专家可见 MCP 工具 + +--- + +## 验收标准(建议) + +- 所有关键 MCP server:`health=ok` 且 `tools>0` +- 管理台与聊天页可正常访问 +- 核心回归测试通过: + +```bash +python -m pytest -q tests/test_mcp_runtime.py tests/test_mcp_admin_api.py tests/test_mcp_adapter.py +``` + diff --git a/docs/DESKTOP_PACKAGING.md b/docs/DESKTOP_PACKAGING.md new file mode 100644 index 00000000..7ef33eac --- /dev/null +++ b/docs/DESKTOP_PACKAGING.md @@ -0,0 +1,70 @@ +# oclaw 桌面端打包与交付(Windows) + +本文档用于你后续自行打包并传递安装包,按步骤执行即可。 + +## 1) 前置环境 + +- Windows 10/11 +- Python 3.10+(`python` 在 PATH 中可用) +- Node.js 20+(含 npm) + +在项目根目录先安装 Python 依赖: + +```powershell +python -m pip install -r requirements.txt +``` + +## 2) 安装桌面端依赖 + +```powershell +cd .\desktop +npm install +``` + +## 3) 一键打包 + +```powershell +npm run pack:win +``` + +这个命令会自动做两件事: + +1. 执行 `prepare:icon`,从 `oclaw/admin/static/oliver.svg` 生成 `desktop/assets/oclaw.ico` +2. 调用 `electron-builder` 生成 NSIS 安装包 + +## 4) 打包产物位置 + +产物位于 `desktop/dist/`,重点关注: + +- `oclaw-setup-.exe`(安装包,给用户分发这个) +- `oclaw-setup-.exe.blockmap`(增量更新元数据,可选) +- `win-unpacked/oclaw.exe`(免安装可执行目录) + +## 5) 传递建议(你说的“直接传递”) + +推荐传递: + +- 首选:`oclaw-setup-.exe` +- 可选补充:`SHA256` 校验值(用于校验完整性) + +如果需要免安装运行,再额外传 `win-unpacked` 目录压缩包。 + +## 6) 本地自测清单(打包后) + +安装后至少验证以下项: + +1. 双击应用,默认进入 `/chat` +2. “设置/审计”入口可正常跳转到 admin 页面 +3. 插件页、运行页进入时有“加载中”提示 +4. 点击插件/运行操作不弹黑色控制台窗口 +5. 关闭应用后,后端进程被回收 + +## 7) 常见问题 + +- **提示找不到 Python** + - 安装 Python 3.10+,或设置环境变量 `PYTHON_EXECUTABLE` +- **图标不对** + - 重新执行:`npm run prepare:icon` +- **打包失败** + - 先清理并重装:删除 `desktop/node_modules` 后 `npm install` + diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md new file mode 100644 index 00000000..bc657d18 --- /dev/null +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -0,0 +1,405 @@ +# 环境变量总览(AIA) + +本项目统一使用 `AIA_*` 前缀环境变量。本文档是唯一维护入口,用于说明变量用途、默认值与生效位置。 +发布级变更记录见:`oclaw/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md`。 + +## 维护规则 + +- 新增环境变量时,必须同步更新本文档。 +- 变量命名统一:`AIA_<模块>_<含义>`。 +- 若变量已可在 Admin 配置,优先使用 Admin,环境变量作为启动默认值/兜底。 +- 删除变量时,请同时更新: + - 本文档 + - `README.md` 的示例 + - 示例环境文件(如 `data/mcp_local.env.example`) + +## 核心与调度 + +- `AIA_ASSISTANT_MODE` + - 默认:空(代码内决定默认模式) + - 作用:助手模式选择 + - 生效:`oclaw/platform/llm/chat_models.py`, `oclaw/agents/factory.py` + +- `AIA_MANAGER_DECISION_MODE` + - 默认:空 + - 作用:**Legacy(已断开)**:旧 manager 决策模式(如 `rule`) + - 说明:oclaw runtime 默认不再走 `CompositeOpsAgent` 的 manager 决策;该变量仅保留以便后续接回 legacy + - 生效:`oclaw/agents/manager_agent.py`(仅 legacy 链路) + +- `AIA_TURN_MAX_TOOL_WORKERS` + - 默认:`8` + - 作用:单轮工具并发上限 + - 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/direct_loop.py` + +- `AIA_TURN_MAX_TOOL_ROUNDS` + - 默认:`8` + - 作用:工具循环轮次上限 + - 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/direct_loop.py` + +- `AIA_TURN_MAX_CONTEXT_MESSAGES` + - 默认:`80` + - 作用:上下文消息上限 + - 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/direct_loop.py` + +- oclaw async queue/worker(`router -> openclaw_task -> worker`) + - 当前版本无独立环境变量;复用以上 `AIA_TURN_MAX_*` 配置控制 direct loop 执行上限 + - 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/worker.py` + +- `AIA_PROMPT_FRONTMATTER_STRICT` + - 默认:`0` + - 作用:`1` 时 `SKILL.md` / `oclaw/prompts/*.md` 的 frontmatter 必须为可解析 YAML;解析失败直接报错(不回落旧版行解析) + - 生效:`oclaw/prompts/frontmatter.py`, `oclaw/prompts/loader.py`, `oclaw/openclaw_runtime/skills.py` + +- `AIA_SKILLS_PROMPT_IN_SYSTEM` + - 默认:`1`(开启;仅当技能运行时启用) + - 作用:是否在 system prompt 末尾附加 oclaw 风格的 `` 目录块(与原生 `tools` 并存) + - 说明:设为 `0` 可关闭以降低 token;Admin `AIA_SKILL_RUNTIME_ENABLED` 关闭时本块不生成 + - 生效:`oclaw/openclaw_runtime/skills_prompt.py`, `oclaw/openclaw_runtime/direct_loop.py` + +- `AIA_SKILLS_PROMPT_MAX_CHARS` + - 默认:`18000` + - 作用:技能目录 XML 块最大字符数(超出则从列表尾部丢弃条目) + - 生效:`oclaw/openclaw_runtime/skills_prompt.py` + +- `AIA_SKILL_DISABLED_NAMES` + - 默认:空数组(`[]`) + - 作用:按技能名禁用模型可见/可执行技能(JSON 数组字符串,例如 `["skill_a","skill_b"]`) + - 说明:禁用后同时影响 manifest/prompt 渲染与 direct loop 工具暴露 + - 生效:`oclaw/openclaw_runtime/skills.py`, `oclaw/openclaw_runtime/skills_prompt.py`, `oclaw/openclaw_runtime/skill_installer.py` + +- `AIA_SKILL_AUTO_INSTALL_ENABLED` + - 默认:`1` + - 作用:是否允许自动安装 skill(admin auto-install / retry-install(auto)) + - 说明:关闭后返回 `auto_install_disabled`,并标记为不可重试 + - 生效:`oclaw/openclaw_runtime/skill_installer.py`, `oclaw/admin/skills_api.py` + +- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES` + - 默认:`provider_timeout,provider_rate_limited,provider_temporary_error,provider_unavailable,context_overflow,tool_execution_failed` + - 作用:Agent Core run 外环的错误重试白名单(逗号分隔) + - 说明:仅当 attempt 返回 `status=retry` 且 `error_code` 命中该白名单时才进入下一次 attempt;未知 code 默认在 Admin 保存时会被过滤并告警 + - 补充:`relay_envelope_invalid`、`relay_envelope_unsupported_version` 属于输入契约错误,运行时固定按 non-retryable 处理(即使被误加入白名单也不会进入重试链) + - 生效:`oclaw/openclaw_runtime/agent_core_run.py` + +- `AIA_OPENCLAW_ROUTER_MODE` + - 默认:`rule` + - 取值:`rule`(启发式)或 `llm_json`(由当前 executor 的 `model.chat` 产出 `{mode,reason}` JSON;解析失败则回落 `rule`) + - 说明:亦可通过同名环境变量覆盖;提示词见 `oclaw/prompts_openclaw/router/decide_route.md` + - 生效:`oclaw/openclaw_runtime/router.py`, `oclaw/openclaw_runtime/gateway.py` + +- oclaw trace 字段与 `event_type` ↔ `oc_stage` 对照见 `oclaw/docs/openclaw-trace-taxonomy.md` +- Skill 安装错误码与重试建议、trace 排障路径见 `oclaw/docs/openclaw-skill-troubleshooting.md` +- Relay 文件指针(含 ACP 父子 run)错误码与排障见 `oclaw/docs/openclaw-skill-troubleshooting.md` 的“Relay 文件指针排障” + +- `AIA_OPENCLAW_RETRY_CODES_STRICT_MODE` + - 默认:`0` + - 作用:控制 Admin 保存 `AIA_OPENCLAW_RETRYABLE_ERROR_CODES` 时的未知 code 行为 + - 说明:`0`=过滤并告警;`1`=直接拒绝保存(HTTP 400) + - 生效:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js` + +- `AIA_TOOL_ENFORCED_RETRY_MODE` + - 默认:`first_round_only` + - 作用:**Legacy(已断开)**:工具必需场景下的强制重试策略 + - 生效:仅 legacy 链路(保留占位,暂不影响 oclaw) + +- `AIA_TOOL_LOOP_STATE_MACHINE` + - 默认:`1` + - 作用:**Legacy(已断开)**:工具循环状态机开关 + - 生效:仅 legacy 链路(保留占位,暂不影响 oclaw) + +- `AIA_TOOL_SIGNATURE_BUDGET` + - 默认:`2` + - 作用:**Legacy(已断开)**:同签名工具调用预算 + - 生效:仅 legacy 链路(保留占位,暂不影响 oclaw) + +- `AIA_OPENCLAW_ALLOW_LEGACY_FALLBACK` + - 默认:`0`(关闭) + - 作用:oclaw 执行失败时,是否允许回退到 legacy `executor.run_turn(...)` + - 说明:默认 fail-closed(不回退),避免无意中触发旧 manager/runner + - 生效:`oclaw/openclaw_runtime/gateway.py`, `oclaw/agents/specialist_agent.py` + +## LLM 传输与 replay(OpenAI 兼容) + +思路参考 oclaw(MIT)对 `openai-completions` 的 transcript 策略:在请求前修复/规范化 `tool_calls[].id` 与 `role=tool` 的 `tool_call_id`,减少网关 400(空 id、断链、非法字符)。 + +- `AIA_REPLAY_POLICY_ENABLED` + - 默认:`1`(启用) + - 作用:是否启用发送前 replay 规范化(修复孤儿 tool 引用 + 重写 tool id) + - 生效:`oclaw/platform/llm/replay_policy.py`, `oclaw/platform/llm/chat_models.py` + +- `AIA_REPLAY_REPAIR_TOOL_PAIRING` + - 默认:`1` + - 作用:是否先剥离「assistant 中不存在的 tool_call_id」的 tool 消息上的 id(再执行 id 重写) + - 生效:`oclaw/platform/llm/replay_policy.py` + +- `AIA_TOOL_CALL_ID_MAX_LEN` + - 默认:`40` + - 作用:重写后的 tool_call_id 最大长度(适配多数 OpenAI 兼容网关) + - 生效:`oclaw/platform/llm/tool_call_id.py` + +- `AIA_PROMPT_TOOL_FALLBACK` + - 默认:`1` + - 作用:原生 tools 失败并进入 prompt-tool 降级时,是否向 system 注入 tools JSON;设为 `0` 则仅剥离 tool 结构、不注入工具清单 + - 生效:`oclaw/platform/llm/chat_models.py` + +- `AIA_NATIVE_TOOLS_DENYLIST_HOSTS` + - 默认:空(无静态名单;另有进程内按错误动态记录的 `(host, model)` 缓存) + - 作用:可选的 host 子串黑名单,强制走 prompt-tool 模式 + - 生效:`oclaw/platform/llm/chat_models.py` + +## 工具执行与安全 + +- `AIA_DISABLE_TOOL_CONFIRM` + - 默认:`0` + - 作用:**Legacy(已断开)**:是否禁用高风险工具确认 + - 说明:oclaw 工具执行已移除执行时确认策略;该变量保留以便后续接回 legacy + - 生效:仅 legacy 链路(保留占位) + +- `AIA_ENABLE_MCP_TOOLS` + - 默认:`1` + - 作用:启用 MCP 工具 + - 生效:`oclaw/tools/catalog.py` + +- `AIA_ENABLE_PLUGIN_TOOLS` + - 默认:`0` + - 作用:启用插件工具 + - 生效:`oclaw/tools/catalog.py` + +- `AIA_ENABLE_RUN_COMMAND` + - 默认:`0` + - 作用:允许高风险 `run_command` 工具 + - 生效:`oclaw/tools/catalog.py`, `oclaw/tools/experts/workspace/shell_tools.py` + +- `AIA_TOOL_LLM_MESSAGE_MAX_CHARS` + - 默认:`0`(不限制) + - 作用:工具结果写回 LLM 的消息长度上限 + - 说明:`0` 表示不做限制(不推荐,可能触发部分网关的单条消息上限 400) + - 生效:`oclaw/chat/tool_runtime.py` + - 观测:管理端聊天流 `tool_use_result` 事件会携带 `llm_wire.{truncated_for_llm,max_chars,result_bytes,result_for_llm_bytes,truncate_ms}` + +- `AIA_TOOL_LOG_MAX_CHARS` + - 默认:`200000` + - 作用:`tool_log` 中 args/result 截断上限 + - 生效:`oclaw/platform/persistence/sqlite_store.py` + +## MCP 与工具线侧 + +- `AIA_MCP_SPECIALISTS` + - 默认:`generalist` + - 作用:允许使用 MCP 的 specialist 列表 + - 生效:`oclaw/tools/mcp/adapter.py` + +- `AIA_MCP_ENV_ALLOWLIST` + - 默认:内置 allowlist(Brave/Google/GitHub/Context7) + - 作用:MCP 子进程可透传环境变量白名单 + - 生效:`oclaw/ops/mcp_env.py` + +- `AIA_MCP_FILESYSTEM_EXTRA_ROOTS` + - 默认:空 + - 作用:追加给 filesystem MCP 的根目录 + - 生效:`oclaw/tools/mcp/filesystem_argv.py` + +- `AIA_MCP_WIRE_USAGE_POLICY` + - 默认:空(按 base_url 继承) + - 作用:是否启用 MCP 工具线侧分层策略 + - 生效:`oclaw/platform/llm/tool_wire_policy.py` + +- `AIA_MCP_WIRE_PENALTY_DISABLE` + - 默认:`0` + - 作用:禁用线侧陈旧惩罚 + - 生效:`oclaw/platform/llm/tool_wire_policy.py` + +- `AIA_MCP_WIRE_TOP_N_FULL` + - 默认:`20` + - 作用:全量上送工具数量 + - 生效:`oclaw/platform/llm/tool_wire_policy.py` + +- `AIA_MCP_WIRE_STALE_HOURS` + - 默认:`3` + - 作用:陈旧判定小时阈值 + - 生效:`oclaw/platform/llm/tool_wire_policy.py` + +- `AIA_MCP_WIRE_PENALTY_MINUTES` + - 默认:`30` + - 作用:惩罚窗口分钟数 + - 生效:`oclaw/platform/llm/tool_wire_policy.py` + +- `AIA_MCP_WIRE_MEDIUM_RANK_START` + - 默认:`21` + - 作用:中等层起始排名 + - 生效:`oclaw/platform/llm/tool_wire_policy.py` + +- `AIA_MCP_WIRE_MEDIUM_RANK_END` + - 默认:`50` + - 作用:中等层结束排名 + - 生效:`oclaw/platform/llm/tool_wire_policy.py` + +- `AIA_MCP_WIRE_MEDIUM_DESC_CHARS` + - 默认:`520` + - 作用:中等层描述截断长度 + - 生效:`oclaw/platform/llm/tool_wire_policy.py` + +- `AIA_MCP_WIRE_MINIMAL_DESC_CAP` + - 默认:`80` + - 作用:最小层描述长度 + - 生效:`oclaw/platform/llm/tool_wire_policy.py` + +## LLM 工具载荷与模型兼容 + +- `AIA_OPENAI_TOOLS_MAX_JSON_CHARS` + - 默认:空(按代码内部策略) + - 作用:OpenAI tools payload JSON 上限 + - 生效:`oclaw/platform/llm/chat_models.py` + +- `AIA_SHRINK_OPENAI_TOOLS` + - 默认:`0` + - 作用:强制启用 tools payload 压缩 + - 生效:`oclaw/platform/llm/chat_models.py` + +- `AIA_SHRINK_OPENAI_TOOLS_MAX_JSON` + - 默认:`28000` + - 作用:压缩目标上限 + - 生效:`oclaw/platform/llm/chat_models.py` + +- `AIA_GEMINI_OPENAI_NONSTREAM_TOOLS` + - 默认:`0` + - 作用:Gemini OpenAI 兼容下 tools 非流式开关 + - 生效:`oclaw/platform/llm/chat_models.py` + +## 图像能力 + +- `AIA_IMAGE_MODEL` + - 默认:空(走 profile/模型默认) + - 作用:图像模型 + - 生效:`oclaw/platform/llm/image_message_client.py`, `oclaw/agents/specialist_agent.py` + +- `AIA_IMAGE_BASE_URL` + - 默认:`https://api.openai.com/v1` + - 作用:图像服务 base URL + - 生效:`oclaw/platform/llm/image_message_client.py` + +- `AIA_IMAGE_API_KEY` + - 默认:空 + - 作用:图像 API Key + - 生效:`oclaw/platform/llm/image_message_client.py` + +- `AIA_IMAGE_CHAT_ENDPOINT` + - 默认:`/chat/completions` + - 作用:图像接口 endpoint + - 生效:`oclaw/platform/llm/image_message_client.py` + +- `AIA_IMAGE_RETRIES` + - 默认:`3` + - 作用:图像请求重试次数 + - 生效:`oclaw/platform/llm/image_message_client.py` + +- `AIA_IMAGE_RETRY_BACKOFF_SEC` + - 默认:`0.8` + - 作用:图像请求重试退避秒数 + - 生效:`oclaw/platform/llm/image_message_client.py` + +- `AIA_IMAGE_STATUS_RETRIES` + - 默认:`4` + - 作用:图像状态轮询重试次数 + - 生效:`oclaw/platform/llm/image_message_client.py` + +- `AIA_IMAGE_STATUS_RETRY_BACKOFF_SEC` + - 默认:`5.0` + - 作用:图像状态轮询退避秒数 + - 生效:`oclaw/platform/llm/image_message_client.py` + +## Memory / RAG + +- `AIA_RAG_MODE` + - 默认:`keyword` + - 作用:RAG 模式(`keyword`/`vector`) + - 生效:`oclaw/orchestration/memory.py` + +- `AIA_RAG_EMBEDDING_MODE` + - 默认:空(优先 OpenAI,失败回退 hash) + - 作用:embedding 模式(如 `hash`) + - 生效:`oclaw/platform/embeddings/embedding_client.py` + +- `AIA_MEMORY_EPISODIC_TTL_DAYS` + - 默认:`90` + - 作用:episodic memory 过期天数 + - 生效:`oclaw/orchestration/memory.py` + +## 工作区路径策略 + +- `AIA_WORKSPACE_ROOT` + - 默认:项目根 + - 作用:工作区主根路径 + - 生效:`oclaw/tools/experts/workspace/workspace_base.py`, `oclaw/indexing/workspace_indexer.py` + +- `AIA_WORKSPACE_EXTRA_ROOTS` + - 默认:空 + - 作用:额外可访问根路径(`|` 分隔) + - 生效:`oclaw/tools/experts/workspace/workspace_base.py`, `oclaw/tools/mcp/filesystem_argv.py` + +- `AIA_WORKSPACE_ALLOW_ANY_PATH` + - 默认:`0` + - 作用:是否放开内置工具路径限制(高风险) + - 生效:`oclaw/tools/experts/workspace/workspace_base.py` + +## 网关与运行 + +- `AIA_ASSISTANT_GATEWAY_HOST` + - 默认:`0.0.0.0` + - 作用:网关监听地址 + - 生效:`oclaw/app_server/fastapi_main.py`, `oclaw/ops/main.py` + +- `AIA_ASSISTANT_GATEWAY_PORT` + - 默认:`8787` + - 作用:网关监听端口 + - 生效:`oclaw/app_server/fastapi_main.py`, `oclaw/ops/main.py` + +- `AIA_RUNTIME_LOG_DIR` + - 默认:空(使用内部默认目录) + - 作用:运行日志目录 + - 生效:`oclaw/ops/runtime.py` + +- `AIA_SSE_QUEUE_MAXSIZE` + - 默认:`2000` + - 作用:SSE 事件队列上限 + - 生效:`oclaw/admin/chat_api.py` + +## WeCom 长连接 + +- `AIA_WECOM_LONGCONN_WORKERS` + - 默认:`2` + - 作用:入站处理 worker 数 + - 生效:`oclaw/channels/wecom/longconn_runner.py` + +- `AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE` + - 默认:`200` + - 作用:入站队列长度上限 + - 生效:`oclaw/channels/wecom/longconn_runner.py` + +## 安全与密钥 + +- `AIA_ASSISTANT_PASSWORD` + - 默认:空(必须配置) + - 作用:管理台管理员密码(bootstrap/login) + - 生效:`oclaw/platform/config/passwords.py`, `oclaw/admin/routes.py` + +- `AIA_ASSISTANT_MASTER_KEY` + - 默认:空 + - 作用:密钥加密(Fernet)主密钥 + - 生效:`oclaw/platform/persistence/sqlite_store.py`, `oclaw/admin/routes.py` + +## 存储与迁移 + +- `AIA_ASSISTANT_DB_PATH` + - 默认:`data/ai_ops.sqlite` + - 作用:SQLite 路径 + - 生效:`oclaw/platform/config/paths.py` + +- `AIA_ASSISTANT_PREMERGE_BACKUP_KEEP` + - 默认:`3` + - 作用:预迁移备份保留数量 + - 生效:`oclaw/platform/config/paths.py` + +- `AIA_LEGACY_DB_FORCE_PREMERGE` + - 默认:`0` + - 作用:是否强制 legacy DB 覆盖(谨慎使用) + - 生效:`oclaw/platform/config/paths.py` diff --git a/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md b/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md new file mode 100644 index 00000000..56a079a9 --- /dev/null +++ b/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md @@ -0,0 +1,181 @@ +# 环境变量变更台账(AIA) + +用于记录每次版本发布中的环境变量变更,便于升级与回滚评估。 + +## 使用规则 + +- 每次发布前后都要补一条记录(即使“无变更”也要写)。 +- 记录粒度: + - 新增变量 + - 删除变量 + - 重命名(含兼容窗口) + - 默认值变化 + - 语义变化(同名但行为变了) +- 变更后必须同步: + - `oclaw/docs/ENVIRONMENT_VARIABLES.md` + - `README.md` 示例 + - 示例 env 文件(如 `data/mcp_local.env.example`) + +--- + +## 模板 + +```md +## YYYY-MM-DD / vX.Y.Z + +### Added +- `AIA_XXX` + - 默认值: + - 用途: + - 影响模块: + +### Changed +- `AIA_YYY` + - 变更前: + - 变更后: + - 影响: + - 是否需要重启:是/否 + +### Deprecated +- `AIA_ZZZ` + - 弃用原因: + - 兼容截止版本: + - 替代变量: + +### Removed +- `AIA_OLD` + - 删除原因: + - 升级动作: + +### Migration Checklist +- [ ] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md` +- [ ] 已更新 `README.md` +- [ ] 已更新示例 env 文件 +- [ ] 已验证 Admin 配置页(如适用) +- [ ] 已执行编译/测试回归 +``` + +--- + +## 2026-04-21 / Unreleased + +### Added +- `AIA_PROMPT_FRONTMATTER_STRICT`(默认 `0`):强制 YAML frontmatter。 +- `AIA_SKILLS_PROMPT_IN_SYSTEM`(默认 `1`):oclaw 风格 `` 注入 system prompt。 +- `AIA_SKILLS_PROMPT_MAX_CHARS`(默认 `18000`):技能目录块字符预算。 +- 依赖:`PyYAML`(`requirements.txt`)。 + +### Changed +- `oclaw/prompts/loader.py` 与 `oclaw/openclaw_runtime/skills.py` 统一使用 YAML 解析 frontmatter(失败时默认回落旧行解析,除非开启 STRICT)。 + +--- + +## 2026-04-19 / Unreleased + +### Added +- `oclaw/docs/ENVIRONMENT_VARIABLES.md`(变量总览基线文档) +- `oclaw/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md`(本台账) +- OpenAI 兼容 replay 相关(见 `oclaw/docs/ENVIRONMENT_VARIABLES.md`「LLM 传输与 replay」): + - `AIA_REPLAY_POLICY_ENABLED`(默认 `1`) + - `AIA_REPLAY_REPAIR_TOOL_PAIRING`(默认 `1`) + - `AIA_TOOL_CALL_ID_MAX_LEN`(默认 `40`) + - `AIA_PROMPT_TOOL_FALLBACK`(默认 `1`) + - `oclaw/platform/llm/OPENCLAW_MIT_LICENSE.txt`(oclaw 启发实现之 MIT 署名) + +### Changed +- 变量前缀统一为 `AIA_*`,项目内不再使用 `OPS_*` / `AI_OPS_*`。 +- `README.md` 与 `data/mcp_local.env.example` 示例变量已同步为 `AIA_*`。 +- 环境变量维护流程已文档化:后续变量改动需同时更新 + - `oclaw/docs/ENVIRONMENT_VARIABLES.md`(当前生效基线) + - `oclaw/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md`(版本变更历史) + - `README.md` / 示例 env(用户可见配置入口) + +### Removed +- 代码中的 `OPS_*` / `AI_OPS_*` 引用(已清理完成)。 + +### Migration Checklist +- [x] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md` +- [x] 已更新 `README.md` +- [x] 已更新示例 env 文件 +- [x] 已验证 Admin 配置页(如适用) +- [x] 已执行编译/测试回归 + +--- + +## 2026-04-20 / Unreleased + +### Added +- `AIA_OPENCLAW_ALLOW_LEGACY_FALLBACK` + - 默认值:`0` + - 用途:oclaw runtime 失败时是否允许回退到 legacy `run_turn` + - 影响模块:`oclaw/openclaw_runtime/gateway.py`, `oclaw/agents/specialist_agent.py` + +### Changed +- `AIA_TURN_MAX_*`(tool workers/rounds/context) + - 变更前:由 legacy turn runner 读取(历史文件名可能为 `agent_core.py`) + - 变更后:由 oclaw runtime 读取并生效(`oclaw/openclaw_runtime/gateway.py`) + - 是否需要重启:是(读取自 settings/db/env 的时机取决于运行方式) + +### Deprecated +- `AIA_MANAGER_DECISION_MODE`, `AIA_TOOL_ENFORCED_RETRY_MODE`, `AIA_TOOL_LOOP_STATE_MACHINE`, `AIA_TOOL_SIGNATURE_BUDGET`, `AIA_DISABLE_TOOL_CONFIRM` + - 弃用原因:oclaw runtime 已断开 legacy manager/runner/tool-policy 链路(代码保留,默认不生效) + - 兼容截止版本:待定 + - 替代变量:无(后续如需恢复 legacy 将重新定义接入点) + +### Migration Checklist +- [x] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md` +- [x] 已更新 `README.md` +- [x] 已更新示例 env 文件 +- [x] 已验证 Admin 配置页(如适用) +- [x] 已执行编译/测试回归 + +--- + +## 2026-04-20 / Unreleased (oclaw MVP 补齐) + +### Changed +- 无新增环境变量;oclaw runtime 在现有变量下补齐了 memory stage、router sync/async 分流、sqlite task queue、worker 执行链路。 + - 影响模块:`oclaw/openclaw_runtime/gateway.py`, `oclaw/openclaw_runtime/direct_loop.py`, `oclaw/openclaw_runtime/router.py`, `oclaw/openclaw_runtime/worker.py`, `oclaw/platform/persistence/sqlite_store.py` + - 是否需要重启:是(升级代码后建议重启进程以启动 worker 与新路由逻辑) + +### Migration Checklist +- [x] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md` +- [x] 已更新 `README.md` +- [x] 已更新示例 env 文件 +- [x] 已验证 Admin 配置页(如适用) +- [ ] 已执行编译/测试回归 + +--- + +## 2026-04-20 / Unreleased (AgentCore Retry Matrix) + +### Added +- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES` + - 默认值:`provider_timeout,provider_rate_limited,provider_temporary_error,provider_unavailable,context_overflow,tool_execution_failed` + - 用途:控制 Agent Core run 外环可重试错误白名单 + - 影响模块:`oclaw/openclaw_runtime/agent_core_run.py`, `oclaw/admin/routes.py`, `oclaw/admin/static/app.js` + +### Changed +- Agent Core 重试策略从“status=retry 即重试”升级为“retry + error_code 命中白名单才重试”。 + - 是否需要重启:是(新策略读取设置后在进程内生效) + +### Migration Checklist +- [x] 已更新 `oclaw/docs/ENVIRONMENT_VARIABLES.md` +- [x] 已更新 `README.md` +- [x] 已更新示例 env 文件 +- [x] 已验证 Admin 配置页(如适用) +- [x] 已执行编译/测试回归 + +### Changed +- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES` 已接入 Admin「Tool Policy」页读写链路。 + - 影响模块:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js` +- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES` 保存时增加未知 code 过滤与告警返回(`unknown_retryable_error_codes`)。 + - 影响模块:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js`, `oclaw/openclaw_runtime/agent_core_run.py` +- `AIA_OPENCLAW_RETRYABLE_ERROR_CODES` 新增严格模式:可配置为未知 code 直接拒绝保存(400)。 + - 影响模块:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js` + +### Added +- `AIA_OPENCLAW_RETRY_CODES_STRICT_MODE` + - 默认值:`0` + - 用途:控制 Admin 保存 retry code 时对未知值的处理(过滤告警 / 拒绝) + - 影响模块:`oclaw/admin/routes.py`, `oclaw/admin/static/app.js` diff --git a/docs/GATEWAY_IMAGE_GENERATE.md b/docs/GATEWAY_IMAGE_GENERATE.md new file mode 100644 index 00000000..e0431d38 --- /dev/null +++ b/docs/GATEWAY_IMAGE_GENERATE.md @@ -0,0 +1,59 @@ +# Gateway `image.generate` Contract + +This document describes the Python gateway method `image.generate`. + +## Method + +- Name: `image.generate` +- Handler: `oclaw/gateway/server_methods/image.py` + +## Input Params + +- `prompt` (required, non-empty string) +- `provider` (optional, non-empty string) +- `size` (optional, non-empty string) +- `quality` (optional, non-empty string) +- `idempotencyKey` (optional, non-empty string; used as `requestId` when provided) + +## Behavior + +1. If `context.image_generate` exists, it is used as the primary backend hook. +2. Otherwise, the handler falls back to `extensions/image-generation-core/api.py::generate_image`. +3. Provider resolution uses: + - `context.image_generation_providers` first + - then `context.get_runtime_snapshot()["image_generation_providers"]` (if available) +4. Provider selection order: + - explicit `provider` param + - `context.config.image.defaultProvider` + - first matched entry in `context.config.image.providerPriority` + - first available capable provider +5. Capability filter: + - providers are considered image-capable when any of the following is true: + - `capabilities.image_generation == true` + - `capabilities` list contains `image` / `image_generation` + - `kind` is `image` / `image_generation` + - provider exposes callable `generate` + +## Error Semantics + +- `INVALID_REQUEST`: + - missing/blank `prompt` + - invalid optional parameter types/empty strings +- `NOT_FOUND`: + - explicit `provider` is not registered +- `UNAVAILABLE`: + - runtime/image backend failure + +## Success Payload (normalized) + +Success responses include normalized fields: + +- `ok: true` +- `status: "succeeded"` +- `requestId: string` +- `provider: string` +- `model: string | null` +- `prompt: string` + +Provider-specific fields are preserved and merged into the payload. + diff --git a/docs/GATEWAY_TELEGRAM_NORMALIZATION.md b/docs/GATEWAY_TELEGRAM_NORMALIZATION.md new file mode 100644 index 00000000..4c54665b --- /dev/null +++ b/docs/GATEWAY_TELEGRAM_NORMALIZATION.md @@ -0,0 +1,50 @@ +# Gateway Telegram Normalization + +This document describes how gateway send-related handlers normalize Telegram transport targets. + +## Scope + +Normalization is applied in these handlers: + +- `send` +- `chat.send` +- `sessions.send` + +The shared implementation lives in `oclaw/gateway/server_methods/telegram_send_normalize.py`. + +## Input fields + +When `channel == "telegram"`, the normalizer reads: + +- `to` +- `threadId` (optional) +- `replyToId` (optional) + +Supported `to` examples: + +- `telegram:group:-100123:topic:42` +- `telegram:-100123:42` +- `@username` +- `https://t.me/username` + +## Output fields + +The normalizer returns: + +- normalized `to` +- `target`: + - `chatId` + - `chatType` (`direct` | `group` | `unknown`) + - `messageThreadId` (when present in target) +- `threadId` (normalized integer if present/resolved) +- `replyToMessageId` (normalized integer if present) + +## Precedence + +- `threadId` in request params overrides thread inferred from target suffix. +- `replyToId` is converted to `replyToMessageId` if numeric; otherwise omitted. + +## Notes + +- Non-Telegram channels pass through unchanged. +- Normalized fields are included in handler payloads and in forwarded params for `chat.send -> enqueue_chat_send`. diff --git a/docs/LLM_TRANSPORTS.md b/docs/LLM_TRANSPORTS.md new file mode 100644 index 00000000..b042fe1f --- /dev/null +++ b/docs/LLM_TRANSPORTS.md @@ -0,0 +1,79 @@ +# LLM provider/transport capability matrix (oclaw) + +This project follows an **OpenClaw-style explicit provider/transport selection**: + +- You select the transport via **LLM profile `mode`** (not by inferring from `base_url`). +- `base_url` can be the same unified gateway URL for all providers; the `mode` determines the wire protocol. + +## Profile field mapping + +- **`mode`**: transport selector (`openai`, `openai_responses`, `anthropic`, `google`, `ollama`, `rule`) +- **`model`**: model id/name passed to the provider transport +- **`base_url`**: gateway host URL (may be shared across providers) +- **`api_key`** (profile secret): primary credential source (reused across modes) + +Transport selection happens in `oclaw/agents/factory.py`. + +## Modes and transports + +### `openai` (Chat Completions, OpenAI-compatible) +- **Transport**: `oclaw/platform/llm/transports/openai_chat_completions.py::OpenAIChatModel` +- **API**: `/v1/chat/completions` (streaming supported) +- **Tools**: OpenAI tool calling (`tools[]` / `tool_calls`) +- **Streaming**: token deltas via `on_token` → WS `chat.delta` +- **Key**: profile secret or `OPENAI_API_KEY` + +### `openai_responses` (Responses API, OpenAI-compatible) +- **Transport**: `oclaw/platform/llm/transports/openai_responses.py::OpenAIResponsesModel` +- **API**: `/v1/responses` (streaming events) +- **Tools**: function call items parsed from response output +- **Streaming**: output text deltas via `on_token` → WS `chat.delta` +- **Key**: profile secret or `OPENAI_API_KEY` + +### `anthropic` (Anthropic Messages streaming) +- **Transport**: `oclaw/platform/llm/transports/anthropic_messages.py::AnthropicMessagesModel` +- **API**: Anthropic `messages.stream` surface (gateway must provide Anthropic-compatible protocol) +- **Tools**: tool use blocks assembled into `LLMToolCall` +- **Streaming**: text deltas via `on_token` → WS `chat.delta` +- **Key**: profile secret or `ANTHROPIC_API_KEY` (fallback: `OPENAI_API_KEY` for unified gateways) + +### `google` (Gemini native SSE) +- **Transport**: `oclaw/platform/llm/transports/google_gemini_sse.py::GoogleGeminiChatModel` +- **API**: `:streamGenerateContent?alt=sse` (Gemini native) +- **Tools**: `functionDeclarations` with `parametersJsonSchema`; parses `functionCall` +- **Streaming**: text deltas via `on_token` → WS `chat.delta` +- **Key**: profile secret or `GOOGLE_API_KEY`/`GEMINI_API_KEY` (fallback: `OPENAI_API_KEY` for unified gateways) +- **Thinking controls** (optional env): + - `AIA_GEMINI_THINKING=on|off` + - `AIA_GEMINI_THINKING_LEVEL=` + - `AIA_GEMINI_THINKING_BUDGET=` + +### `ollama` (local OpenAI-compatible) +- **Transport**: `OpenAIChatModel` with Ollama-compatible base url +- **API**: `/v1/chat/completions` (Ollama OpenAI-compat) +- **Key**: uses a dummy key (`ollama`) if needed by SDK + +### `rule` (no remote LLM) +- **Transport**: `oclaw/platform/llm/transports/simple.py::RuleBasedChatModel` +- **Purpose**: deterministic tool routing/diagnostics without any model provider + +## Streaming and UI contract + +All transports stream assistant output through the same internal callback: + +1. Transport calls `on_token(text_delta)` +2. WS gateway maps that to `event=chat payload.state=delta` +3. Final persisted assistant message is emitted as: + - `event=chat payload.state=final` (with `message` payload for folding) + - `event=session.message` +4. Tool UI signals from runtime are emitted as `event=session.tool` + +## Adding the remaining models + +For additional providers, follow this pattern: + +1. Add a new transport class under `oclaw/platform/llm/transports/` +2. Extend `mode` selection in `oclaw/agents/factory.py` +3. Add an **offline stream parser test** under `tests/` +4. Add/verify WS contract tests (delta + final + session.tool) + diff --git a/docs/MCP_LOCAL_SERVER.md b/docs/MCP_LOCAL_SERVER.md new file mode 100644 index 00000000..e70f23d5 --- /dev/null +++ b/docs/MCP_LOCAL_SERVER.md @@ -0,0 +1,528 @@ +# 本地 MCP 工具开发与接入指南(stdio / JSON-RPC) + +本文档用于指导你在本地编写 MCP Server,并接入当前系统的 MCP 市场。 + +当前系统对 MCP 的主流程已统一为标准协议: + +- `initialize` +- `notifications/initialized` +- `tools/list` +- `tools/call` + +> 兼容说明:系统内部仍保留对旧 `op` 风格消息的回退兼容,但不建议新工具继续使用旧协议。 + +--- + +## 1. 你要实现什么 + +你写的本地工具不是直接写成 `ToolSpec`,而是写成一个 **MCP Server 子进程**,通过标准输入输出(stdio)和系统通信。 + +系统会: + +1. 启动你的进程(`entry_command + entry_args`) +2. 发送 `initialize` +3. 接收 `tools/list` +4. 在用户调用时发送 `tools/call` + +--- + +## 2. 最小可运行示例(Python) + +保存为 `mcp_echo_server.py`: + +```python +from __future__ import annotations + +import json +import sys +from typing import Any + + +def ok(rid: Any, result: dict[str, Any]) -> None: + sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +def err(rid: Any, code: int, message: str) -> None: + sys.stdout.write( + json.dumps( + {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}, + ensure_ascii=False, + ) + + "\n" + ) + sys.stdout.flush() + + +for raw in sys.stdin: + raw = raw.strip() + if not raw: + continue + + try: + req = json.loads(raw) + except Exception: + continue + + rid = req.get("id") + method = str(req.get("method") or "") + params = req.get("params") if isinstance(req.get("params"), dict) else {} + + if method == "initialize": + ok( + rid, + { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "echo-mcp", "version": "0.1.0"}, + }, + ) + continue + + if method == "notifications/initialized": + # 通知类消息不需要返回 + continue + + if method == "tools/list": + ok( + rid, + { + "tools": [ + { + "name": "echo", + "description": "回显输入文本。", + "inputSchema": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "要回显的文本"} + }, + "required": ["text"], + "additionalProperties": False, + }, + } + ] + }, + ) + continue + + if method == "tools/call": + tool_name = str(params.get("name") or "") + arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {} + + if tool_name != "echo": + err(rid, -32601, f"unknown tool: {tool_name}") + continue + + text = str(arguments.get("text") or "") + ok(rid, {"content": [{"type": "text", "text": text}]}) + continue + + err(rid, -32601, f"method not found: {method}") +``` + +--- + +## 3. 在管理台中接入 + +### 3.0 已安装 MCP 从库里消失时(换库 / 表被清空) + +`mcp_server_registry` 存在默认 SQLite(见 `data/ai_ops.sqlite`)中。若列表变成 0 条,可在仓库根执行 **`python scripts/seed_mcp_registry.py`**,从 **`data/mcp_registry.seed.json`** 写回示例条目(含 `local-echo` 与 `mcp-context7`,可按需编辑该 JSON 再执行)。写回后在管理台对各服务执行 **Health** → **Sync Tools**;若曾换过 `OPS_ASSISTANT_DB_PATH`,请确认网关与脚本指向**同一**库文件。 + +### 3.1 表单安装(推荐) + +在 MCP 页面填: + +- `source_type`: 可自定义用于记录(如 `pypi`) +- `source_ref`: 自定义来源标识(如 `local-echo`) +- `entry_command`: `python` +- `entry_args`: `D:/path/to/mcp_echo_server.py` + +然后执行: + +1. `Install` +2. `Health` +3. `Sync Tools` + +工具数量 > 0 即接通成功。 + +### 3.2 JSON 安装 + +**单条**(在 Plugins **「3」MCP 安装** 的 **Install from JSON** 中粘贴,或作 array 的其中一个元素): + +```json +{ + "source_type": "pypi", + "source_ref": "local-echo", + "server_id": "local-echo", + "version": "", + "entry_command": "python", + "entry_args": ["D:/project/chatgpt/examples/mcp_echo_server.py"], + "required_permissions": [], + "risk_level": "low", + "enabled": true, + "timeout_s": 30 +} +``` + +**批量**:以下三种写法 **`Install from JSON`** 都支持,会**逐条** preflight + install(任一条失败会记结果并继续下一条,最后看结果 JSON): + +1. **数组**:`[ { 上面一条的字段… }, { … } ]` +2. **对象包一层 `servers`**(与 `data/mcp_registry.seed.json`、导出文件一致): + ```json + { + "servers": [ + { "source_type": "npm", "source_ref": "@upstash/context7-mcp", "server_id": "mcp-context7", "version": "", "entry_command": "npx", "entry_args": ["-y", "@upstash/context7-mcp"], "env_schema": {}, "required_permissions": [], "risk_level": "medium", "enabled": true, "timeout_s": 60, "dry_run": false } + ] + } + ``` +3. **带 `payload` 的单条**(如 `examples/mcp_install_context7.json`): + ```json + { "payload": { "source_type": "npm", "source_ref": "…", "server_id": "…" } } + ``` + +路径占位符 `__REPO_ROOT__/…` 在**管理台安装 / preflight** 中会与 `scripts/seed_mcp_registry.py` 一样展开为仓库根下的绝对路径(如 `mcp-echo` 的脚本路径、filesystem 的目录根、sqlite 的库文件路径)。若不用占位符,可直接写本机绝对路径。 + +**导出与自动备份** + +- 在 **【4】已安装 MCP 服务** 使用 **Export JSON (download)**,可下载当前库中**全部**已安装 MCP 的可重装 JSON(`servers` 包 + `exported_at`)。 +- 每次 **安装、重装、卸载且删除库记录、Delete** 成功后,会刷新 **`oclaw/_local/mcp_registry_migrated.json`**(与导出内容同结构,便于换库/换机后把文件粘回 **Install from JSON** 或 `python scripts/seed_mcp_registry.py path/to/file.json` 注意 seed 会跑 npm/pypi 安装步骤,与 `dry_run` 等字段一致)。该文件建议加入 `.gitignore`(如未忽略),避免本机差异被误提交;密钥仍放在 `oclaw/_local/mcp_local.env` 等环境变量,不在此 JSON 中。 + +### 3.3 MCP 工具线侧策略(上送压缩与惩罚) + +在管理台 **Plugins(插件)** 页中的 **「线侧策略」** 折叠区块(**【6】全局参数**、**【7】已安装工具**)可配置发往 LLM 的 OpenAI 格式 `tools[]` 的**分层压缩**、**全局闲置惩罚**,以及**按完整工具名** `mcp__{server_id}__{tool_name}` 的策略(与模型 `base_url` 解耦时,将 **wire_policy** 设为 `always`)。**【7】** 中每个工具的等级为**数字输入框**(任意整数 1–9998;留空表示未配置;0 与留空语义不同,见下表)。 + +**【7】表格筛选与专家列(管理台)** + +- 表头**第二行**为各列筛选输入(子串匹配,不区分大小写):`server`、`tool`、`wire_name`、**专家**、`count`(上下界)、`last_ts`、惩罚/解封说明、策略**等级**(上下界)。分页条数按**筛选后**结果计算。 +- **专家**列由本页当前 **MCP 专家绑定草稿**(`mapping`)与后端返回的 `available_specialists` 合并推导:某 `server_id` 出现在哪些专家的绑定列表中,即显示为逗号分隔的专家 id;可按专家子串筛选。勾选「仅已勾选」时只显示当前勾选的行(勾选集合在筛选、翻页间保留)。 +- 同页 **【8】专家 MCP 绑定看板(自动)**:按当前草稿与**已安装 MCP** 各服务的 `tools` 列表,汇总每个专家绑定的 **MCP 个数** 与 **tool 条数**(各已绑定 server 的 `tools` 长度之和);专家集合随 `available_specialists` 与 `mapping` 中的键自动扩展,无需写死。 +- **【9】MCP 专家绑定(编辑)** 为原绑定编辑区(勾选、反向视图、保存);与【8】看板联动,改绑定后看板即时刷新(无需单独保存看板)。 + +**持久化(SQLite `app_setting`)** + +| 键 | 含义 | +| --- | --- | +| `mcp_tool_wire_admin_config` | JSON:全局参数 + `wire_policy`(`inherit` / `always` / `never`)、`penalty_disable` 等 | +| `mcp_tool_wire_tool_policies` | JSON:`{ "mcp__sid__tool": 等级 }` | +| `mcp_tool_wire_penalty_state` | JSON:各工具惩罚状态机(`phase`、`omit_until`、`wave_ts`、`kind`),由运行时维护,一般无需手改 | + +**`wire_policy`** + +- `inherit`:与原先一致,默认在 DashScope 兼容 URL 上启用线侧策略;其它环境变量 `OPS_MCP_WIRE_*` 仍可作为默认值来源。 +- `always`:**不依赖 URL**,始终启用分层与惩罚逻辑(适合非 DashScope 网关也要控 payload)。 +- `never`:关闭分层/惩罚逻辑;**等级 `9999` 永久封禁仍会过滤该工具**(不上送)。 + +**按工具等级(`mcp_tool_wire_tool_policies`)** + +| 配置 | 库中是否存在键 | 行为 | +| --- | --- | --- | +| **未配置**(管理台留空 / `GET` 中 `policy_level` 为 `null`、`policy_in_db` 为 `false`) | 否 | **自动走全局**:参与用量排名与分层压缩;适用**全局**闲置小时与罚时长;可被 **Top N 全量**豁免全局闲置惩罚。新安装 MCP 在 **Sync Tools** 后出现新 `wire_name`,默认即为此状态,无需手工登记。 | +| **显式 `0`** | 是 | **不参与**全局闲置 omission;仍参与用量分层。与「未配置」不同。 | +| **显式 `1`~`9998`** | 是 | 与 Top N **无关**:距上次成功调用超过 **N×10 分钟** 视为闲置,进入罚时 **N×10 分钟** 的上送 omission;罚满后需再次闲置达到阈值才会再罚(状态与 `last_ts` / `kind` 对齐)。 | +| **显式 `9999`** | 是 | **永久**从线侧 `tools[]` 中移除(彻底封禁)。 | + +**生效优先级(同一工具上的概念顺序,便于排障)** + +1. **`9999` 永久封禁**(若已写入 `mcp_tool_wire_tool_policies`):在组装 `tools[]` 的较早阶段即剔除,不进入后续分层与动态惩罚状态机。 +2. **显式 `1`~`9998`**:走按工具闲置/罚分钟逻辑,**不享受** Top N 对「全局惩罚」的豁免。 +3. **显式 `0`**:跳过全局闲置 omission,仍走压缩档位。 +4. **未配置**:走全局线侧逻辑(含全局闲置与 Top N 豁免等),由 `prepare_openai_tools_for_llm_api` 与 `mcp_tool_wire_admin_config` / 环境变量共同决定。 + +`wire_policy=never` 时关闭分层与动态惩罚,但 **`9999` 仍会过滤** 对应工具。 + +全局闲置小时、罚时长(分钟)、Top N 全量、medium 档位等,在 **【6】** 中可调;未写入 `app_setting` 的项继续沿用环境变量(见仓库根 `data/mcp_local.env.example` 中 `OPS_MCP_WIRE_*`)。 + +**Admin HTTP API**(需 `admin:tenant:write`,与 MCP 安装类接口一致) + +- `GET /admin/api/mcp/tool-wire` — 返回合并后的 `config`、当前 `policies`、`penalty_state`,以及已安装 MCP 工具列表(每条含 `policy_level`:`null` 表示未在库中配置,`policy_in_db` 标明是否持久化过)及惩罚/解封说明。 +- `POST /admin/api/mcp/tool-wire/config` — 保存全局参数(部分字段可增量合并)。 +- `POST /admin/api/mcp/tool-wire/policies` — body:`{ "policies": { "mcp__...": 整数等级 }, "clears": ["mcp__...", ...](可选) }`。先按 `clears` 从已存策略中**删除键**(用于管理台留空后恢复「未配置」),再合并 `policies`。 +- `POST /admin/api/mcp/tool-wire/policies/batch` — body:`{ "level": 等级, "wire_names": ["mcp__...", ...] }`,批量写入策略。 + +实现代码:`oclaw/platform/llm/tool_wire_policy.py`;在发 Chat Completions 前由 `prepare_openai_tools_for_llm_api` 应用。 + +--- + +## 4. 专家分配(谁能用这个工具) + +当前系统支持“按专家组”分配 MCP 工具可见性: + +- 在 MCP 页面 `MCP specialists` 勾选可用专家 +- 保存后立即生效(持久化在数据库) + +注意:当前是“专家级分配”,不是“单工具级分配”。 + +--- + +## 5. 编写规范(强烈建议) + +1. **stdout 只输出 JSON-RPC 响应行** + 日志请写到 `stderr`,否则容易触发 `protocol_mismatch`。 + +2. **`tools/list` 返回稳定 schema** + 建议所有参数都写明 `type` 与 `required`,并设 `additionalProperties: false`。 + +3. **`tools/call` 出错要可解释** + 优先通过 JSON-RPC `error` 返回明确错误信息。 + +4. **避免长时间阻塞** + 长任务应拆分或优化,否则会出现 `mcp_runtime_timeout`。 + +--- + +## 6. 常见错误与排查 + +- `mcp_runtime_timeout` + 含义:子进程超时未返回。 + 排查:先本地单独运行 server,确认单次调用耗时;必要时调大 `timeout_s`。 + +- `mcp_runtime_protocol_mismatch` + 含义:收到的不是 JSON-RPC 响应。 + 排查:检查是否把日志打印到了 stdout。 + +- `mcp_runtime_bad_json` + 含义:输出不是合法 JSON。 + 排查:检查编码、换行、对象结构。 + +- `mcp_tools_list_invalid` + 含义:`tools/list` 返回结构不符合预期。 + 排查:确认返回 `result.tools` 为数组,元素包含 `name`、`inputSchema`。 + +--- + +## 7. 建议开发流程 + +1. 本地先用脚本手动跑通 JSON-RPC +2. 管理台安装(建议先 `dry_run`) +3. 执行 `Health`、`Sync Tools` +4. 在 `Check Installed` 做批量体检 +5. 按专家映射开放给目标专家 + +--- + +## 8. 与原有内置工具关系 + +MCP 工具是增量能力,不会替代原有内置工具体系。 +最终都走统一 `ToolExecutor` 执行链(策略、超时、审计一致)。 + +# Local MCP Server Guide (stdio) + +## Goal + +Write local tools as a **standard MCP server over stdio (JSON-RPC)**, then connect them from Admin MCP Market. + +This project now uses MCP standard flow in runtime: + +- `initialize` +- `notifications/initialized` +- `tools/list` +- `tools/call` + +Legacy custom `op` messages are only compatibility fallback. + +--- + +## Minimal Python MCP Server + +Save as `mcp_echo_server.py`: + +```python +from __future__ import annotations + +import json +import sys +from typing import Any + + +def _ok(rid: Any, result: dict[str, Any]) -> None: + sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +def _err(rid: Any, code: int, message: str) -> None: + sys.stdout.write( + json.dumps( + {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}, + ensure_ascii=False, + ) + + "\n" + ) + sys.stdout.flush() + + +for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + req = json.loads(line) + except Exception: + continue + rid = req.get("id") + method = str(req.get("method") or "") + params = req.get("params") if isinstance(req.get("params"), dict) else {} + + if method == "initialize": + _ok(rid, {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "echo-mcp", "version": "0.1.0"}}) + continue + + if method == "notifications/initialized": + # Notification has no response. + continue + + if method == "tools/list": + _ok( + rid, + { + "tools": [ + { + "name": "echo", + "description": "Echo input text.", + "inputSchema": { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + "additionalProperties": False, + }, + } + ] + }, + ) + continue + + if method == "tools/call": + name = str(params.get("name") or "") + args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {} + if name != "echo": + _err(rid, -32601, f"unknown tool: {name}") + continue + text = str(args.get("text") or "") + _ok(rid, {"content": [{"type": "text", "text": text}]}) + continue + + _err(rid, -32601, f"method not found: {method}") +``` + +Run manually (sanity check): + +```bash +python mcp_echo_server.py +``` + +Then send one JSON-RPC request line from stdin to verify. + +--- + +## Install in Admin MCP Market + +For local Python script: + +- `source_type`: `pypi` (or any source type you use for bookkeeping) +- `source_ref`: custom label (for example `local-echo`) +- `entry_command`: `python` +- `entry_args`: `` + +Example JSON install payload: + +```json +{ + "source_type": "pypi", + "source_ref": "local-echo", + "server_id": "local-echo", + "version": "", + "entry_command": "python", + "entry_args": ["D:/project/chatgpt/examples/mcp_echo_server.py"], + "required_permissions": [], + "risk_level": "low", + "enabled": true, + "timeout_s": 30 +} +``` + +After install: + +1. `Health` +2. `Sync Tools` +3. Verify tool count > 0 + +--- + +## @modelcontextprotocol/server-filesystem 与网关工作区 + +官方 **`@modelcontextprotocol/server-filesystem`** 只在**进程启动时**把命令行里列出的目录当作可访问根;多装一个路径就要多传一个 argv,否则 `list_directory` 等工具无法列出该目录。 + +本仓库在启动该 MCP 时会**自动合并**与内置工作区一致的路径来源,并**去重后追加**到 `entry_command` + `entry_args` 之后(不改变你在管理台填写的主根,只追加额外根): + +| 来源 | 说明 | +| --- | --- | +| `OPS_WORKSPACE_EXTRA_ROOTS` | 环境变量,`\|` 分隔 | +| `OPS_MCP_FILESYSTEM_EXTRA_ROOTS` | 环境变量或 SQLite `settings` 表同名键,仅影响该 MCP | +| Admin「工作区路径」 | **当前用户聊天会话**(`ui_session_owner` 绑定的 `session_id`)对应账号的 `extra_roots`(`\|` 拆分);不会合并其他用户。若 `ui_session_owner` 行缺失,会从请求里携带的 `tenant_id` / `user_id`(`metadata`)**再拉一份**同一条 allowlist,与内置 `resolve_workspace_path` 及 MCP 追加 argv 对齐。 | +| Windows 路径 | 在网关侧与 MCP argv 中会对路径作规范化;若仍报「无权限」或子进程报路径不在根下,可对比管理台中保存的「绝对路径」与资源管理器里实际盘符/大小写是否一致,修改工作区后对该 MCP **Health → Sync Tools**。 | + +**与 `allow_any_path` 的关系**:管理台里的 **`allow_any_path` 只影响网关内置工具**(走 `resolve_workspace_path` 的读文件、glob、`run_command` 等),相当于在 Python 侧跳过「必须在 workspace 根或 `extra_roots` 下」的检查。官方 **`server-filesystem` 不认这个字段**:子进程只认启动时写在 argv 里的**具体目录列表**,没有「允许任意路径」的等价开关,因此单靠 `allow_any_path: true` **不会**把 `D:\download` 等路径自动加进 MCP。要让 MCP 列到这些目录,请把它们写进 **`extra_roots`**(或 `OPS_WORKSPACE_EXTRA_ROOTS` / `OPS_MCP_FILESYSTEM_EXTRA_ROOTS`),再 **Health → Sync Tools**。 + +**运维注意**:同一网关进程内,不同用户对话会各自 materialize 一套 MCP 工具绑定(argv 含该用户 `extra_roots` + 全局 env)。管理台 **Health / Sync Tools** 无用户会话上下文,此时仅合并 **环境变量与 settings**,不含任一用户的 DB `extra_roots`。 + +修改环境或 DB 后,请对相应 MCP 执行 **`Health` → `Sync Tools`**(或重启网关),以便新进程带上更新后的 argv。 + +--- + +## Tool Result Format Recommendations + +For `tools/call` result: + +- success: return `{"content":[{"type":"text","text":"..."}]}` +- failure: return JSON-RPC `error` or `result` with `isError=true` + +Keep responses deterministic and JSON-serializable. + +--- + +## Common Errors + +- `mcp_runtime_timeout` + Server did not answer in time. Check blocking calls, raise `timeout_s`, or optimize startup. + +- `mcp_runtime_protocol_mismatch` + Output is not JSON-RPC response line. Ensure stdout only emits JSON-RPC lines (move logs to stderr). + +- `mcp_runtime_bad_json` + Response line is malformed JSON. Validate serialization and newline framing. + +- `mcp_tools_list_invalid` + `tools/list` did not return valid `tools` array. + +--- + +## 通识工具库与 Cursor / Claude Code / oclaw(能力对齐说明) + +**已能覆盖的常见编码助手能力**:仓库读写与搜索(内置 workspace + MCP filesystem)、Git 本地与 GitHub 远端、网页抓取与浏览器自动化(fetch / playwright)、会话库 SQLite、日历与时间、PDF、顺序思考与 memory MCP 等。 + +**单靠 MCP 无法等价的部分**:IDE 内 LSP 实时红线(Cursor 编辑器集成)、oclaw 式 **ACP 外接** Claude Code/Codex 子进程(需单独编排/通道产品化)。 + +### Context7(库文档时效) + +- **作用**:按库名/版本拉取较新的官方文档片段,减少「API 记错版本」类幻觉。 +- **安装**:`python scripts/install_mcp_context7.py`,或管理台 `POST /admin/api/mcp/install` 使用 [`examples/mcp_install_context7.json`](../examples/mcp_install_context7.json) 中的 `payload`。 +- **密钥**:在 **`oclaw/_local/mcp_local.env`**(推荐)或 `data/mcp_local.env`(兼容)设置 `CONTEXT7_API_KEY`(见 [context7.com/dashboard](https://context7.com/dashboard))。两处都存在时**同键以 `oclaw/_local/mcp_local.env` 为准**(覆盖 `data` 中的同键)。未自定义 `OPS_MCP_ENV_ALLOWLIST` 时,网关默认 allowlist 已包含 `CONTEXT7_API_KEY`(见 `oclaw/ops/mcp_env.py`);若你自定义了 allowlist,请手动追加该键。 +- **装完后**:`Health` → `Sync Tools` → 将 `mcp-context7` 加入通识 specialist 的 MCP 绑定(若脚本已成功 Sync,会自动追加)。 + +### 通识侧终端能力(`run_command`) + +与 Claude Code「在仓库里跑命令」类似的能力来自内置 **`run_command`**,但通识 lane 需同时满足: + +1. 环境 **`OPS_ENABLE_RUN_COMMAND=1`**(见 `oclaw/tools/catalog.py` 与 `oclaw/tools/experts/workspace/shell_tools.py` 门控)。 +2. 仅在 **可信仓库 / 内网** 开启;否则易误执行高危命令。 + +### Postgres / Linear / Slack / Sentry 等 + +按实际业务栈再装对应 MCP 即可;无相关系统则不必安装,避免工具膨胀与误选。 + +--- + +## Multi-specialist Assignment + +MCP tools are assigned in Admin UI by specialist mapping. +Only selected specialists can see/use MCP tools at runtime. + diff --git a/docs/OCLAW_COMPAT_REMOVAL_CHECKLIST.md b/docs/OCLAW_COMPAT_REMOVAL_CHECKLIST.md new file mode 100644 index 00000000..08151577 --- /dev/null +++ b/docs/OCLAW_COMPAT_REMOVAL_CHECKLIST.md @@ -0,0 +1,35 @@ +# OCLAW Compat Layer Removal Checklist + +Use this checklist to verify readiness and post-delete safety for `oclaw/app_server/*` compatibility-module removal. + +## Import graph checks +- [x] `rg "src\.app_server\."` has no runtime/code matches (tests excluded or updated). +- [x] CLI/runtime startup paths import from `oclaw.interfaces.*` only. +- [x] WebSocket entrypoint imports resolve through `oclaw.interfaces.ws.*`. + +## Behavior parity checks +- [x] HTTP gateway smoke tests pass (`/health`, `/inbound`, `/gateway/method`, `/ws` handshake). +- [x] WS request/response contract remains unchanged for: + - [x] `connect` + - [x] `chat.send/chat.history/chat.abort` + - [x] `sessions.*` + - [x] `agent.run/agent.wait` +- [x] Plugin bootstrap still loads expected extension set. + +## Prompt/skill checks +- [x] Runtime role context still loads from `oclaw/agent/*`. +- [x] Skill root priority still effective: + - [x] `AIA_SKILLS_ROOT` + - [x] `oclaw/skills` + - [x] `skills/` fallback + +## Extension policy checks +- [x] Primary extension source remains `oclaw/extensions/`. +- [x] Legacy root `extensions/` has been fully merged and removed. +- [x] Duplicate plugin-id diagnostics still emitted as expected. + +## Final cleanup +- [x] Delete compatibility files under `oclaw/app_server/*` only after all checks are green. +- [x] Remove stale docs/comments referring to old primary entrypoints. +- [x] Re-run lint and targeted compile checks after deletion. + diff --git a/docs/OCLAW_MIGRATION_GUIDE.md b/docs/OCLAW_MIGRATION_GUIDE.md new file mode 100644 index 00000000..f5bf88f3 --- /dev/null +++ b/docs/OCLAW_MIGRATION_GUIDE.md @@ -0,0 +1,70 @@ +# OpenClaw Migration Guide + +This repository is migrating from legacy `oclaw/` runtime wiring to the new `oclaw/` architecture root. + +## Current status +- `oclaw/` is the target root for new code. +- `oclaw/app_server/*` compatibility modules have been removed. +- Gateway dispatch now routes through shared `server_methods` handlers for both WS and HTTP method endpoints. + +## Developer rules +- Add new business logic under `oclaw/` (interfaces/application/domain/infrastructure/shared). +- Avoid adding new core logic into legacy `oclaw/` modules. +- Keep `oclaw/` changes limited to re-export or compatibility adaptation. +- Before deleting compatibility modules, use: + - `oclaw/docs/OCLAW_COMPAT_REMOVAL_CHECKLIST.md` + +## Runtime notes +- Gateway HTTP method adapter: `POST /gateway/method` +- WS dispatch first resolves method handlers from shared dispatcher. +- Inbound payload use-case entrypoint: `oclaw.application.gateway.process_inbound_payload_usecase` +- HTTP app entrypoint moved to: `oclaw.interfaces.http.fastapi_app` +- WS entrypoint moved to: `oclaw.interfaces.ws.entrypoint` +- WS runtime bridge path: `oclaw.interfaces.ws.runtime` +- WS runtime implementation seam: + - `oclaw/interfaces/ws/runtime_impl.py` + - `runtime.py` points to this module as stable import surface. +- Server-method WS bridge extracted to: + - `oclaw/interfaces/ws/server_methods_bridge.py` + - legacy class now delegates dispatch/context construction to this bridge. +- Agent turn execution extracted to: + - `oclaw/interfaces/ws/turn_runner.py` + - legacy `run_agent_turn` now delegates to this module. +- WS request dispatch path is now single-source: + - connected requests go through `server_methods` bridge first; + - unknown methods return standardized invalid-request errors. +- Legacy WS `handle_*` and schema-specific validate helpers were removed from the class; + runtime behavior now comes from dispatcher + bridge modules. +- WS schema access is now routed via: + - `oclaw/interfaces/ws/ws_schema.py` + - legacy gateway imports schema helpers through the oclaw namespace. +- WS schema implementation has been migrated to: + - `oclaw/interfaces/ws/schema_impl.py` + - no legacy `oclaw/app_server/ws_schema.py` dependency remains. +- WS auth + hello payload builders moved to: + - `oclaw/interfaces/ws/auth_and_hello.py` + - legacy gateway delegates `resolve_ws_auth` and `build_hello_ok`. +- WS frame/event emit helpers moved to: + - `oclaw/interfaces/ws/events.py` + - legacy gateway delegates `send_res/send_event/emit_*`. +- WS runtime helpers moved to: + - `oclaw/interfaces/ws/runtime_helpers.py` + - legacy gateway delegates `_recv_frame` and `_handle_connect`. +- WS main loop + close behavior moved to: + - `oclaw/interfaces/ws/runtime_loop.py` + - legacy gateway delegates `run()` and `_close_ws()`. +- WS connected-request dispatch moved to: + - `oclaw/interfaces/ws/runtime_dispatch.py` + - legacy gateway delegates `_dispatch_connected()`. + +## Extension source policy +- Primary source: `oclaw/extensions/` +- Legacy root `extensions/` has been merged into `oclaw/extensions/` and removed. + +## Prompt and skills policy +- Role context is loaded from `oclaw/agent/*`. +- Skills root priority: + 1. `AIA_SKILLS_ROOT` + 2. `oclaw/skills` + 3. `skills/` (legacy fallback) + diff --git a/docs/PROMPT_STYLE_GUIDE.md b/docs/PROMPT_STYLE_GUIDE.md new file mode 100644 index 00000000..6644464a --- /dev/null +++ b/docs/PROMPT_STYLE_GUIDE.md @@ -0,0 +1,27 @@ +# Prompt Style Guide + +## Goal +- All model-facing prompts must be Markdown templates under `oclaw/prompts/`. +- Business code must inject variables only; no long inline prompt strings. + +## Template Contract +- File format: `.md` with frontmatter. +- Required frontmatter keys: `title`, `summary`, `read_when`. +- Variables use `{{var_name}}`. +- Missing variables must fail in strict mode. + +## Required Section Order +1. Identity and objective +2. Input constraints +3. Execution rules +4. Output format +5. Safety and prohibitions +6. Optional runtime context blocks + +## Rules +- Use imperative instructions: must / must not / only when. +- Keep deterministic section ordering for cache stability. +- Prefer machine-parseable blocks for injected context. +- Do not embed raw JSON protocol examples unless needed. +- Keep bilingual copies as separate template files when wording diverges. + diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md new file mode 100644 index 00000000..0557b280 --- /dev/null +++ b/docs/RUNBOOK.md @@ -0,0 +1,448 @@ +# 运行手册(RUNBOOK) + +本文档面向“日常运维与排障”场景,聚焦可直接执行的命令与操作顺序。 + +关联文档: + +- trace 字段与阶段对照:`docs/openclaw-trace-taxonomy.md` +- skill 安装/执行排障:`docs/openclaw-skill-troubleshooting.md` + +--- + +## 1. 统一入口(只保留最新) + +所有运维命令统一通过 `scripts/`,不要再使用 `python -m oclaw.ops ...` 或历史 `.bat` 方式。 + +补充:工具脚本也统一放在 `scripts/`(例如 `seed_mcp_registry.py`、`ws_probe.py`)。 + +--- + +## 2. 首次初始化 + +Windows: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_venv.ps1 +``` + +Linux/macOS: + +```bash +./scripts/start_ops.sh +``` + +--- + +## 3. 配置企业微信(Bot 模式) + +在网关启动后,统一在管理台完成通道配置,不再使用旧 CLI 命令入口。 + +--- + +## 4. 启停运行栈 + +启动: + +`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background` + +(含微信 sidecar): + +`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background -WithWeixin` + +(含微信 sidecar + wiki worker): + +`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background -WithWeixin -WithWikiWorker` + +查看状态: + +`powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1` + +(含微信 sidecar): + +`powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1 -WithWeixin` + +(含微信 sidecar + wiki worker): + +`powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1 -WithWeixin -WithWikiWorker` + +停止: + +`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1` + +(含微信 sidecar): + +`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1 -WithWeixin` + +(含微信 sidecar + wiki worker): + +`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1 -WithWeixin -WithWikiWorker` + +说明: + +- `stack up` 不会启动 Streamlit +- 聊天页面统一使用 `http://127.0.0.1:8787/chat` +- `--with-ui` 为历史参数,不再生效 + +--- + +## 5. 仅启动网关 + +`powershell -ExecutionPolicy Bypass -File .\scripts\start_gateway.ps1` + +--- + +## 6. 管理台与认证 + +先启动网关,再访问: + +- `http://127.0.0.1:8787/admin` +- `http://127.0.0.1:8787/chat` + +### 6.1 初始化管理员(幂等) + +```bash +curl -X POST http://127.0.0.1:8787/admin/api/auth/bootstrap +``` + +### 6.2 登录获取 Bearer Token + +```bash +curl -X POST http://127.0.0.1:8787/admin/api/auth/login \ + -H "content-type: application/json" \ + -d '{"tenant_id":"","username":"administrator","password":"","purpose":"console"}' +``` + +### 6.3 带 Token 调用受保护接口 + +```bash +curl http://127.0.0.1:8787/admin/api/users?tenant_id= \ + -H "authorization: Bearer " +``` + +RBAC 规则要点: + +- `owner` 拥有完整管理权限 +- 其它角色权限由 `role_permission` + `user_permission` 决定 +- 跨租户写操作会被拒绝(`403`) + +### 6.4 `/chat` 会话与用户隔离(排障) + +Admin Chat 依赖表 **`ui_session_owner`**(`session_id` → `tenant_id` + `user_id`)判定「谁可见、谁可写」某条 `chat_session`。列表、读消息、流式回复、停止生成、导出等均经该归属校验。 + +**请勿依赖的历史行为(已移除)** + +- 用户会话列表为空时,**不再**自动把库内所有「无 owner」会话划给该用户(否则多用户会互相看到对方会话)。 +- 读取某 `session_id` 时,**不会**再「若无 owner 则绑定到当前请求用户」(否则谁先打开链接谁抢走归属)。 + +**若升级后有人看不到旧会话** + +说明这些 `chat_session` 从未写入 `ui_session_owner`(列表与读接口均只认该表)。处理方式(需在维护窗口评估数据归属): + +1. 自行 SQL 排查:`SELECT s.id, s.title, s.created_at FROM chat_session s LEFT JOIN ui_session_owner o ON o.session_id = s.id WHERE o.session_id IS NULL;` 确认归属后按需 `INSERT INTO ui_session_owner(session_id, tenant_id, user_id, created_at) VALUES (...)`。 +2. 在 **Python 控制台** 仅在**确认整库孤儿会话均属同一用户**时,可显式调用 `SqliteStore.backfill_orphan_chat_sessions_for_user`(该方法会一次性把**当前仍无 owner 的全部**会话绑到传入的 `user_id`,**不适合多用户已混用生产库**)。 +3. **`administrator`** 在 **`/chat` 侧边栏**与普通用户相同,只列出 **自己名下**(`ui_session_owner.user_id` = 管理员账号)的会话,**不会**把他人会话混进自己的列表。查看本租户全部会话请用 **审计 / Session Monitor** 或接口 **`GET /admin/api/chat/admin/sessions`**(需相应权限)。单会话消息读写在管理员仍可按租户校验(便于从监控打开指定 `session_id`)。 + +**浏览器端** + +独立 `/chat` 页在检测到 **登录租户/用户** 与上次不一致时会丢弃 URL 中的 `?session_id=`,避免同一浏览器换账号后仍打开上一用户的深链。 + +**工作区 ``extra_roots``(与编排临时会话)** + +管理台为用户配置的 ``user_workspace_path_allowlist.extra_roots`` 通过 ``ui_session_owner`` 解析到租户+用户。总控编排里专家步往往在**无 owner 的临时** ``chat_session`` 上落库中间消息:内置路径类工具会携带 **用户 UI 会话 id 作为 fallback**,仍按该用户策略合并 ``extra_roots``;MCP filesystem 启动参数本就按用户聊天 ``session_id``(policy)合并,二者现已对齐。 + +### 6.5 主库路径与「删掉的会话又回来了 / 新建用户不见了」 + +默认主库为 **`data/ai_ops.sqlite`**(未设置 ``OPS_ASSISTANT_DB_PATH`` 时)。历史上曾把库放在 **`../data/ai_ops.sqlite`** 或 **`oclaw/platform/data/ai_ops.sqlite`**;首次启动若检测到这些旧位置且新主库尚不存在,会把整库**复制**到 `data/ai_ops.sqlite`,并把旧路径下的附件**补拷**到 `data/attachments/`(不覆盖已有文件)。确认新主库生效后,本机可按需清理旧目录(避免磁盘上留着陈旧副本)。 + +历史上若旧库里的 ``chat_session`` **行数大于**当前主库,会用**整份旧库覆盖**主库。用户大量**删除会话**后主库行数变少,会误触发该逻辑,表现为:**已删会话从旧快照恢复**、**只在主库里出现的新用户/新数据被整库覆盖掉**。 + +**当前版本已关闭该自动覆盖**;仅当显式设置环境变量 **`OPS_LEGACY_DB_FORCE_PREMERGE=1`** 时才允许按旧规则合并(仍会先把当前主库备份到 ``data/_pre_merge_sqlite_<时间戳>/``)。 + +**排障建议**:确认所有网关/进程使用**同一** ``OPS_ASSISTANT_DB_PATH``(或统一依赖默认 ``data/ai_ops.sqlite``);若曾出现覆盖,可在 ``data/_pre_merge_sqlite_*`` 中找回被备份出去的主库副本。 + +--- + +## 7. 常用脚本入口 + +Windows PowerShell: + +- `powershell -ExecutionPolicy Bypass -File .\scripts\start_gateway.ps1` +- `powershell -ExecutionPolicy Bypass -File .\scripts\status_all.ps1` +- `powershell -ExecutionPolicy Bypass -File .\scripts\stop_gateway.ps1` +- (联动)`powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background` +- (联动)`powershell -ExecutionPolicy Bypass -File .\scripts\stop_all.ps1` + +Linux/macOS: + +- `./scripts/start_ops.sh` +- `./scripts/status_ops.sh` +- `./scripts/stop_ops.sh` + +--- + +## 8. MCP 运维流程(管理台) + +推荐顺序: + +1. `Install`(或 `Reinstall`) +2. `Health` +3. `Sync Tools` +4. `Check Installed`(批量体检) + +若失败,重点看错误码: + +- `mcp_runtime_timeout` +- `mcp_runtime_protocol_mismatch` +- `mcp_runtime_bad_json` +- `mcp_tools_list_invalid` + +详细开发与接入参考:`docs/MCP_LOCAL_SERVER.md` + +--- + +## 9. 向量记忆配置(可选) + +可通过环境变量或管理台配置: + +- `MEMORY_VECTOR_ENABLED` (`0/1`) +- `MEMORY_VECTOR_BACKEND` (`sqlite` / `chroma` / `qdrant`) +- `MEMORY_VECTOR_TOPK`(默认 `5`) +- `MEMORY_WRITE_ENABLED` (`0/1`) +- `MEMORY_WRITE_MIN_CONFIDENCE`(默认 `0.75`) + +故障降级策略: + +- 向量后端不可用时,读写回退到 SQLite 向量实现 +- 关闭写入时,不影响聊天主流程 + +--- + +## 10. 常见故障速查 + +### 10.1 管理台无法登录 + +- 是否设置 `AIA_ASSISTANT_PASSWORD` +- 是否重启服务并让新环境变量生效 + +### 10.2 MCP 显示可用但工具数为 0 + +- 先执行 `Health` +- 再执行 `Sync Tools` +- 再执行 `Check Installed` + +### 10.3 Check Installed 全红 + +- 检查 server 是否输出标准 JSON-RPC(stdout) +- 日志必须写 stderr,避免协议污染 +- 确认 `entry_command/entry_args` 正确 + +### 10.4 微信能收不能回 / 回不去 + +按顺序检查: + +1) 网关是否健康(`/health` 必须快速返回) +2) sidecar 是否运行(`weixin_status.ps1`) +3) sidecar 日志是否有 `sendmessage` 失败提示(`weixin_sidecar.err.log`) + +如果 8787 端口被僵尸进程占用,先执行: + +- `powershell -ExecutionPolicy Bypass -File .\scripts\stop_gateway.ps1 -Force` +- `powershell -ExecutionPolicy Bypass -File .\scripts\start_gateway.ps1` +- `powershell -ExecutionPolicy Bypass -File .\scripts\weixin_stop.ps1 -Force` +- `powershell -ExecutionPolicy Bypass -File .\scripts\weixin_start.ps1` + +--- + +## 11. 个人微信(官方 ClawBot)接入 + +说明:本项目采用「A 模式」接入。微信插件由本地 sidecar 运行,直接调用本地 Python gateway。 + +前置条件: + +- 微信端已开通并启用 ClawBot 插件 +- 当前目录为 `D:/project/chatgpt/oclaw` +- 已安装 Node.js(建议 22+)与 npm +- 网关可访问:`http://127.0.0.1:8787/health` + +### 11.1 安装 sidecar 依赖 + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\weixin_install.ps1 +``` + +安装目录: + +- `data/channel_sidecar/openclaw-weixin/` + +### 11.2 扫码登录(获取 bot token) + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\weixin_login.ps1 +``` + +登录态写入: + +- `data/channel_sidecar/openclaw-weixin/state/openclaw-weixin/accounts/*.json` +- `data/channel_sidecar/openclaw-weixin/state/openclaw-weixin/accounts.json` + +### 11.3 启动微信 sidecar + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\weixin_start.ps1 +``` + +查看状态: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\weixin_status.ps1 +``` + +停止: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\weixin_stop.ps1 +``` + +日志文件: + +- `data/channel_sidecar/openclaw-weixin/logs/weixin_sidecar.log` +- `data/channel_sidecar/openclaw-weixin/logs/weixin_sidecar.err.log` + +### 11.4 当前行为说明 + +- 微信回复为“整段返回”,非逐 token 流式 +- 发送前会清理推理/工具痕迹(如 `...`) +- 发送前会处理文本换行(含字面量 `\n`) + +--- + +## 12. LLM 回放策略(reasoning / content / tool) + +适用范围:`oclaw` 运行时构建“下一轮发给模型”的消息序列。 + +### 12.1 设计原则 + +- `content` 与 `reasoning` 分离:正文走 `assistant_text`,推理走独立 `reasoning` 事件。 +- 默认不回放推理文本:回放只包含正文 + tool(及必要的配对字段)。 +- 历史兼容:旧数据中若正文含 `...` 或 `...`,在回放构建时会剥离。 + +### 12.2 工具回放分层 + +- 最近 3 轮工具调用保留全量结果。 +- 更早工具结果降级为摘要(保留 `tool_call_id` 配对信息,避免网关拒绝)。 +- 单条内容仍受现有超长截断限制。 + +### 12.3 推理签名白名单(provider 兼容) + +只针对“签名元字段”而非推理文本。用于部分 provider 在工具连续调用时保持上下文连续性。 + +环境变量:`AIA_REPLAY_REASONING_SIGNATURE_POLICY` + +- `auto`(默认):仅在白名单 provider 路径回放签名元字段(当前包含 Gemini 路径)。 +- `on`:所有模型都回放签名元字段(调试/兼容兜底)。 +- `off`:完全不回放签名元字段(最严格模式)。 + +推荐: + +- 常规生产:保持默认 `auto`。 +- 若遇到特定模型 tool-loop 连续性问题:临时设为 `on` 验证,再收敛到最小白名单。 + +### 12.4 `.env` 最小配置示例 + +```bash +# 工具回放:最近 3 轮全量(默认 3,可按需调整) +AIA_REPLAY_TOOL_FULL_ROUNDS=3 + +# 推理签名回放策略:auto / on / off +# 生产建议 auto:仅白名单 provider 回放签名元字段 +AIA_REPLAY_REASONING_SIGNATURE_POLICY=auto +``` + +--- + +## 13. memory-wiki 插件启用与排障 + +### 13.1 启用配置(oclaw/oclaw.json) + +将 `memory-wiki` 放入启用列表,并建议把 memory slot 指向它: + +```json +{ + "plugins": { + "enabled": ["memory-wiki"], + "slots": { + "memory": "memory-wiki" + }, + "entries": { + "memory-wiki": { + "wiki_root": "oclaw/docs/memory-system/wiki", + "max_search_results": 20, + "max_get_lines": 800, + "auto": { + "enabled": true, + "inject": { + "max_chars": 1800, + "top_k": 6 + }, + "worker": { + "enabled": true + }, + "topic_routing": { + "rules": [ + { + "topic": "network", + "keywords": ["vlan", "router", "switch", "network", "dns", "gateway"] + }, + { + "topic": "devops", + "keywords": ["deploy", "k8s", "kubernetes", "docker", "ci", "ops"] + }, + { + "topic": "engineering", + "keywords": ["bug", "fix", "todo", "feature", "refactor", "test"] + } + ] + } + } + } + } + } +} +``` + +### 13.2 会话可用工具(预期) + +- `wiki_status` +- `wiki_get` +- `wiki_search` +- `wiki_lint` +- `wiki_apply` + +### 13.3 常见故障 + +- `invalid_arguments`:参数缺失或 `path` 非 `.md` / 越界到 wiki 根目录外。 +- `wiki_not_found`:目标文件不存在(`wiki_get` / `wiki_apply delete`)。 +- `invalid_action`:`wiki_apply` 的 `action` 仅支持 `write|append|delete`。 +- `wiki_runtime_error`:运行期异常(编码、权限、IO),先检查路径权限与文件占用。 + +### 13.4 回退方案 + +- 临时禁用 wiki 插件:从 `plugins.enabled` 移除 `memory-wiki` 后重启 gateway。 +- 或仅关闭 memory slot:`plugins.slots.memory` 设为 `"none"`(保留插件安装但不作为 memory 槽位)。 + +### 13.5 自动化链路自检(smoke test) + +在 `gateway + wiki worker` 运行时,执行: + +```powershell +python .\scripts\wiki_auto_smoke_test.py +``` + +该脚本会: + +- 投递一条 `wiki_capture` 任务到 `openclaw_task` +- 轮询任务状态直到 `done/failed/timeout` +- 输出当前写入产物状态(`merged-turns.md`、`topic-index.json`、`index.json`、`LINT_REPORT.md`) + +--- + diff --git a/docs/memory-system/Archives/.gitkeep b/docs/memory-system/Archives/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/memory-system/Archives/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/memory-system/Archives/README.md b/docs/memory-system/Archives/README.md new file mode 100644 index 00000000..21d8c65f --- /dev/null +++ b/docs/memory-system/Archives/README.md @@ -0,0 +1,11 @@ +# Archives + +Use for inactive materials from Projects, Areas, and Resources. + +Archive criteria: + +- project is complete or cancelled +- responsibility is no longer active +- resource is stale and low-value + +Archived content stays searchable but should not appear in daily workflow. diff --git a/docs/memory-system/Areas/.gitkeep b/docs/memory-system/Areas/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/memory-system/Areas/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/memory-system/Areas/README.md b/docs/memory-system/Areas/README.md new file mode 100644 index 00000000..8d05b63d --- /dev/null +++ b/docs/memory-system/Areas/README.md @@ -0,0 +1,11 @@ +# Areas + +Use for ongoing responsibilities without a fixed end date. + +Examples: + +- engineering standards +- health routines +- language learning maintenance + +Review regularly and keep only current responsibility notes. diff --git a/docs/memory-system/Inbox/.gitkeep b/docs/memory-system/Inbox/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/memory-system/Inbox/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/memory-system/Inbox/README.md b/docs/memory-system/Inbox/README.md new file mode 100644 index 00000000..aed5dfc1 --- /dev/null +++ b/docs/memory-system/Inbox/README.md @@ -0,0 +1,11 @@ +# Inbox + +Temporary capture queue for raw inputs. + +Daily target: + +- capture 3-5 valuable items +- distill 1-3 items into atomic notes +- leave no high-value item unprocessed by end of day + +Do not store long-term notes here. diff --git a/docs/memory-system/PARA.md b/docs/memory-system/PARA.md new file mode 100644 index 00000000..883beafe --- /dev/null +++ b/docs/memory-system/PARA.md @@ -0,0 +1,22 @@ +# PARA Container Contract + +These containers are fixed and should remain stable: + +- `Projects`: active, deadline-bound outcomes +- `Areas`: ongoing responsibilities without an end date +- `Resources`: reusable reference knowledge and learning notes +- `Archives`: inactive material moved out of active flow +- `Inbox`: temporary capture queue before processing + +## Operating Rules + +1. Do not add new top-level containers unless there is a major redesign. +2. Process `Inbox` items during daily/weekly routines. +3. Store each item in exactly one primary container. +4. Move inactive items to `Archives` during weekly review. + +## Move Guide + +- `Inbox` -> `Projects`/`Areas`/`Resources` after distillation +- `Projects` -> `Archives` when outcome is finished or dropped +- `Areas`/`Resources` -> `Archives` when no longer relevant diff --git a/docs/memory-system/Projects/.gitkeep b/docs/memory-system/Projects/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/memory-system/Projects/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/memory-system/Projects/README.md b/docs/memory-system/Projects/README.md new file mode 100644 index 00000000..98a76da6 --- /dev/null +++ b/docs/memory-system/Projects/README.md @@ -0,0 +1,11 @@ +# Projects + +Use for time-bound outcomes with a clear deadline. + +Examples: + +- feature launch notes +- exam preparation sprint +- migration checklist + +When completed or dropped, move materials to `../Archives`. diff --git a/docs/memory-system/README.md b/docs/memory-system/README.md new file mode 100644 index 00000000..2f653d4a --- /dev/null +++ b/docs/memory-system/README.md @@ -0,0 +1,90 @@ +# Memory System Implementation + +This folder implements a lightweight memory workflow based on: + +- PARA for operational organization +- Atomic notes for reusable knowledge +- SRS for long-term retention +- Weekly review for maintenance and quality control + +## Fixed PARA Containers + +Do not change these top-level containers unless there is a major system redesign. + +- `Projects`: time-bound outcomes with a deadline +- `Areas`: ongoing responsibilities without an end date +- `Resources`: reference topics and learning materials +- `Archives`: inactive content from all other containers +- `Inbox`: temporary capture queue before classification + +Container contract and move rules are defined in `PARA.md`. + +## Minimal Workflow (10-20 minutes/day) + +1. Capture: move 3-5 raw items into `Inbox`. +2. Distill: convert 1-3 items into atomic notes under `Resources`. +3. Recall: generate 3-10 SRS cards using the conversion guide. +4. Express: produce one output (summary, answer, code note). + +## Weekly Review (30 minutes/week) + +Use `templates/weekly-review.md` to: + +- empty inbox +- improve links +- archive stale items +- define next week's focus reviews + +Use `weekly-review-schedule.md` to keep the review on a fixed weekly slot. + +## Two-Week Minimum Rollout + +- Week 1: + - initialize PARA containers (`Projects`, `Areas`, `Resources`, `Archives`, `Inbox`) + - capture and process notes daily using the workflow below + - convert at least one high-value note/day into SRS cards +- Week 2: + - enforce linking quality (each new note links to at least one existing note) + - run one full weekly review + - tune new-card load based on backlog and study time + +## Templates + +- `templates/atomic-note.md` +- `templates/srs-conversion.md` +- `templates/weekly-review.md` +- `templates/daily-routine.md` + +## Folder Guidance + +- `Projects/README.md` +- `Areas/README.md` +- `Resources/README.md` +- `Archives/README.md` +- `Inbox/README.md` + +## Success Metrics + +- 7 days: daily capture + review runs without backlog blow-up. +- 30 days: core topics can be recalled without opening source material. +- 60-90 days: writing/decision/coding reuse speed improves and repeated lookup drops. +- If review load is too high: reduce new cards first, keep only high-value knowledge. + +## Automation Commands + +Use the unified CLI to run this system with automatic recommendations: + +- `powershell -ExecutionPolicy Bypass -File .\scripts\status_ops.ps1` +- `powershell -ExecutionPolicy Bypass -File .\scripts\start_ops.ps1` +- `powershell -ExecutionPolicy Bypass -File .\scripts\stop_ops.ps1` + +Outputs are generated in `docs/memory-system/runs/`: + +- `daily-YYYY-MM-DD.md` +- `weekly-YYYY-Www.md` + +What is automated: + +- fixed folder validation (`Projects/Areas/Resources/Archives/Inbox`) +- smart new-card limit recommendation (3-10/day based on backlog + inbox pressure) +- automatic focus ordering (`Projects > Areas > Resources`, weighted by active note volume) diff --git a/docs/memory-system/Resources/.gitkeep b/docs/memory-system/Resources/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/memory-system/Resources/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/memory-system/Resources/README.md b/docs/memory-system/Resources/README.md new file mode 100644 index 00000000..19bb2eff --- /dev/null +++ b/docs/memory-system/Resources/README.md @@ -0,0 +1,11 @@ +# Resources + +Use for reusable knowledge and reference topics. + +This is the main home of atomic notes. + +Requirements: + +- one note = one claim +- link each new note to at least one existing note +- create SRS candidates for high-value notes diff --git a/docs/memory-system/runs/daily-2026-04-21.md b/docs/memory-system/runs/daily-2026-04-21.md new file mode 100644 index 00000000..d749ff31 --- /dev/null +++ b/docs/memory-system/runs/daily-2026-04-21.md @@ -0,0 +1,19 @@ +# Daily Memory Run (2026-04-21) + +- Focus topic: `Resources` +- Suggested new cards today: `9` +- Current inbox notes: `0` +- Review backlog input: `25` + +## Auto Steps + +1. Capture 3-5 high-value items into `Inbox`. +2. Distill 1-3 items into atomic notes under `Resources`. +3. Convert notes into Q/A or cloze cards (respect suggested limit). +4. Produce one output (summary/answer/code note). + +## Smart Suggestions + +- Priority order now: `Resources` +- If reviews feel overloaded, reduce new cards before skipping due reviews. +- If inbox keeps growing for 2+ days, process inbox first before new captures. diff --git a/docs/memory-system/runs/weekly-2026-W17.md b/docs/memory-system/runs/weekly-2026-W17.md new file mode 100644 index 00000000..a5014910 --- /dev/null +++ b/docs/memory-system/runs/weekly-2026-W17.md @@ -0,0 +1,24 @@ +# Weekly Memory Review (2026-W17) + +- Run date: `2026-04-21` +- Suggested next-week new cards/day: `9` + +## Snapshot + +- Projects notes: `0` +- Areas notes: `0` +- Resources notes: `0` +- Archives notes: `0` +- Inbox notes: `0` + +## Fixed Review Checklist + +- [ ] Inbox zero +- [ ] Linking/backlinks completion +- [ ] Move inactive notes to Archives +- [ ] Tune next-week card load + +## Auto Focus for Next Week + +- Top topics: `Resources` +- Keep new cards within 3-10/day and adjust by real review pressure. diff --git a/docs/memory-system/templates/atomic-note.md b/docs/memory-system/templates/atomic-note.md new file mode 100644 index 00000000..6d327233 --- /dev/null +++ b/docs/memory-system/templates/atomic-note.md @@ -0,0 +1,35 @@ +# Atomic Note Template + +> Rule: one note, one claim. + +## Title + +`` + +## Viewpoint (Claim) + +- What is the key idea? +- Why does it matter? + +## Source + +- Origin: `` +- Link or citation: `` +- Capture date: `` + +## Associations (Links) + +- Related note 1: `[[...]]` +- Related note 2: `[[...]]` +- Contradiction or alternative: `[[...]]` + +## Reusable Scenarios + +- Where can this be applied? (writing/decision/coding) +- Trigger signal: "When I see X, apply this note." + +## Card Candidates (Optional) + +- Q: `` + A: `` +- Cloze: `