From 7a54015f940a316b9c1e6726c108a5371e2b86d8 Mon Sep 17 00:00:00 2001 From: Losita Date: Mon, 11 May 2026 00:51:12 +0000 Subject: [PATCH] chore: import deployable mai-bot source tree --- .coderabbit.yaml | 49 + .devcontainer/devcontainer.json | 31 + .dockerignore | 25 + .gitattributes | 1 + .gitea/workflows/release-offline.yml | 181 + .gitignore | 43 + .pre-commit-config.yaml | 10 + AGENTS.md | 59 + CLAUDE.md | 1 + CODE_OF_CONDUCT.md | 120 + Dockerfile | 27 + EULA.md | 134 + LICENSE | 684 +- PRIVACY.md | 28 + README.md | 216 +- bot.py | 407 + changelogs/changelog.md | 1437 ++ .../generate_database_datamodel_py.py | 127 + code_scripts/migrate_expression_jargon_db.py | 535 + config/README.md | 8 + crowdin.yml | 22 + dashboard/.prettierrc | 8 + dashboard/LICENSE | 661 + dashboard/README.md | 377 + dashboard/bun.lock | 2502 +++ dashboard/bunfig.toml | 6 + dashboard/components.json | 20 + dashboard/docs/Caddyfile.docker.example | 12 + dashboard/docs/Caddyfile.host.example | 12 + dashboard/docs/main.png | Bin 0 -> 422633 bytes dashboard/docs/webui-tls-ssl-compose.md | 203 + dashboard/docs/webui-tls-ssl.md | 465 + dashboard/electron.vite.config.ts | 137 + dashboard/electron/main/index.ts | 203 + dashboard/electron/main/protocol.ts | 89 + dashboard/electron/main/store.ts | 215 + dashboard/electron/preload/index.ts | 56 + dashboard/electron/resources/.gitkeep | 0 dashboard/eslint.config.js | 44 + dashboard/index.html | 30 + dashboard/package-lock.json | 17778 ++++++++++++++++ dashboard/package.json | 192 + .../public/fonts/JetBrainsMono-Medium.ttf | Bin 0 -> 273860 bytes dashboard/public/maimai.ico | Bin 0 -> 119148 bytes .../scripts/a_memorix_electron_validate.cjs | 406 + dashboard/src/assets/maimai.ico | Bin 0 -> 67715 bytes dashboard/src/assets/react.svg | 1 + dashboard/src/components/CodeEditor.tsx | 54 + dashboard/src/components/CodeEditorImpl.tsx | 105 + dashboard/src/components/ListFieldEditor.tsx | 525 + .../components/RestartingOverlay.legacy.tsx | 189 + .../src/components/animation-provider.tsx | 54 + dashboard/src/components/asset-provider.tsx | 64 + dashboard/src/components/back-to-top.tsx | 101 + .../background-effects-controls.tsx | 267 + dashboard/src/components/background-layer.tsx | 196 + .../src/components/background-uploader.tsx | 284 + .../src/components/component-css-editor.tsx | 83 + .../dynamic-form/DynamicConfigForm.tsx | 462 + .../components/dynamic-form/DynamicField.tsx | 487 + .../src/components/dynamic-form/README.md | 126 + .../__tests__/DynamicConfigForm.test.tsx | 427 + .../__tests__/DynamicField.test.tsx | 475 + .../src/components/dynamic-form/index.ts | 2 + .../components/electron/BackendManager.tsx | 245 + .../electron/BackendSetupWizard.tsx | 256 + .../src/components/electron/TitleBar.tsx | 67 + dashboard/src/components/emoji-thumbnail.tsx | 123 + dashboard/src/components/error-boundary.tsx | 310 + .../src/components/expression-reviewer.tsx | 1726 ++ .../src/components/http-warning-banner.tsx | 61 + dashboard/src/components/index.ts | 13 + dashboard/src/components/layout/Header.tsx | 278 + dashboard/src/components/layout/Layout.tsx | 239 + dashboard/src/components/layout/LogoArea.tsx | 102 + dashboard/src/components/layout/NavItem.tsx | 81 + dashboard/src/components/layout/Sidebar.tsx | 103 + dashboard/src/components/layout/constants.ts | 46 + dashboard/src/components/layout/index.ts | 2 + dashboard/src/components/layout/types.ts | 21 + .../src/components/markdown-renderer.tsx | 134 + .../components/memory/MemoryConfigEditor.tsx | 311 + .../components/memory/MemoryDeleteDialog.tsx | 281 + .../memory/MemoryEpisodeManager.tsx | 518 + .../memory/MemoryMaintenanceManager.tsx | 325 + .../src/components/memory/MemoryMiniTabs.tsx | 54 + .../memory/MemoryProfileManager.tsx | 482 + .../memory/MemoryProgressIndicator.tsx | 130 + dashboard/src/components/plugin-stats.tsx | 303 + dashboard/src/components/restart-overlay.tsx | 416 + dashboard/src/components/search-dialog.tsx | 349 + .../src/components/share-pack-dialog.tsx | 685 + dashboard/src/components/survey/index.ts | 8 + .../src/components/survey/survey-question.tsx | 247 + .../src/components/survey/survey-renderer.tsx | 407 + .../src/components/survey/survey-results.tsx | 292 + dashboard/src/components/theme-provider.tsx | 97 + dashboard/src/components/tour/index.ts | 5 + dashboard/src/components/tour/tour-context.ts | 4 + .../src/components/tour/tour-provider.tsx | 177 + .../src/components/tour/tour-renderer.tsx | 211 + .../tour/tours/model-assignment-tour.ts | 202 + dashboard/src/components/tour/types.ts | 49 + dashboard/src/components/tour/use-tour.ts | 10 + dashboard/src/components/ui/accordion.tsx | 58 + dashboard/src/components/ui/alert-dialog.tsx | 141 + dashboard/src/components/ui/alert.tsx | 60 + dashboard/src/components/ui/announcer.tsx | 83 + dashboard/src/components/ui/avatar.tsx | 48 + dashboard/src/components/ui/badge.tsx | 37 + dashboard/src/components/ui/button.tsx | 58 + dashboard/src/components/ui/calendar.tsx | 211 + .../components/ui/card-with-background.tsx | 29 + dashboard/src/components/ui/card.tsx | 76 + dashboard/src/components/ui/chart.tsx | 378 + dashboard/src/components/ui/checkbox.tsx | 27 + dashboard/src/components/ui/collapsible.tsx | 11 + dashboard/src/components/ui/command.tsx | 152 + dashboard/src/components/ui/context-menu.tsx | 197 + .../components/ui/dialog-with-background.tsx | 29 + dashboard/src/components/ui/dialog.tsx | 183 + dashboard/src/components/ui/dropdown-menu.tsx | 200 + .../src/components/ui/extra-params-dialog.tsx | 82 + dashboard/src/components/ui/help-tooltip.tsx | 63 + dashboard/src/components/ui/input.tsx | 22 + dashboard/src/components/ui/kbd.tsx | 64 + .../src/components/ui/key-value-editor.tsx | 180 + dashboard/src/components/ui/label.tsx | 24 + dashboard/src/components/ui/markdown.tsx | 28 + dashboard/src/components/ui/multi-select.tsx | 259 + .../components/ui/nested-key-value-editor.tsx | 486 + dashboard/src/components/ui/pagination.tsx | 118 + dashboard/src/components/ui/popover.tsx | 31 + dashboard/src/components/ui/progress.tsx | 26 + dashboard/src/components/ui/radio-group.tsx | 41 + dashboard/src/components/ui/scroll-area.tsx | 57 + dashboard/src/components/ui/select.tsx | 158 + dashboard/src/components/ui/separator.tsx | 29 + dashboard/src/components/ui/skeleton.tsx | 15 + dashboard/src/components/ui/skip-nav.tsx | 40 + dashboard/src/components/ui/slider.tsx | 26 + dashboard/src/components/ui/switch.tsx | 27 + dashboard/src/components/ui/table.tsx | 120 + dashboard/src/components/ui/tabs.tsx | 55 + dashboard/src/components/ui/textarea.tsx | 110 + dashboard/src/components/ui/toast.tsx | 143 + dashboard/src/components/ui/toaster.tsx | 35 + dashboard/src/components/ui/tooltip.tsx | 30 + .../src/components/ui/zoomable-chart.tsx | 92 + dashboard/src/components/use-theme.tsx | 47 + dashboard/src/components/waves-background.tsx | 382 + dashboard/src/config/surveys/index.ts | 2 + .../src/config/surveys/maibot-feedback.ts | 103 + .../src/config/surveys/webui-feedback.ts | 107 + dashboard/src/hooks/use-animation.ts | 12 + dashboard/src/hooks/use-auth.ts | 68 + dashboard/src/hooks/use-background.ts | 40 + dashboard/src/hooks/use-media-query.ts | 35 + dashboard/src/hooks/use-toast.ts | 192 + dashboard/src/hooks/useBackendConnections.ts | 62 + dashboard/src/hooks/useWindowControls.ts | 30 + dashboard/src/i18n/index.ts | 37 + dashboard/src/i18n/locales/en.json | 899 + dashboard/src/i18n/locales/ja.json | 899 + dashboard/src/i18n/locales/ko.json | 899 + dashboard/src/i18n/locales/zh.json | 899 + dashboard/src/index.css | 569 + .../src/lib/__tests__/field-hooks.test.ts | 253 + dashboard/src/lib/adapter-config-api.ts | 95 + dashboard/src/lib/animation-context.ts | 10 + dashboard/src/lib/api-base.ts | 120 + dashboard/src/lib/api-helpers.ts | 55 + dashboard/src/lib/api.ts | 19 + dashboard/src/lib/asset-store.ts | 111 + dashboard/src/lib/chat-ws-client.ts | 226 + dashboard/src/lib/config-api.ts | 196 + dashboard/src/lib/config-label.ts | 51 + dashboard/src/lib/emoji-api.ts | 284 + dashboard/src/lib/expression-api.ts | 543 + dashboard/src/lib/fetch-with-auth.ts | 97 + dashboard/src/lib/field-hooks.ts | 109 + dashboard/src/lib/jargon-api.ts | 188 + dashboard/src/lib/keyboard.ts | 93 + dashboard/src/lib/knowledge-api.ts | 78 + dashboard/src/lib/log-stream.ts | 0 dashboard/src/lib/log-websocket.ts | 160 + dashboard/src/lib/maisaka-monitor-client.ts | 323 + dashboard/src/lib/memory-api.ts | 1254 ++ dashboard/src/lib/memory-progress-client.ts | 99 + dashboard/src/lib/pack-api.ts | 571 + dashboard/src/lib/person-api.ts | 330 + dashboard/src/lib/planner-api.ts | 201 + dashboard/src/lib/plugin-api/config.ts | 150 + dashboard/src/lib/plugin-api/index.ts | 5 + dashboard/src/lib/plugin-api/install-flow.ts | 50 + dashboard/src/lib/plugin-api/installed.ts | 62 + dashboard/src/lib/plugin-api/marketplace.ts | 245 + dashboard/src/lib/plugin-api/types.ts | 169 + dashboard/src/lib/plugin-progress-client.ts | 58 + dashboard/src/lib/plugin-stats.ts | 244 + dashboard/src/lib/prompt-api.ts | 74 + dashboard/src/lib/reasoning-process-api.ts | 68 + dashboard/src/lib/restart-context.tsx | 350 + dashboard/src/lib/runtime.ts | 77 + dashboard/src/lib/settings-manager.ts | 281 + dashboard/src/lib/survey-api.ts | 176 + dashboard/src/lib/system-api.ts | 151 + dashboard/src/lib/theme-context.ts | 30 + dashboard/src/lib/theme/palette.ts | 235 + dashboard/src/lib/theme/pipeline.ts | 194 + dashboard/src/lib/theme/presets.ts | 62 + dashboard/src/lib/theme/sanitizer.ts | 111 + dashboard/src/lib/theme/storage.ts | 226 + dashboard/src/lib/theme/tokens.ts | 401 + dashboard/src/lib/token-validator.ts | 82 + dashboard/src/lib/unified-ws.ts | 530 + dashboard/src/lib/utils.ts | 6 + dashboard/src/lib/version.ts | 26 + dashboard/src/main.tsx | 47 + dashboard/src/router.tsx | 335 + dashboard/src/routes/404.tsx | 61 + .../routes/__tests__/plugin-config.test.tsx | 80 + dashboard/src/routes/auth.tsx | 454 + dashboard/src/routes/chat/ChatComposer.tsx | 72 + dashboard/src/routes/chat/ChatHeaderBar.tsx | 124 + .../src/routes/chat/ChatScrollContext.tsx | 14 + dashboard/src/routes/chat/ChatTabBar.tsx | 87 + .../src/routes/chat/ChatWorkspaceSidebar.tsx | 263 + dashboard/src/routes/chat/MessageList.tsx | 255 + dashboard/src/routes/chat/MessageRenderer.tsx | 234 + .../src/routes/chat/VirtualIdentityDialog.tsx | 224 + dashboard/src/routes/chat/index.tsx | 756 + dashboard/src/routes/chat/types.ts | 138 + dashboard/src/routes/chat/utils.ts | 50 + .../src/routes/config/adapter-disabled.tsx | 60 + dashboard/src/routes/config/adapter.tsx | 1364 ++ dashboard/src/routes/config/adapter/index.ts | 10 + dashboard/src/routes/config/adapter/types.ts | 105 + dashboard/src/routes/config/adapter/utils.ts | 285 + dashboard/src/routes/config/bot.tsx | 1098 + .../config/bot/hooks/BotInfoSectionHook.tsx | 17 + .../config/bot/hooks/ChatSectionHook.tsx | 617 + .../config/bot/hooks/DebugSectionHook.tsx | 17 + .../bot/hooks/ExpressionSectionHook.tsx | 17 + .../config/bot/hooks/JsonFieldHookFactory.tsx | 104 + .../bot/hooks/ListItemEditorHookFactory.tsx | 431 + .../bot/hooks/PersonalitySectionHook.tsx | 17 + .../config/bot/hooks/complexFieldHooks.tsx | 756 + .../src/routes/config/bot/hooks/index.ts | 32 + .../routes/config/bot/hooks/useAutoSave.ts | 310 + dashboard/src/routes/config/bot/index.ts | 24 + .../config/bot/sections/BotInfoSection.tsx | 194 + .../config/bot/sections/DebugSection.tsx | 97 + .../config/bot/sections/DreamSection.tsx | 215 + .../config/bot/sections/ExpressionSection.tsx | 1016 + .../config/bot/sections/LPMMSection.tsx | 150 + .../routes/config/bot/sections/LogSection.tsx | 264 + .../bot/sections/MaimMessageSection.tsx | 203 + .../bot/sections/MessageReceiveSection.tsx | 259 + .../bot/sections/PersonalitySection.tsx | 164 + .../config/bot/sections/ProcessingSection.tsx | 1122 + .../config/bot/sections/TelemetrySection.tsx | 29 + .../config/bot/sections/WebUISection.tsx | 287 + .../src/routes/config/bot/sections/index.ts | 16 + dashboard/src/routes/config/bot/types.ts | 254 + dashboard/src/routes/config/model.tsx | 2310 ++ .../config/model/components/ModelCardList.tsx | 117 + .../config/model/components/ModelTable.tsx | 156 + .../config/model/components/Pagination.tsx | 142 + .../model/components/TaskConfigCard.tsx | 204 + .../routes/config/model/components/index.ts | 8 + .../src/routes/config/model/constants.ts | 111 + .../src/routes/config/model/hooks/index.ts | 7 + .../config/model/hooks/useModelAutoSave.ts | 207 + .../config/model/hooks/useModelFetcher.ts | 147 + .../routes/config/model/hooks/useModelTour.ts | 191 + dashboard/src/routes/config/model/index.ts | 15 + dashboard/src/routes/config/model/types.ts | 59 + .../config/modelProvider/ProviderCard.tsx | 136 + .../config/modelProvider/ProviderForm.tsx | 479 + .../config/modelProvider/ProviderList.tsx | 362 + .../src/routes/config/modelProvider/index.ts | 2 + .../src/routes/config/modelProvider/index.tsx | 1022 + .../src/routes/config/modelProvider/types.ts | 33 + .../src/routes/config/modelProvider/utils.ts | 61 + dashboard/src/routes/config/pack-detail.tsx | 932 + dashboard/src/routes/config/pack-market.tsx | 422 + dashboard/src/routes/config/prompts.tsx | 427 + .../src/routes/config/providerTemplates.ts | 235 + dashboard/src/routes/index.tsx | 1278 ++ dashboard/src/routes/logs.tsx | 640 + dashboard/src/routes/mcp-settings.tsx | 670 + dashboard/src/routes/model-presets.tsx | 68 + dashboard/src/routes/monitor/index.tsx | 27 + .../src/routes/monitor/maisaka-monitor.tsx | 981 + .../src/routes/monitor/planner-monitor.tsx | 642 + .../src/routes/monitor/replier-monitor.tsx | 654 + .../src/routes/monitor/use-maisaka-monitor.ts | 613 + dashboard/src/routes/monitor/use-monitor.ts | 78 + dashboard/src/routes/person.tsx | 994 + dashboard/src/routes/plugin-config.tsx | 1018 + dashboard/src/routes/plugin-detail.tsx | 766 + dashboard/src/routes/plugin-mirrors.tsx | 603 + dashboard/src/routes/reasoning-process.tsx | 431 + .../__tests__/knowledge-base.test.tsx | 802 + .../__tests__/knowledge-graph.test.tsx | 440 + .../routes/resource/emoji/EmojiDialogs.tsx | 939 + .../src/routes/resource/emoji/EmojiList.tsx | 305 + dashboard/src/routes/resource/emoji/index.ts | 1 + dashboard/src/routes/resource/emoji/index.tsx | 651 + dashboard/src/routes/resource/emoji/types.ts | 13 + .../resource/expression/ExpressionDialogs.tsx | 568 + .../resource/expression/ExpressionList.tsx | 361 + .../src/routes/resource/expression/index.ts | 1 + .../src/routes/resource/expression/index.tsx | 471 + .../src/routes/resource/expression/types.ts | 47 + .../routes/resource/jargon/JargonDialogs.tsx | 531 + .../src/routes/resource/jargon/JargonList.tsx | 255 + dashboard/src/routes/resource/jargon/index.ts | 1 + .../src/routes/resource/jargon/index.tsx | 460 + dashboard/src/routes/resource/jargon/types.ts | 17 + .../src/routes/resource/knowledge-base.tsx | 2265 ++ .../resource/knowledge-base/constants.ts | 52 + .../knowledge-base/tabs/DeleteTab.tsx | 492 + .../knowledge-base/tabs/FeedbackTab.tsx | 512 + .../knowledge-base/tabs/ImportTab.tsx | 1298 ++ .../knowledge-base/tabs/TuningTab.tsx | 193 + .../routes/resource/knowledge-base/utils.ts | 503 + .../resource/knowledge-graph/GraphDialogs.tsx | 457 + .../knowledge-graph/GraphVisualization.tsx | 260 + .../routes/resource/knowledge-graph/index.ts | 1 + .../routes/resource/knowledge-graph/index.tsx | 967 + .../routes/resource/knowledge-graph/types.ts | 48 + dashboard/src/routes/settings/AboutTab.tsx | 256 + .../src/routes/settings/AppearanceTab.tsx | 972 + dashboard/src/routes/settings/LibraryItem.tsx | 15 + .../src/routes/settings/LocalCacheTab.tsx | 313 + dashboard/src/routes/settings/OtherTab.tsx | 512 + dashboard/src/routes/settings/SecurityTab.tsx | 487 + dashboard/src/routes/settings/ThemeOption.tsx | 51 + dashboard/src/routes/settings/index.tsx | 74 + dashboard/src/routes/settings/types.ts | 51 + dashboard/src/routes/setup/StepForms.tsx | 451 + dashboard/src/routes/setup/api.ts | 326 + dashboard/src/routes/setup/index.tsx | 649 + dashboard/src/routes/setup/types.ts | 42 + dashboard/src/routes/survey/index.ts | 2 + .../src/routes/survey/maibot-feedback.tsx | 110 + .../src/routes/survey/webui-feedback.tsx | 98 + dashboard/src/styles/uppy-custom.css | 159 + dashboard/src/test/setup.ts | 22 + dashboard/src/types/api.ts | 8 + dashboard/src/types/config-schema.ts | 78 + dashboard/src/types/css-modules.d.ts | 1 + dashboard/src/types/electron.d.ts | 95 + dashboard/src/types/emoji.ts | 93 + dashboard/src/types/expression.ts | 175 + dashboard/src/types/jargon.ts | 131 + dashboard/src/types/person.ts | 95 + dashboard/src/types/plugin.ts | 169 + dashboard/src/types/survey.ts | 120 + dashboard/src/types/view-transitions.d.ts | 10 + dashboard/tailwind.config.js | 2 + dashboard/tsconfig.app.json | 37 + dashboard/tsconfig.electron.json | 36 + dashboard/tsconfig.json | 9 + dashboard/tsconfig.node.json | 26 + dashboard/tsconfig.vitest.json | 7 + dashboard/vite.config.ts | 129 + dashboard/vitest.config.ts | 18 + depends-data/char_frequency.json | 12012 +++++++++++ deploy/server-maibot/Dockerfile.offline | 21 + deploy/server-maibot/README_DEPLOY_STEPS.txt | 31 + deploy/server-maibot/activate-release.sh | 79 + .../server-maibot/bot.lecspace.com.nginx.conf | 37 + .../server-maibot/docker-compose.server.yml | 51 + .../docker-entrypoint.offline.sh | 40 + docker-compose.yml | 100 + docker-entrypoint.sh | 15 + dummy | 10 + locales/en-US/config.json | 39 + locales/en-US/core.json | 8 + locales/en-US/prompts.json | 8 + locales/en-US/startup.json | 97 + locales/ja-JP/config.json | 39 + locales/ja-JP/core.json | 8 + locales/ja-JP/prompts.json | 8 + locales/ja-JP/startup.json | 97 + locales/ko/config.json | 39 + locales/ko/core.json | 8 + locales/ko/prompts.json | 8 + locales/ko/startup.json | 97 + locales/zh-CN/config.json | 39 + locales/zh-CN/core.json | 8 + locales/zh-CN/prompts.json | 8 + locales/zh-CN/startup.json | 97 + .../.devcontainer/devcontainer.json | 21 + .../.github/workflows/docker-image.yml | 54 + .../MaiBot-Napcat-Adapter/__init__.py | 1 + .../MaiBot-Napcat-Adapter/_manifest.json | 35 + .../MaiBot-Napcat-Adapter/apis/__init__.py | 18 + .../MaiBot-Napcat-Adapter/apis/account.py | 366 + .../MaiBot-Napcat-Adapter/apis/file.py | 535 + .../MaiBot-Napcat-Adapter/apis/group.py | 593 + .../MaiBot-Napcat-Adapter/apis/message.py | 431 + .../apis/message_tool_patch.py | 69 + .../MaiBot-Napcat-Adapter/apis/support.py | 275 + .../MaiBot-Napcat-Adapter/apis/system.py | 290 + .../MaiBot-Napcat-Adapter/codecs/__init__.py | 1 + .../codecs/inbound/__init__.py | 5 + .../codecs/inbound/cards.py | 545 + .../codecs/inbound/message_codec.py | 661 + .../codecs/inbound/text.py | 90 + .../codecs/notice/__init__.py | 5 + .../codecs/notice/enricher.py | 72 + .../codecs/notice/helpers.py | 83 + .../codecs/notice/message_codec.py | 120 + .../codecs/notice/meta_event_logger.py | 49 + .../codecs/notice/renderer.py | 63 + .../codecs/outbound/__init__.py | 5 + .../codecs/outbound/message_codec.py | 63 + .../codecs/outbound/segment_encoder.py | 500 + .../MaiBot-Napcat-Adapter/config.py | 631 + .../MaiBot-Napcat-Adapter/constants.py | 10 + .../MaiBot-Napcat-Adapter/docs/README.md | 90 + .../MaiBot-Napcat-Adapter/docs/account-api.md | 59 + .../MaiBot-Napcat-Adapter/docs/file-api.md | 83 + .../MaiBot-Napcat-Adapter/docs/group-api.md | 70 + .../MaiBot-Napcat-Adapter/docs/message-api.md | 61 + .../MaiBot-Napcat-Adapter/docs/system-api.md | 54 + .../MaiBot-Napcat-Adapter/docs/typed-api.md | 83 + .../docs/verification.md | 55 + .../MaiBot-Napcat-Adapter/filters.py | 82 + .../heartbeat_monitor.py | 148 + .../MaiBot-Napcat-Adapter/plugin.py | 244 + .../MaiBot-Napcat-Adapter/qq_emoji_list.py | 226 + .../MaiBot-Napcat-Adapter/runtime/__init__.py | 7 + .../MaiBot-Napcat-Adapter/runtime/builder.py | 102 + .../MaiBot-Napcat-Adapter/runtime/bundle.py | 38 + .../MaiBot-Napcat-Adapter/runtime/router.py | 319 + .../MaiBot-Napcat-Adapter/runtime_state.py | 118 + .../services/__init__.py | 16 + .../services/action_service.py | 119 + .../services/ban_state_store.py | 168 + .../services/ban_tracker.py | 176 + .../services/official_bot_guard.py | 59 + .../services/query_service.py | 545 + .../MaiBot-Napcat-Adapter/transport.py | 449 + .../MaiBot-Napcat-Adapter/types.py | 37 + prompts/en-US/.meta.toml | 79 + prompts/en-US/default_expressor.prompt | 16 + prompts/en-US/emoji_content_analysis.prompt | 5 + prompts/en-US/emoji_content_filtration.prompt | 6 + prompts/en-US/emoji_replace.prompt | 12 + prompts/en-US/expression_evaluation.prompt | 15 + prompts/en-US/expression_select.prompt | 22 + prompts/en-US/image_description.prompt | 1 + prompts/en-US/jargon_compare_inference.prompt | 15 + .../en-US/jargon_explainer_summarize.prompt | 11 + .../jargon_inference_content_only.prompt | 11 + .../jargon_inference_with_context.prompt | 19 + prompts/en-US/learn_style.prompt | 49 + prompts/en-US/maisaka_chat.prompt | 45 + prompts/en-US/maisaka_replyer.prompt | 11 + prompts/en-US/maisaka_timing_gate.prompt | 25 + ..._retrieval_react_prompt_head_memory.prompt | 34 + prompts/ja-JP/.meta.toml | 79 + prompts/ja-JP/default_expressor.prompt | 16 + prompts/ja-JP/emoji_content_analysis.prompt | 5 + prompts/ja-JP/emoji_content_filtration.prompt | 6 + prompts/ja-JP/emoji_replace.prompt | 12 + prompts/ja-JP/expression_evaluation.prompt | 15 + prompts/ja-JP/expression_select.prompt | 22 + prompts/ja-JP/image_description.prompt | 1 + prompts/ja-JP/jargon_compare_inference.prompt | 15 + .../ja-JP/jargon_explainer_summarize.prompt | 11 + .../jargon_inference_content_only.prompt | 11 + .../jargon_inference_with_context.prompt | 19 + prompts/ja-JP/learn_style.prompt | 49 + prompts/ja-JP/maisaka_chat.prompt | 44 + prompts/ja-JP/maisaka_replyer.prompt | 11 + prompts/ja-JP/maisaka_timing_gate.prompt | 25 + ..._retrieval_react_prompt_head_memory.prompt | 34 + prompts/zh-CN/.meta.toml | 74 + prompts/zh-CN/default_expressor.prompt | 16 + prompts/zh-CN/emoji_content_analysis.prompt | 5 + prompts/zh-CN/emoji_content_filtration.prompt | 6 + prompts/zh-CN/emoji_replace.prompt | 12 + prompts/zh-CN/expression_evaluation.prompt | 15 + prompts/zh-CN/expression_select.prompt | 22 + prompts/zh-CN/image_description.prompt | 1 + prompts/zh-CN/jargon_compare_inference.prompt | 15 + .../zh-CN/jargon_explainer_summarize.prompt | 11 + .../jargon_inference_content_only.prompt | 11 + .../jargon_inference_with_context.prompt | 19 + prompts/zh-CN/learn_style.prompt | 49 + prompts/zh-CN/maisaka_chat.prompt | 38 + prompts/zh-CN/maisaka_replyer.prompt | 7 + prompts/zh-CN/maisaka_timing_gate.prompt | 25 + pyproject.toml | 102 + ...test_chat_summary_writeback_integration.py | 398 + .../test_embedding_dimension_control.py | 191 + .../test_feedback_correction_chat_flow.py | 780 + .../test_feedback_correction_core.py | 396 + .../test_graph_store_persistence.py | 82 + .../test_group_chat_stream_fixture_schema.py | 86 + .../A_memorix_test/test_knowledge_fetcher.py | 124 + .../test_memory_flow_service.py | 355 + .../test_memory_graph_search_kernel.py | 113 + pytests/A_memorix_test/test_memory_service.py | 281 + .../test_metadata_store_sources.py | 21 + .../test_person_memory_writeback.py | 81 + .../test_person_profile_service.py | 115 + .../test_query_long_term_memory_tool.py | 184 + .../test_summary_importer_model_config.py | 140 + .../test_web_import_manager_payloads.py | 182 + pytests/common_test/test_chat_config_utils.py | 89 + .../test_database_migration_foundation.py | 908 + .../common_test/test_expression_learner.py | 81 + pytests/common_test/test_expression_schema.py | 78 + pytests/common_test/test_jargon_miner.py | 90 + pytests/common_test/test_jargon_schema.py | 84 + .../test_maisaka_expression_selector.py | 135 + .../test_person_info_group_cardname.py | 355 + pytests/config_test/test_config_base.py | 533 + .../test_config_manager_hot_reload.py | 104 + .../test_config_manager_startup_upgrade.py | 22 + pytests/config_test/test_file_watcher.py | 138 + .../test_llm_request_hot_reload.py | 76 + .../test_model_info_normalization.py | 11 + pytests/config_test/test_startup_bindings.py | 104 + pytests/conftest.py | 10 + pytests/i18n_test/test_i18n.py | 66 + pytests/i18n_test/test_i18n_validate.py | 110 + pytests/image_sys_test/emoji_manager_test.py | 2637 +++ pytests/image_sys_test/image_manager_test.py | 295 + .../image_sys_test/test_image_data_model.py | 91 + pytests/logger.py | 22 + pytests/message_test/session_message_test.py | 422 + pytests/prompt_test/test_prompt_i18n.py | 220 + pytests/prompt_test/test_prompt_manager.py | 893 + pytests/test_context_message_fallback.py | 73 + pytests/test_gemini_thought_signatures.py | 72 + pytests/test_html_render_service.py | 194 + pytests/test_llm_provider_registry.py | 101 + pytests/test_maisaka_builtin_context.py | 113 + pytests/test_maisaka_builtin_query_memory.py | 241 + pytests/test_maisaka_memory_retention.py | 105 + pytests/test_maisaka_message_adapter.py | 54 + pytests/test_maisaka_monitor_protocol.py | 619 + pytests/test_maisaka_timing_gate.py | 339 + pytests/test_message_gateway_runtime.py | 170 + pytests/test_napcat_adapter_sdk.py | 879 + .../test_openai_client_toolless_request.py | 164 + pytests/test_platform_io_dedupe.py | 209 + pytests/test_platform_io_legacy_driver.py | 178 + pytests/test_plugin_config_runtime.py | 553 + pytests/test_plugin_dependency_pipeline.py | 225 + pytests/test_plugin_message_utils_runtime.py | 86 + pytests/test_plugin_runtime.py | 3593 ++++ pytests/test_plugin_runtime_action_bridge.py | 284 + pytests/test_plugin_runtime_api.py | 524 + pytests/test_plugin_runtime_render.py | 96 + pytests/test_prompt_message_roundtrip.py | 18 + pytests/test_runtime_business_hooks.py | 136 + pytests/test_send_service.py | 344 + pytests/test_tool_availability.py | 297 + pytests/utils_test/message_utils_test.py | 367 + pytests/utils_test/statistic_test.py | 117 + pytests/utils_test/test_request_snapshot.py | 131 + pytests/utils_test/test_session_utils.py | 42 + pytests/webui/__init__.py | 0 pytests/webui/test_app.py | 161 + pytests/webui/test_config_schema.py | 147 + pytests/webui/test_emoji_routes.py | 461 + pytests/webui/test_expression_routes.py | 529 + pytests/webui/test_jargon_routes.py | 512 + pytests/webui/test_memory_routes.py | 870 + .../webui/test_memory_routes_integration.py | 533 + pytests/webui/test_model_routes.py | 187 + .../webui/test_plugin_management_routes.py | 136 + pytests/webui/test_statistics_service.py | 332 + pytests/webui/test_system_routes.py | 13 + requirements.txt | 36 + saka.py | 45 + .../analyze_reply_effect_score_correlation.py | 336 + scripts/analyze_tool_usage_by_chat.py | 323 + scripts/build_io_pairs.py | 380 + .../evaluate_expressions_count_analysis.py | 553 + scripts/evaluate_expressions_llm_v6.py | 535 + scripts/evaluate_expressions_manual.py | 275 + scripts/i18n_extract_candidates.py | 81 + scripts/i18n_validate.py | 411 + scripts/make_scripts/generate_requirements.py | 40 + scripts/mmipkg_tool.py | 1132 + scripts/preview_reply_effect_scores.py | 2532 +++ scripts/run.sh | 973 + scripts/run_a_memorix_webui_backend.py | 25 + scripts/run_lpmm.sh | 51 + scripts/sync_a_memorix_subtree.sh | 21 + scripts/test_memory_retrieval.py | 459 + scripts/test_model_tool_call_params.py | 845 + scripts/test_tool_call_api_matrix.py | 777 + scripts/verify_a_memorix_webui.sh | 83 + src/A_memorix/CHANGELOG.md | 728 + src/A_memorix/CONFIG_REFERENCE.md | 384 + src/A_memorix/IMPORT_GUIDE.md | 368 + src/A_memorix/LICENSE | 661 + src/A_memorix/LICENSE-MAIBOT-GPL.md | 22 + src/A_memorix/MODIFICATION_POLICY.md | 101 + src/A_memorix/QUICK_START.md | 315 + src/A_memorix/README.md | 272 + src/A_memorix/RELEASE_SUMMARY_1.0.0.md | 46 + src/A_memorix/__init__.py | 5 + src/A_memorix/config_schema.json | 1384 ++ src/A_memorix/core/__init__.py | 84 + src/A_memorix/core/embedding/__init__.py | 18 + src/A_memorix/core/embedding/api_adapter.py | 490 + src/A_memorix/core/embedding/manager.py | 510 + src/A_memorix/core/embedding/presets.py | 72 + src/A_memorix/core/retrieval/__init__.py | 56 + src/A_memorix/core/retrieval/dual_path.py | 2035 ++ .../core/retrieval/graph_relation_recall.py | 272 + src/A_memorix/core/retrieval/pagerank.py | 482 + .../core/retrieval/posterior_graph.py | 792 + src/A_memorix/core/retrieval/sparse_bm25.py | 409 + src/A_memorix/core/retrieval/threshold.py | 450 + src/A_memorix/core/runtime/__init__.py | 26 + .../core/runtime/lifecycle_orchestrator.py | 266 + .../core/runtime/sdk_memory_kernel.py | 6321 ++++++ .../runtime/search_runtime_initializer.py | 251 + src/A_memorix/core/storage/__init__.py | 53 + src/A_memorix/core/storage/graph_store.py | 1470 ++ src/A_memorix/core/storage/knowledge_types.py | 183 + src/A_memorix/core/storage/metadata_store.py | 7194 +++++++ src/A_memorix/core/storage/type_detection.py | 137 + src/A_memorix/core/storage/vector_store.py | 777 + src/A_memorix/core/strategies/__init__.py | 0 src/A_memorix/core/strategies/base.py | 89 + src/A_memorix/core/strategies/factual.py | 98 + src/A_memorix/core/strategies/narrative.py | 126 + src/A_memorix/core/strategies/quote.py | 52 + src/A_memorix/core/utils/__init__.py | 33 + .../core/utils/aggregate_query_service.py | 360 + .../core/utils/episode_retrieval_service.py | 182 + .../utils/episode_segmentation_service.py | 311 + src/A_memorix/core/utils/episode_service.py | 563 + src/A_memorix/core/utils/hash.py | 129 + src/A_memorix/core/utils/import_payloads.py | 196 + src/A_memorix/core/utils/io.py | 84 + src/A_memorix/core/utils/matcher.py | 89 + src/A_memorix/core/utils/metadata.py | 11 + src/A_memorix/core/utils/monitor.py | 189 + .../core/utils/path_fallback_service.py | 164 + .../core/utils/person_profile_service.py | 782 + src/A_memorix/core/utils/plugin_id_policy.py | 27 + src/A_memorix/core/utils/quantization.py | 344 + src/A_memorix/core/utils/relation_query.py | 121 + .../core/utils/relation_write_service.py | 166 + .../core/utils/retrieval_tuning_manager.py | 1865 ++ .../core/utils/runtime_self_check.py | 240 + .../core/utils/search_execution_service.py | 444 + .../core/utils/search_postprocess.py | 90 + src/A_memorix/core/utils/summary_importer.py | 573 + src/A_memorix/core/utils/time_parser.py | 170 + .../core/utils/web_import_manager.py | 3772 ++++ src/A_memorix/host_service.py | 466 + src/A_memorix/paths.py | 56 + src/A_memorix/plugin.py | 290 + src/A_memorix/requirements.txt | 52 + src/A_memorix/runtime_registry.py | 27 + src/A_memorix/scripts/_bootstrap.py | 22 + .../scripts/audit_vector_consistency.py | 208 + .../scripts/backfill_relation_vectors.py | 265 + .../scripts/backfill_temporal_metadata.py | 65 + src/A_memorix/scripts/convert_lpmm.py | 530 + src/A_memorix/scripts/import_lpmm_json.py | 165 + src/A_memorix/scripts/migrate_chat_history.py | 99 + .../scripts/migrate_maibot_memory.py | 1743 ++ .../scripts/migrate_person_memory_points.py | 109 + src/A_memorix/scripts/process_knowledge.py | 828 + src/A_memorix/scripts/rebuild_episodes.py | 119 + .../scripts/release_vnext_migrate.py | 847 + src/A_memorix/scripts/runtime_self_check.py | 144 + src/__init__.py | 14 + src/chat/__init__.py | 13 + src/chat/event_helpers.py | 169 + src/chat/heart_flow/heartFC_utils.py | 29 + src/chat/heart_flow/heartflow_manager.py | 105 + .../heart_flow/heartflow_message_processor.py | 96 + src/chat/image_system/image_manager.py | 433 + src/chat/knowledge/LICENSE | 674 + src/chat/knowledge/embedding_store.py | 675 + src/chat/knowledge/global_logger.py | 5 + src/chat/knowledge/ie_process.py | 280 + src/chat/knowledge/kg_manager.py | 589 + src/chat/knowledge/mem_active_manager.py | 33 + src/chat/knowledge/open_ie.py | 154 + src/chat/knowledge/prompt_template.py | 70 + src/chat/knowledge/qa_manager.py | 141 + src/chat/knowledge/utils/__init__.py | 0 src/chat/knowledge/utils/dyn_topk.py | 51 + src/chat/knowledge/utils/hash.py | 8 + src/chat/knowledge/utils/json_fix.py | 98 + src/chat/message_receive/__init__.py | 5 + src/chat/message_receive/bot.py | 659 + src/chat/message_receive/chat_manager.py | 279 + src/chat/message_receive/message.py | 467 + .../message_receive/uni_message_sender.py | 370 + .../replyer/maisaka_expression_selector.py | 288 + src/chat/replyer/maisaka_generator.py | 29 + src/chat/replyer/maisaka_generator_base.py | 708 + src/chat/replyer/replyer_manager.py | 69 + src/chat/utils/common_utils.py | 55 + src/chat/utils/prompt_builder.py | 282 + src/chat/utils/statistic.py | 2495 +++ src/chat/utils/timer_calculator.py | 158 + src/chat/utils/typo_generator.py | 477 + src/chat/utils/utils.py | 912 + src/cli/__init__.py | 3 + src/cli/console.py | 17 + src/cli/input_reader.py | 56 + src/cli/maisaka_cli.py | 120 + src/cli/maisaka_cli_sender.py | 27 + src/common/__init__.py | 1 + src/common/data_models/__init__.py | 28 + .../data_models/action_record_data_model.py | 65 + .../data_models/chat_session_data_model.py | 53 + .../chat_target_info_data_model.py | 73 + .../embedding_service_data_models.py | 19 + .../data_models/expression_data_model.py | 88 + src/common/data_models/image_data_model.py | 247 + src/common/data_models/jargon_data_model.py | 95 + src/common/data_models/llm_data_model.py | 23 + .../data_models/llm_service_data_models.py | 197 + .../data_models/mai_message_data_model.py | 209 + .../message_component_data_model.py | 428 + .../data_models/person_info_data_model.py | 177 + .../data_models/planned_action_data_models.py | 100 + .../reply_generation_data_models.py | 193 + .../data_models/tool_record_data_model.py | 59 + src/common/database/__init__.py | 0 src/common/database/database.py | 167 + src/common/database/database_model.py | 306 + src/common/database/migrations/__init__.py | 81 + src/common/database/migrations/bootstrap.py | 171 + src/common/database/migrations/builtin.py | 267 + src/common/database/migrations/exceptions.py | 33 + .../database/migrations/frozen_v2_schema.py | 282 + .../database/migrations/legacy_v1_to_v2.py | 1485 ++ src/common/database/migrations/manager.py | 205 + src/common/database/migrations/models.py | 305 + src/common/database/migrations/planner.py | 108 + src/common/database/migrations/progress.py | 319 + src/common/database/migrations/registry.py | 98 + src/common/database/migrations/resolver.py | 135 + src/common/database/migrations/schema.py | 98 + src/common/database/migrations/v2_to_v3.py | 269 + src/common/database/migrations/v3_to_v4.py | 155 + .../database/migrations/version_store.py | 57 + src/common/i18n/__init__.py | 70 + src/common/i18n/exceptions.py | 18 + src/common/i18n/formatting.py | 29 + src/common/i18n/loaders.py | 161 + src/common/i18n/manager.py | 201 + src/common/logger.py | 921 + src/common/logger_color_and_mapping.py | 297 + src/common/message_repository.py | 294 + src/common/message_server/__init__.py | 8 + src/common/message_server/api.py | 190 + src/common/message_server/server.py | 130 + .../universal_message_sender.py | 216 + src/common/prompt_i18n.py | 348 + src/common/remote.py | 178 + src/common/utils/math_utils.py | 122 + src/common/utils/port_checker.py | 82 + src/common/utils/system_utils.py | 11 + src/common/utils/utils_action.py | 32 + src/common/utils/utils_config.py | 331 + src/common/utils/utils_file.py | 56 + src/common/utils/utils_image.py | 133 + src/common/utils/utils_message.py | 680 + src/common/utils/utils_person.py | 39 + src/common/utils/utils_session.py | 42 + src/common/utils/utils_voice.py | 42 + src/config/__init__.py | 0 src/config/config.py | 685 + src/config/config_base.py | 298 + src/config/config_upgrade_hooks.py | 99 + src/config/config_utils.py | 166 + src/config/default_model_config.py | 108 + src/config/file_watcher.py | 237 + src/config/legacy_migration.py | 422 + src/config/legacy_upgrade_confirmation.py | 223 + src/config/model_configs.py | 508 + src/config/official_configs.py | 3844 ++++ src/config/startup_bindings.py | 135 + src/core/__init__.py | 6 + src/core/announcement_manager.py | 120 + src/core/config_types.py | 273 + src/core/event_bus.py | 225 + src/core/tooling.py | 445 + src/core/types.py | 420 + src/emoji_system/emoji_manager.py | 1171 + src/emoji_system/maisaka_tool.py | 282 + src/learners/expression_auto_check_task.py | 153 + src/learners/expression_learner.py | 793 + src/learners/expression_review_store.py | 35 + src/learners/expression_utils.py | 289 + src/learners/jargon_explainer.py | 86 + src/learners/jargon_miner.py | 686 + src/learners/learner_utils_old.py | 445 + src/llm_models/LICENSE | 21 + src/llm_models/__init__.py | 0 src/llm_models/exceptions.py | 92 + src/llm_models/model_client/__init__.py | 28 + src/llm_models/model_client/adapter_base.py | 267 + src/llm_models/model_client/base_client.py | 466 + src/llm_models/model_client/gemini_client.py | 1238 ++ src/llm_models/model_client/openai_client.py | 1652 ++ src/llm_models/model_client/plugin_client.py | 191 + src/llm_models/openai_compat.py | 145 + src/llm_models/payload_content/__init__.py | 3 + src/llm_models/payload_content/message.py | 294 + src/llm_models/payload_content/resp_format.py | 239 + src/llm_models/payload_content/tool_option.py | 538 + src/llm_models/request_snapshot.py | 514 + src/llm_models/utils.py | 229 + src/llm_models/utils_model.py | 1140 + src/main.py | 202 + src/maisaka/builtin_tool/__init__.py | 177 + src/maisaka/builtin_tool/context.py | 289 + src/maisaka/builtin_tool/continue_tool.py | 36 + src/maisaka/builtin_tool/finish.py | 34 + src/maisaka/builtin_tool/no_reply.py | 34 + src/maisaka/builtin_tool/query_jargon.py | 143 + src/maisaka/builtin_tool/query_memory.py | 306 + src/maisaka/builtin_tool/query_person_info.py | 129 + src/maisaka/builtin_tool/reply.py | 306 + src/maisaka/builtin_tool/send_emoji.py | 526 + src/maisaka/builtin_tool/tool_search.py | 106 + .../builtin_tool/view_complex_message.py | 99 + src/maisaka/builtin_tool/wait.py | 51 + src/maisaka/chat_history_visual_refresher.py | 140 + src/maisaka/chat_loop_service.py | 786 + src/maisaka/context_messages.py | 564 + src/maisaka/display/__init__.py | 29 + src/maisaka/display/display_utils.py | 93 + src/maisaka/display/preview_path_utils.py | 58 + src/maisaka/display/prompt_cli_renderer.py | 1203 ++ src/maisaka/display/prompt_preview_logger.py | 88 + src/maisaka/display/stage_status_board.py | 125 + src/maisaka/history_post_processor.py | 99 + src/maisaka/history_utils.py | 178 + src/maisaka/message_adapter.py | 111 + src/maisaka/monitor_events.py | 589 + src/maisaka/planner_message_utils.py | 114 + src/maisaka/reasoning_engine.py | 1540 ++ src/maisaka/reply_effect/__init__.py | 5 + src/maisaka/reply_effect/image_utils.py | 100 + src/maisaka/reply_effect/judge.py | 116 + src/maisaka/reply_effect/models.py | 167 + src/maisaka/reply_effect/path_utils.py | 24 + src/maisaka/reply_effect/quote_utils.py | 32 + src/maisaka/reply_effect/scoring.py | 262 + src/maisaka/reply_effect/storage.py | 86 + src/maisaka/reply_effect/tracker.py | 273 + src/maisaka/runtime.py | 1851 ++ src/maisaka/tool_provider.py | 74 + src/maisaka/visual_mode_utils.py | 43 + src/manager/async_task_manager.py | 193 + src/manager/local_store_manager.py | 75 + src/mcp_module/__init__.py | 19 + src/mcp_module/config.py | 162 + src/mcp_module/connection.py | 608 + src/mcp_module/hooks.py | 20 + src/mcp_module/host_llm_bridge.py | 595 + src/mcp_module/manager.py | 590 + src/mcp_module/models.py | 418 + src/mcp_module/provider.py | 65 + src/person_info/__init__.py | 0 src/person_info/person_info.py | 598 + src/platform_io/__init__.py | 34 + src/platform_io/dedupe.py | 133 + src/platform_io/drivers/__init__.py | 11 + src/platform_io/drivers/base.py | 104 + src/platform_io/drivers/legacy_driver.py | 92 + src/platform_io/drivers/plugin_driver.py | 211 + src/platform_io/manager.py | 611 + src/platform_io/outbound_tracker.py | 286 + src/platform_io/registry.py | 70 + src/platform_io/route_key_factory.py | 150 + src/platform_io/routing.py | 141 + src/platform_io/types.py | 264 + src/plugin_runtime/__init__.py | 27 + src/plugin_runtime/capabilities/__init__.py | 11 + src/plugin_runtime/capabilities/components.py | 866 + src/plugin_runtime/capabilities/core.py | 403 + src/plugin_runtime/capabilities/data.py | 804 + src/plugin_runtime/capabilities/registry.py | 96 + src/plugin_runtime/capabilities/render.py | 121 + src/plugin_runtime/component_query.py | 985 + src/plugin_runtime/dependency_pipeline.py | 441 + src/plugin_runtime/hook_catalog.py | 52 + src/plugin_runtime/hook_payloads.py | 181 + src/plugin_runtime/hook_schema_utils.py | 31 + src/plugin_runtime/host/__init__.py | 1 + src/plugin_runtime/host/api_registry.py | 349 + src/plugin_runtime/host/authorization.py | 67 + src/plugin_runtime/host/capability_service.py | 91 + src/plugin_runtime/host/component_registry.py | 1276 ++ src/plugin_runtime/host/event_dispatcher.py | 184 + src/plugin_runtime/host/hook_dispatcher.py | 665 + src/plugin_runtime/host/hook_spec_registry.py | 190 + src/plugin_runtime/host/logger_bridge.py | 45 + src/plugin_runtime/host/message_gateway.py | 114 + src/plugin_runtime/host/message_utils.py | 548 + src/plugin_runtime/host/rpc_server.py | 448 + src/plugin_runtime/host/supervisor.py | 1620 ++ src/plugin_runtime/integration.py | 1522 ++ src/plugin_runtime/protocol/__init__.py | 1 + src/plugin_runtime/protocol/codec.py | 47 + src/plugin_runtime/protocol/envelope.py | 552 + src/plugin_runtime/protocol/errors.py | 77 + src/plugin_runtime/runner/__init__.py | 1 + src/plugin_runtime/runner/log_handler.py | 239 + .../runner/manifest_validator.py | 1294 ++ src/plugin_runtime/runner/plugin_loader.py | 602 + src/plugin_runtime/runner/rpc_client.py | 335 + src/plugin_runtime/runner/runner_main.py | 2092 ++ src/plugin_runtime/tool_provider.py | 58 + src/plugin_runtime/transport/__init__.py | 1 + src/plugin_runtime/transport/base.py | 116 + src/plugin_runtime/transport/factory.py | 57 + src/plugin_runtime/transport/named_pipe.py | 206 + src/plugin_runtime/transport/tcp.py | 62 + src/plugin_runtime/transport/uds.py | 133 + src/prompt/prompt_manager.py | 381 + src/services/__init__.py | 7 + src/services/database_service.py | 228 + src/services/embedding_service.py | 160 + src/services/generator_service.py | 225 + src/services/html_render_service.py | 937 + src/services/llm_cache_stats.py | 1520 ++ src/services/llm_service.py | 633 + src/services/memory_flow_service.py | 575 + src/services/memory_service.py | 478 + src/services/message_service.py | 295 + src/services/send_service.py | 1294 ++ src/services/service_task_resolver.py | 108 + src/services/statistics_service.py | 491 + src/webui/app.py | 265 + src/webui/config_schema.py | 154 + src/webui/core/__init__.py | 32 + src/webui/core/auth.py | 161 + src/webui/core/rate_limiter.py | 247 + src/webui/core/security.py | 309 + src/webui/dashboard_update.py | 286 + src/webui/dependencies.py | 70 + src/webui/logs_ws.py | 157 + src/webui/middleware/__init__.py | 17 + src/webui/middleware/anti_crawler.py | 824 + src/webui/routers/__init__.py | 33 + src/webui/routers/chat/__init__.py | 18 + src/webui/routers/chat/routes.py | 130 + src/webui/routers/chat/serializers.py | 175 + src/webui/routers/chat/service.py | 1226 ++ src/webui/routers/config.py | 917 + src/webui/routers/emoji/__init__.py | 3 + src/webui/routers/emoji/routes.py | 1014 + src/webui/routers/emoji/schemas.py | 167 + src/webui/routers/emoji/support.py | 143 + src/webui/routers/expression.py | 837 + src/webui/routers/jargon.py | 668 + src/webui/routers/knowledge.py | 397 + src/webui/routers/logs.py | 0 src/webui/routers/memory.py | 1808 ++ src/webui/routers/model.py | 428 + src/webui/routers/person.py | 421 + src/webui/routers/plugin/__init__.py | 19 + src/webui/routers/plugin/catalog.py | 210 + src/webui/routers/plugin/config_routes.py | 656 + src/webui/routers/plugin/management.py | 525 + src/webui/routers/plugin/progress.py | 151 + src/webui/routers/plugin/runtime_routes.py | 28 + src/webui/routers/plugin/schemas.py | 129 + src/webui/routers/plugin/support.py | 308 + src/webui/routers/reasoning_process.py | 236 + src/webui/routers/statistics.py | 45 + src/webui/routers/system.py | 429 + src/webui/routers/websocket/__init__.py | 7 + src/webui/routers/websocket/auth.py | 104 + src/webui/routers/websocket/manager.py | 322 + src/webui/routers/websocket/unified.py | 588 + src/webui/routes.py | 338 + src/webui/schemas/__init__.py | 109 + src/webui/schemas/auth.py | 41 + src/webui/schemas/chat.py | 27 + src/webui/schemas/emoji.py | 116 + src/webui/schemas/plugin.py | 136 + src/webui/schemas/statistics.py | 46 + src/webui/services/__init__.py | 1 + src/webui/services/git_mirror_service.py | 720 + src/webui/utils/__init__.py | 1 + src/webui/utils/network_security.py | 74 + src/webui/utils/toml_utils.py | 105 + src/webui/webui_server.py | 162 + tests/test_config_upgrade_hooks.py | 76 + uv.lock | 2826 +++ 1009 files changed, 312999 insertions(+), 16 deletions(-) create mode 100644 .coderabbit.yaml create mode 100644 .devcontainer/devcontainer.json create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .gitea/workflows/release-offline.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 Dockerfile create mode 100644 EULA.md create mode 100644 PRIVACY.md create mode 100644 bot.py create mode 100644 changelogs/changelog.md create mode 100644 code_scripts/generate_database_datamodel_py.py create mode 100644 code_scripts/migrate_expression_jargon_db.py create mode 100644 config/README.md create mode 100644 crowdin.yml create mode 100644 dashboard/.prettierrc create mode 100644 dashboard/LICENSE create mode 100644 dashboard/README.md create mode 100644 dashboard/bun.lock create mode 100644 dashboard/bunfig.toml create mode 100644 dashboard/components.json create mode 100644 dashboard/docs/Caddyfile.docker.example create mode 100644 dashboard/docs/Caddyfile.host.example create mode 100644 dashboard/docs/main.png create mode 100644 dashboard/docs/webui-tls-ssl-compose.md create mode 100644 dashboard/docs/webui-tls-ssl.md create mode 100644 dashboard/electron.vite.config.ts create mode 100644 dashboard/electron/main/index.ts create mode 100644 dashboard/electron/main/protocol.ts create mode 100644 dashboard/electron/main/store.ts create mode 100644 dashboard/electron/preload/index.ts create mode 100644 dashboard/electron/resources/.gitkeep create mode 100644 dashboard/eslint.config.js create mode 100644 dashboard/index.html create mode 100644 dashboard/package-lock.json create mode 100644 dashboard/package.json create mode 100644 dashboard/public/fonts/JetBrainsMono-Medium.ttf create mode 100644 dashboard/public/maimai.ico create mode 100644 dashboard/scripts/a_memorix_electron_validate.cjs create mode 100644 dashboard/src/assets/maimai.ico create mode 100644 dashboard/src/assets/react.svg create mode 100644 dashboard/src/components/CodeEditor.tsx create mode 100644 dashboard/src/components/CodeEditorImpl.tsx create mode 100644 dashboard/src/components/ListFieldEditor.tsx create mode 100644 dashboard/src/components/RestartingOverlay.legacy.tsx create mode 100644 dashboard/src/components/animation-provider.tsx create mode 100644 dashboard/src/components/asset-provider.tsx create mode 100644 dashboard/src/components/back-to-top.tsx create mode 100644 dashboard/src/components/background-effects-controls.tsx create mode 100644 dashboard/src/components/background-layer.tsx create mode 100644 dashboard/src/components/background-uploader.tsx create mode 100644 dashboard/src/components/component-css-editor.tsx create mode 100644 dashboard/src/components/dynamic-form/DynamicConfigForm.tsx create mode 100644 dashboard/src/components/dynamic-form/DynamicField.tsx create mode 100644 dashboard/src/components/dynamic-form/README.md create mode 100644 dashboard/src/components/dynamic-form/__tests__/DynamicConfigForm.test.tsx create mode 100644 dashboard/src/components/dynamic-form/__tests__/DynamicField.test.tsx create mode 100644 dashboard/src/components/dynamic-form/index.ts create mode 100644 dashboard/src/components/electron/BackendManager.tsx create mode 100644 dashboard/src/components/electron/BackendSetupWizard.tsx create mode 100644 dashboard/src/components/electron/TitleBar.tsx create mode 100644 dashboard/src/components/emoji-thumbnail.tsx create mode 100644 dashboard/src/components/error-boundary.tsx create mode 100644 dashboard/src/components/expression-reviewer.tsx create mode 100644 dashboard/src/components/http-warning-banner.tsx create mode 100644 dashboard/src/components/index.ts create mode 100644 dashboard/src/components/layout/Header.tsx create mode 100644 dashboard/src/components/layout/Layout.tsx create mode 100644 dashboard/src/components/layout/LogoArea.tsx create mode 100644 dashboard/src/components/layout/NavItem.tsx create mode 100644 dashboard/src/components/layout/Sidebar.tsx create mode 100644 dashboard/src/components/layout/constants.ts create mode 100644 dashboard/src/components/layout/index.ts create mode 100644 dashboard/src/components/layout/types.ts create mode 100644 dashboard/src/components/markdown-renderer.tsx create mode 100644 dashboard/src/components/memory/MemoryConfigEditor.tsx create mode 100644 dashboard/src/components/memory/MemoryDeleteDialog.tsx create mode 100644 dashboard/src/components/memory/MemoryEpisodeManager.tsx create mode 100644 dashboard/src/components/memory/MemoryMaintenanceManager.tsx create mode 100644 dashboard/src/components/memory/MemoryMiniTabs.tsx create mode 100644 dashboard/src/components/memory/MemoryProfileManager.tsx create mode 100644 dashboard/src/components/memory/MemoryProgressIndicator.tsx create mode 100644 dashboard/src/components/plugin-stats.tsx create mode 100644 dashboard/src/components/restart-overlay.tsx create mode 100644 dashboard/src/components/search-dialog.tsx create mode 100644 dashboard/src/components/share-pack-dialog.tsx create mode 100644 dashboard/src/components/survey/index.ts create mode 100644 dashboard/src/components/survey/survey-question.tsx create mode 100644 dashboard/src/components/survey/survey-renderer.tsx create mode 100644 dashboard/src/components/survey/survey-results.tsx create mode 100644 dashboard/src/components/theme-provider.tsx create mode 100644 dashboard/src/components/tour/index.ts create mode 100644 dashboard/src/components/tour/tour-context.ts create mode 100644 dashboard/src/components/tour/tour-provider.tsx create mode 100644 dashboard/src/components/tour/tour-renderer.tsx create mode 100644 dashboard/src/components/tour/tours/model-assignment-tour.ts create mode 100644 dashboard/src/components/tour/types.ts create mode 100644 dashboard/src/components/tour/use-tour.ts create mode 100644 dashboard/src/components/ui/accordion.tsx create mode 100644 dashboard/src/components/ui/alert-dialog.tsx create mode 100644 dashboard/src/components/ui/alert.tsx create mode 100644 dashboard/src/components/ui/announcer.tsx create mode 100644 dashboard/src/components/ui/avatar.tsx create mode 100644 dashboard/src/components/ui/badge.tsx create mode 100644 dashboard/src/components/ui/button.tsx create mode 100644 dashboard/src/components/ui/calendar.tsx create mode 100644 dashboard/src/components/ui/card-with-background.tsx create mode 100644 dashboard/src/components/ui/card.tsx create mode 100644 dashboard/src/components/ui/chart.tsx create mode 100644 dashboard/src/components/ui/checkbox.tsx create mode 100644 dashboard/src/components/ui/collapsible.tsx create mode 100644 dashboard/src/components/ui/command.tsx create mode 100644 dashboard/src/components/ui/context-menu.tsx create mode 100644 dashboard/src/components/ui/dialog-with-background.tsx create mode 100644 dashboard/src/components/ui/dialog.tsx create mode 100644 dashboard/src/components/ui/dropdown-menu.tsx create mode 100644 dashboard/src/components/ui/extra-params-dialog.tsx create mode 100644 dashboard/src/components/ui/help-tooltip.tsx create mode 100644 dashboard/src/components/ui/input.tsx create mode 100644 dashboard/src/components/ui/kbd.tsx create mode 100644 dashboard/src/components/ui/key-value-editor.tsx create mode 100644 dashboard/src/components/ui/label.tsx create mode 100644 dashboard/src/components/ui/markdown.tsx create mode 100644 dashboard/src/components/ui/multi-select.tsx create mode 100644 dashboard/src/components/ui/nested-key-value-editor.tsx create mode 100644 dashboard/src/components/ui/pagination.tsx create mode 100644 dashboard/src/components/ui/popover.tsx create mode 100644 dashboard/src/components/ui/progress.tsx create mode 100644 dashboard/src/components/ui/radio-group.tsx create mode 100644 dashboard/src/components/ui/scroll-area.tsx create mode 100644 dashboard/src/components/ui/select.tsx create mode 100644 dashboard/src/components/ui/separator.tsx create mode 100644 dashboard/src/components/ui/skeleton.tsx create mode 100644 dashboard/src/components/ui/skip-nav.tsx create mode 100644 dashboard/src/components/ui/slider.tsx create mode 100644 dashboard/src/components/ui/switch.tsx create mode 100644 dashboard/src/components/ui/table.tsx create mode 100644 dashboard/src/components/ui/tabs.tsx create mode 100644 dashboard/src/components/ui/textarea.tsx create mode 100644 dashboard/src/components/ui/toast.tsx create mode 100644 dashboard/src/components/ui/toaster.tsx create mode 100644 dashboard/src/components/ui/tooltip.tsx create mode 100644 dashboard/src/components/ui/zoomable-chart.tsx create mode 100644 dashboard/src/components/use-theme.tsx create mode 100644 dashboard/src/components/waves-background.tsx create mode 100644 dashboard/src/config/surveys/index.ts create mode 100644 dashboard/src/config/surveys/maibot-feedback.ts create mode 100644 dashboard/src/config/surveys/webui-feedback.ts create mode 100644 dashboard/src/hooks/use-animation.ts create mode 100644 dashboard/src/hooks/use-auth.ts create mode 100644 dashboard/src/hooks/use-background.ts create mode 100644 dashboard/src/hooks/use-media-query.ts create mode 100644 dashboard/src/hooks/use-toast.ts create mode 100644 dashboard/src/hooks/useBackendConnections.ts create mode 100644 dashboard/src/hooks/useWindowControls.ts create mode 100644 dashboard/src/i18n/index.ts create mode 100644 dashboard/src/i18n/locales/en.json create mode 100644 dashboard/src/i18n/locales/ja.json create mode 100644 dashboard/src/i18n/locales/ko.json create mode 100644 dashboard/src/i18n/locales/zh.json create mode 100644 dashboard/src/index.css create mode 100644 dashboard/src/lib/__tests__/field-hooks.test.ts create mode 100644 dashboard/src/lib/adapter-config-api.ts create mode 100644 dashboard/src/lib/animation-context.ts create mode 100644 dashboard/src/lib/api-base.ts create mode 100644 dashboard/src/lib/api-helpers.ts create mode 100644 dashboard/src/lib/api.ts create mode 100644 dashboard/src/lib/asset-store.ts create mode 100644 dashboard/src/lib/chat-ws-client.ts create mode 100644 dashboard/src/lib/config-api.ts create mode 100644 dashboard/src/lib/config-label.ts create mode 100644 dashboard/src/lib/emoji-api.ts create mode 100644 dashboard/src/lib/expression-api.ts create mode 100644 dashboard/src/lib/fetch-with-auth.ts create mode 100644 dashboard/src/lib/field-hooks.ts create mode 100644 dashboard/src/lib/jargon-api.ts create mode 100644 dashboard/src/lib/keyboard.ts create mode 100644 dashboard/src/lib/knowledge-api.ts create mode 100644 dashboard/src/lib/log-stream.ts create mode 100644 dashboard/src/lib/log-websocket.ts create mode 100644 dashboard/src/lib/maisaka-monitor-client.ts create mode 100644 dashboard/src/lib/memory-api.ts create mode 100644 dashboard/src/lib/memory-progress-client.ts create mode 100644 dashboard/src/lib/pack-api.ts create mode 100644 dashboard/src/lib/person-api.ts create mode 100644 dashboard/src/lib/planner-api.ts create mode 100644 dashboard/src/lib/plugin-api/config.ts create mode 100644 dashboard/src/lib/plugin-api/index.ts create mode 100644 dashboard/src/lib/plugin-api/install-flow.ts create mode 100644 dashboard/src/lib/plugin-api/installed.ts create mode 100644 dashboard/src/lib/plugin-api/marketplace.ts create mode 100644 dashboard/src/lib/plugin-api/types.ts create mode 100644 dashboard/src/lib/plugin-progress-client.ts create mode 100644 dashboard/src/lib/plugin-stats.ts create mode 100644 dashboard/src/lib/prompt-api.ts create mode 100644 dashboard/src/lib/reasoning-process-api.ts create mode 100644 dashboard/src/lib/restart-context.tsx create mode 100644 dashboard/src/lib/runtime.ts create mode 100644 dashboard/src/lib/settings-manager.ts create mode 100644 dashboard/src/lib/survey-api.ts create mode 100644 dashboard/src/lib/system-api.ts create mode 100644 dashboard/src/lib/theme-context.ts create mode 100644 dashboard/src/lib/theme/palette.ts create mode 100644 dashboard/src/lib/theme/pipeline.ts create mode 100644 dashboard/src/lib/theme/presets.ts create mode 100644 dashboard/src/lib/theme/sanitizer.ts create mode 100644 dashboard/src/lib/theme/storage.ts create mode 100644 dashboard/src/lib/theme/tokens.ts create mode 100644 dashboard/src/lib/token-validator.ts create mode 100644 dashboard/src/lib/unified-ws.ts create mode 100644 dashboard/src/lib/utils.ts create mode 100644 dashboard/src/lib/version.ts create mode 100644 dashboard/src/main.tsx create mode 100644 dashboard/src/router.tsx create mode 100644 dashboard/src/routes/404.tsx create mode 100644 dashboard/src/routes/__tests__/plugin-config.test.tsx create mode 100644 dashboard/src/routes/auth.tsx create mode 100644 dashboard/src/routes/chat/ChatComposer.tsx create mode 100644 dashboard/src/routes/chat/ChatHeaderBar.tsx create mode 100644 dashboard/src/routes/chat/ChatScrollContext.tsx create mode 100644 dashboard/src/routes/chat/ChatTabBar.tsx create mode 100644 dashboard/src/routes/chat/ChatWorkspaceSidebar.tsx create mode 100644 dashboard/src/routes/chat/MessageList.tsx create mode 100644 dashboard/src/routes/chat/MessageRenderer.tsx create mode 100644 dashboard/src/routes/chat/VirtualIdentityDialog.tsx create mode 100644 dashboard/src/routes/chat/index.tsx create mode 100644 dashboard/src/routes/chat/types.ts create mode 100644 dashboard/src/routes/chat/utils.ts create mode 100644 dashboard/src/routes/config/adapter-disabled.tsx create mode 100644 dashboard/src/routes/config/adapter.tsx create mode 100644 dashboard/src/routes/config/adapter/index.ts create mode 100644 dashboard/src/routes/config/adapter/types.ts create mode 100644 dashboard/src/routes/config/adapter/utils.ts create mode 100644 dashboard/src/routes/config/bot.tsx create mode 100644 dashboard/src/routes/config/bot/hooks/BotInfoSectionHook.tsx create mode 100644 dashboard/src/routes/config/bot/hooks/ChatSectionHook.tsx create mode 100644 dashboard/src/routes/config/bot/hooks/DebugSectionHook.tsx create mode 100644 dashboard/src/routes/config/bot/hooks/ExpressionSectionHook.tsx create mode 100644 dashboard/src/routes/config/bot/hooks/JsonFieldHookFactory.tsx create mode 100644 dashboard/src/routes/config/bot/hooks/ListItemEditorHookFactory.tsx create mode 100644 dashboard/src/routes/config/bot/hooks/PersonalitySectionHook.tsx create mode 100644 dashboard/src/routes/config/bot/hooks/complexFieldHooks.tsx create mode 100644 dashboard/src/routes/config/bot/hooks/index.ts create mode 100644 dashboard/src/routes/config/bot/hooks/useAutoSave.ts create mode 100644 dashboard/src/routes/config/bot/index.ts create mode 100644 dashboard/src/routes/config/bot/sections/BotInfoSection.tsx create mode 100644 dashboard/src/routes/config/bot/sections/DebugSection.tsx create mode 100644 dashboard/src/routes/config/bot/sections/DreamSection.tsx create mode 100644 dashboard/src/routes/config/bot/sections/ExpressionSection.tsx create mode 100644 dashboard/src/routes/config/bot/sections/LPMMSection.tsx create mode 100644 dashboard/src/routes/config/bot/sections/LogSection.tsx create mode 100644 dashboard/src/routes/config/bot/sections/MaimMessageSection.tsx create mode 100644 dashboard/src/routes/config/bot/sections/MessageReceiveSection.tsx create mode 100644 dashboard/src/routes/config/bot/sections/PersonalitySection.tsx create mode 100644 dashboard/src/routes/config/bot/sections/ProcessingSection.tsx create mode 100644 dashboard/src/routes/config/bot/sections/TelemetrySection.tsx create mode 100644 dashboard/src/routes/config/bot/sections/WebUISection.tsx create mode 100644 dashboard/src/routes/config/bot/sections/index.ts create mode 100644 dashboard/src/routes/config/bot/types.ts create mode 100644 dashboard/src/routes/config/model.tsx create mode 100644 dashboard/src/routes/config/model/components/ModelCardList.tsx create mode 100644 dashboard/src/routes/config/model/components/ModelTable.tsx create mode 100644 dashboard/src/routes/config/model/components/Pagination.tsx create mode 100644 dashboard/src/routes/config/model/components/TaskConfigCard.tsx create mode 100644 dashboard/src/routes/config/model/components/index.ts create mode 100644 dashboard/src/routes/config/model/constants.ts create mode 100644 dashboard/src/routes/config/model/hooks/index.ts create mode 100644 dashboard/src/routes/config/model/hooks/useModelAutoSave.ts create mode 100644 dashboard/src/routes/config/model/hooks/useModelFetcher.ts create mode 100644 dashboard/src/routes/config/model/hooks/useModelTour.ts create mode 100644 dashboard/src/routes/config/model/index.ts create mode 100644 dashboard/src/routes/config/model/types.ts create mode 100644 dashboard/src/routes/config/modelProvider/ProviderCard.tsx create mode 100644 dashboard/src/routes/config/modelProvider/ProviderForm.tsx create mode 100644 dashboard/src/routes/config/modelProvider/ProviderList.tsx create mode 100644 dashboard/src/routes/config/modelProvider/index.ts create mode 100644 dashboard/src/routes/config/modelProvider/index.tsx create mode 100644 dashboard/src/routes/config/modelProvider/types.ts create mode 100644 dashboard/src/routes/config/modelProvider/utils.ts create mode 100644 dashboard/src/routes/config/pack-detail.tsx create mode 100644 dashboard/src/routes/config/pack-market.tsx create mode 100644 dashboard/src/routes/config/prompts.tsx create mode 100644 dashboard/src/routes/config/providerTemplates.ts create mode 100644 dashboard/src/routes/index.tsx create mode 100644 dashboard/src/routes/logs.tsx create mode 100644 dashboard/src/routes/mcp-settings.tsx create mode 100644 dashboard/src/routes/model-presets.tsx create mode 100644 dashboard/src/routes/monitor/index.tsx create mode 100644 dashboard/src/routes/monitor/maisaka-monitor.tsx create mode 100644 dashboard/src/routes/monitor/planner-monitor.tsx create mode 100644 dashboard/src/routes/monitor/replier-monitor.tsx create mode 100644 dashboard/src/routes/monitor/use-maisaka-monitor.ts create mode 100644 dashboard/src/routes/monitor/use-monitor.ts create mode 100644 dashboard/src/routes/person.tsx create mode 100644 dashboard/src/routes/plugin-config.tsx create mode 100644 dashboard/src/routes/plugin-detail.tsx create mode 100644 dashboard/src/routes/plugin-mirrors.tsx create mode 100644 dashboard/src/routes/reasoning-process.tsx create mode 100644 dashboard/src/routes/resource/__tests__/knowledge-base.test.tsx create mode 100644 dashboard/src/routes/resource/__tests__/knowledge-graph.test.tsx create mode 100644 dashboard/src/routes/resource/emoji/EmojiDialogs.tsx create mode 100644 dashboard/src/routes/resource/emoji/EmojiList.tsx create mode 100644 dashboard/src/routes/resource/emoji/index.ts create mode 100644 dashboard/src/routes/resource/emoji/index.tsx create mode 100644 dashboard/src/routes/resource/emoji/types.ts create mode 100644 dashboard/src/routes/resource/expression/ExpressionDialogs.tsx create mode 100644 dashboard/src/routes/resource/expression/ExpressionList.tsx create mode 100644 dashboard/src/routes/resource/expression/index.ts create mode 100644 dashboard/src/routes/resource/expression/index.tsx create mode 100644 dashboard/src/routes/resource/expression/types.ts create mode 100644 dashboard/src/routes/resource/jargon/JargonDialogs.tsx create mode 100644 dashboard/src/routes/resource/jargon/JargonList.tsx create mode 100644 dashboard/src/routes/resource/jargon/index.ts create mode 100644 dashboard/src/routes/resource/jargon/index.tsx create mode 100644 dashboard/src/routes/resource/jargon/types.ts create mode 100644 dashboard/src/routes/resource/knowledge-base.tsx create mode 100644 dashboard/src/routes/resource/knowledge-base/constants.ts create mode 100644 dashboard/src/routes/resource/knowledge-base/tabs/DeleteTab.tsx create mode 100644 dashboard/src/routes/resource/knowledge-base/tabs/FeedbackTab.tsx create mode 100644 dashboard/src/routes/resource/knowledge-base/tabs/ImportTab.tsx create mode 100644 dashboard/src/routes/resource/knowledge-base/tabs/TuningTab.tsx create mode 100644 dashboard/src/routes/resource/knowledge-base/utils.ts create mode 100644 dashboard/src/routes/resource/knowledge-graph/GraphDialogs.tsx create mode 100644 dashboard/src/routes/resource/knowledge-graph/GraphVisualization.tsx create mode 100644 dashboard/src/routes/resource/knowledge-graph/index.ts create mode 100644 dashboard/src/routes/resource/knowledge-graph/index.tsx create mode 100644 dashboard/src/routes/resource/knowledge-graph/types.ts create mode 100644 dashboard/src/routes/settings/AboutTab.tsx create mode 100644 dashboard/src/routes/settings/AppearanceTab.tsx create mode 100644 dashboard/src/routes/settings/LibraryItem.tsx create mode 100644 dashboard/src/routes/settings/LocalCacheTab.tsx create mode 100644 dashboard/src/routes/settings/OtherTab.tsx create mode 100644 dashboard/src/routes/settings/SecurityTab.tsx create mode 100644 dashboard/src/routes/settings/ThemeOption.tsx create mode 100644 dashboard/src/routes/settings/index.tsx create mode 100644 dashboard/src/routes/settings/types.ts create mode 100644 dashboard/src/routes/setup/StepForms.tsx create mode 100644 dashboard/src/routes/setup/api.ts create mode 100644 dashboard/src/routes/setup/index.tsx create mode 100644 dashboard/src/routes/setup/types.ts create mode 100644 dashboard/src/routes/survey/index.ts create mode 100644 dashboard/src/routes/survey/maibot-feedback.tsx create mode 100644 dashboard/src/routes/survey/webui-feedback.tsx create mode 100644 dashboard/src/styles/uppy-custom.css create mode 100644 dashboard/src/test/setup.ts create mode 100644 dashboard/src/types/api.ts create mode 100644 dashboard/src/types/config-schema.ts create mode 100644 dashboard/src/types/css-modules.d.ts create mode 100644 dashboard/src/types/electron.d.ts create mode 100644 dashboard/src/types/emoji.ts create mode 100644 dashboard/src/types/expression.ts create mode 100644 dashboard/src/types/jargon.ts create mode 100644 dashboard/src/types/person.ts create mode 100644 dashboard/src/types/plugin.ts create mode 100644 dashboard/src/types/survey.ts create mode 100644 dashboard/src/types/view-transitions.d.ts create mode 100644 dashboard/tailwind.config.js create mode 100644 dashboard/tsconfig.app.json create mode 100644 dashboard/tsconfig.electron.json create mode 100644 dashboard/tsconfig.json create mode 100644 dashboard/tsconfig.node.json create mode 100644 dashboard/tsconfig.vitest.json create mode 100644 dashboard/vite.config.ts create mode 100644 dashboard/vitest.config.ts create mode 100644 depends-data/char_frequency.json create mode 100644 deploy/server-maibot/Dockerfile.offline create mode 100644 deploy/server-maibot/README_DEPLOY_STEPS.txt create mode 100644 deploy/server-maibot/activate-release.sh create mode 100644 deploy/server-maibot/bot.lecspace.com.nginx.conf create mode 100644 deploy/server-maibot/docker-compose.server.yml create mode 100644 deploy/server-maibot/docker-entrypoint.offline.sh create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 dummy create mode 100644 locales/en-US/config.json create mode 100644 locales/en-US/core.json create mode 100644 locales/en-US/prompts.json create mode 100644 locales/en-US/startup.json create mode 100644 locales/ja-JP/config.json create mode 100644 locales/ja-JP/core.json create mode 100644 locales/ja-JP/prompts.json create mode 100644 locales/ja-JP/startup.json create mode 100644 locales/ko/config.json create mode 100644 locales/ko/core.json create mode 100644 locales/ko/prompts.json create mode 100644 locales/ko/startup.json create mode 100644 locales/zh-CN/config.json create mode 100644 locales/zh-CN/core.json create mode 100644 locales/zh-CN/prompts.json create mode 100644 locales/zh-CN/startup.json create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/.devcontainer/devcontainer.json create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/.github/workflows/docker-image.yml create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/_manifest.json create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/account.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/file.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/group.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/message.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/message_tool_patch.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/support.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/system.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/cards.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/message_codec.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/text.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/enricher.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/helpers.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/message_codec.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/meta_event_logger.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/renderer.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/message_codec.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/segment_encoder.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/config.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/constants.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/README.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/account-api.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/file-api.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/group-api.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/message-api.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/system-api.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/typed-api.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/verification.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/filters.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/heartbeat_monitor.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/plugin.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/qq_emoji_list.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/runtime/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/runtime/builder.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/runtime/bundle.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/runtime/router.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/runtime_state.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/services/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/services/action_service.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/services/ban_state_store.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/services/ban_tracker.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/services/official_bot_guard.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/services/query_service.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/transport.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/types.py create mode 100644 prompts/en-US/.meta.toml create mode 100644 prompts/en-US/default_expressor.prompt create mode 100644 prompts/en-US/emoji_content_analysis.prompt create mode 100644 prompts/en-US/emoji_content_filtration.prompt create mode 100644 prompts/en-US/emoji_replace.prompt create mode 100644 prompts/en-US/expression_evaluation.prompt create mode 100644 prompts/en-US/expression_select.prompt create mode 100644 prompts/en-US/image_description.prompt create mode 100644 prompts/en-US/jargon_compare_inference.prompt create mode 100644 prompts/en-US/jargon_explainer_summarize.prompt create mode 100644 prompts/en-US/jargon_inference_content_only.prompt create mode 100644 prompts/en-US/jargon_inference_with_context.prompt create mode 100644 prompts/en-US/learn_style.prompt create mode 100644 prompts/en-US/maisaka_chat.prompt create mode 100644 prompts/en-US/maisaka_replyer.prompt create mode 100644 prompts/en-US/maisaka_timing_gate.prompt create mode 100644 prompts/en-US/memory_retrieval_react_prompt_head_memory.prompt create mode 100644 prompts/ja-JP/.meta.toml create mode 100644 prompts/ja-JP/default_expressor.prompt create mode 100644 prompts/ja-JP/emoji_content_analysis.prompt create mode 100644 prompts/ja-JP/emoji_content_filtration.prompt create mode 100644 prompts/ja-JP/emoji_replace.prompt create mode 100644 prompts/ja-JP/expression_evaluation.prompt create mode 100644 prompts/ja-JP/expression_select.prompt create mode 100644 prompts/ja-JP/image_description.prompt create mode 100644 prompts/ja-JP/jargon_compare_inference.prompt create mode 100644 prompts/ja-JP/jargon_explainer_summarize.prompt create mode 100644 prompts/ja-JP/jargon_inference_content_only.prompt create mode 100644 prompts/ja-JP/jargon_inference_with_context.prompt create mode 100644 prompts/ja-JP/learn_style.prompt create mode 100644 prompts/ja-JP/maisaka_chat.prompt create mode 100644 prompts/ja-JP/maisaka_replyer.prompt create mode 100644 prompts/ja-JP/maisaka_timing_gate.prompt create mode 100644 prompts/ja-JP/memory_retrieval_react_prompt_head_memory.prompt create mode 100644 prompts/zh-CN/.meta.toml create mode 100644 prompts/zh-CN/default_expressor.prompt create mode 100644 prompts/zh-CN/emoji_content_analysis.prompt create mode 100644 prompts/zh-CN/emoji_content_filtration.prompt create mode 100644 prompts/zh-CN/emoji_replace.prompt create mode 100644 prompts/zh-CN/expression_evaluation.prompt create mode 100644 prompts/zh-CN/expression_select.prompt create mode 100644 prompts/zh-CN/image_description.prompt create mode 100644 prompts/zh-CN/jargon_compare_inference.prompt create mode 100644 prompts/zh-CN/jargon_explainer_summarize.prompt create mode 100644 prompts/zh-CN/jargon_inference_content_only.prompt create mode 100644 prompts/zh-CN/jargon_inference_with_context.prompt create mode 100644 prompts/zh-CN/learn_style.prompt create mode 100644 prompts/zh-CN/maisaka_chat.prompt create mode 100644 prompts/zh-CN/maisaka_replyer.prompt create mode 100644 prompts/zh-CN/maisaka_timing_gate.prompt create mode 100644 pyproject.toml create mode 100644 pytests/A_memorix_test/test_chat_summary_writeback_integration.py create mode 100644 pytests/A_memorix_test/test_embedding_dimension_control.py create mode 100644 pytests/A_memorix_test/test_feedback_correction_chat_flow.py create mode 100644 pytests/A_memorix_test/test_feedback_correction_core.py create mode 100644 pytests/A_memorix_test/test_graph_store_persistence.py create mode 100644 pytests/A_memorix_test/test_group_chat_stream_fixture_schema.py create mode 100644 pytests/A_memorix_test/test_knowledge_fetcher.py create mode 100644 pytests/A_memorix_test/test_memory_flow_service.py create mode 100644 pytests/A_memorix_test/test_memory_graph_search_kernel.py create mode 100644 pytests/A_memorix_test/test_memory_service.py create mode 100644 pytests/A_memorix_test/test_metadata_store_sources.py create mode 100644 pytests/A_memorix_test/test_person_memory_writeback.py create mode 100644 pytests/A_memorix_test/test_person_profile_service.py create mode 100644 pytests/A_memorix_test/test_query_long_term_memory_tool.py create mode 100644 pytests/A_memorix_test/test_summary_importer_model_config.py create mode 100644 pytests/A_memorix_test/test_web_import_manager_payloads.py create mode 100644 pytests/common_test/test_chat_config_utils.py create mode 100644 pytests/common_test/test_database_migration_foundation.py create mode 100644 pytests/common_test/test_expression_learner.py create mode 100644 pytests/common_test/test_expression_schema.py create mode 100644 pytests/common_test/test_jargon_miner.py create mode 100644 pytests/common_test/test_jargon_schema.py create mode 100644 pytests/common_test/test_maisaka_expression_selector.py create mode 100644 pytests/common_test/test_person_info_group_cardname.py create mode 100644 pytests/config_test/test_config_base.py create mode 100644 pytests/config_test/test_config_manager_hot_reload.py create mode 100644 pytests/config_test/test_config_manager_startup_upgrade.py create mode 100644 pytests/config_test/test_file_watcher.py create mode 100644 pytests/config_test/test_llm_request_hot_reload.py create mode 100644 pytests/config_test/test_model_info_normalization.py create mode 100644 pytests/config_test/test_startup_bindings.py create mode 100644 pytests/conftest.py create mode 100644 pytests/i18n_test/test_i18n.py create mode 100644 pytests/i18n_test/test_i18n_validate.py create mode 100644 pytests/image_sys_test/emoji_manager_test.py create mode 100644 pytests/image_sys_test/image_manager_test.py create mode 100644 pytests/image_sys_test/test_image_data_model.py create mode 100644 pytests/logger.py create mode 100644 pytests/message_test/session_message_test.py create mode 100644 pytests/prompt_test/test_prompt_i18n.py create mode 100644 pytests/prompt_test/test_prompt_manager.py create mode 100644 pytests/test_context_message_fallback.py create mode 100644 pytests/test_gemini_thought_signatures.py create mode 100644 pytests/test_html_render_service.py create mode 100644 pytests/test_llm_provider_registry.py create mode 100644 pytests/test_maisaka_builtin_context.py create mode 100644 pytests/test_maisaka_builtin_query_memory.py create mode 100644 pytests/test_maisaka_memory_retention.py create mode 100644 pytests/test_maisaka_message_adapter.py create mode 100644 pytests/test_maisaka_monitor_protocol.py create mode 100644 pytests/test_maisaka_timing_gate.py create mode 100644 pytests/test_message_gateway_runtime.py create mode 100644 pytests/test_napcat_adapter_sdk.py create mode 100644 pytests/test_openai_client_toolless_request.py create mode 100644 pytests/test_platform_io_dedupe.py create mode 100644 pytests/test_platform_io_legacy_driver.py create mode 100644 pytests/test_plugin_config_runtime.py create mode 100644 pytests/test_plugin_dependency_pipeline.py create mode 100644 pytests/test_plugin_message_utils_runtime.py create mode 100644 pytests/test_plugin_runtime.py create mode 100644 pytests/test_plugin_runtime_action_bridge.py create mode 100644 pytests/test_plugin_runtime_api.py create mode 100644 pytests/test_plugin_runtime_render.py create mode 100644 pytests/test_prompt_message_roundtrip.py create mode 100644 pytests/test_runtime_business_hooks.py create mode 100644 pytests/test_send_service.py create mode 100644 pytests/test_tool_availability.py create mode 100644 pytests/utils_test/message_utils_test.py create mode 100644 pytests/utils_test/statistic_test.py create mode 100644 pytests/utils_test/test_request_snapshot.py create mode 100644 pytests/utils_test/test_session_utils.py create mode 100644 pytests/webui/__init__.py create mode 100644 pytests/webui/test_app.py create mode 100644 pytests/webui/test_config_schema.py create mode 100644 pytests/webui/test_emoji_routes.py create mode 100644 pytests/webui/test_expression_routes.py create mode 100644 pytests/webui/test_jargon_routes.py create mode 100644 pytests/webui/test_memory_routes.py create mode 100644 pytests/webui/test_memory_routes_integration.py create mode 100644 pytests/webui/test_model_routes.py create mode 100644 pytests/webui/test_plugin_management_routes.py create mode 100644 pytests/webui/test_statistics_service.py create mode 100644 pytests/webui/test_system_routes.py create mode 100644 requirements.txt create mode 100644 saka.py create mode 100644 scripts/analyze_reply_effect_score_correlation.py create mode 100644 scripts/analyze_tool_usage_by_chat.py create mode 100644 scripts/build_io_pairs.py create mode 100644 scripts/evaluate_expressions_count_analysis.py create mode 100644 scripts/evaluate_expressions_llm_v6.py create mode 100644 scripts/evaluate_expressions_manual.py create mode 100644 scripts/i18n_extract_candidates.py create mode 100644 scripts/i18n_validate.py create mode 100644 scripts/make_scripts/generate_requirements.py create mode 100644 scripts/mmipkg_tool.py create mode 100644 scripts/preview_reply_effect_scores.py create mode 100644 scripts/run.sh create mode 100644 scripts/run_a_memorix_webui_backend.py create mode 100644 scripts/run_lpmm.sh create mode 100644 scripts/sync_a_memorix_subtree.sh create mode 100644 scripts/test_memory_retrieval.py create mode 100644 scripts/test_model_tool_call_params.py create mode 100644 scripts/test_tool_call_api_matrix.py create mode 100644 scripts/verify_a_memorix_webui.sh create mode 100644 src/A_memorix/CHANGELOG.md create mode 100644 src/A_memorix/CONFIG_REFERENCE.md create mode 100644 src/A_memorix/IMPORT_GUIDE.md create mode 100644 src/A_memorix/LICENSE create mode 100644 src/A_memorix/LICENSE-MAIBOT-GPL.md create mode 100644 src/A_memorix/MODIFICATION_POLICY.md create mode 100644 src/A_memorix/QUICK_START.md create mode 100644 src/A_memorix/README.md create mode 100644 src/A_memorix/RELEASE_SUMMARY_1.0.0.md create mode 100644 src/A_memorix/__init__.py create mode 100644 src/A_memorix/config_schema.json create mode 100644 src/A_memorix/core/__init__.py create mode 100644 src/A_memorix/core/embedding/__init__.py create mode 100644 src/A_memorix/core/embedding/api_adapter.py create mode 100644 src/A_memorix/core/embedding/manager.py create mode 100644 src/A_memorix/core/embedding/presets.py create mode 100644 src/A_memorix/core/retrieval/__init__.py create mode 100644 src/A_memorix/core/retrieval/dual_path.py create mode 100644 src/A_memorix/core/retrieval/graph_relation_recall.py create mode 100644 src/A_memorix/core/retrieval/pagerank.py create mode 100644 src/A_memorix/core/retrieval/posterior_graph.py create mode 100644 src/A_memorix/core/retrieval/sparse_bm25.py create mode 100644 src/A_memorix/core/retrieval/threshold.py create mode 100644 src/A_memorix/core/runtime/__init__.py create mode 100644 src/A_memorix/core/runtime/lifecycle_orchestrator.py create mode 100644 src/A_memorix/core/runtime/sdk_memory_kernel.py create mode 100644 src/A_memorix/core/runtime/search_runtime_initializer.py create mode 100644 src/A_memorix/core/storage/__init__.py create mode 100644 src/A_memorix/core/storage/graph_store.py create mode 100644 src/A_memorix/core/storage/knowledge_types.py create mode 100644 src/A_memorix/core/storage/metadata_store.py create mode 100644 src/A_memorix/core/storage/type_detection.py create mode 100644 src/A_memorix/core/storage/vector_store.py create mode 100644 src/A_memorix/core/strategies/__init__.py create mode 100644 src/A_memorix/core/strategies/base.py create mode 100644 src/A_memorix/core/strategies/factual.py create mode 100644 src/A_memorix/core/strategies/narrative.py create mode 100644 src/A_memorix/core/strategies/quote.py create mode 100644 src/A_memorix/core/utils/__init__.py create mode 100644 src/A_memorix/core/utils/aggregate_query_service.py create mode 100644 src/A_memorix/core/utils/episode_retrieval_service.py create mode 100644 src/A_memorix/core/utils/episode_segmentation_service.py create mode 100644 src/A_memorix/core/utils/episode_service.py create mode 100644 src/A_memorix/core/utils/hash.py create mode 100644 src/A_memorix/core/utils/import_payloads.py create mode 100644 src/A_memorix/core/utils/io.py create mode 100644 src/A_memorix/core/utils/matcher.py create mode 100644 src/A_memorix/core/utils/metadata.py create mode 100644 src/A_memorix/core/utils/monitor.py create mode 100644 src/A_memorix/core/utils/path_fallback_service.py create mode 100644 src/A_memorix/core/utils/person_profile_service.py create mode 100644 src/A_memorix/core/utils/plugin_id_policy.py create mode 100644 src/A_memorix/core/utils/quantization.py create mode 100644 src/A_memorix/core/utils/relation_query.py create mode 100644 src/A_memorix/core/utils/relation_write_service.py create mode 100644 src/A_memorix/core/utils/retrieval_tuning_manager.py create mode 100644 src/A_memorix/core/utils/runtime_self_check.py create mode 100644 src/A_memorix/core/utils/search_execution_service.py create mode 100644 src/A_memorix/core/utils/search_postprocess.py create mode 100644 src/A_memorix/core/utils/summary_importer.py create mode 100644 src/A_memorix/core/utils/time_parser.py create mode 100644 src/A_memorix/core/utils/web_import_manager.py create mode 100644 src/A_memorix/host_service.py create mode 100644 src/A_memorix/paths.py create mode 100644 src/A_memorix/plugin.py create mode 100644 src/A_memorix/requirements.txt create mode 100644 src/A_memorix/runtime_registry.py create mode 100644 src/A_memorix/scripts/_bootstrap.py create mode 100644 src/A_memorix/scripts/audit_vector_consistency.py create mode 100644 src/A_memorix/scripts/backfill_relation_vectors.py create mode 100644 src/A_memorix/scripts/backfill_temporal_metadata.py create mode 100644 src/A_memorix/scripts/convert_lpmm.py create mode 100644 src/A_memorix/scripts/import_lpmm_json.py create mode 100644 src/A_memorix/scripts/migrate_chat_history.py create mode 100644 src/A_memorix/scripts/migrate_maibot_memory.py create mode 100644 src/A_memorix/scripts/migrate_person_memory_points.py create mode 100644 src/A_memorix/scripts/process_knowledge.py create mode 100644 src/A_memorix/scripts/rebuild_episodes.py create mode 100644 src/A_memorix/scripts/release_vnext_migrate.py create mode 100644 src/A_memorix/scripts/runtime_self_check.py create mode 100644 src/__init__.py create mode 100644 src/chat/__init__.py create mode 100644 src/chat/event_helpers.py create mode 100644 src/chat/heart_flow/heartFC_utils.py create mode 100644 src/chat/heart_flow/heartflow_manager.py create mode 100644 src/chat/heart_flow/heartflow_message_processor.py create mode 100644 src/chat/image_system/image_manager.py create mode 100644 src/chat/knowledge/LICENSE create mode 100644 src/chat/knowledge/embedding_store.py create mode 100644 src/chat/knowledge/global_logger.py create mode 100644 src/chat/knowledge/ie_process.py create mode 100644 src/chat/knowledge/kg_manager.py create mode 100644 src/chat/knowledge/mem_active_manager.py create mode 100644 src/chat/knowledge/open_ie.py create mode 100644 src/chat/knowledge/prompt_template.py create mode 100644 src/chat/knowledge/qa_manager.py create mode 100644 src/chat/knowledge/utils/__init__.py create mode 100644 src/chat/knowledge/utils/dyn_topk.py create mode 100644 src/chat/knowledge/utils/hash.py create mode 100644 src/chat/knowledge/utils/json_fix.py create mode 100644 src/chat/message_receive/__init__.py create mode 100644 src/chat/message_receive/bot.py create mode 100644 src/chat/message_receive/chat_manager.py create mode 100644 src/chat/message_receive/message.py create mode 100644 src/chat/message_receive/uni_message_sender.py create mode 100644 src/chat/replyer/maisaka_expression_selector.py create mode 100644 src/chat/replyer/maisaka_generator.py create mode 100644 src/chat/replyer/maisaka_generator_base.py create mode 100644 src/chat/replyer/replyer_manager.py create mode 100644 src/chat/utils/common_utils.py create mode 100644 src/chat/utils/prompt_builder.py create mode 100644 src/chat/utils/statistic.py create mode 100644 src/chat/utils/timer_calculator.py create mode 100644 src/chat/utils/typo_generator.py create mode 100644 src/chat/utils/utils.py create mode 100644 src/cli/__init__.py create mode 100644 src/cli/console.py create mode 100644 src/cli/input_reader.py create mode 100644 src/cli/maisaka_cli.py create mode 100644 src/cli/maisaka_cli_sender.py create mode 100644 src/common/__init__.py create mode 100644 src/common/data_models/__init__.py create mode 100644 src/common/data_models/action_record_data_model.py create mode 100644 src/common/data_models/chat_session_data_model.py create mode 100644 src/common/data_models/chat_target_info_data_model.py create mode 100644 src/common/data_models/embedding_service_data_models.py create mode 100644 src/common/data_models/expression_data_model.py create mode 100644 src/common/data_models/image_data_model.py create mode 100644 src/common/data_models/jargon_data_model.py create mode 100644 src/common/data_models/llm_data_model.py create mode 100644 src/common/data_models/llm_service_data_models.py create mode 100644 src/common/data_models/mai_message_data_model.py create mode 100644 src/common/data_models/message_component_data_model.py create mode 100644 src/common/data_models/person_info_data_model.py create mode 100644 src/common/data_models/planned_action_data_models.py create mode 100644 src/common/data_models/reply_generation_data_models.py create mode 100644 src/common/data_models/tool_record_data_model.py create mode 100644 src/common/database/__init__.py create mode 100644 src/common/database/database.py create mode 100644 src/common/database/database_model.py create mode 100644 src/common/database/migrations/__init__.py create mode 100644 src/common/database/migrations/bootstrap.py create mode 100644 src/common/database/migrations/builtin.py create mode 100644 src/common/database/migrations/exceptions.py create mode 100644 src/common/database/migrations/frozen_v2_schema.py create mode 100644 src/common/database/migrations/legacy_v1_to_v2.py create mode 100644 src/common/database/migrations/manager.py create mode 100644 src/common/database/migrations/models.py create mode 100644 src/common/database/migrations/planner.py create mode 100644 src/common/database/migrations/progress.py create mode 100644 src/common/database/migrations/registry.py create mode 100644 src/common/database/migrations/resolver.py create mode 100644 src/common/database/migrations/schema.py create mode 100644 src/common/database/migrations/v2_to_v3.py create mode 100644 src/common/database/migrations/v3_to_v4.py create mode 100644 src/common/database/migrations/version_store.py create mode 100644 src/common/i18n/__init__.py create mode 100644 src/common/i18n/exceptions.py create mode 100644 src/common/i18n/formatting.py create mode 100644 src/common/i18n/loaders.py create mode 100644 src/common/i18n/manager.py create mode 100644 src/common/logger.py create mode 100644 src/common/logger_color_and_mapping.py create mode 100644 src/common/message_repository.py create mode 100644 src/common/message_server/__init__.py create mode 100644 src/common/message_server/api.py create mode 100644 src/common/message_server/server.py create mode 100644 src/common/message_server/universal_message_sender.py create mode 100644 src/common/prompt_i18n.py create mode 100644 src/common/remote.py create mode 100644 src/common/utils/math_utils.py create mode 100644 src/common/utils/port_checker.py create mode 100644 src/common/utils/system_utils.py create mode 100644 src/common/utils/utils_action.py create mode 100644 src/common/utils/utils_config.py create mode 100644 src/common/utils/utils_file.py create mode 100644 src/common/utils/utils_image.py create mode 100644 src/common/utils/utils_message.py create mode 100644 src/common/utils/utils_person.py create mode 100644 src/common/utils/utils_session.py create mode 100644 src/common/utils/utils_voice.py create mode 100644 src/config/__init__.py create mode 100644 src/config/config.py create mode 100644 src/config/config_base.py create mode 100644 src/config/config_upgrade_hooks.py create mode 100644 src/config/config_utils.py create mode 100644 src/config/default_model_config.py create mode 100644 src/config/file_watcher.py create mode 100644 src/config/legacy_migration.py create mode 100644 src/config/legacy_upgrade_confirmation.py create mode 100644 src/config/model_configs.py create mode 100644 src/config/official_configs.py create mode 100644 src/config/startup_bindings.py create mode 100644 src/core/__init__.py create mode 100644 src/core/announcement_manager.py create mode 100644 src/core/config_types.py create mode 100644 src/core/event_bus.py create mode 100644 src/core/tooling.py create mode 100644 src/core/types.py create mode 100644 src/emoji_system/emoji_manager.py create mode 100644 src/emoji_system/maisaka_tool.py create mode 100644 src/learners/expression_auto_check_task.py create mode 100644 src/learners/expression_learner.py create mode 100644 src/learners/expression_review_store.py create mode 100644 src/learners/expression_utils.py create mode 100644 src/learners/jargon_explainer.py create mode 100644 src/learners/jargon_miner.py create mode 100644 src/learners/learner_utils_old.py create mode 100644 src/llm_models/LICENSE create mode 100644 src/llm_models/__init__.py create mode 100644 src/llm_models/exceptions.py create mode 100644 src/llm_models/model_client/__init__.py create mode 100644 src/llm_models/model_client/adapter_base.py create mode 100644 src/llm_models/model_client/base_client.py create mode 100644 src/llm_models/model_client/gemini_client.py create mode 100644 src/llm_models/model_client/openai_client.py create mode 100644 src/llm_models/model_client/plugin_client.py create mode 100644 src/llm_models/openai_compat.py create mode 100644 src/llm_models/payload_content/__init__.py create mode 100644 src/llm_models/payload_content/message.py create mode 100644 src/llm_models/payload_content/resp_format.py create mode 100644 src/llm_models/payload_content/tool_option.py create mode 100644 src/llm_models/request_snapshot.py create mode 100644 src/llm_models/utils.py create mode 100644 src/llm_models/utils_model.py create mode 100644 src/main.py create mode 100644 src/maisaka/builtin_tool/__init__.py create mode 100644 src/maisaka/builtin_tool/context.py create mode 100644 src/maisaka/builtin_tool/continue_tool.py create mode 100644 src/maisaka/builtin_tool/finish.py create mode 100644 src/maisaka/builtin_tool/no_reply.py create mode 100644 src/maisaka/builtin_tool/query_jargon.py create mode 100644 src/maisaka/builtin_tool/query_memory.py create mode 100644 src/maisaka/builtin_tool/query_person_info.py create mode 100644 src/maisaka/builtin_tool/reply.py create mode 100644 src/maisaka/builtin_tool/send_emoji.py create mode 100644 src/maisaka/builtin_tool/tool_search.py create mode 100644 src/maisaka/builtin_tool/view_complex_message.py create mode 100644 src/maisaka/builtin_tool/wait.py create mode 100644 src/maisaka/chat_history_visual_refresher.py create mode 100644 src/maisaka/chat_loop_service.py create mode 100644 src/maisaka/context_messages.py create mode 100644 src/maisaka/display/__init__.py create mode 100644 src/maisaka/display/display_utils.py create mode 100644 src/maisaka/display/preview_path_utils.py create mode 100644 src/maisaka/display/prompt_cli_renderer.py create mode 100644 src/maisaka/display/prompt_preview_logger.py create mode 100644 src/maisaka/display/stage_status_board.py create mode 100644 src/maisaka/history_post_processor.py create mode 100644 src/maisaka/history_utils.py create mode 100644 src/maisaka/message_adapter.py create mode 100644 src/maisaka/monitor_events.py create mode 100644 src/maisaka/planner_message_utils.py create mode 100644 src/maisaka/reasoning_engine.py create mode 100644 src/maisaka/reply_effect/__init__.py create mode 100644 src/maisaka/reply_effect/image_utils.py create mode 100644 src/maisaka/reply_effect/judge.py create mode 100644 src/maisaka/reply_effect/models.py create mode 100644 src/maisaka/reply_effect/path_utils.py create mode 100644 src/maisaka/reply_effect/quote_utils.py create mode 100644 src/maisaka/reply_effect/scoring.py create mode 100644 src/maisaka/reply_effect/storage.py create mode 100644 src/maisaka/reply_effect/tracker.py create mode 100644 src/maisaka/runtime.py create mode 100644 src/maisaka/tool_provider.py create mode 100644 src/maisaka/visual_mode_utils.py create mode 100644 src/manager/async_task_manager.py create mode 100644 src/manager/local_store_manager.py create mode 100644 src/mcp_module/__init__.py create mode 100644 src/mcp_module/config.py create mode 100644 src/mcp_module/connection.py create mode 100644 src/mcp_module/hooks.py create mode 100644 src/mcp_module/host_llm_bridge.py create mode 100644 src/mcp_module/manager.py create mode 100644 src/mcp_module/models.py create mode 100644 src/mcp_module/provider.py create mode 100644 src/person_info/__init__.py create mode 100644 src/person_info/person_info.py create mode 100644 src/platform_io/__init__.py create mode 100644 src/platform_io/dedupe.py create mode 100644 src/platform_io/drivers/__init__.py create mode 100644 src/platform_io/drivers/base.py create mode 100644 src/platform_io/drivers/legacy_driver.py create mode 100644 src/platform_io/drivers/plugin_driver.py create mode 100644 src/platform_io/manager.py create mode 100644 src/platform_io/outbound_tracker.py create mode 100644 src/platform_io/registry.py create mode 100644 src/platform_io/route_key_factory.py create mode 100644 src/platform_io/routing.py create mode 100644 src/platform_io/types.py create mode 100644 src/plugin_runtime/__init__.py create mode 100644 src/plugin_runtime/capabilities/__init__.py create mode 100644 src/plugin_runtime/capabilities/components.py create mode 100644 src/plugin_runtime/capabilities/core.py create mode 100644 src/plugin_runtime/capabilities/data.py create mode 100644 src/plugin_runtime/capabilities/registry.py create mode 100644 src/plugin_runtime/capabilities/render.py create mode 100644 src/plugin_runtime/component_query.py create mode 100644 src/plugin_runtime/dependency_pipeline.py create mode 100644 src/plugin_runtime/hook_catalog.py create mode 100644 src/plugin_runtime/hook_payloads.py create mode 100644 src/plugin_runtime/hook_schema_utils.py create mode 100644 src/plugin_runtime/host/__init__.py create mode 100644 src/plugin_runtime/host/api_registry.py create mode 100644 src/plugin_runtime/host/authorization.py create mode 100644 src/plugin_runtime/host/capability_service.py create mode 100644 src/plugin_runtime/host/component_registry.py create mode 100644 src/plugin_runtime/host/event_dispatcher.py create mode 100644 src/plugin_runtime/host/hook_dispatcher.py create mode 100644 src/plugin_runtime/host/hook_spec_registry.py create mode 100644 src/plugin_runtime/host/logger_bridge.py create mode 100644 src/plugin_runtime/host/message_gateway.py create mode 100644 src/plugin_runtime/host/message_utils.py create mode 100644 src/plugin_runtime/host/rpc_server.py create mode 100644 src/plugin_runtime/host/supervisor.py create mode 100644 src/plugin_runtime/integration.py create mode 100644 src/plugin_runtime/protocol/__init__.py create mode 100644 src/plugin_runtime/protocol/codec.py create mode 100644 src/plugin_runtime/protocol/envelope.py create mode 100644 src/plugin_runtime/protocol/errors.py create mode 100644 src/plugin_runtime/runner/__init__.py create mode 100644 src/plugin_runtime/runner/log_handler.py create mode 100644 src/plugin_runtime/runner/manifest_validator.py create mode 100644 src/plugin_runtime/runner/plugin_loader.py create mode 100644 src/plugin_runtime/runner/rpc_client.py create mode 100644 src/plugin_runtime/runner/runner_main.py create mode 100644 src/plugin_runtime/tool_provider.py create mode 100644 src/plugin_runtime/transport/__init__.py create mode 100644 src/plugin_runtime/transport/base.py create mode 100644 src/plugin_runtime/transport/factory.py create mode 100644 src/plugin_runtime/transport/named_pipe.py create mode 100644 src/plugin_runtime/transport/tcp.py create mode 100644 src/plugin_runtime/transport/uds.py create mode 100644 src/prompt/prompt_manager.py create mode 100644 src/services/__init__.py create mode 100644 src/services/database_service.py create mode 100644 src/services/embedding_service.py create mode 100644 src/services/generator_service.py create mode 100644 src/services/html_render_service.py create mode 100644 src/services/llm_cache_stats.py create mode 100644 src/services/llm_service.py create mode 100644 src/services/memory_flow_service.py create mode 100644 src/services/memory_service.py create mode 100644 src/services/message_service.py create mode 100644 src/services/send_service.py create mode 100644 src/services/service_task_resolver.py create mode 100644 src/services/statistics_service.py create mode 100644 src/webui/app.py create mode 100644 src/webui/config_schema.py create mode 100644 src/webui/core/__init__.py create mode 100644 src/webui/core/auth.py create mode 100644 src/webui/core/rate_limiter.py create mode 100644 src/webui/core/security.py create mode 100644 src/webui/dashboard_update.py create mode 100644 src/webui/dependencies.py create mode 100644 src/webui/logs_ws.py create mode 100644 src/webui/middleware/__init__.py create mode 100644 src/webui/middleware/anti_crawler.py create mode 100644 src/webui/routers/__init__.py create mode 100644 src/webui/routers/chat/__init__.py create mode 100644 src/webui/routers/chat/routes.py create mode 100644 src/webui/routers/chat/serializers.py create mode 100644 src/webui/routers/chat/service.py create mode 100644 src/webui/routers/config.py create mode 100644 src/webui/routers/emoji/__init__.py create mode 100644 src/webui/routers/emoji/routes.py create mode 100644 src/webui/routers/emoji/schemas.py create mode 100644 src/webui/routers/emoji/support.py create mode 100644 src/webui/routers/expression.py create mode 100644 src/webui/routers/jargon.py create mode 100644 src/webui/routers/knowledge.py create mode 100644 src/webui/routers/logs.py create mode 100644 src/webui/routers/memory.py create mode 100644 src/webui/routers/model.py create mode 100644 src/webui/routers/person.py create mode 100644 src/webui/routers/plugin/__init__.py create mode 100644 src/webui/routers/plugin/catalog.py create mode 100644 src/webui/routers/plugin/config_routes.py create mode 100644 src/webui/routers/plugin/management.py create mode 100644 src/webui/routers/plugin/progress.py create mode 100644 src/webui/routers/plugin/runtime_routes.py create mode 100644 src/webui/routers/plugin/schemas.py create mode 100644 src/webui/routers/plugin/support.py create mode 100644 src/webui/routers/reasoning_process.py create mode 100644 src/webui/routers/statistics.py create mode 100644 src/webui/routers/system.py create mode 100644 src/webui/routers/websocket/__init__.py create mode 100644 src/webui/routers/websocket/auth.py create mode 100644 src/webui/routers/websocket/manager.py create mode 100644 src/webui/routers/websocket/unified.py create mode 100644 src/webui/routes.py create mode 100644 src/webui/schemas/__init__.py create mode 100644 src/webui/schemas/auth.py create mode 100644 src/webui/schemas/chat.py create mode 100644 src/webui/schemas/emoji.py create mode 100644 src/webui/schemas/plugin.py create mode 100644 src/webui/schemas/statistics.py create mode 100644 src/webui/services/__init__.py create mode 100644 src/webui/services/git_mirror_service.py create mode 100644 src/webui/utils/__init__.py create mode 100644 src/webui/utils/network_security.py create mode 100644 src/webui/utils/toml_utils.py create mode 100644 src/webui/webui_server.py create mode 100644 tests/test_config_upgrade_hooks.py create mode 100644 uv.lock diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..572b854c --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,49 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json + +language: "zh-CN" + +reviews: + profile: "chill" + request_changes_workflow: false + high_level_summary: true + high_level_summary_placeholder: "@coderabbitai summary" + poem: false + review_status: true + commit_status: true + collapse_walkthrough: false + auto_review: + enabled: true + drafts: false + base_branches: + - "main" + - "dev" + path_filters: + - "!logs/**" + - "!data/**" + - "!depends-data/**" + - "!dashboard/dist-electron/**" + - "!dashboard/node_modules/**" + - "!**/*.log" + - "!**/*.jsonl" + - "!**/*.db" + - "!**/*.db-shm" + - "!**/*.db-wal" + - "!**/*.bak" + path_instructions: + - path: "src/**/*.py" + instructions: | + 本项目使用 Ruff 进行代码检查与格式化,行宽限制为 120 字符,字符串使用双引号。 + 请重点关注以下方面: + - 异步代码的正确性(async/await 使用是否合理) + - 异常处理是否覆盖了边界情况 + - import 顺序需遵循项目规范:标准库/第三方库在前,本地模块在后;本地同级模块使用相对导入,跨目录使用以 `from src` 开头的绝对导入 + - 避免硬编码的敏感信息(API Key、密码等) + - path: "plugins/**/*.py" + instructions: | + 插件目录,请关注插件接口的规范使用以及与核心模块的依赖隔离性。 + - path: "*.toml" + instructions: | + 配置文件,请检查字段合法性和格式规范,注意不要泄露敏感默认值。 + +chat: + auto_reply: true diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..e61a92e3 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,31 @@ +{ + "name": "MaiBot-DevContainer", + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", + "features": { + "ghcr.io/rocker-org/devcontainer-features/apt-packages:1": { + "packages": [ + "tmux" + ] + }, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "forwardPorts": [ + "8000:8000" + ], + "postCreateCommand": "pip3 install --user -r requirements.txt", + "customizations": { + "jetbrains": { + "backend": "PyCharm" + }, + "vscode": { + "extensions": [ + "tamasfe.even-better-toml", + "njpwerner.autodocstring", + "ms-python.python", + "KevinRose.vsc-python-indent", + "ms-python.vscode-pylance", + "ms-python.autopep8" + ] + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..61a88dff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +.git +__pycache__ +*.pyo +*.pyd +.DS_Store +mongodb +napcat +docs/ +.github/ +# test +.env +.venv/ +.pytest_cache/ +.ruff_cache/ +.tmp_*/ +node_modules/ +dashboard/node_modules/ +data/ +logs/ +temp/ +tmp/ +mai_knowledge/ +depends-data/ +!depends-data/ +!depends-data/char_frequency.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitea/workflows/release-offline.yml b/.gitea/workflows/release-offline.yml new file mode 100644 index 00000000..59626f53 --- /dev/null +++ b/.gitea/workflows/release-offline.yml @@ -0,0 +1,181 @@ +name: offline-release + +on: + workflow_dispatch: + +jobs: + build-upload: + runs-on: local-build + steps: + - name: Prepare local worktree + env: + MAIBOT_REPO_URL: https://git.lecspace.com/${{ gitea.repository }}.git + MAIBOT_GIT_REPO_URL: ${{ secrets.MAIBOT_GIT_REPO_URL }} + MAIBOT_REPO_SHA: ${{ gitea.sha }} + MAIBOT_GITEA_USER: ${{ secrets.MAIBOT_GITEA_USER }} + MAIBOT_GITEA_TOKEN: ${{ secrets.MAIBOT_GITEA_TOKEN }} + shell: powershell + run: | + $ErrorActionPreference = "Stop" + Set-StrictMode -Version Latest + + function Add-GitHubEnv { + param([string]$Line) + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::AppendAllText($env:GITHUB_ENV, $Line + [Environment]::NewLine, $utf8NoBom) + } + + $worktreeRoot = Join-Path ([System.IO.Path]::GetTempPath()) "maibot-actions" + $worktree = Join-Path $worktreeRoot $env:MAIBOT_REPO_SHA + $repoUrl = if ([string]::IsNullOrWhiteSpace($env:MAIBOT_GIT_REPO_URL)) { $env:MAIBOT_REPO_URL } else { $env:MAIBOT_GIT_REPO_URL } + + if (Test-Path $worktree) { + Remove-Item -LiteralPath $worktree -Recurse -Force + } + New-Item -ItemType Directory -Force -Path $worktreeRoot | Out-Null + + $gitArgs = @() + if (-not [string]::IsNullOrWhiteSpace($env:MAIBOT_GITEA_TOKEN)) { + $giteaUser = $env:MAIBOT_GITEA_USER + if ([string]::IsNullOrWhiteSpace($giteaUser)) { $giteaUser = "Losita" } + $basicToken = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $giteaUser, $env:MAIBOT_GITEA_TOKEN))) + $gitArgs += @("-c", ("http.extraHeader=Authorization: Basic {0}" -f $basicToken)) + } + + & git @gitArgs clone --no-checkout $repoUrl $worktree + if ($LASTEXITCODE -ne 0) { throw "source clone failed." } + + & git -C $worktree checkout --force $env:MAIBOT_REPO_SHA + if ($LASTEXITCODE -ne 0) { throw "source checkout failed." } + + & git -C $worktree clean -dffx + if ($LASTEXITCODE -ne 0) { throw "source cleanup failed." } + + $appTag = (& git -C $worktree rev-parse --short=12 HEAD).Trim() + Add-GitHubEnv "APP_TAG=$appTag" + Add-GitHubEnv "MAIBOT_WORKTREE=$worktree" + + - name: Build release archive + shell: powershell + run: | + $ErrorActionPreference = "Stop" + Set-StrictMode -Version Latest + + function Add-GitHubEnv { + param([string]$Line) + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::AppendAllText($env:GITHUB_ENV, $Line + [Environment]::NewLine, $utf8NoBom) + } + + Set-Location $env:MAIBOT_WORKTREE + $archiveDir = Join-Path ([System.IO.Path]::GetTempPath()) "maibot-release" + New-Item -ItemType Directory -Force -Path $archiveDir | Out-Null + $archivePath = Join-Path $archiveDir ("mai-bot-{0}.tgz" -f $env:APP_TAG) + if (Test-Path $archivePath) { + Remove-Item -LiteralPath $archivePath -Force + } + + & git archive --format=tar.gz --output=$archivePath HEAD + if ($LASTEXITCODE -ne 0) { throw "release archive failed." } + + Add-GitHubEnv "RELEASE_ARCHIVE=$archivePath" + + - name: Upload release to server + env: + MAIBOT_RELEASE_HOST: ${{ secrets.MAIBOT_RELEASE_HOST }} + MAIBOT_RELEASE_USER: ${{ secrets.MAIBOT_RELEASE_USER }} + MAIBOT_RELEASE_PORT: ${{ secrets.MAIBOT_RELEASE_PORT }} + MAIBOT_RELEASE_ROOT: ${{ secrets.MAIBOT_RELEASE_ROOT }} + MAIBOT_SSH_KEY: ${{ secrets.MAIBOT_SSH_KEY }} + shell: powershell + run: | + $ErrorActionPreference = "Stop" + Set-StrictMode -Version Latest + + $hostName = $env:MAIBOT_RELEASE_HOST + if ([string]::IsNullOrWhiteSpace($hostName)) { $hostName = "192.140.166.210" } + $userName = $env:MAIBOT_RELEASE_USER + if ([string]::IsNullOrWhiteSpace($userName)) { $userName = "root" } + $port = $env:MAIBOT_RELEASE_PORT + if ([string]::IsNullOrWhiteSpace($port)) { $port = "22" } + $releaseRoot = $env:MAIBOT_RELEASE_ROOT + if ([string]::IsNullOrWhiteSpace($releaseRoot)) { $releaseRoot = "/srv/maibot/releases" } + if ($releaseRoot -notmatch '^/srv/maibot/releases(/.*)?$') { throw "release root must stay under /srv/maibot/releases." } + + $remote = "{0}@{1}" -f $userName, $hostName + $remoteArchive = ("{0}/{1}.tgz" -f $releaseRoot.TrimEnd('/'), $env:APP_TAG) + $sshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=30", "-p", $port) + $scpArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=30", "-P", $port) + + if (-not [string]::IsNullOrWhiteSpace($env:MAIBOT_SSH_KEY)) { + $keyPath = Join-Path ([System.IO.Path]::GetTempPath()) ("maibot-release-{0}.key" -f $env:APP_TAG) + $env:MAIBOT_SSH_KEY.Replace("`r`n", "`n") | Out-File -FilePath $keyPath -Encoding ascii -NoNewline + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + & icacls $keyPath /inheritance:r /grant:r "$($env:USERNAME):(R)" | Out-Null + } else { + & chmod 600 $keyPath + } + $sshArgs += @("-i", $keyPath) + $scpArgs += @("-i", $keyPath) + } + + & ssh @sshArgs $remote "mkdir -p '$releaseRoot'" + if ($LASTEXITCODE -ne 0) { throw "remote release root prepare failed." } + + & scp @scpArgs $env:RELEASE_ARCHIVE ("{0}:{1}" -f $remote, $remoteArchive) + if ($LASTEXITCODE -ne 0) { throw "release upload failed." } + + - name: Cleanup worktree + if: ${{ always() }} + shell: powershell + run: | + $ErrorActionPreference = "Stop" + $worktreeRoot = Join-Path ([System.IO.Path]::GetTempPath()) "maibot-actions" + $expectedPrefix = $worktreeRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if (-not [string]::IsNullOrWhiteSpace($env:MAIBOT_WORKTREE) -and $env:MAIBOT_WORKTREE.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + Remove-Item -LiteralPath $env:MAIBOT_WORKTREE -Recurse -Force -ErrorAction SilentlyContinue + } + + deploy: + runs-on: build-host + needs: build-upload + steps: + - name: Deploy release + env: + MAIBOT_REPO_SHA: ${{ gitea.sha }} + MAIBOT_RELEASE_ROOT: ${{ secrets.MAIBOT_RELEASE_ROOT }} + MAIBOT_RUNTIME_ROOT: ${{ secrets.MAIBOT_RUNTIME_ROOT }} + shell: bash + run: | + set -euo pipefail + + app_tag="${MAIBOT_REPO_SHA:0:12}" + release_root="${MAIBOT_RELEASE_ROOT:-/srv/maibot/releases}" + runtime_root="${MAIBOT_RUNTIME_ROOT:-/root/maibot-offline}" + + case "$release_root" in + /srv/maibot/releases|/srv/maibot/releases/*) ;; + *) + echo "release root must stay under /srv/maibot/releases" >&2 + exit 1 + ;; + esac + + case "$runtime_root" in + /root/maibot-offline|/root/maibot-offline/*) ;; + *) + echo "runtime root must stay under /root/maibot-offline" >&2 + exit 1 + ;; + esac + + archive="${release_root}/${app_tag}.tgz" + release_dir="${release_root}/${app_tag}" + + test -f "$archive" + rm -rf "$release_dir" + mkdir -p "$release_dir" + tar -xzf "$archive" -C "$release_dir" + chmod +x "$release_dir/deploy/server-maibot/activate-release.sh" + MAIBOT_RUNTIME_ROOT="$runtime_root" "$release_dir/deploy/server-maibot/activate-release.sh" "$release_dir" + rm -f "$archive" diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..67026a9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Python caches and virtualenvs +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +env/ +venv/ + +# Runtime state +data/ +logs/ +plugins/ +docker-config/ +config/*.toml +config/*.bak* +!config/README.md + +# Frontend local outputs +dashboard/.vite/ +dashboard/node_modules/ +dashboard/dist/ +dashboard/dist-ssr/ + +# Local environment and editor files +.env +.env.* +.idea/ +.DS_Store +*.local + +# Local deployment artifacts +*.log +*.pem +*.key +*.tar +*.tgz +*.zip +acme/ +backups/ +bin/ +_staging/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..8a04e2d8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,10 @@ +repos: +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.9.10 + hooks: + # Run the linter. + - id: ruff + args: [ --fix ] + # Run the formatter. + - id: ruff-format diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e977b378 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# 代码规范 +# import 规范 +在从外部库进行导入时候,请遵循以下顺序: +1. 对于标准库和第三方库的导入,请按照如下顺序: + - 需要使用`from ... import ...`语法的导入放在前面。 + - 直接使用`import ...`语法的导入放在后面。 + - 对于使用`from ... import ...`导入的多个项,请**在保证不会引起import错误的前提下**,按照**字母顺序**排列。 + - 对于使用`import ...`导入的多个项,请**在保证不会引起import错误的前提下**,按照**字母顺序**排列。 +2. 对于本地模块的导入,请按照如下顺序: + - 对于同一个文件夹下的模块导入,使用相对导入,排列顺序按照**不发生import错误的前提下**,随便排列。 + - 对于不同文件夹下的模块导入,使用绝对导入。这些导入应该以`from src`开头,并且按照**不发生import错误的前提下**,尽量使得第二层的文件夹名称相同的导入放在一起;第二层文件夹名称排列随机。 +3. 标准库和第三方库的导入应该放在本地模块导入的前面。 +4. 各个导入块之间应该使用一个空行进行分隔。 +5. 对于现有的代码,如果导入顺序不符合上述规范,在重构代码时应该调整导入顺序以符合规范。 + +## 注释规范 +1. 尽量保持良好的注释 +2. 如果原来的代码中有注释,则重构的时候,除非这部分代码被删除,否则相同功能的代码应该保留注释(可以对注释进行修改以保持准确性,但不应该删除注释)。 +3. 如果原来的代码中没有注释,则重构的时候,如果某个功能块的代码较长或者逻辑较为复杂,则应该添加注释来解释这部分代码的功能和逻辑。 +## 类型注解规范 +1. 重构代码时,如果原来的代码中有类型注解,则相同功能的代码应该保留类型注解(可以对类型注解进行修改以保持准确性,但不应该删除类型注解)。 +2. 重构代码时,如果原来的代码中没有类型注解,则重构的时候,如果某个函数的功能较为复杂或者参数较多,则应该添加类型注解来提高代码的可读性和可维护性。(对于简单的变量,可以不添加类型注解) +3. 对于参数化泛型,应该使用`typing`模块中的类型注解来指定参数化泛型的类型。 + - 例如,使用`List[int]`来表示一个包含整数的列表,使用`Dict[str, Any]`来表示一个键为字符串,值为任意类型的字典。 +## 变量规范 +1. 当确定某个变量/实例是某种类型的时候(优先按照类型注解确定,除非你分析出类型注解是错误的),可以不必使用`or`进行fallback。 + - 例如,`bot_nickname = (global_config.bot.nickname or "").strip()` 可以改为 `bot_nickname = global_config.bot.nickname.strip()`,前提是我们确定`global_config.bot.nickname`一定是一个字符串。 +## 类属性使用规范 +1. 应该尽量减少使用getattr和setattr方法,除非是在对一个动态类进行处理或者使用Monkeypatch完成Pytest +2. 在重构代码时,如果遇到getattr和setattr,应该尝试检查这个类实例是否有这个属性,如果有,则直接替换为类属性访问写法。 + - 举例:`v = getattr(instance, "value", "")` 在检查到`instance`有`value`属性后应该改为`v = instance.value` + +# 运行/调试/构建/测试/依赖 +优先使用uv +依赖项以 pyproject.toml 为准,要同步更新requirements.txt + +# 语言规范 +项目的首选语言为简体中文,无论是注释语言,日志展示语言,还是 WebUI 展示语言都首要以简体中文为首要实现目标 + +# 配置文件修改 +如果你需要改动配置文件,不需要修改实际的bot_config.toml或者model_config.toml,只需要修改配置文件模版,并新增一个版本号即可,也不必要为配置改动创建测试文件。 + + +# 关于 A_memorix 修改 +如果修改涉及 `src/A_memorix`,请先阅读 `src/A_memorix/MODIFICATION_POLICY.md`。 + +# prompt模板、 +涉及对prompt模板的修改,要同步修改英文和日文的文件,对齐到中文 + +默认原则: +1. `src/A_memorix` 的实现层改动应优先遵守 `src/A_memorix/MODIFICATION_POLICY.md` 中的归属约束。 +2. 不要提交无边界的 `ruff`、格式化、导入整理或大面积实现整理。 +3. 本地实验目录或依赖其运行的测试,除非明确说明并确认,否则不要进入共享历史。 + +# maibot插件开发文档 +https://github.com/Mai-with-u/maibot-plugin-sdk/blob/main/docs/guide.md + +# 如何提交maibot插件 +https://github.com/Mai-with-u/plugin-repo/blob/main/CONTRIBUTING.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..f95b6c9b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,120 @@ +# 贡献者契约行为准则 + +## 我们的承诺 + +作为成员、贡献者和维护者,我们承诺为每个人提供友好、安全和受欢迎的环境,无论年龄、体型、身体或精神上的残疾、民族、性别特征、性别认同和表达、经验水平、教育、社会经济地位、国籍、个人外貌、种族、宗教或性取向如何。 + +我们承诺以有助于建立开放、友好、多元化、包容和健康社区的方式行事和互动。 + +## 我们的标准 + +有助于为我们的社区创造积极环境的行为示例包括: + +* 表现出对其他人的同理心和善意 +* 尊重不同的意见、观点和经验 +* 优雅地给出和接受建设性反馈 +* 承担责任,为我们的错误向受影响的人道歉,并从中学习经验 +* 专注于不仅对我们个人,而且对整个社区最有利的事情 +* 使用友善和包容的语言 +* 专业地讨论技术问题,避免人身攻击 + +不可接受的行为示例包括: + +* 使用性暗示的语言或图像,以及任何形式的性关注或性挑逗 +* 恶意评论、侮辱或贬损性评论,以及人身攻击或政治攻击 +* 公开或私下的骚扰 +* 未经明确许可,发布他人的私人信息,如物理地址或电子邮件地址 +* 在专业环境中合理认为不当的其他行为 +* 故意传播错误信息或误导性内容 +* 恶意破坏项目资源或社区讨论 + +## 执行责任 + +社区维护者负责澄清和执行我们可接受行为的标准,并会对他们认为不当、威胁、冒犯或有害的任何行为采取适当和公平的纠正措施。 + +社区维护者有权删除、编辑或拒绝与本行为准则不符的评论、提交、代码、wiki编辑、问题和其他贡献,并会在适当时传达审核决定的原因。 + +## 适用范围 + +本行为准则适用于所有社区空间,包括但不限于: + +* GitHub 仓库及相关讨论区 +* Issue 和 Pull Request 讨论 +* 项目相关的在线论坛、聊天室和社交媒体 +* 项目官方活动和会议 +* 代表项目或社区的任何其他场合 + +当个人代表项目或其社区时,本行为准则也适用于公共空间。代表的示例包括使用官方电子邮件地址、通过官方社交媒体账户发布信息,或在在线或线下活动中担任指定代表。 + +## 特定于MaiBot项目的指导原则 + +### 技术讨论原则 +* 保持技术讨论的专业性和建设性 +* 在提出问题前,请先查看现有文档和已有的issues +* 提供清晰、详细的错误报告和功能请求 +* 尊重不同的技术选择和实现方案 + +### AI/LLM相关内容规范 +* 讨论AI技术应当负责任和伦理 +* 不得分享或讨论可能造成伤害的AI应用 +* 尊重数据隐私和用户权益 +* 遵守相关法律法规和平台政策 + +### 多语言支持 +* 主要使用中文进行交流,但欢迎其他语言的贡献者 +* 对非中文母语用户保持耐心和友善 +* 在必要时提供翻译帮助 + +## 报告机制 + +如果您遇到或目睹违反行为准则的行为,请通过以下方式报告: + +1. **GitHub Issues**: 对于公开的违规行为,可以在相关issue中直接指出 +2. **私下联系**: 可以通过GitHub私信联系项目维护者 + +所有报告都将得到及时和公正的处理。我们承诺保护报告者的隐私和安全。 + +## 执行措施 + +社区维护者将遵循以下社区影响指导原则来确定违反本行为准则的后果: + +### 1. 更正 +**社区影响**: 使用不当语言或其他被认为在社区中不专业或不受欢迎的行为。 + +**后果**: 由社区维护者私下发出书面警告,提供关于违规性质的明确说明和行为不当的原因解释。可能会要求公开道歉。 + +### 2. 警告 +**社区影响**: 通过单个事件或一系列行为违规。 + +**后果**: 警告并说明继续违规的后果。在规定的时间内,不得与相关人员互动,包括主动与执行行为准则的人员互动。这包括避免在社区空间以及外部渠道(如社交媒体)中的互动。违反这些条款可能导致临时或永久禁令。 + +### 3. 临时禁令 +**社区影响**: 严重违反社区标准,包括持续的不当行为。 + +**后果**: 在规定的时间内临时禁止与社区进行任何形式的互动或公开交流。在此期间,不允许与相关人员进行公开或私下互动,包括主动与执行行为准则的人员互动。违反这些条款可能导致永久禁令。 + +### 4. 永久禁令 +**社区影响**: 表现出违反社区标准的模式,包括持续的不当行为、对个人的骚扰,或对某类个人的攻击或贬低。 + +**后果**: 永久禁止在社区内进行任何形式的公开互动。 + +## 归属 + +本行为准则改编自[贡献者契约](https://www.contributor-covenant.org/),版本2.1,可在 https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 获得。 + +社区影响指导原则的灵感来自[Mozilla 的行为准则执行阶梯](https://github.com/mozilla/diversity)。 + +有关本行为准则的常见问题解答,请参见 https://www.contributor-covenant.org/faq。翻译版本可在 https://www.contributor-covenant.org/translations 获得。 + +## 联系方式 + +如果您对本行为准则有任何疑问或建议,请通过以下方式联系我们: + +* 在GitHub上创建issue进行讨论 +* 联系项目维护者 + +--- + +**感谢您帮助我们建设一个友好、包容的开源社区!** + +*最后更新时间: 2025年6月21日* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..a539f03b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Runtime image +FROM python:3.13-slim +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +# Working directory +WORKDIR /MaiMBot + +ENV MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1 +ENV PATH="/MaiMBot/.venv/bin:${PATH}" + +# Copy dependency metadata +COPY pyproject.toml uv.lock ./ + +RUN apt-get update && apt-get install -y git + +# Install runtime dependencies +RUN uv sync --frozen --no-dev --no-install-project + +# Copy project source +COPY . . + +RUN git clone --depth 1 --branch main https://github.com/Mai-with-u/MaiBot-Napcat-Adapter.git plugin-templates/MaiBot-Napcat-Adapter +RUN chmod +x docker-entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT [ "./docker-entrypoint.sh" ] diff --git a/EULA.md b/EULA.md new file mode 100644 index 00000000..ebc7a141 --- /dev/null +++ b/EULA.md @@ -0,0 +1,134 @@ +# **MaiBot最终用户许可协议** + +**版本:V1.2** +**更新日期:2025年12月01日** +**生效日期:2025年12月01日** +**适用的MaiBot版本号:所有版本** + +**2025© MaiBot项目团队** + +--- + +## 一、一般条款 + +**1.1** MaiBot项目(包括MaiBot的源代码、可执行文件、文档,以及其它在本协议中所列出的文件)(以下简称“本项目”)是由开发者及贡献者(以下简称“项目团队”)共同维护,为用户提供自动回复功能的机器人代码项目。以下最终用户许可协议(EULA,以下简称“本协议”)是用户(以下简称“您”)与项目团队之间关于使用本项目所订立的合同条件。 + +**1.2** 在运行或使用本项目之前,您**必须阅读并同意本协议的所有条款**。未成年人或其它无/不完全民事行为能力责任人请**在监护人的陪同下**阅读并同意本协议。如果您不同意,则不得运行或使用本项目。在这种情况下,您应立即从您的设备上卸载或删除本项目及其所有副本。 + +## 二、许可授权 + +### 源代码许可 + +**2.1** 您**了解**本项目的源代码是基于GPLv3(GNU通用公共许可证第三版)开源协议发布的。您**可以自由使用、修改、分发**本项目的源代码,但**必须遵守**GPLv3许可证的要求。详细内容请参阅项目仓库中的LICENSE文件。 + +**2.2** 您**了解**本项目的源代码中可能包含第三方开源代码,这些代码的许可证可能与GPLv3许可证不同。您**同意**在使用这些代码时**遵守**相应的许可证要求. + +### 输入输出内容授权 + +**2.4** 您**了解**本项目是使用您的配置信息、提交的指令(以下简称“输入内容”)和生成的内容(以下简称“输出内容”)构建请求发送到第三方生成回复的机器人项目。 +**2.4** 您**授权**本项目使用您的输入和输出内容按照项目的隐私政策用于以下行为: + +- 调用第三方API生成回复; +- 调用第三方API用于构建本项目专用的存储于您使用的数据库中的知识库和记忆库; +- 调用第三方开发的插件系统功能; +- 收集并记录本项目专用的存储于您使用的设备中的日志; + +**2.4** 您**了解**本项目的源代码中包含第三方API的调用代码,这些API的使用可能受到第三方的服务条款和隐私政策的约束。在使用这些API时,您**必须遵守**相应的服务条款。 + +**2.5** 项目团队**不对**第三方API的服务质量、稳定性、准确性、安全性负责,亦**不对**第三方API的服务变更、终止、限制等行为负责。 + +## 三、用户行为 + +**3.1** 您**了解**本项目会将您的配置信息、输入指令和生成内容发送到第三方,您**不应**在输入指令和生成内容中包含以下内容: + +- 涉及任何国家或地区秘密、商业秘密或其他可能会对国家或地区安全或者公共利益造成不利影响的数据; +- 涉及个人隐私、个人信息或其他敏感信息的数据; +- 任何侵犯他人合法权益的内容; +- 任何违反国家或地区法律法规、政策规定的内容; + +**3.2** 您**不应**将本项目用于以下用途: + +- 违反任何国家或地区法律法规、政策规定的行为; + +**3.3** 您**应当**自行确保您被存储在本项目的知识库、记忆库和日志中的输入和输出内容的合法性与合规性以及存储行为的合法性与合规性。您需**自行承担**由此产生的任何法律责任。 + +**3.4** 对于第三方插件的使用,您**不应**: + +- 安装、使用任何来源不明或未经验证的第三方插件; +- 使用任何违反法律法规、政策规定或第三方平台规则的第三方插件; + +**3.5** 您**应当**自行确保您安装和使用的第三方插件的合法性与合规性以及安装和使用行为的合法性与合规性。您需**自行承担**由此产生的任何法律责任。 + +**3.6** 由于本项目会将您的输入指令和生成内容发送到第三方,当您将本项目用于第三方交流环境(如与除您以外的人私聊、群聊、论坛、直播等)时,您**应当**事先明确告知其他交流参与者本项目的使用情况,包括但不限于: + +- 本项目的输出内容是由人工智能生成的; +- 本项目会将交流内容发送到第三方; +- 本项目的隐私政策和用户行为要求; + +您需**自行承担**由此产生的任何后果和法律责任。 + +**3.7** 项目团队**不鼓励**也**不支持**将本项目用于商业用途,但若您确实需要将本项目用于商业用途,您**应当**标明项目地址(如“本项目由MaiBot()驱动”),并**自行承担**由此产生的任何法律责任。 + +## 四、免责条款 + +**4.1** 本项目的输出内容依赖第三方API,**不受**项目团队控制,亦**不代表**项目团队的观点。 + +**4.2** 除本协议条目2.4提到的隐私政策之外,项目团队**不会**对您提供任何形式的担保,亦**不对**使用本项目的造成的任何直接或间接后果负责。 + +**4.3** 关于第三方插件,项目团队**声明**: + +- 项目团队**不对**任何第三方插件的功能、安全性、稳定性、合规性或适用性提供任何形式的保证或担保; +- 项目团队**不对**因使用第三方插件而产生的任何直接或间接后果承担责任; +- 项目团队**不对**第三方插件的质量问题、技术支持、bug修复等事宜负责。如有相关问题,应**直接联系插件开发者**; + +## 五、其他条款 + +**5.1** 项目团队有权**随时修改本协议的条款**,但**无义务**通知您。修改后的协议将在本项目的新版本中推送,您应定期检查本协议的最新版本。 + +**5.2** 项目团队**保留**本协议的最终解释权。 + +## 附录:其他重要须知 + +### 一、风险提示 + +**1.1** 隐私安全风险 + +- 本项目会将您的配置信息、输入指令和生成内容发送到第三方API,而这些API的服务质量、稳定性、准确性、安全性不受项目团队控制。 +- 本项目会收集您的输入和输出内容,用于构建本项目专用的知识库和记忆库,以提高回复的准确性和连贯性。 + +**因此,为了保障您的隐私信息安全,请注意以下事项:** + +- 避免在涉及个人隐私、个人信息或其他敏感信息的环境中使用本项目; +- 避免在不可信的环境中使用本项目; + +**1.2** 精神健康风险 + +本项目仅为工具型机器人,不具备情感交互能力。建议用户: + +- 避免过度依赖AI回复处理现实问题或情绪困扰; +- 如感到心理不适,请及时寻求专业心理咨询服务; +- 如遇心理困扰,请寻求专业帮助(全国心理援助热线:12355); + +**1.3** 第三方插件风险 + +本项目的插件系统允许加载第三方开发的插件,这可能带来以下风险: + +- **安全风险**:第三方插件可能包含恶意代码、安全漏洞或未知的安全威胁; +- **稳定性风险**:插件可能导致系统崩溃、性能下降或功能异常; +- **隐私风险**:插件可能收集、传输或泄露您的个人信息和数据; +- **合规风险**:插件的功能或行为可能违反相关法律法规或平台规则; +- **兼容性风险**:插件可能与主程序或其他插件产生冲突; + +**因此,在使用第三方插件时,请务必:** + +- 仅从可信来源获取和安装插件; +- 在安装前仔细了解插件的功能、权限和开发者信息; +- 定期检查和更新已安装的插件; +- 如发现插件异常行为,请立即停止使用并卸载; + +### 二、其他 + +**2.1** 争议解决 + +- 本协议适用中国法律,争议提交相关地区法院管辖; +- 若因GPLv3许可产生纠纷,以许可证官方解释为准。 diff --git a/LICENSE b/LICENSE index 1cff56f1..f288702d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,674 @@ -MIT License + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 -Copyright (c) 2026 Losita + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the -following conditions: + Preamble -The above copyright notice and this permission notice shall be included in all copies or substantial -portions of the Software. + The GNU General Public License is a free, copyleft license for +software and other kinds of works. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 00000000..f247b68b --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,28 @@ +### MaiBot用户隐私条款 +**版本:V1.1** +**更新日期:2025年7月10日** +**生效日期:2025年3月18日** +**适用的MaiBot版本号:所有版本** + +**2025© MaiBot项目团队** + +MaiBot项目团队(以下简称项目团队)**尊重并保护**用户(以下简称您)的隐私。若您选择使用MaiBot项目(以下简称本项目),则您需同意本项目按照以下隐私条款处理您的输入和输出内容: + +**1.1** 本项目**会**收集您的输入和输出内容并发送到第三方API,用于生成新的输出内容。因此您的输入和输出内容**会**同时受到本项目和第三方API的隐私政策约束。 + +**1.2** 本项目**会**收集您的输入和输出内容,用于构建本项目专用的仅存储在您使用的数据库中的知识库和记忆库,以提高回复的准确性和连贯性。 + +**1.3** 本项目**会**收集您的输入和输出内容,用于生成仅存储于您部署或使用的设备中的不会上传至互联网的日志。但当您向项目团队反馈问题时,项目团队可能需要您提供日志文件以帮助解决问题。 + +**1.4** 本项目可能**会**收集部分统计信息(如使用频率、基础指令类型)以改进服务,您可在[bot_config.toml]中随时关闭此功能**。 + +**1.5** 关于第三方插件的隐私处理: + - 本项目包含插件系统,允许加载第三方开发者开发的插件; + - **第三方插件可能会**收集、处理、存储或传输您的数据,这些行为**完全由插件开发者控制**,与项目团队无关; + - 项目团队**无法监控或控制**第三方插件的数据处理行为,亦**无法保证**第三方插件的隐私安全性; + - 第三方插件的隐私政策**由插件开发者负责制定和执行**,您应直接向插件开发者了解其隐私处理方式; + - 您使用第三方插件时,**需自行评估**插件的隐私风险并**自行承担**相关后果; + +**1.6** 由于您的自身行为或不可抗力等情形,导致上述可能涉及您隐私或您认为是私人信息的内容发生被泄露、批漏,或被第三方获取、使用、转让等情形的,均由您**自行承担**不利后果,我们对此**不承担**任何责任。**特别地,因使用第三方插件而导致的任何隐私泄露或数据安全问题,项目团队概不负责。** + +**1.7** 项目团队保留在未来更新隐私条款的权利,但没有义务通知您。若您不同意更新后的隐私条款,您应立即停止使用本项目。 \ No newline at end of file diff --git a/README.md b/README.md index 8134ac9e..6a15a842 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,215 @@ -# mai-bot + -forked from https://github.com/Mai-with-u/MaiBot \ No newline at end of file +
+ + + 双语 / Bilingual | 中文 | English + +
+
+ +

麦麦 MaiBot MaiSaka

+ An interactive agent based on large language models. + + +

+ Python Version + License + Status + Contributors + Forks + Stars + Ask DeepWiki +

+
+ +
+ + +MaiBot Character + + + +## 介绍 +Introduction + +麦麦 MaiSaka 是一个基于大语言模型的可交互智能体。 +MaiSaka is an interactive agent based on large language models. + +MaiSaka 不仅仅是一个机器人,不仅仅是一个可以帮你完成任务的“有帮助的助手”,她还是一个致力于了解你,并以真实人类的风格进行交互的数字生命。她不追求完美,不追求高效,但追求亲切和真实。 +MaiSaka is more than just a bot, and more than a "helpful assistant" that completes tasks. She is a digital life form that tries to understand you and interact in a genuinely human style. She does not pursue perfection or efficiency above all else. She pursues warmth and authenticity. + +- 💭 **没有人喜欢 GPT 的语言风格**:麦麦使用了更加自然、贴合人类对话习惯的交互方式,不是长篇大论或者 markdown 格式的分点,而是或长或短的闲谈。 + No one likes GPT-sounding dialogue: MaiSaka uses a more natural conversational style. Instead of long-winded markdown-heavy replies, she chats in a way that feels casual, varied, and human. +- 🎭 **不再是傻乎乎的一问一答**:懂得在合适的时间说话,把握聊天中的气氛,在合适的时候开口,在合适的时候闭嘴。 + No longer stuck in rigid Q&A: She knows when to speak, how to read the room, when to join a conversation, and when to stay quiet. +- 🧠 **麦麦·成为人类**:在多人对话中,麦麦会模仿其他人的说话风格,还会自主理解新词或者小圈子里的黑话,不断进化。 + MaiSaka becoming human: In group conversations, MaiSaka imitates how people around her speak, learns new slang and in-group language, and keeps evolving. +- ❤️ **永远都在更加了解你**:基于心理学中人格理论,麦麦会不断积累对于你的了解,不论是你的信息、喜恶或是行为风格,她都记在心里。 + Always learning more about you: Inspired by personality theory in psychology, MaiSaka gradually builds an understanding of your preferences, traits, habits, and behavior style. +- 🔌 **插件系统**:提供强大的 API 和事件系统,拥有无限扩展可能。 + Plugin system: Provides powerful APIs and an event system with virtually unlimited room for extension. + +### 快速导航 +Quick Navigation + +

+ 🌟 演示视频 Demo Video  |  + 📦 快速入门 Quick Start  |  + 📃 核心文档 Core Documentation  |  + 💬 加入社区 Join Community +

+ + +
+ + + +--- + + + +## 🔥 更新和安装 +Updates and Installation + +> **最新版本: v1.0.0** ([📄 更新日志](changelogs/changelog.md)) +> Latest Version: v1.0.0 (📄 Changelog) + +- **下载**:前往 [Release](https://github.com/MaiM-with-u/MaiBot/releases/) 页面下载最新版本。 + Download: Visit the Release page to get the latest version. +- **启动器**:[Mailauncher](https://github.com/MaiM-with-u/mailauncher/releases/)(仅支持 MacOS,早期开发中)。 + Launcher: Mailauncher (MacOS only, still in early development). + +| 分支 / Branch | 说明 / Description | +| :--- | :--- | +| `main` | ✅ **稳定发布版本(推荐)**
Stable release (recommended) | +| `dev` | 🚧 开发测试版本,包含新功能,可能不稳定
Development testing branch with new features, may be unstable | + + + +### 📚 部署教程 +Deployment Guide + +👉 **[🚀 最新版本部署教程](https://docs.mai-mai.org/manual/deployment/mmc_deploy_windows.html)** +Latest Deployment Guide + +--- + + + +## 💬 讨论与社区 +Discussion and Community + +我们欢迎所有对 MaiBot 感兴趣的朋友加入! +We welcome everyone interested in MaiBot to join us. + +| 类别 / Category | 群组 / Group | 说明 / Description | +| :--- | :--- | :--- | +| **技术交流**
Technical | [麦麦脑电图](https://qm.qq.com/q/RzmCiRtHEW)
MaiBrain EEG | 技术交流 / 答疑
Technical discussion / Q&A | +| **技术交流**
Technical | [麦麦大脑磁共振](https://qm.qq.com/q/VQ3XZrWgMs)
MaiBrain MRI | 技术交流 / 答疑
Technical discussion / Q&A | +| **技术交流**
Technical | [麦麦要当 VTB](https://qm.qq.com/q/wGePTl1UyY)
Mai Wants to Be a VTuber | 技术交流 / 答疑
Technical discussion / Q&A | +| **闲聊吹水**
Casual Chat | [麦麦之闲聊群](https://qm.qq.com/q/JxvHZnxyec)
Mai Casual Chat Group | 仅限闲聊,不答疑
Casual chat only, no support | +| **插件开发**
Plugin Development | [插件开发群](https://qm.qq.com/q/1036092828)
Plugin Dev Group | 进阶开发与测试
Advanced development and testing | + +--- + +## 📚 文档 +Documentation + +> [!NOTE] +> 部分内容可能更新不够及时,请注意版本对应。 +> Some content may not be updated promptly, so please pay attention to version compatibility. + +- **[📚 核心 Wiki 文档](https://docs.mai-mai.org)**:最全面的文档中心,了解麦麦的一切。 + 📚 Core Wiki Documentation: The most comprehensive documentation hub for everything about MaiSaka. + +### 🧩 衍生项目 +Related Projects + +- **[Amaidesu](https://github.com/MaiM-with-u/Amaidesu)**:让麦麦在 B 站开播。 + Let MaiSaka stream on Bilibili. +- **[MoFox_Bot](https://github.com/MoFox-Studio/MoFox-Core)**:基于 MaiCore 0.10.0 的增强型 Fork,更稳定更有趣。 + An enhanced fork based on MaiCore 0.10.0, with improved stability and more fun features. +- **[MaiCraft](https://github.com/MaiM-with-u/Maicraft)**:让麦麦陪你玩 Minecraft(暂时停止维护中)。 + Let MaiSaka accompany you in Minecraft (currently paused). + +--- + +## 💡 设计理念 +Design Philosophy + +> **千石可乐说:** +> SengokuCola says: +> - 这个项目最初只是为了给牛牛 bot 添加一点额外的功能,但是功能越写越多,最后决定重写。其目的是为了创造一个活跃在 QQ 群聊的“生命体”。目的并不是为了写一个功能齐全的机器人,而是一个尽可能让人感知到真实的类人存在。 +> This project originally started as a few extra features for the NiuNiu bot, but it kept growing until a full rewrite became inevitable. The goal was to create a "life form" active in QQ group chats, not a feature-complete bot, but something as human-like and real-feeling as possible. +> - 程序的功能设计理念基于一个核心的原则:“最像而不是好”。 +> The core design principle is: "more lifelike, not merely better." +> - 如果人类真的需要一个 AI 来陪伴自己,并不是所有人都需要一个完美的,能解决所有问题的“helpful assistant”,而是一个会犯错的,拥有自己感知和想法的“生命形式”。 +> If people truly want AI companionship, not everyone needs a perfect "helpful assistant" that solves every problem. Some people may want a life form that can make mistakes and has its own perceptions and thoughts. + +> **xxxxx 说:** +> xxxxx says: +> *Code is open, but the soul is yours.* + +--- + +## 🙋 贡献和致谢 +Contributing and Acknowledgments + +欢迎参与贡献!请先阅读 [贡献指南](docs/CONTRIBUTE.md)。 +Contributions are welcome. Please read the Contribution Guide first. + +### 🌟 贡献者 +Contributors + + + contributors + + +### 🤝 开源项目友链 +Open Source Friends + +- **[AstrBot](https://github.com/AstrBotDevs/AstrBot)**: 优秀的LLM Agent项目 + +### ❤️ 特别致谢 +Special Thanks + +- **[萨卡班甲鱼](https://en.wikipedia.org/wiki/Sacabambaspis)**:千石可乐很喜欢的生物。 + Sacabambaspis: SengokuCola's favorite creature. +- **[略nd](https://space.bilibili.com/1344099355)**:为麦麦绘制早期的精美人设。 + Drew MaiSaka's beautiful early character design. +- **[NapCat](https://github.com/NapNeko/NapCatQQ)**:现代化的基于 NTQQ 的 Bot 协议实现。 + A modern NTQQ-based bot protocol implementation. + +--- + +## 📊 仓库状态 +Repository Status + +![Alt](https://repobeats.axiom.co/api/embed/9faca9fccfc467931b87dd357b60c6362b5cfae0.svg "麦麦仓库状态") + +### Star 趋势 +Star History + +[![Star 趋势](https://starchart.cc/MaiM-with-u/MaiBot.svg?variant=adaptive)](https://starchart.cc/MaiM-with-u/MaiBot) + +--- + +## 📌 注意事项 & License +Notice & License + +> [!IMPORTANT] +> 使用前请阅读 [用户协议 (EULA)](EULA.md) 和 [隐私协议](PRIVACY.md)。AI 生成内容请仔细甄别。 +> Please read the End User License Agreement (EULA) and Privacy Policy before use. Please evaluate AI-generated content carefully. + +**License**: GPL-3.0 diff --git a/bot.py b/bot.py new file mode 100644 index 00000000..6c5ea8e1 --- /dev/null +++ b/bot.py @@ -0,0 +1,407 @@ +# raise RuntimeError("System Not Ready") +from pathlib import Path +from rich.traceback import install + +import asyncio +import hashlib +import os +import platform +# import shutil +import subprocess +import sys +import time +import traceback + +from src.common.i18n import set_locale, t, tn +from src.common.logger import get_logger, initialize_logging, shutdown_logging +from src.config.legacy_upgrade_confirmation import require_legacy_upgrade_confirmation + +# 设置工作目录为脚本所在目录 +script_dir = os.path.dirname(os.path.abspath(__file__)) +os.chdir(script_dir) +set_locale(os.getenv("MAIBOT_LOCALE", "zh-CN")) + +# 检查是否是 Worker 进程,只在 Worker 进程中输出详细的初始化信息 +# Runner 进程只需要基本的日志功能,不需要详细的初始化日志 +is_worker = os.environ.get("MAIBOT_WORKER_PROCESS") == "1" +initialize_logging(verbose=is_worker) +install(extra_lines=3) +logger = get_logger("main") + +# 定义重启退出码 +RESTART_EXIT_CODE = 42 +# print("-----------------------------------------") +# print("\n\n\n\n\n") +# print(t("startup.dev_branch_warning")) +# print("\n\n\n\n\n") +# print("-----------------------------------------") + + +def run_runner_process(): + """ + Runner 进程逻辑:作为守护进程运行,负责启动和监控 Worker 进程。 + 处理重启请求 (退出码 42) 和 Ctrl+C 信号。 + """ + script_file = sys.argv[0] + python_executable = sys.executable + + # 设置环境变量,标记子进程为 Worker 进程 + env = os.environ.copy() + env["MAIBOT_WORKER_PROCESS"] = "1" + + while True: + logger.info(t("startup.launching_script", script_file=script_file)) + logger.info(t("startup.compiling_shaders")) + + # 启动子进程 (Worker) + # 使用 sys.executable 确保使用相同的 Python 解释器 + cmd = [python_executable, script_file] + sys.argv[1:] + + process = subprocess.Popen(cmd, env=env) + + try: + # 等待子进程结束 + return_code = process.wait() + + if return_code == RESTART_EXIT_CODE: + logger.info(t("startup.restart_requested", exit_code=RESTART_EXIT_CODE)) + time.sleep(1) # 稍作等待 + continue + else: + logger.info(t("startup.program_exited", return_code=return_code)) + sys.exit(return_code) + + except KeyboardInterrupt: + # 向子进程发送终止信号 + if process.poll() is None: + # 在 Windows 上,Ctrl+C 通常已经发送给了子进程(如果它们共享控制台) + # 但为了保险,我们可以尝试 terminate + try: + process.terminate() + process.wait(timeout=5) + except subprocess.TimeoutExpired: + logger.warning(t("startup.child_process_force_kill")) + process.kill() + sys.exit(0) + + +# 检查是否是 Worker 进程 +# 如果没有设置 MAIBOT_WORKER_PROCESS 环境变量,说明是直接运行的脚本, +# 此时应该作为 Runner 运行。 +if os.environ.get("MAIBOT_WORKER_PROCESS") != "1": + if __name__ == "__main__": + require_legacy_upgrade_confirmation(Path(script_dir)) + run_runner_process() + # 如果作为模块导入,不执行 Runner 逻辑,但也不应该执行下面的 Worker 逻辑 + sys.exit(0) + +# 以下是 Worker 进程的逻辑 + +# 最早期初始化日志系统,确保所有后续模块都使用正确的日志格式 +# 注意:Runner 进程已经在第 37 行初始化了日志系统,但 Worker 进程是独立进程,需要重新初始化 +# 由于 Runner 和 Worker 是不同进程,它们有独立的内存空间,所以都会初始化一次 +# 这是正常的,但为了避免重复的初始化日志,我们在 initialize_logging() 中添加了防重复机制 +# 不过由于是不同进程,每个进程仍会初始化一次,这是预期的行为 + +require_legacy_upgrade_confirmation(Path(script_dir)) + +logger.info(t("startup.worker_dir_set", script_dir=script_dir)) + +from src.main import MainSystem # noqa +from src.manager.async_task_manager import async_task_manager # noqa + + +# logger = get_logger("main") + + +# install(extra_lines=3) + +# 设置工作目录为脚本所在目录 +# script_dir = os.path.dirname(os.path.abspath(__file__)) +# os.chdir(script_dir) +confirm_logger = get_logger("confirm") +# 获取没有加载env时的环境变量 +env_mask = {key: os.getenv(key) for key in os.environ} + +uvicorn_server = None +driver = None +app = None +loop = None + + +def print_opensource_notice(): + """打印开源项目提示,防止倒卖""" + from colorama import init, Fore, Style + + init() + + notice_lines = [ + "", + f"{Fore.CYAN}{'═' * 70}{Style.RESET_ALL}", + f"{Fore.GREEN}{t('startup.opensource_title')}{Style.RESET_ALL}", + f"{Fore.CYAN}{'─' * 70}{Style.RESET_ALL}", + f"{Fore.YELLOW}{t('startup.opensource_free_notice')}{Style.RESET_ALL}", + f"{Fore.WHITE}{t('startup.opensource_scamming_notice')}{Style.RESET_ALL}", + "", + f"{Fore.WHITE}{t('startup.opensource_repo')}{Fore.BLUE}{t('startup.opensource_repo_value')} {Style.RESET_ALL}", + f"{Fore.WHITE}{t('startup.opensource_docs')}{Fore.BLUE}{t('startup.opensource_docs_value')} {Style.RESET_ALL}", + f"{Fore.WHITE}{t('startup.opensource_group')}{Fore.BLUE}{t('startup.opensource_group_value')}{Style.RESET_ALL}", + f"{Fore.CYAN}{'─' * 70}{Style.RESET_ALL}", + f"{Fore.RED} ⚠ {t('startup.opensource_resale_warning').strip()}{Style.RESET_ALL}", + f"{Fore.CYAN}{'═' * 70}{Style.RESET_ALL}", + "", + ] + + for line in notice_lines: + print(line) + + +def easter_egg(): + # 彩蛋 + from colorama import init, Fore + + init() + text = t("startup.easter_egg") + rainbow_colors = [Fore.RED, Fore.YELLOW, Fore.GREEN, Fore.CYAN, Fore.BLUE, Fore.MAGENTA] + rainbow_text = "" + for i, char in enumerate(text): + rainbow_text += rainbow_colors[i % len(rainbow_colors)] + char + print(rainbow_text) + + +async def graceful_shutdown(): # sourcery skip: use-named-expression + try: + logger.info(t("startup.shutdown_started")) + + # 关闭 WebUI 服务器 + # try: + # from src.webui.webui_server import get_webui_server + + # webui_server = get_webui_server() + # if webui_server and webui_server._server: + # await webui_server.shutdown() + # except Exception as e: + # logger.warning(f"关闭 WebUI 服务器时出错: {e}") + + from src.core.event_bus import event_bus + from src.core.types import EventType + + # 触发 ON_STOP 事件 + await event_bus.emit(event_type=EventType.ON_STOP) + + # 停止新版本插件运行时 + from src.plugin_runtime.integration import get_plugin_runtime_manager + + await get_plugin_runtime_manager().stop() + + # 停止所有异步任务 + await async_task_manager.stop_and_wait_all_tasks() + + # 获取所有剩余任务,排除当前任务 + remaining_tasks = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()] + + if remaining_tasks: + logger.info(tn("startup.remaining_tasks_cancelling", len(remaining_tasks))) + + # 取消所有剩余任务 + for task in remaining_tasks: + if not task.done(): + task.cancel() + + # 等待所有任务完成,设置超时 + try: + await asyncio.wait_for(asyncio.gather(*remaining_tasks, return_exceptions=True), timeout=15.0) + logger.info(t("startup.remaining_tasks_cancelled")) + except asyncio.TimeoutError: + logger.warning(t("startup.remaining_tasks_cancel_timeout")) + except Exception as e: + logger.error(t("startup.remaining_tasks_cancel_error", error=e)) + + logger.info(t("startup.shutdown_completed")) + + except Exception as e: + logger.error(t("startup.shutdown_failed", error=e), exc_info=True) + + +def _calculate_file_hash(file_path: Path, file_type: str) -> str: + """计算文件的MD5哈希值""" + if not file_path.exists(): + logger.error(t("startup.file_not_found", file_type=file_type)) + raise FileNotFoundError(t("startup.file_not_found", file_type=file_type)) + + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + return hashlib.md5(content.encode("utf-8")).hexdigest() + + +def _check_agreement_status(file_hash: str, confirm_file: Path, env_var: str) -> tuple[bool, bool]: + """检查协议确认状态 + + Returns: + tuple[bool, bool]: (已确认, 未更新) + """ + # 检查环境变量确认 + if file_hash == os.getenv(env_var): + return True, False + + # 检查确认文件 + if confirm_file.exists(): + with open(confirm_file, "r", encoding="utf-8") as f: + confirmed_content = f.read() + if file_hash == confirmed_content: + return True, False + + return False, True + + +def _prompt_user_confirmation(eula_hash: str, privacy_hash: str) -> None: + """提示用户确认协议""" + confirm_logger.critical(t("startup.agreement_reconfirm")) + confirm_logger.critical( + t( + "startup.agreement_confirm_prompt", + eula_hash=eula_hash, + privacy_hash=privacy_hash, + ) + ) + + while True: + user_input = input().strip().lower() + if user_input in ["同意", "confirmed"]: + return + confirm_logger.critical(t("startup.agreement_confirm_retry")) + + +def _save_confirmations(eula_updated: bool, privacy_updated: bool, eula_hash: str, privacy_hash: str) -> None: + """保存用户确认结果""" + if eula_updated: + logger.info( + t( + "startup.agreement_updated", + agreement_name=t("startup.eula_name"), + file_hash=eula_hash, + ) + ) + Path("eula.confirmed").write_text(eula_hash, encoding="utf-8") + + if privacy_updated: + logger.info( + t( + "startup.agreement_updated", + agreement_name=t("startup.privacy_name"), + file_hash=privacy_hash, + ) + ) + Path("privacy.confirmed").write_text(privacy_hash, encoding="utf-8") + + +def check_eula(): + """检查EULA和隐私条款确认状态""" + # 计算文件哈希值 + eula_hash = _calculate_file_hash(Path("EULA.md"), "EULA.md") + privacy_hash = _calculate_file_hash(Path("PRIVACY.md"), "PRIVACY.md") + + # 检查确认状态 + eula_confirmed, eula_updated = _check_agreement_status(eula_hash, Path("eula.confirmed"), "EULA_AGREE") + privacy_confirmed, privacy_updated = _check_agreement_status( + privacy_hash, Path("privacy.confirmed"), "PRIVACY_AGREE" + ) + + # 早期返回:如果都已确认且未更新 + if eula_confirmed and privacy_confirmed: + return + + # 如果有更新,需要重新确认 + if eula_updated or privacy_updated: + _prompt_user_confirmation(eula_hash, privacy_hash) + _save_confirmations(eula_updated, privacy_updated, eula_hash, privacy_hash) + + +def raw_main(): + # 利用 TZ 环境变量设定程序工作的时区 + if platform.system().lower() != "windows": + time.tzset() # type: ignore + + # 打印开源提示(防止倒卖) + print_opensource_notice() + + check_eula() + logger.info(t("startup.eula_privacy_checked")) + + easter_egg() + + # 返回MainSystem实例 + return MainSystem() + + +if __name__ == "__main__": + exit_code = 0 # 用于记录程序最终的退出状态 + try: + # 获取MainSystem实例 + main_system = raw_main() + + # 创建事件循环 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # 初始化 WebSocket 日志推送 + from src.common.logger import initialize_ws_handler + + initialize_ws_handler(loop) + + try: + # 执行初始化和任务调度 + loop.run_until_complete(main_system.initialize()) + # Schedule tasks returns a future that runs forever. + # We can run console_input_loop concurrently. + main_tasks = loop.create_task(main_system.schedule_tasks()) + loop.run_until_complete(main_tasks) + + except KeyboardInterrupt: + logger.warning(t("startup.interrupt_received")) + + # 取消主任务 + if "main_tasks" in locals() and main_tasks and not main_tasks.done(): + main_tasks.cancel() + try: + loop.run_until_complete(main_tasks) + except asyncio.CancelledError: + pass + + # 执行优雅关闭 + if loop and not loop.is_closed(): + try: + loop.run_until_complete(graceful_shutdown()) + except Exception as ge: + logger.error(t("startup.graceful_shutdown_error", error=ge)) + # 新增:检测外部请求关闭 + + except SystemExit as e: + # 捕获 SystemExit (例如 sys.exit()) 并保留退出代码 + if isinstance(e.code, int): + exit_code = e.code + else: + exit_code = 1 if e.code else 0 + if exit_code == RESTART_EXIT_CODE: + logger.info(t("startup.restart_signal_received")) + + except Exception as e: + logger.error(t("startup.main_error", error=f"{str(e)} {str(traceback.format_exc())}")) + exit_code = 1 # 标记发生错误 + finally: + # 确保 loop 在任何情况下都尝试关闭(如果存在且未关闭) + if "loop" in locals() and loop and not loop.is_closed(): + loop.close() + print(t("startup.event_loop_closed")) + + # 关闭日志系统,释放文件句柄 + try: + shutdown_logging() + except Exception as e: + print(t("startup.logging_shutdown_error", error=e)) + + print(t("startup.prepare_exit")) + + # 使用 os._exit() 强制退出,避免被阻塞 + # 由于已经在 graceful_shutdown() 中完成了所有清理工作,这是安全的 + os._exit(exit_code) diff --git a/changelogs/changelog.md b/changelogs/changelog.md new file mode 100644 index 00000000..504cd23e --- /dev/null +++ b/changelogs/changelog.md @@ -0,0 +1,1437 @@ +# [1.0.0] - 2026-5-2 + +## 核心功能更新 + +### MaiSaka / 回复系统 + +- 原生支持多模态模型、工具调用、多轮工具调用与 MCP。 +- 升级 replyer 回复器,统一群聊与私聊回复链路,并支持多模态回复。 +- 优化能力选择、工具调用和回复生成的稳定性。 +- 支持单独的上下文长度与回复频率控制。 + +### 记忆系统 / A_Memorix + +- 引入并主线化 A_Memorix 长期记忆系统,替代旧记忆链路。 +- 超级优化 A-Mem 记忆算法,显著提升长期记忆检索、写回、迁移和反馈修正能力。 +- 优化长期记忆控制台体验,完善记忆管理、知识库反馈和相关 WebUI 接口。 + +### 插件系统 / Runtime + +- 提供独立插件开发 SDK,并重构插件系统为 plugin_runtime。 +- 插件组件支持按群聊类型启用、按 session_id 启用,以及黑名单/白名单控制。 +- 支持插件对 LLM provider 进行兼容适配。 + +### WebUI / API + +- WebUI 新增或重构聊天组件、人格与表情配置表单、知识库相关界面。 +- API 响应模型与文档完成部分重构。 +- 日志系统新增上限和配置,避免日志持续膨胀。 + + +## 完整更新清单 + +### 核心架构 + +- 大规模重构核心运行结构,新增 `src/services` 服务层,包括 LLM、生成器、发送、消息、数据库、记忆、HTML 渲染、Embedding 等服务。 +- 新增统一的 `platform_io` 消息平台抽象,提供驱动、路由、去重、出站追踪、插件驱动和旧版驱动兼容。 +- 引入新的消息中间层和网关设计,为插件、适配器、主程序之间的消息流转建立统一基础。 +- 重构数据模型,新增聊天目标、规划动作、回复生成结果、LLM 服务请求、API 响应等模型。 +- 新增数据库迁移管理器,支持迁移进度记录、表级/记录级追踪和旧数据兼容。 +- 统一机器人识别逻辑,支持多平台场景,包括 WebUI。 + +### MaiSaka / 回复系统 + +- 新增并持续完善 maisaka 主回复链路,逐步接管群聊与私聊回复逻辑。 +- 新增 planner / replyer / timing / subagent 等运行结构,支持 wait 打断、防抖、重试和状态监控。 +- 新增 Maisaka 实时聊天流监控、阶段状态面板、控制台工具调用展示、prompt log HTML 预览。 +- 精简表达选择逻辑,表达方式模型改为 replyer,并支持开启表达方式简化模式。 +- 回复器支持多模态与非多模态统一行为,新增模型 visual 参数,避免非多模态模型误传图片。 +- 支持复杂消息、转发消息、图片原始数据解析、URL 图片浏览、表情包类消息标记。 +- 优化上下文压缩,显示实时上下文占用,压缩早期 assistant 信息。 +- 私聊支持独立上下文长度和回复频率控制,群聊与私聊链路更统一。 +- 新增聊天特定额外 prompt、多语言 prompt、prompt 独立文件管理、用户自定义 prompt 与覆盖能力。 +- 新增工具索引展开方式和按顺序选择相关能力,压缩工具描述,提高工具调用成功率。 +- 修复无参工具、孤儿工具、Gemini tool、timing gate 非法工具调用等问题。 +- 新增内置 at 动作与原生 @ 能力,可按需关闭引用回复。 +- 新增回复后打分追踪器,用于记录和分析回复效果。 +- 优化回复频率控制、引用回复概率、打字时间、重复思考、wait 行为和 replyer 空回复处理。 + +### 记忆系统 / A_Memorix + +- 新增并主线化 A_Memorix 长期记忆系统,包含运行时、检索、存储、管理界面和迁移脚本。 +- 超级优化 A-Mem 记忆算法,提升检索准确率、长期记忆写回质量和整体稳定性。 +- 新增记忆测试、检索工具、记忆服务、记忆自动化钩子与写回链路。 +- 支持将旧 LPMM/旧记忆数据迁移到新长期记忆系统。 +- 优化记忆检索速度、token 消耗、时间信息、上下文检索方式和人物事实提取。 +- 优化长期记忆控制台体验,提升记忆查看、调试与管理效率。 +- 新增记忆反馈修正、知识库反馈详情、图存储持久化、总结导入、embedding 维度控制等回归测试。 +- 移除旧 `memory_system` 中的大量检索工具与聊天总结逻辑,改由新服务层和 A_Memorix 承担。 + +### 插件系统 / Runtime + +- 大规模替换旧 `plugin_system`,新增 `plugin_runtime`。 +- 新增插件能力注册、组件注册、事件分发、Hook 分发、API 注册、Supervisor、Runner、RPC Server/Client。 +- 插件组件支持按群聊类型启用、按 session_id 启用,以及黑名单/白名单控制。 +- 插件支持通过 msg_id 获取消息,提升插件对上下文消息的追溯能力。 +- 支持插件 manifest 校验、包式插件导入、临时 `sys.path` 管理、导入保护和模块访问控制。 +- 新增插件配置版本管理、配置归一化、运行时配置校验、批量插件重载。 +- 新增插件依赖流水线、HTML 渲染服务、插件 SDK 集成增强。 +- 新增旧数据库 peewee 兼容层,初步重构插件 database API。 +- 新增插件侧消息网关能力、出站追踪、会话 ID 计算和适配器回执消息 ID 更新。 +- 支持插件对 LLM provider 进行兼容适配。 +- 默认禁用示例插件。 +- 修复 Windows 平台插件运行时信号处理、DLL 导入隔离、包式导入、重载机制等问题。 +- 限制 maibot-plugin-sdk 版本范围,并升级到 2.3.0 相关适配。 + +### MCP / 工具系统 + +- 新增独立 `mcp_module`,包含连接、管理、Provider、Host LLM Bridge、Hook 与数据模型。 +- 引入统一插件与 MCP 工具系统,移除旧工具系统和 tool_use 模型。 +- 工具支持索引检索、延迟展开、统一控制台展示、失败请求留档与重试分析。 +- 新增 host LLM bridge,使 MCP 工具和宿主模型调用链路更统一。 +- 修复 timing gate 场景下意外启用 tool 的问题。 + +### 模型 / 缓存 / 配置 + +- 配置系统引入 ConfigBase 测试与更严格校验,支持自动检测并升级旧版配置。 +- 支持 Union / Optional 字段转换,并禁止不安全的多类型 Union。 +- 更新 config 初始化流程,新增配置版本并加入工具筛选、回复器、多模态、Maim Message、日志颜色等配置。 +- 支持模型请求缓存相关配置,大幅优化缓存命中率,并进一步优化模型缓存命中率。 +- 修改部分模型调用链路,修复部分模型请求问题和 deepseek v4 相关问题。 +- 修复 OpenRouter 和 Groq 的 reasoning 字段支持问题。 +- 修复门控在部分模型出错的问题。 +- 模型配置移除无用模型、utils_small、弃用的 LLM_judge 类型和 tool_use 模型。 +- 新增模型随机选择策略、模型 visual 参数、OpenAI 兼容性增强。 +- visual_style 配置项改为模板内容,人设结构完成调整。 +- 表达方式模型改为 replyer,并可开启简化模式。 +- 移除 Planner 问题配置项、无用配置、旧路径显示配置、模板配置文件和部分内部温度设定。 +- 修复 Qwen 3.5 空回复、Gemini 请求思考签名、部分模型不支持 gif、OpenAI client 工具请求等问题。 +- 移除 uv.lock,更新 pyproject.toml / requirements.txt 依赖,最终 HEAD 又移除部分依赖。 + +### WebUI / API + +- WebUI 后端整体重构,拆分为 app、依赖、中间件、routers、schemas、services、utils 等结构。 +- 新增统一 WebSocket 连接管理与路由。 +- 新增或重构聊天组件、人格与表情配置表单、知识库相关界面。 +- 新增聊天、配置、表情包、表达方式、黑话、插件、记忆、知识库、统计、系统等路由重构。 +- 新增规划器和回复器监控 API、日志搜索、日志上限数量配置、prompt log 预览。 +- API 响应模型与文档完成部分重构。 +- 新增本地已安装插件 README 读取 API、插件安装/配置/运行时管理相关 API。 +- 新增静态资源包提示和错误处理,后续修复为仅使用包内 WebUI 静态资源。 +- 修复 knowledgebase 反馈详情类型问题、WebUI memory 路由、配置 schema 测试、回复格式和部分显示问题。 +- 注意:历史中有大量 dashboard 前端提交和 WebUI dist 迁移/删除,但本次没有修改 dashboard。 + +### 表情包 / 图片 + +- 新增表情包系统重构,包含注册、识别、缓存、发送、选择、数据库迁移。 +- 表情包选择改为一次性选择全部,支持配置,并接入 subagent。 +- 移除旧内置 emoji 插件,改为 Maisaka 内置动作或新系统能力。 +- 修复表情包发送无记录、识别失败、缓存问题、图片存储问题、图片过大自动重试等。 +- 新增异步后台图片/表情处理、图片展示模式优化、复杂消息查看。 + +### 表达方式 / 黑话 / 学习 + +- 新增自动表达优化、表达方式检查脚本、表达方式最后修改来源字段。 +- 表达方式模型改为 replyer,表达选择逻辑进一步精简,优化 replyer 表现。 +- 表达方式支持开启简化模式。 +- 修复私聊表达风格随机、表达方式学习与使用、表达方式全局共享。 +- 新增 planner 黑话缓存,恢复表达学习、黑话学习、黑话使用和表达使用。 +- 修复黑话提取学习缓存和 Jargon 提取问题。 +- 新增表达方式快速版本,优化表达方式提取与 LLM 判断标记。 + +### 日志 / 部署 / 工程 + +- 日志系统新增上限和配置,避免日志膨胀。 +- 溢出部分冗余 log,减少运行时噪音。 +- 修复 Docker 非交互模式无法启动的问题。 +- 优化部分导入,加快启动速度。 +- 更新 README、徽章、快速导航、版本信息和主仓库地址。 +- 新增/更新 changelog、设计文档、todo、记忆契约文档、Caddy 反向代理与 TLS/SSL 文档。 +- 新增 AGENTS.md,并更新代码规范、导入顺序、注释规范、语言规范。 +- 新增 Crowdin 配置和多语言资源,包含中英日韩等 locale。 +- 新增 CodeRabbit 配置、PR 模板、测试计划和若干调试/迁移脚本。 +- 新增 agentlite 子项目/模块,包含 agent、tool、provider、skills、MCP、文件/网页/shell 工具和大量测试、示例、文档。 +- 添加 astrbot 友链之粉毛必定女装。 + + + + + + + +# [0.12.2] - 2026-1-11 + +## 功能更改 + +- 优化私聊wait逻辑 +- 超时时强制引用回复 +- 修复部分适配器断联问题 +- 修复表达反思配置未生效 +- 优化记忆检索逻辑 +- 更新readme + +# [0.12.1] - 2025-12-31 + +## 🌟 主要更新 + +- 添加年度总结!可以在webui查看 +- 可选让llm判定引用回复 +- 表达方式优化!现在可以进行自动和手动评估,使其更精准 +- 回复和规划记录!webui可以查看每一条回复和plan的详情 + +## 细节功能更改 + +- 优化间隔过长消息的显示 +- enable_jargon_detection +- global_memory_blacklist。指定部分群聊不参与全局记忆 +- 移除utils_small模型,移除弃用的lpmm模型 + +# [0.12.0] - 2025-12-21 + +## 🌟 重大更新 + +- 添加思考力度机制,动态控制回复时间和长度 +- planner和replyer现在开启联动,更好的回复逻辑 +- 新的私聊系统,吸收了pfc的优秀机制 +- 增加麦麦做梦功能 +- mcp插件作为内置插件加入,默认不启用 +- 添加全局记忆配置项,现在可以选择让记忆为全局的 + +## 🌟 WebUI 重大更新 + +- **模型预设市场功能正式完善并发布**:现在可以将模型配置完整分享,分享按钮位于模型配置界面右上角 +- **全面安全加固**:为所有 WebUI API 和 WebSocket 端点添加身份认证保护,Cookie 添加 Secure 和 SameSite 属性,支持环境感知动态配置 +- **前端认证重构**:从 localStorage 迁移到 HttpOnly Cookie,新增 WebSocket 临时 token 认证机制,解决跨域开发环境下 Cookie 无法携带的问题 +- **增强插件配置管理**:支持原始 TOML 配置的加载和保存,前端支持查看和编辑插件配置文件源文件 + +## 细节功能更改 + +- 移除频率自动调整 +- 移除情绪功能 +- 优化记忆差许多呢超时设置 +- 部分配置为0的bug +- 插件安装时可以主动选择克隆的分支 +- 首页中反馈问卷功能,可以提交反馈信息和建议信息 +- 黑话和表达不再提取包含名称的内容 +- 模型界面支持编辑 extra params 额外字段 +- 模型界面中的任务分配子界面支持编辑慢请求检测阈值 +- 模型界面中支持对单个模型单独指定温度参数和 max tokens 参数 +- 首页所有数据卡片支持自动选择单位+显示详细信息功能 +- WebUI 聊天室表情包、图片、富文本消息支持 +- 麦麦适配器配置界面的工作模式支持折叠 +- WebUI 插件配置解析支持动态 list 表单 +- WebUI 插件配置中的动态 list 支持开关、滑块和下拉框类型 +- 在插件商场、插件配置详情界面增加了重启按钮 +- 加强安全性和隐私保护:添加登录接口速率限制,防止暴力破解攻击,收紧 CORS 配置(限制允许的 HTTP 方法和请求头),完善路径校验(validate_safe_path 防止目录穿越攻击),fetchWithAuth 支持 FormData 文件上传,新增 robots.txt 路由和 X-Robots-Tag 响应头防止搜索引擎索引,前端添加 meta noindex/nofollow 标签阻止爬虫收录 +- 修复并优化了聊天室、模型配置、日志查看器、黑话管理、WebUI 端口占用、配置向导、首页图表、聊天室消息重复、移动端日志不可见、模型提供商删除、主程序配置换行符、HTTP 警告横幅、重启界面、LPMM 配置、人物信息、插件端点安全认证、WebSocket token 等问题,提升整体稳定性与体验。 +- 完成主程序配置与模型配置界面重构、模型提供商与麦麦适配器配置重构(引入 TOML 校验)、WebSocket 认证逻辑抽取为共享模块统一 WS 端点,升级 React 到 19.2.1 并更新依赖,WebUI 配置与可视化全部迁移到主配置及模型配置中,优化配置更新提示、插件详情页面和路径安全校验,并增强模型与梦境等多项配置的可视化和自动检验。 + +# [0.11.6] - 2025-12-2 + +### 🌟 重大更新 + +- 大幅提高记忆检索能力,略微提高token消耗 +- 重构历史消息概括器,更好的主题记忆 +- 日志查看器性能革命性优化 +- 支持可视化查看麦麦LPMM知识图谱 +- 支持根据不同的模型提供商/模板/URL自动获取模型,可以不用手动输入模型了 +- 新增Baka引导系统,使用React-JoyTour实现很棒的用户引导系统(让Baka也能看懂!) +- 本地聊天室功能!!你可以直接在WebUI网页和麦麦聊天!! +- 使用cookie模式替换原有的LocalStorage Token存储,可能需要重新手动输入一遍Token +- WebUI本地聊天室支持用户模拟和平台模拟的功能! +- WebUI新增黑话管理 & 编辑界面 + +### 细节功能更改 + +- 可选记忆识别中是否启用jargon +- 解耦表情包识别和图片识别 +- 修复部分破损json的解析问题 +- 黑话更高的提取效率,增加提取准确性 +- 升级jargon,更快更精准 +- 新增Lpmm可视化 + +### webui细节更新 + +- 修复侧边栏收起、UI及表格横向滚动等问题,优化Toast动画 +- 修复适配器配置、插件克隆、表情包注册等相关BUG +- 新增适配器/模型预设模式及模板,自动填写URL和类型 +- 支持模型任务列表拖拽排序 +- 更新重启弹窗和首次引导内容 +- 多处界面命名及标题优化,如模型配置相关菜单重命名和描述更新 +- 修复聊天配置“提及回复”相关开关命名错误 +- 调试配置新增“显示记忆/Planner/LPMM Prompt”选项 +- 新增卡片尺寸、排序、字号、行间距等个性化功能 +- 聊天ID及群聊选择优化,显示可读名称 +- 聊天编辑界面精简字段,新增后端聊天列表API支持 +- 默认行间距减小,显示更紧凑 +- 修复页面滚动、表情包排序、发言频率为0等问题 +- 新增React异常Traceback界面及模型列表搜索 +- 更新WebUI Icon,修复适配器docker路径等问题 +- 插件配置可视化编辑,表单控件/元数据/布局类型扩展 +- 新增插件API与开发文档 +- 新增机器人状态卡片和快速操作按钮 +- 调整饼图显示、颜色算法,修复部分统计及解析错误 +- 新增缓存、WebSocket配置 +- 表情包支持上传和缩略图 +- 修复首页极端加载、重启后CtrlC失效、主程序配置移动端适配等问题 +- 新增表达反思设置和WebUI聊天室“思考中”占位组件 +- 细节如移除部分字段或UI控件、优化按钮/弹窗/编辑逻辑等 + +# [0.11.5] - 2025-11-21 + +### 🌟 重大更新 + +- WebUI 现支持手动重启麦麦,曲线救国版“热重载” +- 新增麦麦 QQ 适配器可视化编辑 UI(独立进程,需手动上传/下载并覆盖适配器文件) +- 麦麦主程序配置支持可视化模式与源代码模式双模式编辑,后端执行 TOML 校验 +- 优化 planner 与 replyer 协同机制,调试日志更细 + +### 新增 + +- 表情包管理、人物信息管理、表达方式管理界面手机端适配 +- 配置页“重启麦麦”提示 +- 详细的debug prompt显示配置 +- 麦麦界面操作主题色按钮 +- 前端集成 CodeMirror(Python/JSON/TOML 语法高亮)并对 JSON 配置提供自动纠错提示 + +### 修复 + +- 表情包缩略图过小 +- 添加模型后无法立即显示 +- 插件市场分类错误 +- 浅色模式下日志查看器背景异常 +- 表情包详情窗口无法关闭 +- 情绪标签无法正常读取(issues/1373) +- 模型任务配置界面模型温度不可更改(issues/1369) +- 模型任务配置界面部分输入框无法删除默认值 +- 插件商店默认勾选“仅显示兼容插件” +- 插件商店标签页数量显示错误 +- 日志查看器日志换行异常 +- 主题色未正确应用 +- 侧边栏滚动条问题 +- 移除前端 TOML 解析库导致的兼容问题 + +### 优化 + +- 首页加载用户体验 +- 首页加载速度(9s → <1s) +- 整个界面的初屏加载速度 + +### 更新 + +- 适配器配置支持上传模式与指定路径模式;指定路径模式可免去反复上传/下载配置文件 +- 适配器配置界面标签页响应式优化,小屏仅显示简短标签 +- 麦麦资源管理所有界面的批量删除能力 +- 资源管理所有界面的分页、每页数量选择与页跳转 +- 插件市场新增点赞、点踩、评分与下载量统计(基于 Cloudflare,保证国内可访问) +- 麦麦表情包查看界面的描述部分支持 Markdown 渲染 + +# [0.11.4] - 2025-11-19 + +### 🌟 主要更新内容 + +- **首个官方 Web 管理界面上线**:在此版本之前,MaiBot 没有 WebUI,所有配置需手动编辑 TOML 文件 +- **认证系统**:Token 安全登录(支持系统生成 64 位随机令牌 / 自定义 Token),首次配置向导 +- **配置管理(可视化编辑,无需手动改 TOML)**: + - 麦麦主程序配置:基础设置、人格、表情、黑话、情绪等 + - 模型提供商配置:OpenAI、Anthropic、DeepSeek、Qwen、Ollama 等 + - 模型配置:对话/视觉/嵌入模型分配 +- **资源管理**: + - 表情包管理:查看、搜索、注册、封禁 + - 表达方式管理:查看麦麦的表达记录 + - 人物信息管理:查看联系人列表 +- **插件系统**: + - 插件市场浏览 + - 一键安装/卸载/更新 + - 版本兼容性检查 + - 实时安装进度推送 +- **日志查看器**: + - WebSocket 实时日志流 + - 日志级别过滤(DEBUG/INFO/WARNING/ERROR/CRITICAL) + - 搜索功能 +- **主题定制**: + - 浅色/深色/跟随系统 + - 12 种主题色(6 单色 + 6 渐变色) + - 自定义颜色选择器 +- **全局搜索**:Cmd/Ctrl + K 快捷键,快速跳转任意页面 + +### 细节 + +- **技术栈**: + - 前端: React 19 + TypeScript + Vite + TanStack Router + shadcn/ui + - 后端: FastAPI + Uvicorn + WebSocket + - 特点: SPA 单页应用,前后端同端口,静态文件托管 +- **使用方式**:参照 template.env 文件更新 .env 文件,添加两个字段: + - `WEBUI_ENABLED=true` + - `WEBUI_MODE=production` +- **WebUI 开源协议**:GPLv3 +- **WebUI 地址**:[https://github.com/Mai-with-u/MaiBot-Dashboard](https://github.com/Mai-with-u/MaiBot-Dashboard) + +告别手动编辑配置文件,享受现代化图形界面! + +# [0.11.3] - 2025-11-18 + +### 功能更改和修复 + +- 优化记忆提取策略 +- 优化黑话提取 +- 优化表达方式学习 +- 修改readme +- 加入测试版webui + +提示:清理旧的记忆数据和表达方式,表现更好 +方法:删除数据库中 expression jargon 和 thinking_back 的全部内容 + +# [0.11.2] - 2025-11-16 + +### 🌟 主要功能更改 + +- "海马体Agent"记忆系统上线,最新最好的记忆系统,默认已接入lpmm +- 添加黑话jargon学习系统 +- 添加群特殊Prompt系统 +- 优化直接提及时的回复速度 + +### 细节功能更改 + +- 添加 WebUI 模块及相关 API 路由和 Token 管理功能 +- 可通过海马体Agent记录和查询群昵称 +- 添加聊天记录总结模块 +- 添加大量新统计指标 + +### 功能更改和修复 + +- 移除表达方式学习上限限制 +- 移除部分未使用代码 +- 移除问题追踪和旧版记忆 +- 移除Exp+model表达方式,移除无用代码 +- 移除问题跟踪和记忆整理 +- 移除主动发言功能 +- 优化自我识别和情绪 +- 优化记忆提取能力 +- 优化planner,提及时消耗更少,连续no_reply时降低敏感度 +- 压缩1/3的planner消耗 +- 优化记忆检索占用 +- 优化记忆提取和聊天压缩 +- 优化错别字生成和分段 +- 优化log和添加changelog +- 美化统计界面 +- 修正记忆提取LLM统计 +- 修复docker问题 +- 修复一些潜在问题 +- 修复bool和boolean问题 +- 修复超时给到所有信息的Bug +- 修复回复超长现可返回原文 +- 修复私聊记忆 +- 修复prompt问题 +- 修复(bot): 恢复戳一戳正常响应 +- 提供更多细节debug配置 + +# [0.11.1] - 2025-11-4 + +### 功能更改和修复 + +- 记忆现在能够被遗忘,并且拥有更好的合并 +- 修复部分llm请求问题 +- 优化记忆提取 +- 提供replyer的细节debug配置 + +# [0.11.0] - 2025-10-27 + +### 🌟 主要功能更改 + +- 重构记忆系统,新的记忆系统更可靠,双通道查询,可以查询文本记忆和过去聊天记录 +- 主动发言功能,麦麦会自主提出问题(可精细调控频率) +- 支持多重人格设定,可以随机切换成不同状态 +- 新增表达方式学习新模式,更少的占用 +- 添加表情包管理插件 +- 现可更好的支持多平台 +- 添加deepthink插件(默认关闭),让麦麦可以深度思考一些问题 +- 现已内置BetterFrequency插件 + +### 细节功能更改 + +- 修复配置文件转义问题 +- 情绪系统现在可以由配置文件控制开关 +- 修复平行动作控制失效的问题 +- 添加planner防抖,防止短时间快速消耗token +- 优化planner历史状态记录 +- 修复吞字问题 +- 修复意外换行问题 +- 移除VLM的token限制 +- 为tool工具添加chat_id字段 +- 更新依赖表 +- 修复负载均衡 +- 现统计模型名而不是模型标识符 +- 修改默认推荐模型为ds v3.2 +- 优化了对gemini和不同模型的支持,优化了对gemini搜索的支持 + +# [0.10.3] - 2025-9-22 + +### 🌟 主要功能更改 + +- planner支持多动作,移除Sub_planner +- 移除激活度系统,现在回复完全由planner控制 +- 现可自定义planner行为,更优化的聊天频率控制 +- 支持发送转发和合并转发 +- 关系现在支持多人的信息 +- 更好的event系统,正式建立 + +### 细节功能更改 + +- 支持所有表达方式互通 +- 现可使用付费嵌入模型 +- 添加多种发送类型 +- 优化识图token限制 +- 为空回复添加重试机制 +- 加入brainchat模式,为私聊支持做准备 +- 修复qq号格式 + +# [0.10.2] - 2025-8-31 + +### 🌟 主要功能更改 + +- 精简了人格相关配置,提供更清晰,有效的自定义 +- 大幅优化了聊天逻辑,更易配置,动态控制 +- 现在支持提及100%回复 + +### 细节功能更改 + +- 更好的event系统 +- 记忆系统优化 +- 为空回复添加重试机制 +- 修复tts插件可能的复读问题 + +# [0.10.1] - 2025-8-24 + +### 🌟 主要功能更改 + +- planner现在改为大小核结构,移除激活阶段,提高回复速度和动作调用精准度 +- 优化关系的表现的效率 + +### 细节功能更改 + +- 优化识图的表现 +- 为planner添加单独控制的提示词 +- 修复激活值计算异常的BUG +- 修复lpmm日志错误 +- 修复首句不回复的问题 +- 修复emoji管理器的一个BUG +- 优化对模型请求的处理 +- 重构内部代码 +- 暂时禁用记忆 + +# [0.10.0] - 2025-8-18 + +### 🌟 主要功能更改 + +- 优化的回复生成,现在的回复对上下文把控更加精准 +- 新的回复逻辑控制,现在合并了normal和focus模式,更加统一 +- 优化表达方式系统,现在学习和使用更加精准 +- 新的关系系统,现在的关系构建更精准也更克制 +- 工具系统重构,现在合并到了插件系统中 +- 彻底重构了整个LLM Request了,现在支持模型轮询和更多灵活的参数 + - 同时重构了整个模型配置系统,升级需要重新配置llm配置文件 +- **警告所有插件开发者:插件系统即将迎来不稳定时期,随时会发动更改。** + +#### 🔧 工具系统重构 + +- **工具系统整合**: 工具系统现在完全合并到插件系统中,提供统一的扩展能力 +- **工具启用控制**: 支持配置是否启用特定工具,提供更人性化的直接调用方式 +- **配置文件读取**: 工具现在支持读取配置文件,增强配置灵活性 + +#### 🚀 LLM系统全面重构 + +- **LLM Request重构**: 彻底重构了整个LLM Request系统,现在支持模型轮询和更多灵活的参数 +- **模型配置升级**: 同时重构了整个模型配置系统,升级需要重新配置llm配置文件 +- **任务类型支持**: 新增任务类型和能力字段至模型配置,增强模型初始化逻辑 +- **异常处理增强**: 增强LLMRequest类的异常处理,添加统一的模型异常处理方法 + +#### 🔌 插件系统稳定化 + +- **插件系统重构完成**: 随着LLM Request的重构,插件系统彻底重构完成,进入稳定状态 +- **API扩展**: 仅增加新的API,保持向后兼容性 +- **插件管理优化**: 让插件管理配置真正有用,提升管理体验 + +#### 💾 记忆系统优化 + +- **及时构建**: 记忆系统再优化,现在及时构建,并且不会重复构建 +- **精确提取**: 记忆提取更精确,提升记忆质量 + +#### 🎭 表达方式系统 + +- **表达方式记录**: 记录使用的表达方式,提供更好的学习追踪 +- **学习优化**: 优化表达方式提取,修复表达学习出错问题 +- **配置优化**: 优化表达方式配置和逻辑,提升系统稳定性 + +#### 🔄 聊天系统统一 + +- **normal和focus合并**: 彻底合并normal和focus,完全基于planner决定target message +- **no_reply内置**: 将no_reply功能移动到主循环中,简化系统架构 +- **回复优化**: 优化reply,填补缺失值,让麦麦可以回复自己的消息 +- **频率控制API**: 加入聊天频率控制相关API,提供更精细的控制 + +#### 日志系统改进 + +- **日志颜色优化**: 修改了log的颜色,更加护眼 +- **日志清理优化**: 修复了日志清理先等24h的问题,提升系统性能 +- **计时定位**: 通过计时定位LLM异常延时,提升问题排查效率 + +### 🐛 问题修复 + +#### 代码质量提升 + +- **lint问题修复**: 修复了lint爆炸的问题,代码更加规范了 +- **导入优化**: 修复导入爆炸和文档错误,优化代码结构 + +#### 系统稳定性 + +- **循环导入**: 修复了import时循环导入的问题 +- **并行动作**: 修复并行动作炸裂问题,提升并发处理能力 +- **空响应处理**: 空响应就raise,避免系统异常 + +#### 功能修复 + +- **API问题**: 修复api问题,提升系统可用性 +- **notice问题**: 为组件方法提供新参数,暂时解决notice问题 +- **关系构建**: 修复不认识的用户构建关系问题 +- **流式解析**: 修复流式解析越界问题,避免空choices的SSE帧错误 + +#### 配置和兼容性 + +- **默认值**: 添加默认值,提升配置灵活性 +- **类型问题**: 修复类型问题,提升代码健壮性 +- **配置加载**: 优化配置加载逻辑,提升系统启动稳定性 + +# [0.9.1] - 2025-7-26 + +### 主要修复和优化 + +- 优化回复意愿 +- 优化专注模式回复频率 +- 优化关键词提取 +- 修复部分模型产生的400问题 + +### 细节优化 + +- 修复reply导致的planner异常空跳 +- 修复表达方式迁移空目录问题 +- 修复reply_to空字段问题 +- 无可用动作导致的空plan问题 +- 修复人格未压缩导致产生句号分割 +- 将metioned bot 和 at应用到focus prompt中 +- 更好的兴趣度计算 +- 修复部分模型由于enable_thinking导致的400问题 +- 移除dependency_manager + +# [0.9.0] - 2025-7-24 + +### 摘要 + +MaiBot 0.9.0 重磅升级!本版本带来两大核心突破:**全面重构的插件系统**提供更强大的扩展能力和管理功能;**normal和focus模式统一化处理**大幅简化架构并提升性能。同时新增s4u prompt模式优化、语音消息支持、全新情绪系统和mais4u直播互动功能,为MaiBot带来更自然、更智能的交互体验! + +### 🌟 主要功能概览 + +#### 🔌 插件系统全面重构 - 重点升级 + +- **完整管理API**: 全新的插件管理API,支持插件的启用、禁用、重载和卸载操作 +- **权限控制系统**: 为插件管理增加完善的权限控制,确保系统安全性 +- **智能依赖管理**: 优化插件依赖管理和自动注册机制,减少配置复杂度 + +#### ⚡ Normal和Focus模式统一化处理 - 重点升级 + +- **架构统一**: 彻底统一normal和focus聊天模式,消除模式间的差异和复杂性 +- **智能模式切换**: 优化频率控制和模式切换逻辑,normal可以无缝切换到focus +- **统一LLM激活**: normal模式现在支持LLM激活插件,与focus模式功能对等 +- **一致的关系构建**: normal采用与focus一致的关系构建机制,提升交互质量 +- **统一退出机制**: 为focus提供更合理的退出方法,简化状态管理 + +#### 🎯 s4u prompt模式 + +- **s4u prompt模式**: 新增专门的s4u prompt构建方式,提供更好的交互效果 +- **配置化启用**: 可在配置文件中选择启用s4u prompt模式,灵活控制 +- **兼容性保持**: 与现有系统完全兼容,可随时切换启用或禁用 + +#### 🎤 语音消息支持 + +- **Voice消息处理**: 新增对voice类型消息的支持,麦麦现在可以识别和处理语音消息(需要模型配置) + +#### 全新情绪系统 + +- **持续情绪**: 麦麦现在拥有持续的情绪状态,情绪会影响回复风格和行为 + +### 💻 更新预览 + +#### 关系系统优化 + +- **prompt优化**: 优化关系prompt和person_info信息展示 +- **构建间隔**: 让关系构建间隔可配置,提升灵活性 +- **关系配置**: 优化关系配置,采用和focus一致的关系构建 + +#### 表情包系统升级 + +- **识别增强**: 加强emoji的识别能力,优化emoji显示 +- **匹配精准**: 更精准的表情包匹配算法 + +#### 完善mais4u系统(需要amaidesu支持) + +- **直播互动**: 新增mais4u直播功能,支持实时互动和思考状态展示 +- **动作控制**: 支持眨眼、微动作、注视等多种动作适配 + +#### 日志系统优化 + +- **显示优化**: 优化Logger前缀映射、颜色格式和计时信息显示 +- **级别优化**: 优化日志级别和信息过滤,提升调试体验 +- **日志查看器**: 升级logger_viewer,移除无用脚本 + +#### 配置系统改进 + +- **配置简化**: 简化配置文件,让配置更加精简易懂 +- **prompt显示**: 可选打开prompt显示功能 +- **配置更新**: 更好的配置文件更新机制和更新内容显示 + +#### 问题修复与优化 + +- 修复normal planner没有超时退出问题,添加回复超时检查 +- 重构no_action逻辑,不再使用小模型,采用激活度决定 +- 修复图片与文字混合兴趣值为0的情况 +- 适配无兴趣度消息处理 +- 优化Docker镜像构建流程,合并AMD64和ARM64构建步骤 +- 移除vtb插件和take_picture_plugin,功能已由其他系统接管,移除pfc遗留代码和其他过时功能 +- 移除observation和processor等冗余组件,大幅简化focus代码逻辑 +- 修复了LPMM的学习问题 + +# [0.8.1] - 2025-7-5 + +功能更新: + +- normal现在和focus一样支持tool +- focus现在和normal一样每次调用lpmm +- 移除人格表达 + +优化和修复: + +- 修复表情包配置无效问题 +- 合并normal和focus的prompt构建 +- 非TTY环境禁用console_input_loop +- 修复过滤消息仍被存储至数据库的问题 +- 私聊强制开启focus模式 +- 支持解析reply_to和at +- 修复focus冷却时间导致的固定沉默 +- 移除豆包画图插件,此插件现在插件广场提供 +- 修复表达器无法读取原始文本 +- 修复normal planner没有超时退出问题 + +# [0.8.0] - 2025-6-27 + +MaiBot 0.8.0 现已推出! + +### **主要升级点:** + +1.插件系统正式加入,现已上线插件商店,同时支持normal和focus +2.大幅降低了token消耗,更省钱 +3.加入人物印象系统,麦麦可以对群友有不同的印象 +4.可以精细化控制不同时段和不同群聊的发言频率 + +#### 其他升级 + +日志系统重构使用structlog +大量稳定性修复和性能优化。 +MMC启动速度加快 + +### 🔌 插系统正式推出 + +**全面重构的插件生态系统,支持强大 的扩展能力** + +- **插件API重构**: 全面重构插件系统,统一加载机制,区分内部插件和外部插件 +- **插件仓库**:现可以分享和下载插件 +- **依赖管理**: 新增插件依赖管理系统,支持自动注册和依赖检查 +- **命令支持**: 插件现已支持命令(command)功能,提供更丰富的交互方式 +- **示例插件升级**: 更新禁言插件、豆包绘图插件、TTS插件等示例插件 +- **配置文件管理**: 插件支持自动生成和管理配置文件,支持版本自动更新 +- **文档完善**: 补全插件API文档,提供详细的开发指南 + +### 👥 人物印象系统 + +**麦麦现在能认得群友,记住每个人的特点** + +- **人物侧写功能**: 加入了人物侧写!麦麦现在能认得群友,新增用户侧写功能,将印象拆分为多方面特点 + +### ⚡ Focus模式大幅优化 - 降低Token消耗与提升速度 + +- **Planner架构更新**: 更新planner架构,大大加快速度和表现效果! +- **处理器重构**: + - 移除冗余处理器 + - 精简处理器上下文,减少不必要的处理 + - 后置工具处理器,大大减少token消耗 +- **统计系统**: 提供focus统计功能,可查看详细的no_action统计信息 + +### ⏰ 聊天频率精细控制 + +**支持时段化的精细频率管理,让麦麦在合适的时间说合适的话** + +- **时段化控制**: 添加时段talk_frequency控制,支持不同时间段不同群聊的精细频率管理 +- **严格频率控制**: 实现更加严格和可靠的频率控制机制 +- **Normal模式优化**: 大幅优化normal模式的频率控制逻辑,提升回复的智能性 + +### 🎭 表达方式系统大幅优化 + +**智能学习群友聊天风格,让麦麦的表达更加多样化** + +- **智能学习机制**: 优化表达方式学习算法,支持衰减机制,太久没学的会被自动抛弃 +- **表达方式选择**: 新增表达方式选择器,让表达使用更合理 +- **跨群互通配置**: 表达方式现在可以选择在不同群互通或独立 +- **可视化工具**: 提供表达方式可视化脚本和检查脚本 + +### 💾 记忆系统改进 + +**更快的记忆处理和更好的短期记忆管理** + +- **海马体优化**: 大大优化海马体同步速度,提升记忆处理效率 +- **工作记忆升级**: 精简升级工作记忆模块,提供更好的短期记忆管理 +- **聊天记录构建**: 优化聊天记录构建方式,提升记忆提取效率 + +### 📊 日志系统重构 + +**使用structlog提供更好的结构化日志** + +- **structlog替换**: 使用structlog替代loguru,提供更好的结构化日志 +- **日志查看器**: 新增日志查看脚本,支持更好的日志浏览 +- **可配置日志**: 提供可配置的日志级别和格式,支持不同环境的需求 + +### 🎯 其他改进 + +- **emoji系统**: 移除emoji默认发送模式,优化表情包审查功能 +- **控制台发送**: 添加不完善的控制台发送功能 +- **行为准则**: 添加贡献者契约行为准则 +- **图像清理**: 自动清理images文件夹,优化存储空间使用 + +# [0.7.0] -2025-6-1 + +- 你可以选择normal,focus和auto多种不同的聊天方式。normal提供更少的消耗,更快的回复速度。focus提供更好的聊天理解,更多工具使用和插件能力 +- 现在,你可以自定义麦麦的表达方式,并且麦麦也可以学习群友的聊天风格(需要在配置文件中打开) +- 不再需要繁琐的安装MongoDB!弃用MongoDB,采用轻量sqlite,无需额外安装(提供数据迁移脚本) +- focus模式初步支持了插件,我们提供了两个示例插件(需要手动启用),可以让麦麦实现更丰富的操作。禁言插件和豆包绘图插件是示例用插件。 + +**重构专注聊天(HFC - focus_chat)** + +- 模块化设计,可以自定义不同的部件 + - 观察器(获取信息) + - 信息处理器(处理信息) + - 重构:聊天思考(子心流)处理器 + - 重构:聊天处理器 + - 重构:聊天元信息处理器 + - 重构:工具处理器 + - 新增:工作记忆处理器 + - 新增:自我认知处理器 + - 新增:动作处理器 + - 决策器(选择动作) + - 执行器(执行动作) + - 回复动作 + - 不回复动作 + - 退出HFC动作 + - 插件:禁言动作 + - 表达器:装饰语言风格 +- 可通过插件添加和自定义HFC部件(目前只支持action定义) +- 为专注模式添加关系线索 +- 在专注模式下,麦麦可以决定自行发送语音消息(需要搭配tts适配器) +- 优化reply,减少复读 +- 可自定义连续回复次数 +- 可自定义处理器超时时间 + +**优化普通聊天(normal_chat)** + +- 添加可学习的表达方式 +- 增加了talk_frequency参数来有效控制回复频率 +- 优化了进入和离开normal_chat的方式 +- 添加时间信息 + +**新增表达方式学习** + +- 麦麦配置单独表达方式 +- 自主学习群聊中的表达方式,更贴近群友 +- 可自定义的学习频率和开关 +- 根据人设生成额外的表达方式 + +**聊天管理** + +- 移除不在线状态 +- 优化自动模式下normal与focus聊天的切换机制 +- 大幅精简聊天状态切换规则,减少复杂度 +- 移除聊天限额数量 + +**插件系统** + +- 示例插件:禁言插件 +- 示例插件:豆包绘图插件 + +**人格** + +- 简化了人格身份的配置 +- 优化了在focus模式下人格的表现和稳定性 + +**数据库重构** + +- 移除了默认使用MongoDB,采用轻量sqlite +- 无需额外安装数据库 +- 提供迁移脚本 + +**优化** + +- 移除日程系统,减少幻觉(将会在未来版本回归) +- 移除主心流思考和LLM进入聊天判定 +- 支持qwen3模型,支持自定义是否思考和思考长度 +- 优化提及和at的判定 +- 添加配置项 +- 添加临时配置文件读取器 + +# [0.6.3-fix-4] - 2025-5-18 + +- 0.6.3 的最后一个修复版 + +### fix1-fix4修复日志 + +**聊天状态** + +- 大幅精简聊天状态切换,提高麦麦说话能力 +- 移除OFFLINE和ABSENT状态 +- 移除聊天数量限制 +- 聊天默认normal_chat +- 默认关闭focus_chat + +**知识库LPMM** + +- 增加嵌入模型一致性校验功能 +- 强化数据导入处理,增加非法文段检测功能 +- 修正知识获取逻辑,调整相关性输出顺序 +- 添加数据导入的用户确认删除功能 + +**专注模式** + +- 默认提取记忆,优化记忆表现 +- 添加心流查重 +- 为复读增加硬限制 +- 支持获取子心流循环信息和状态的API接口 +- 优化工具调用的信息获取与缓存 + +**表情包系统** + +- 优化表情包识别和处理 +- 提升表情匹配逻辑 + +**日志系统** + +- 优化日志样式配置 +- 添加丰富的追踪信息以增强调试能力 + +**API** + +- 添加GraphQL路由支持 +- 新增强制停止MAI Bot的API接口 + +# [0.6.3] - 2025-4-15 + +### 摘要 + +- MaiBot 0.6.3 版本发布!核心重构回复逻辑,统一为心流系统管理,智能切换交互模式。 +- 引入全新的 LPMM 知识库系统,大幅提升信息获取能力。 +- 新增昵称系统,改善群聊中的身份识别。 +- 提供独立的桌宠适配器连接程序。 +- 优化日志输出,修复若干问题。 + +### 🌟 核心功能增强 + +#### 统一回复逻辑 (Unified Reply Logic) + +- **核心重构**: 移除了经典 (Reasoning) 与心流 (Heart Flow) 模式的区分,将回复逻辑完全整合到 `SubHeartflow` 中进行统一管理,由主心流统一调控。保留 Heart FC 模式的特色功能。 +- **智能交互模式**: `SubHeartflow` 现在可以根据情境智能选择不同的交互模式: + - **普通聊天 (Normal Chat)**: 类似于之前的 Reasoning 模式,进行常规回复(激活逻辑暂未改变)。 + - **心流聊天 (Heart Flow Chat)**: 基于改进的 PFC 模式,能更好地理解上下文,减少重复和认错人的情况,并支持**工具调用**以获取额外信息。 + - **离线模式 (Offline/Absent)**: 在特定情况下,麦麦可能会选择暂时不查看或回复群聊消息。 +- **状态管理**: 交互模式的切换由 `SubHeartflow` 内部逻辑和 `SubHeartflowManager` 根据整体状态 (`MaiState`) 和配置进行管理。 +- **流程优化**: 拆分了子心流的思考模块,使整体对话流程更加清晰。 +- **状态判断改进**: 将 CHAT 状态判断交给 LLM 处理,使对话更自然。 +- **回复机制**: 实现更为灵活的概率回复机制,使机器人能够自然地融入群聊环境。 +- **重复性检查**: 加入心流回复重复性检查机制,防止麦麦陷入固定回复模式。 + +#### 全新知识库系统 (New Knowledge Base System - LPMM) + +- **引入 LPMM**: 新增了 **LPMM (Large Psychology Model Maker)** 知识库系统,具有强大的信息检索能力,能显著提升麦麦获取和利用知识的效率。 +- **功能集成**: 集成了 LPMM 知识库查询功能,进一步扩展信息检索能力。 +- **推荐使用**: 强烈建议使用新的 LPMM 系统以获得最佳体验。旧的知识库系统仍然可用作为备选。 + +#### 昵称系统 (Nickname System) + +- **自动取名**: 麦麦现在会尝试给群友取昵称,减少对易变的群昵称的依赖,从而降低认错人的概率。 +- **持续完善**: 该系统目前仍处于早期阶段,会持续进行优化。 + +#### 记忆与上下文增强 (Memory and Context Enhancement) + +- **聊天记录压缩**: 大幅优化聊天记录压缩系统,使机器人能够处理5倍于之前的上下文记忆量。 +- **长消息截断**: 新增了长消息自动截断与模糊化功能,随着时间推移降低超长消息的权重,避免被特定冗余信息干扰。 +- **记忆提取**: 优化记忆提取功能,提高对历史对话的理解和引用能力。 +- **记忆整合**: 为记忆系统加入了合并与整合机制,优化长期记忆的结构与效率。 +- **中期记忆调用**: 完善中期记忆调用机制,使机器人能够更自然地回忆和引用较早前的对话。 +- **Prompt 优化**: 进一步优化了关系系统和记忆系统相关的提示词(prompt)。 + +#### 私聊 PFC 功能增强 (Private Chat PFC Enhancement) + +- **功能修复与优化**: 修复了私聊 PFC 载入聊天记录缺失的 bug,优化了 prompt 构建,增加了审核机制,调整了重试次数,并将机器人发言存入数据库。 +- **实验性质**: 请注意,PFC 仍然是一个实验性功能,可能在未来版本中被修改或移除,目前不接受相关 Bug 反馈。 + +#### 情感与互动增强 (Emotion and Interaction Enhancement) + +- **全新表情包系统**: 新的表情包系统上线,表情含义更丰富,发送更快速。 +- **表情包使用优化**: 优化了表情包的选择逻辑,减少重复使用特定表情包的情况,使表达更生动。 +- **提示词优化**: 优化提示词(prompt)构建,增强对话质量和情感表达。 +- **积极性配置**: 优化"让麦麦更愿意说话"的相关配置,使机器人更积极参与对话。 +- **颜文字保护**: 保护颜文字处理机制,确保表情正确显示。 + +#### 工具与集成 (Tools and Integration) + +- **动态更新**: 使用工具调用来更新关系和心情,取代原先的固定更新机制。 +- **智能调用**: 工具调用时会考虑上下文,使调用更加智能。 +- **知识库依赖**: 添加 LPMM 知识库依赖,扩展知识检索工具。 + +### 💻 系统架构优化 + +#### 日志优化 (Logging Optimization) + +- **输出更清晰**: 优化了日志信息的格式和内容,使其更易于阅读和理解。 + +#### 模型与消息整合 (Model and Message Integration) + +- **模型合并**: 合并工具调用模型和心流模型,提高整体一致性。 +- **消息规范**: 全面改用 `maim_message`,移除对 `rest` 的支持。 + +#### (临时) 简易 GUI (Temporary Simple GUI) + +- **运行状态查看**: 提供了一个非常基础的图形用户界面,用于查看麦麦的运行状态。 +- **临时方案**: 这是一个临时性的解决方案,功能简陋,**将在 0.6.4 版本中被全新的 Web UI 所取代**。此 GUI 不会包含在主程序包中,而是通过一键包提供,并且不接受 Bug 反馈。 + +### 🐛 问题修复 + +- **记忆检索优化**: 提高了记忆检索的准确性和效率。 +- 修复了一些其他小问题。 + +### 🔧 其他改进 + +#### 桌宠适配器 (Bug Catcher Adapter) + +- **独立适配器**: 提供了一个"桌宠"独立适配器,用于连接麦麦和桌宠。 +- **获取方式**: 可在 MaiBot 的 GitHub 组织中找到该适配器,不包含在主程序内。 + +#### 一键包内容 (One-Click Package Contents) + +- **辅助程序**: 一键包中包含了简易 GUI 和 **麦麦帮助配置** 等辅助程序,后者可在配置出现问题时提供帮助。 + +# [0.6.2] - 2025-4-14 + +### 摘要 + +- MaiBot 0.6.2 版本发布! +- 优化了心流的观察系统,优化提示词和表现,现在心流表现更好! +- 新增工具调用能力,可以更好地获取信息 +- 本次更新主要围绕工具系统、心流系统、消息处理和代码优化展开,新增多个工具类,优化了心流系统的逻辑,改进了消息处理流程,并修复了多个问题。 + +### 🌟 核心功能增强 + +#### 工具系统 + +- 新增了知识获取工具系统,支持通过心流调用获取多种知识 +- 新增了工具系统使用指南,详细说明工具结构、自动注册机制和添加步骤 +- 新增了多个实用工具类,包括心情调整工具`ChangeMoodTool`、关系查询工具`RelationshipTool`、数值比较工具`CompareNumbersTool`、日程获取工具`GetCurrentTaskTool`、上下文压缩工具`CompressContextTool`和知识获取工具`GetKnowledgeTool` +- 更新了`ToolUser`类,支持自动获取已注册工具定义并调用`execute`方法 +- 需要配置支持工具调用的模型才能使用完整功能 + +#### 心流系统 + +- 新增了上下文压缩缓存功能,可以有更持久的记忆 +- 新增了心流系统的README.md文件,详细介绍了系统架构、主要功能和工作流程。 +- 优化了心流系统的逻辑,包括子心流自动清理和合理配置更新间隔。 +- 改进了心流观察系统,优化了提示词设计和系统表现,使心流运行更加稳定高效。 +- 更新了`Heartflow`类的方法和属性,支持异步生成提示词并提升生成质量。 + +#### 消息处理 + +- 改进了消息处理流程,包括回复检查、消息生成和发送逻辑。 +- 新增了`ReplyGenerator`类,用于根据观察信息和对话信息生成回复。 +- 优化了消息队列管理系统,支持按时间顺序处理消息。 + +#### 现在可以启用更好的表情包发送系统 + +### 💻 系统架构优化 + +#### 部署支持 + +- 更新了Docker部署文档,优化了服务配置和挂载路径。 +- 完善了Linux和Windows脚本支持。 + +### 🐛 问题修复 + +- 修复了消息处理器中的正则表达式匹配问题。 +- 修复了图像处理中的帧大小和拼接问题。 +- 修复了私聊时产生`reply`消息的bug。 +- 修复了配置文件加载时的版本兼容性问题。 + +### 📚 文档更新 + +- 更新了`README.md`文件,包括Python版本要求和协议信息。 +- 新增了工具系统和心流系统的详细文档。 +- 优化了部署相关文档的完整性。 + +### 🔧 其他改进 + +- 新增了崩溃日志记录器,记录崩溃信息到日志文件。 +- 优化了统计信息输出,在控制台显示详细统计信息。 +- 改进了异常处理机制,提升系统稳定性。 +- 现可配置部分模型的temp参数 + +# [0.6.0] - 2025-4-4 + +### 摘要 + +- MaiBot 0.6.0 重磅升级! 核心重构为独立智能体MaiCore,新增思维流对话系统,支持拟真思考过程。记忆与关系系统2.0让交互更自然,动态日程引擎实现智能调整。优化部署流程,修复30+稳定性问题,隐私政策同步更新,推荐所有用户升级体验全新AI交互!(V3激烈生成) + +### 🌟 核心功能增强 + +#### 架构重构 + +- 将MaiBot重构为MaiCore独立智能体 +- 移除NoneBot相关代码,改为插件方式与NoneBot对接 + +#### 思维流系统 + +- 提供两种聊天逻辑,思维流(心流)聊天(ThinkFlowChat)和推理聊天(ReasoningChat) +- 思维流聊天能够在回复前后进行思考 +- 思维流自动启停机制,提升资源利用效率 +- 思维流与日程系统联动,实现动态日程生成 + +#### 回复系统 + +- 更改了回复引用的逻辑,从基于时间改为基于新消息 +- 提供私聊的PFC模式,可以进行有目的,自由多轮对话(实验性) + +#### 记忆系统优化 + +- 优化记忆抽取策略 +- 优化记忆prompt结构 +- 改进海马体记忆提取机制,提升自然度 + +#### 关系系统优化 + +- 优化关系管理系统,适用于新版本 +- 改进关系值计算方式,提供更丰富的关系接口 + +#### 表情包系统 + +- 可以识别gif表情包 +- 表情包增加存储上限 +- 自动清理缓存图片 + +## 日程系统优化 + +- 日程现在动态更新 +- 日程可以自定义想象力程度 +- 日程会与聊天情况交互(思维流模式下) + +### 💻 系统架构优化 + +#### 配置系统改进 + +- 新增更多项目的配置项 +- 修复配置文件保存问题 +- 优化配置结构: + - 调整模型配置组织结构 + - 优化配置项默认值 + - 调整配置项顺序 +- 移除冗余配置 + +#### 部署支持扩展 + +- 优化Docker构建流程 +- 完善Windows脚本支持 +- 优化Linux一键安装脚本 + +### 🐛 问题修复 + +#### 功能稳定性 + +- 修复表情包审查器问题 +- 修复心跳发送问题 +- 修复拍一拍消息处理异常 +- 修复日程报错问题 +- 修复文件读写编码问题 +- 修复西文字符分割问题 +- 修复自定义API提供商识别问题 +- 修复人格设置保存问题 +- 修复EULA和隐私政策编码问题 + +### 📚 文档更新 + +- 更新README.md内容 +- 优化文档结构 +- 更新EULA和隐私政策 +- 完善部署文档 + +### 🔧 其他改进 + +- 新增详细统计系统 +- 优化表情包审查功能 +- 改进消息转发处理 +- 优化代码风格和格式 +- 完善异常处理机制 +- 可以自定义时区 +- 优化日志输出格式 +- 版本硬编码,新增配置自动更新功能 +- 优化了统计信息,会在控制台显示统计信息 + +# [0.5.15] - 2025-3-17 + +### 🌟 核心功能增强 + +#### 关系系统升级 + +- 新增关系系统构建与启用功能 +- 优化关系管理系统 +- 改进prompt构建器结构 +- 新增手动修改记忆库的脚本功能 +- 增加alter支持功能 + +#### 启动器优化 + +- 新增MaiLauncher.bat 1.0版本 +- 优化Python和Git环境检测逻辑 +- 添加虚拟环境检查功能 +- 改进工具箱菜单选项 +- 新增分支重置功能 +- 添加MongoDB支持 +- 优化脚本逻辑 +- 修复虚拟环境选项闪退和conda激活问题 +- 修复环境检测菜单闪退问题 +- 修复.env文件复制路径错误 + +#### 日志系统改进 + +- 新增GUI日志查看器 +- 重构日志工厂处理机制 +- 优化日志级别配置 +- 支持环境变量配置日志级别 +- 改进控制台日志输出 +- 优化logger输出格式 + +### 💻 系统架构优化 + +#### 配置系统升级 + +- 更新配置文件到0.0.10版本 +- 优化配置文件可视化编辑 +- 新增配置文件版本检测功能 +- 改进配置文件保存机制 +- 修复重复保存可能清空list内容的bug +- 修复人格设置和其他项配置保存问题 + +#### WebUI改进 + +- 优化WebUI界面和功能 +- 支持安装后管理功能 +- 修复部分文字表述错误 + +#### 部署支持扩展 + +- 优化Docker构建流程 +- 改进MongoDB服务启动逻辑 +- 完善Windows脚本支持 +- 优化Linux一键安装脚本 +- 新增Debian 12专用运行脚本 + +### 🐛 问题修复 + +#### 功能稳定性 + +- 修复bot无法识别at对象和reply对象的问题 +- 修复每次从数据库读取额外加0.5的问题 +- 修复新版本由于版本判断不能启动的问题 +- 修复配置文件更新和学习知识库的确认逻辑 +- 优化token统计功能 +- 修复EULA和隐私政策处理时的编码兼容问题 +- 修复文件读写编码问题,统一使用UTF-8 +- 修复颜文字分割问题 +- 修复willing模块cfg变量引用问题 + +### 📚 文档更新 + +- 更新CLAUDE.md为高信息密度项目文档 +- 添加mermaid系统架构图和模块依赖图 +- 添加核心文件索引和类功能表格 +- 添加消息处理流程图 +- 优化文档结构 +- 更新EULA和隐私政策文档 + +### 🔧 其他改进 + +- 更新全球在线数量展示功能 +- 优化statistics输出展示 +- 新增手动修改内存脚本(支持添加、删除和查询节点和边) + +### 主要改进方向 + +1. 完善关系系统功能 +2. 优化启动器和部署流程 +3. 改进日志系统 +4. 提升配置系统稳定性 +5. 加强文档完整性 + +# [0.5.14] - 2025-3-14 + +### 🌟 核心功能增强 + +#### 记忆系统优化 + +- 修复了构建记忆时重复读取同一段消息导致token消耗暴增的问题 +- 优化了记忆相关的工具模型代码 + +#### 消息处理升级 + +- 新增了不回答已撤回消息的功能 +- 新增每小时自动删除存留超过1小时的撤回消息 +- 优化了戳一戳功能的响应机制 +- 修复了回复消息未正常发送的问题 +- 改进了图片发送错误时的处理机制 + +#### 日程系统改进 + +- 修复了长时间运行的bot在跨天后无法生成新日程的问题 +- 优化了日程文本解析功能 +- 修复了解析日程时遇到markdown代码块等额外内容的处理问题 + +### 💻 系统架构优化 + +#### 日志系统升级 + +- 建立了新的日志系统 +- 改进了错误处理机制 +- 优化了代码格式化规范 + +#### 部署支持扩展 + +- 改进了NAS部署指南,增加HOST设置说明 +- 优化了部署文档的完整性 + +### 🐛 问题修复 + +#### 功能稳定性 + +- 修复了utils_model.py中的潜在问题 +- 修复了set_reply相关bug +- 修复了回应所有戳一戳的问题 +- 优化了bot被戳时的判断逻辑 + +### 📚 文档更新 + +- 更新了README.md的内容 +- 完善了NAS部署指南 +- 优化了部署相关文档 + +### 主要改进方向 + +1. 提升记忆系统的效率和稳定性 +2. 完善消息处理机制 +3. 优化日程系统功能 +4. 改进日志和错误处理 +5. 加强部署文档的完整性 + +# [0.5.13] - 2025-3-12 + +### 🌟 核心功能增强 + +#### 记忆系统升级 + +- 新增了记忆系统的时间戳功能,包括创建时间和最后修改时间 +- 新增了记忆图节点和边的时间追踪功能 +- 新增了自动补充缺失时间字段的功能 +- 新增了记忆遗忘机制,基于时间条件自动遗忘旧记忆 +- 优化了记忆系统的数据同步机制 +- 优化了记忆系统的数据结构,确保所有数据类型的一致性 + +#### 私聊功能完善 + +- 新增了完整的私聊功能支持,包括消息处理和回复 +- 新增了聊天流管理器,支持群聊和私聊的上下文管理 +- 新增了私聊过滤开关功能 +- 优化了关系管理系统,支持跨平台用户关系 + +#### 消息处理升级 + +- 新增了消息队列管理系统,支持按时间顺序处理消息 +- 新增了消息发送控制器,实现人性化的发送速度和间隔 +- 新增了JSON格式分享卡片读取支持 +- 新增了Base64格式表情包CQ码支持 +- 改进了消息处理流程,支持多种消息类型 + +### 💻 系统架构优化 + +#### 配置系统改进 + +- 新增了配置文件自动更新和版本检测功能 +- 新增了配置文件热重载API接口 +- 新增了配置文件版本兼容性检查 +- 新增了根据不同环境(dev/prod)显示不同级别的日志功能 +- 优化了配置文件格式和结构 + +#### 部署支持扩展 + +- 新增了Linux系统部署指南 +- 新增了Docker部署支持的详细文档 +- 新增了NixOS环境支持(使用venv方式) +- 新增了优雅的shutdown机制 +- 优化了Docker部署文档 + +### 🛠️ 开发体验提升 + +#### 工具链升级 + +- 新增了ruff代码格式化和检查工具 +- 新增了知识库一键启动脚本 +- 新增了自动保存脚本,定期保存聊天记录和关系数据 +- 新增了表情包自动获取脚本 +- 优化了日志记录(使用logger.debug替代print) +- 精简了日志输出,禁用了Uvicorn/NoneBot默认日志 + +#### 安全性强化 + +- 新增了API密钥安全管理机制 +- 新增了数据库完整性检查功能 +- 新增了表情包文件完整性自动检查 +- 新增了异常处理和自动恢复机制 +- 优化了安全性检查机制 + +### 🐛 关键问题修复 + +#### 系统稳定性 + +- 修复了systemctl强制停止的问题 +- 修复了ENVIRONMENT变量在同一终端下不能被覆盖的问题 +- 修复了libc++.so依赖问题 +- 修复了数据库索引创建失败的问题 +- 修复了MongoDB连接配置相关问题 +- 修复了消息队列溢出问题 +- 修复了配置文件加载时的版本兼容性问题 + +#### 功能完善性 + +- 修复了私聊时产生reply消息的bug +- 修复了回复消息无法识别的问题 +- 修复了CQ码解析错误 +- 修复了情绪管理器导入问题 +- 修复了小名无效的问题 +- 修复了表情包发送时的参数缺失问题 +- 修复了表情包重复注册问题 +- 修复了变量拼写错误问题 + +### 主要改进方向 + +1. 提升记忆系统的智能性和可靠性 +2. 完善私聊功能的完整生态 +3. 优化系统架构和部署便利性 +4. 提升开发体验和代码质量 +5. 加强系统安全性和稳定性 diff --git a/code_scripts/generate_database_datamodel_py.py b/code_scripts/generate_database_datamodel_py.py new file mode 100644 index 00000000..607c06f2 --- /dev/null +++ b/code_scripts/generate_database_datamodel_py.py @@ -0,0 +1,127 @@ +from pathlib import Path +import ast +import subprocess +import sys + +base_file_path = Path(__file__).parent.parent.absolute().resolve() / "src" / "common" / "database" / "database_model.py" +target_file_path = ( + Path(__file__).parent.parent.absolute().resolve() / "src" / "common" / "database" / "database_datamodel.py" +) + +with open(base_file_path, "r", encoding="utf-8") as f: + source_text = f.read() +source_lines = source_text.splitlines() + +try: + tree = ast.parse(source_text) +except SyntaxError as e: + raise e + +code_lines = [ + "from typing import Optional", + "from pydantic import BaseModel", + "from datetime import datetime", + "from .database_model import ModelUser, ImageType", +] + + +def src(node): + seg = ast.get_source_segment(source_text, node) + return seg if seg is not None else ast.unparse(node) + + +for node in tree.body: + if not isinstance(node, ast.ClassDef): + continue + # 判断是否 SQLModel 且 table=True + has_sqlmodel = any( + (isinstance(b, ast.Name) and b.id == "SQLModel") or (isinstance(b, ast.Attribute) and b.attr == "SQLModel") + for b in node.bases + ) + has_table_kw = any( + (kw.arg == "table" and isinstance(kw.value, ast.Constant) and kw.value.value is True) for kw in node.keywords + ) + if not (has_sqlmodel and has_table_kw): + continue + + class_name = node.name + code_lines.append("") + code_lines.append(f"class {class_name}(BaseModel):") + + fields_added = 0 + for item in node.body: + # 跳过 __tablename__ 等 + if isinstance(item, ast.Assign): + if len(item.targets) != 1 or not isinstance(item.targets[0], ast.Name): + continue + name = item.targets[0].id + if name == "__tablename__": + continue + value_src = src(item.value) + line = f" {name} = {value_src}" + fields_added += 1 + lineno = getattr(item, "lineno", None) + elif isinstance(item, ast.AnnAssign): + # 注解赋值 + if not isinstance(item.target, ast.Name): + continue + name = item.target.id + ann = src(item.annotation) if item.annotation is not None else None + if item.value is None: + line = f" {name}: {ann}" if ann else f" {name}" + elif isinstance(item.value, ast.Call) and ( + (isinstance(item.value.func, ast.Name) and item.value.func.id == "Field") + or (isinstance(item.value.func, ast.Attribute) and item.value.func.attr == "Field") + ): + default_kw = next((kw for kw in item.value.keywords if kw.arg == "default"), None) + if default_kw is None: + # 没有 default,保留类型但不赋值 + line = f" {name}: {ann}" if ann else f" {name}" + else: + default_src = src(default_kw.value) + line = f" {name}: {ann} = {default_src}" + else: + value_src = src(item.value) + line = f" {name}: {ann} = {value_src}" if ann else f" {name} = {value_src}" + fields_added += 1 + lineno = getattr(item, "lineno", None) + else: + continue + + # 提取同一行的行内注释作为字段说明(如果存在) + comment = None + if lineno is not None: + src_line = source_lines[lineno - 1] + if "#" in src_line: + # 取第一个 # + comment = src_line.split("#", 1)[1].strip() + # 避免三引号冲突 + comment = comment.replace('"""', '\\"""') + + code_lines.append(line) + if comment: + code_lines.append(f' """{comment}"""') + else: + print(f"Warning: No comment found for field '{name}' in class '{class_name}'.") + + if fields_added == 0: + code_lines.append(" pass") + +with open(target_file_path, "w", encoding="utf-8") as f: + f.write("\n".join(code_lines) + "\n") + +try: + result = subprocess.run(["ruff", "format", str(target_file_path)], capture_output=True, text=True) +except FileNotFoundError: + print("ruff 未找到,请安装 ruff 并确保其在 PATH 中(例如:pip install ruff)", file=sys.stderr) + sys.exit(127) + +# 输出 ruff 的 stdout/stderr +if result.stdout: + print(result.stdout, end="") +if result.stderr: + print(result.stderr, file=sys.stderr, end="") + +if result.returncode != 0: + print(f"ruff 检查失败,退出码:{result.returncode}", file=sys.stderr) + sys.exit(result.returncode) diff --git a/code_scripts/migrate_expression_jargon_db.py b/code_scripts/migrate_expression_jargon_db.py new file mode 100644 index 00000000..b2402a41 --- /dev/null +++ b/code_scripts/migrate_expression_jargon_db.py @@ -0,0 +1,535 @@ +from argparse import ArgumentParser, Namespace +from collections.abc import Iterable +from datetime import datetime +from pathlib import Path +from sys import path as sys_path +from typing import Any, Optional + +import json +import sqlite3 + +from sqlalchemy import text +from sqlmodel import Session, SQLModel, create_engine, delete + +ROOT_PATH = Path(__file__).resolve().parent.parent +if str(ROOT_PATH) not in sys_path: + sys_path.insert(0, str(ROOT_PATH)) + +from src.common.database.database_model import Expression, Jargon, ModifiedBy # noqa: E402 + + +def build_argument_parser() -> ArgumentParser: + """构建命令行参数解析器。""" + parser = ArgumentParser( + description="将旧版 expression/jargon 数据迁移到新版 expressions/jargons 数据库。" + ) + parser.add_argument("--source-db", dest="source_db", help="旧版 SQLite 数据库路径") + parser.add_argument("--target-db", dest="target_db", help="新版 SQLite 数据库路径") + parser.add_argument( + "--clear-target", + dest="clear_target", + action="store_true", + help="迁移前清空目标库中的 expressions 和 jargons 表", + ) + return parser + + +def prompt_path(prompt_text: str, current_value: Optional[str] = None) -> Path: + """读取数据库路径输入。""" + while True: + suffix = f" [{current_value}]" if current_value else "" + raw_text = input(f"{prompt_text}{suffix}: ").strip() + value = raw_text or current_value or "" + if not value: + print("路径不能为空,请重新输入。") + continue + return Path(value).expanduser().resolve() + + +def prompt_yes_no(prompt_text: str, default: bool = False) -> bool: + """读取是否确认输入。""" + default_hint = "Y/n" if default else "y/N" + raw_text = input(f"{prompt_text} [{default_hint}]: ").strip().lower() + if not raw_text: + return default + return raw_text in {"y", "yes"} + + +def ensure_sqlite_file(path: Path, should_exist: bool) -> None: + """校验 SQLite 文件路径。""" + if should_exist and not path.is_file(): + raise FileNotFoundError(f"数据库文件不存在:{path}") + if not should_exist: + path.parent.mkdir(parents=True, exist_ok=True) + + +def connect_sqlite(path: Path) -> sqlite3.Connection: + """创建 SQLite 连接。""" + connection = sqlite3.connect(path) + connection.row_factory = sqlite3.Row + return connection + + +def table_exists(connection: sqlite3.Connection, table_name: str) -> bool: + """检查表是否存在。""" + result = connection.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + (table_name,), + ).fetchone() + return result is not None + + +def resolve_source_table_name(connection: sqlite3.Connection, candidates: list[str]) -> str: + """从候选表名中解析实际存在的表名。""" + for table_name in candidates: + if table_exists(connection, table_name): + return table_name + raise ValueError(f"未找到候选表:{', '.join(candidates)}") + + +def get_table_columns(connection: sqlite3.Connection, table_name: str) -> set[str]: + """获取表字段名集合。""" + rows = connection.execute(f"PRAGMA table_info('{table_name}')").fetchall() + return {str(row["name"]) for row in rows} + + +def get_table_nullable_map(connection: sqlite3.Connection, table_name: str) -> dict[str, bool]: + """获取表字段是否允许 NULL 的映射。""" + rows = connection.execute(f"PRAGMA table_info('{table_name}')").fetchall() + return {str(row["name"]): not bool(row["notnull"]) for row in rows} + + +def load_rows(connection: sqlite3.Connection, table_name: str) -> list[sqlite3.Row]: + """读取整张表的数据。""" + return connection.execute(f"SELECT * FROM {table_name}").fetchall() + + +def normalize_optional_text(raw_value: Any) -> Optional[str]: + """标准化可空文本字段。""" + if raw_value is None: + return None + return str(raw_value) + + +def ensure_nullable_compatibility( + table_name: str, + column_name: str, + row_id: Any, + value: Any, + nullable_map: dict[str, bool], +) -> None: + """检查待迁移值是否与目标表可空约束兼容。""" + if value is None and not nullable_map.get(column_name, True): + raise ValueError( + f"目标表 {table_name}.{column_name} 不允许 NULL,但源记录 id={row_id} 的该字段为 NULL。" + ) + + +def normalize_string_list(raw_value: Any) -> list[str]: + """将旧库中的 JSON/文本字段标准化为字符串列表。""" + if raw_value is None: + return [] + if isinstance(raw_value, list): + return [str(item).strip() for item in raw_value if str(item).strip()] + if isinstance(raw_value, str): + raw_text = raw_value.strip() + if not raw_text: + return [] + try: + parsed = json.loads(raw_text) + except json.JSONDecodeError: + return [raw_text] + if isinstance(parsed, list): + return [str(item).strip() for item in parsed if str(item).strip()] + if isinstance(parsed, str): + parsed_text = parsed.strip() + return [parsed_text] if parsed_text else [] + if parsed is None: + return [] + return [str(parsed).strip()] + return [str(raw_value).strip()] + + +def normalize_modified_by(raw_value: Any) -> Optional[ModifiedBy]: + """标准化审核来源字段。""" + if raw_value is None: + return None + + normalized_raw_value = raw_value + if isinstance(raw_value, str): + raw_text = raw_value.strip() + if raw_text.startswith('"') and raw_text.endswith('"'): + try: + normalized_raw_value = json.loads(raw_text) + except json.JSONDecodeError: + normalized_raw_value = raw_text + else: + normalized_raw_value = raw_text + + value = str(normalized_raw_value).strip().lower() + if value in {"", "none", "null"}: + return None + if value in {ModifiedBy.AI.value, ModifiedBy.AI.name.lower()}: + return ModifiedBy.AI + if value in {ModifiedBy.USER.value, ModifiedBy.USER.name.lower()}: + return ModifiedBy.USER + return None + + +def parse_optional_bool(raw_value: Any) -> Optional[bool]: + """解析可空布尔值,兼容整数和字符串。""" + if raw_value is None: + return None + if isinstance(raw_value, bool): + return raw_value + if isinstance(raw_value, int): + return bool(raw_value) + if isinstance(raw_value, float): + return bool(int(raw_value)) + + value = str(raw_value).strip().lower() + if value in {"", "none", "null"}: + return None + if value in {"1", "true", "t", "yes", "y"}: + return True + if value in {"0", "false", "f", "no", "n"}: + return False + raise ValueError(f"无法解析布尔值:{raw_value}") + + +def parse_bool(raw_value: Any, default: bool = False) -> bool: + """解析非空布尔值。""" + parsed = parse_optional_bool(raw_value) + return default if parsed is None else parsed + + +def timestamp_to_datetime(raw_value: Any, fallback_now: bool) -> Optional[datetime]: + """将旧库中的 Unix 时间戳转换为 datetime。""" + if raw_value is None or raw_value == "": + return datetime.now() if fallback_now else None + if isinstance(raw_value, datetime): + return raw_value + try: + return datetime.fromtimestamp(float(raw_value)) + except (TypeError, ValueError, OSError, OverflowError): + return datetime.now() if fallback_now else None + + +def build_session_id_dict(raw_chat_id: Any, fallback_count: int) -> str: + """将旧版 jargon.chat_id 转换为新版 session_id_dict。""" + if raw_chat_id is None: + return json.dumps({}, ensure_ascii=False) + + if isinstance(raw_chat_id, str): + raw_text = raw_chat_id.strip() + else: + raw_text = str(raw_chat_id).strip() + + if not raw_text: + return json.dumps({}, ensure_ascii=False) + + try: + parsed = json.loads(raw_text) + except json.JSONDecodeError: + return json.dumps({raw_text: max(fallback_count, 1)}, ensure_ascii=False) + + if isinstance(parsed, str): + parsed_text = parsed.strip() + session_counts = {parsed_text: max(fallback_count, 1)} if parsed_text else {} + return json.dumps(session_counts, ensure_ascii=False) + + if not isinstance(parsed, list): + return json.dumps({}, ensure_ascii=False) + + session_counts: dict[str, int] = {} + for item in parsed: + if not isinstance(item, list) or not item: + continue + session_id = str(item[0]).strip() + if not session_id: + continue + item_count = 1 + if len(item) > 1: + try: + item_count = int(item[1]) + except (TypeError, ValueError): + item_count = 1 + session_counts[session_id] = max(item_count, 1) + + return json.dumps(session_counts, ensure_ascii=False) + + +def create_target_engine(target_db_path: Path): + """创建目标数据库引擎。""" + return create_engine( + f"sqlite:///{target_db_path.as_posix()}", + echo=False, + connect_args={"check_same_thread": False}, + ) + + +def clear_target_tables(session: Session) -> None: + """清空目标表。""" + session.exec(delete(Expression)) + session.exec(delete(Jargon)) + + +def migrate_expressions( + old_rows: Iterable[sqlite3.Row], + target_session: Session, + expression_columns: set[str], +) -> int: + """迁移 expression 数据。""" + migrated_count = 0 + modified_by_ai_count = 0 + modified_by_user_count = 0 + modified_by_null_count = 0 + unknown_modified_by_values: dict[str, int] = {} + for row in old_rows: + create_time = timestamp_to_datetime(row["create_date"] if "create_date" in expression_columns else None, True) + last_active_time = timestamp_to_datetime( + row["last_active_time"] if "last_active_time" in expression_columns else None, + True, + ) + content_list = normalize_string_list(row["content_list"] if "content_list" in expression_columns else None) + raw_modified_by = row["modified_by"] if "modified_by" in expression_columns else None + modified_by = normalize_modified_by(raw_modified_by) + if modified_by == ModifiedBy.AI: + modified_by_ai_count += 1 + elif modified_by == ModifiedBy.USER: + modified_by_user_count += 1 + else: + modified_by_null_count += 1 + if raw_modified_by not in (None, "", "null", "NULL", "None"): + unknown_key = str(raw_modified_by) + unknown_modified_by_values[unknown_key] = unknown_modified_by_values.get(unknown_key, 0) + 1 + + target_session.execute( + text( + """ + INSERT INTO expressions ( + id, + situation, + style, + content_list, + count, + last_active_time, + create_time, + session_id, + checked, + rejected, + modified_by + ) VALUES ( + :id, + :situation, + :style, + :content_list, + :count, + :last_active_time, + :create_time, + :session_id, + :checked, + :rejected, + :modified_by + ) + """ + ), + { + "id": int(row["id"]) if row["id"] is not None else None, + "situation": str(row["situation"]).strip(), + "style": str(row["style"]).strip(), + "content_list": json.dumps(content_list, ensure_ascii=False), + "count": int(row["count"]) if "count" in expression_columns and row["count"] is not None else 1, + "last_active_time": last_active_time or datetime.now(), + "create_time": create_time or datetime.now(), + "session_id": str(row["chat_id"]).strip() if "chat_id" in expression_columns and row["chat_id"] else None, + "checked": parse_bool(row["checked"] if "checked" in expression_columns else None, default=False), + "rejected": parse_bool(row["rejected"] if "rejected" in expression_columns else None, default=False), + "modified_by": modified_by.name if modified_by is not None else None, + }, + ) + migrated_count += 1 + + print( + "Expression modified_by 迁移统计:" + f" AI={modified_by_ai_count}, USER={modified_by_user_count}, NULL={modified_by_null_count}" + ) + if unknown_modified_by_values: + preview_items = list(unknown_modified_by_values.items())[:10] + preview_text = ", ".join(f"{value!r} x{count}" for value, count in preview_items) + print(f"警告:以下旧 modified_by 值未识别,已按 NULL 迁移:{preview_text}") + return migrated_count + + +def migrate_jargons( + old_rows: Iterable[sqlite3.Row], + target_session: Session, + jargon_columns: set[str], + jargon_nullable_map: dict[str, bool], +) -> int: + """迁移 jargon 数据。""" + migrated_count = 0 + coerced_meaning_null_count = 0 + for row in old_rows: + count = int(row["count"]) if "count" in jargon_columns and row["count"] is not None else 0 + raw_content_value = row["raw_content"] if "raw_content" in jargon_columns else None + raw_content_list = normalize_string_list(raw_content_value) + meaning_value = normalize_optional_text(row["meaning"] if "meaning" in jargon_columns else None) + is_jargon_value = parse_optional_bool(row["is_jargon"] if "is_jargon" in jargon_columns else None) + inference_content_key = ( + "inference_content_only" + if "inference_content_only" in jargon_columns + else "inference_with_content_only" + if "inference_with_content_only" in jargon_columns + else None + ) + + ensure_nullable_compatibility("jargons", "is_jargon", row["id"], is_jargon_value, jargon_nullable_map) + + if meaning_value is None and not jargon_nullable_map.get("meaning", True): + meaning_value = "" + coerced_meaning_null_count += 1 + + # 显式执行 SQL,避免 ORM 在 None 场景下回填模型默认值。 + target_session.execute( + text( + """ + INSERT INTO jargons ( + id, + content, + raw_content, + meaning, + session_id_dict, + count, + is_jargon, + is_complete, + is_global, + last_inference_count, + inference_with_context, + inference_with_content_only + ) VALUES ( + :id, + :content, + :raw_content, + :meaning, + :session_id_dict, + :count, + :is_jargon, + :is_complete, + :is_global, + :last_inference_count, + :inference_with_context, + :inference_with_content_only + ) + """ + ), + { + "id": int(row["id"]) if row["id"] is not None else None, + "content": str(row["content"]).strip(), + "raw_content": json.dumps(raw_content_list, ensure_ascii=False) if raw_content_value is not None else None, + "meaning": meaning_value, + "session_id_dict": build_session_id_dict( + row["chat_id"] if "chat_id" in jargon_columns else None, + fallback_count=count, + ), + "count": count, + "is_jargon": is_jargon_value, + "is_complete": parse_bool(row["is_complete"] if "is_complete" in jargon_columns else None, default=False), + "is_global": parse_bool(row["is_global"] if "is_global" in jargon_columns else None, default=False), + "last_inference_count": ( + int(row["last_inference_count"]) + if "last_inference_count" in jargon_columns and row["last_inference_count"] is not None + else 0 + ), + "inference_with_context": ( + str(row["inference_with_context"]) + if "inference_with_context" in jargon_columns and row["inference_with_context"] is not None + else None + ), + "inference_with_content_only": ( + str(row[inference_content_key]) + if inference_content_key and row[inference_content_key] is not None + else None + ), + }, + ) + migrated_count += 1 + + if coerced_meaning_null_count > 0: + print( + f"警告:目标表 jargons.meaning 不允许 NULL,已将 {coerced_meaning_null_count} 条旧记录的 NULL meaning 转为空字符串。" + ) + return migrated_count + + +def confirm_target_replacement(target_db_path: Path, clear_target: bool) -> bool: + """确认是否写入目标数据库。""" + if clear_target: + return prompt_yes_no(f"将清空目标库中的 expressions/jargons 后再迁移,确认继续吗?\n目标库:{target_db_path}") + return prompt_yes_no(f"将写入目标库,若主键冲突会导致迁移失败,确认继续吗?\n目标库:{target_db_path}") + + +def parse_arguments() -> Namespace: + """解析参数。""" + return build_argument_parser().parse_args() + + +def main() -> None: + """脚本入口。""" + args = parse_arguments() + + print("旧版 expression/jargon -> 新版 expressions/jargons 迁移工具") + source_db_path = prompt_path("请输入旧版数据库路径", args.source_db) + target_db_path = prompt_path("请输入新版数据库路径", args.target_db) + clear_target = args.clear_target or prompt_yes_no("迁移前是否清空目标库中的 expressions 和 jargons 表?", False) + + if source_db_path == target_db_path: + raise ValueError("旧版数据库路径和新版数据库路径不能相同。") + + ensure_sqlite_file(source_db_path, should_exist=True) + ensure_sqlite_file(target_db_path, should_exist=False) + + print(f"旧库:{source_db_path}") + print(f"新库:{target_db_path}") + print(f"清空目标表:{'是' if clear_target else '否'}") + + if not confirm_target_replacement(target_db_path, clear_target): + print("已取消迁移。") + return + + source_connection = connect_sqlite(source_db_path) + try: + expression_table_name = resolve_source_table_name(source_connection, ["expression", "expressions"]) + jargon_table_name = resolve_source_table_name(source_connection, ["jargon", "jargons"]) + expression_columns = get_table_columns(source_connection, expression_table_name) + jargon_columns = get_table_columns(source_connection, jargon_table_name) + expression_rows = load_rows(source_connection, expression_table_name) + jargon_rows = load_rows(source_connection, jargon_table_name) + finally: + source_connection.close() + + target_engine = create_target_engine(target_db_path) + SQLModel.metadata.create_all(target_engine) + + target_sqlite_connection = connect_sqlite(target_db_path) + try: + jargon_nullable_map = get_table_nullable_map(target_sqlite_connection, "jargons") + finally: + target_sqlite_connection.close() + + with Session(target_engine) as target_session: + if clear_target: + clear_target_tables(target_session) + target_session.commit() + + expression_count = migrate_expressions(expression_rows, target_session, expression_columns) + jargon_count = migrate_jargons(jargon_rows, target_session, jargon_columns, jargon_nullable_map) + target_session.commit() + + print("迁移完成。") + print(f"已迁移 expression 记录:{expression_count}") + print(f"已迁移 jargon 记录:{jargon_count}") + + +if __name__ == "__main__": + main() diff --git a/config/README.md b/config/README.md new file mode 100644 index 00000000..673abbfd --- /dev/null +++ b/config/README.md @@ -0,0 +1,8 @@ +Runtime config files are intentionally not committed here. + +Create these files locally or on the server under the runtime-mounted config directory: + +- `bot_config.toml` +- `model_config.toml` + +The server deployment mounts `docker-config/mmc` into `/MaiMBot/config`, so production secrets and bot-specific settings stay outside Git. diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 00000000..515a3add --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,22 @@ +project_id_env: CROWDIN_PROJECT_ID +api_token_env: CROWDIN_PERSONAL_TOKEN +base_path: . +base_url: "https://api.crowdin.com" +preserve_hierarchy: true + +export_languages: + - en-US + - ja + - ko +files: + - source: /locales/zh-CN/*.json + translation: /locales/%locale%/%original_file_name% + + - source: /prompts/zh-CN/**/*.prompt + translation: /prompts/%locale%/**/%original_file_name% + + - source: /dashboard/src/i18n/locales/zh.json + translation: /dashboard/src/i18n/locales/%two_letters_code%.json + languages_mapping: + two_letters_code: + en-US: en diff --git a/dashboard/.prettierrc b/dashboard/.prettierrc new file mode 100644 index 00000000..e999e95b --- /dev/null +++ b/dashboard/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/dashboard/LICENSE b/dashboard/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/dashboard/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 00000000..74a7e9f5 --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,377 @@ +# MaiBot Dashboard + +> MaiBot 的现代化 Web 管理面板 - 基于 React 19 + TypeScript + Vite 构建 + +
+ +[![React](https://img.shields.io/badge/React-19.2-61DAFB?logo=react&logoColor=white)](https://react.dev/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![Vite](https://img.shields.io/badge/Vite-7.2-646CFF?logo=vite&logoColor=white)](https://vitejs.dev/) +[![TailwindCSS](https://img.shields.io/badge/TailwindCSS-4.2-38B2AC?logo=tailwind-css&logoColor=white)](https://tailwindcss.com/) + +
+ +## 📖 项目简介 + +MaiBot Dashboard 是 MaiBot 聊天机器人的 Web 管理界面,提供了直观的配置管理、实时监控、插件管理、资源管理等功能。通过自动解析后端配置类,动态生成表单,实现了配置的可视化编辑。 + +
+ MaiBot Dashboard 界面预览 +
+ +### ✨ 核心特性 + +- 🎨 **现代化 UI** - 基于 shadcn/ui 组件库,支持亮色/暗色主题切换 +- ⚡ **高性能** - 使用 Vite 7.2 构建,React 19 最新特性 +- 🔐 **安全认证** - Token 认证机制,支持自定义和自动生成 Token +- 📝 **智能配置** - 自动解析 Python dataclass,生成配置表单 +- 🎯 **类型安全** - 完整的 TypeScript 类型定义 +- 🔄 **实时更新** - WebSocket 实时日志流、配置自动保存 +- 📱 **响应式设计** - 完美适配桌面和移动设备 +- 💬 **本地对话** - 直接在 WebUI 与麦麦对话,无需外部平台 + +## 🎯 功能模块 + +### 📊 仪表盘(首页) +- **实时统计** - 总请求数、Token 消耗、费用统计、在线时长 +- **模型统计** - 各模型的使用次数、费用、平均响应时间 +- **趋势图表** - 每小时请求量、Token 消耗、费用趋势折线图 +- **模型分布** - 饼图展示模型使用占比 +- **最近活动** - 实时刷新的请求活动列表 + +### 💬 本地聊天室 +- **WebSocket 实时通信** - 与麦麦直接对话 +- **消息历史** - 自动加载 SQLite 存储的历史消息 +- **连接状态** - 实时显示 WebSocket 连接状态 +- **自定义昵称** - 可自定义用户身份 +- **移动端适配** - 完整的响应式聊天界面 + +### ⚙️ 配置管理 + +#### 麦麦主程序配置 +- **分组展示** - 配置项按功能分组(基础设置、功能开关等) +- **智能表单** - 根据配置类型自动生成对应控件 +- **自动保存** - 2秒防抖自动保存,无需手动操作 +- **一键重启** - 保存并重启麦麦,使配置生效 + +#### AI 模型厂商配置 +- **提供商管理** - 添加、编辑、删除 API 提供商 +- **模板选择** - 预设常用厂商模板(OpenAI、DeepSeek、硅基流动等) +- **连接测试** - ⚡ 测试提供商连接状态和 API Key 有效性 +- **批量操作** - 批量删除、批量测试所有提供商 +- **搜索过滤** - 按名称、URL、类型快速筛选 + +#### 模型管理与分配 +- **模型列表** - 管理可用的模型配置 +- **使用状态** - 显示模型是否被任务使用 +- **任务分配** - 为不同功能分配模型(回复、工具调用、VLM 等) +- **参数调整** - 温度、最大 Token 等参数配置 +- **新手引导** - 交互式引导教程 + +#### 适配器配置 +- **NapCat 配置** - 管理 QQ 机器人适配器 +- **Docker 支持** - 支持容器模式配置 +- **配置导入导出** - 跨环境迁移配置 + +### 📋 实时日志 +- **WebSocket 流式传输** - 实时接收后端日志 +- **虚拟滚动** - 高性能处理大量日志 +- **多级过滤** - 按日志级别(DEBUG/INFO/WARNING/ERROR)过滤 +- **模块过滤** - 按日志来源模块筛选 +- **时间范围** - 日期选择器筛选日志 +- **搜索高亮** - 关键字搜索并高亮显示 +- **字号调整** - 自定义日志显示字号和行间距 +- **日志导出** - 导出过滤后的日志 + +### 🔌 插件管理 +- **插件市场** - 浏览和搜索可用插件 +- **分类筛选** - 按类别、状态筛选插件 +- **一键安装** - 自动处理依赖并安装插件 +- **版本兼容** - 检查插件与 MaiBot 版本兼容性 +- **进度显示** - WebSocket 实时显示安装进度 +- **插件统计** - 下载量、更新时间等信息 +- **卸载更新** - 管理已安装插件 + +### 👤 人物关系管理 +- **人物列表** - 查看所有已知用户信息 +- **详情编辑** - 编辑用户昵称、备注等信息 +- **关系统计** - 查看消息数、互动频率等统计 +- **批量操作** - 批量删除用户记录 + +### 📦 资源管理 + +#### 表情包管理 +- **预览管理** - 图片/GIF 预览 +- **分类过滤** - 按注册状态、描述筛选 +- **编辑标签** - 修改表情包描述和属性 +- **批量禁用** - 启用/禁用表情包 + +#### 表达方式管理 +- **表达列表** - 查看麦麦学习的表达方式 +- **来源追踪** - 记录表达来源群组和用户 +- **编辑创建** - 手动添加或编辑表达 + +#### 知识图谱 +- **可视化展示** - ReactFlow 交互式图谱 +- **节点搜索** - 搜索实体和关系 +- **布局算法** - 自动布局优化 +- **详情查看** - 点击节点查看详细信息 + +### ⚙️ 系统设置 +- **主题切换** - 亮色/暗色/跟随系统 +- **动画控制** - 开启/关闭界面动画 +- **Token 管理** - 查看、复制、重新生成认证 Token +- **版本信息** - 查看前端和后端版本 + +## 🏗️ 技术架构 + +### 前端技术栈 + +``` +React 19.2.0 # UI 框架 +├── TypeScript 5.9 # 类型系统 +├── Vite 7.2 # 构建工具 +├── TanStack Router # 路由管理 +├── TanStack Virtual # 虚拟滚动 +├── Jotai # 状态管理 +├── Tailwind CSS 4.2 # 样式框架 +├── ReactFlow # 知识图谱可视化 +├── Recharts # 数据图表 +└── shadcn/ui # 组件库 + ├── Radix UI # 无障碍组件 + └── lucide-react # 图标库 +``` + +### 后端集成 + +``` +FastAPI # Python 后端框架 +├── WebSocket # 实时日志、聊天 +├── config_schema.py # 配置架构生成器 +├── config_routes.py # 配置管理 API +├── model_routes.py # 模型管理 API +├── chat_routes.py # 本地聊天 API +├── plugin_routes.py # 插件管理 API +├── person_routes.py # 人物管理 API +├── emoji_routes.py # 表情包管理 API +├── expression_routes.py # 表达管理 API +├── knowledge_routes.py # 知识图谱 API +├── logs_routes.py # 日志 API +└── tomlkit # TOML 文件处理 +``` + +## 📁 项目结构 + +``` +MaiBot-Dashboard/ +├── src/ +│ ├── components/ # 组件目录 +│ │ ├── ui/ # shadcn/ui 组件 +│ │ ├── layout.tsx # 布局组件(侧边栏+导航) +│ │ ├── tour/ # 新手引导组件 +│ │ ├── plugin-stats.tsx # 插件统计组件 +│ │ ├── RestartingOverlay.tsx # 重启遮罩 +│ │ └── use-theme.tsx # 主题管理 +│ ├── routes/ # 路由页面 +│ │ ├── index.tsx # 仪表盘首页 +│ │ ├── auth.tsx # 登录页 +│ │ ├── chat.tsx # 本地聊天室 +│ │ ├── logs.tsx # 日志查看 +│ │ ├── plugins.tsx # 插件管理 +│ │ ├── person.tsx # 人物管理 +│ │ ├── settings.tsx # 系统设置 +│ │ ├── config/ # 配置管理页面 +│ │ │ ├── bot.tsx # 麦麦主程序配置 +│ │ │ ├── modelProvider.tsx # 模型提供商 +│ │ │ ├── model.tsx # 模型管理 +│ │ │ └── adapter.tsx # 适配器配置 +│ │ └── resource/ # 资源管理页面 +│ │ ├── emoji.tsx # 表情包管理 +│ │ ├── expression.tsx # 表达方式管理 +│ │ └── knowledge-graph.tsx # 知识图谱 +│ ├── lib/ # 工具库 +│ │ ├── config-api.ts # 配置 API 客户端 +│ │ ├── plugin-api.ts # 插件 API 客户端 +│ │ ├── person-api.ts # 人物 API 客户端 +│ │ ├── expression-api.ts # 表达 API 客户端 +│ │ ├── log-websocket.ts # 日志 WebSocket +│ │ ├── fetch-with-auth.ts # 认证请求封装 +│ │ └── utils.ts # 通用工具函数 +│ ├── types/ # 类型定义 +│ │ ├── config-schema.ts # 配置架构类型 +│ │ ├── plugin.ts # 插件类型 +│ │ ├── person.ts # 人物类型 +│ │ └── expression.ts # 表达类型 +│ ├── hooks/ # React Hooks +│ │ ├── use-auth.ts # 认证逻辑 +│ │ ├── use-animation.ts # 动画控制 +│ │ └── use-toast.ts # 消息提示 +│ ├── store/ # 全局状态 +│ │ └── auth.ts # 认证状态 +│ ├── router.tsx # 路由配置 +│ ├── main.tsx # 应用入口 +│ └── index.css # 全局样式 +├── public/ # 静态资源 +├── vite.config.ts # Vite 配置 +├── tailwind.config.js # Tailwind v4 兼容占位配置 +├── tsconfig.json # TypeScript 配置 +└── package.json # 依赖管理 +``` + +## 🚀 快速开始 + +### 环境要求 + +- Node.js >= 18.0.0 +- Bun >= 1.0.0 (推荐) 或 npm/yarn/pnpm + +### 安装依赖 + +```bash +# 使用 Bun(推荐) +bun install + +# 或使用 npm +npm install +``` + +### 开发模式 + +```bash +# 启动开发服务器 (默认端口: 7999) +bun run dev + +# 或 +npm run dev +``` + +访问 http://localhost:7999 查看应用。 + +### 生产构建 + +```bash +# 构建生产版本 +bun run build + +# 预览生产构建 +bun run preview +``` + +构建产物会输出到 `dist/` 目录,由 MaiBot 后端静态服务。 + +### 代码格式化 + +```bash +# 格式化代码 +bun run format +``` + +## 🔧 开发配置 + +### Vite 代理配置 + +开发模式下,Vite 会将 API 请求代理到后端: + +```typescript +// vite.config.ts +proxy: { + '/api': { + target: 'http://127.0.0.1:8001', + changeOrigin: true, + ws: true, // WebSocket 支持 + }, +}, +``` + +### 环境变量 + +开发环境默认使用 `http://localhost:7999`,生产环境使用相对路径。 + +## 📸 界面预览 + +### 仪表盘 +实时统计、模型使用分布、趋势图表 + +### 本地聊天 +直接与麦麦对话,消息实时同步 + +### 配置管理 +分组配置项,自动生成表单,自动保存 + +### 模型提供商 +一键测试连接状态,模板快速添加 + +### 日志查看 +实时日志流,多级过滤,虚拟滚动 + +## 📦 依赖说明 + +### 核心依赖 + +| 包名 | 版本 | 用途 | +|------|------|------| +| react | ^19.2.0 | UI 框架 | +| react-dom | ^19.2.0 | React DOM 渲染 | +| typescript | ~5.9.3 | 类型系统 | +| vite | ^7.2.2 | 构建工具 | +| @tanstack/react-router | ^1.136.1 | 路由管理 | +| @tanstack/react-virtual | ^3.x | 虚拟滚动 | +| jotai | ^2.15.1 | 状态管理 | +| axios | ^1.13.2 | HTTP 客户端 | +| recharts | ^2.x | 数据图表 | +| reactflow | ^11.x | 知识图谱可视化 | +| dagre | ^0.8.x | 图布局算法 | + +### UI 组件库 + +| 包名 | 版本 | 用途 | +|------|------|------| +| @radix-ui/react-* | ^1.x | 无障碍组件基础 | +| lucide-react | ^0.553.0 | 图标库 | +| tailwindcss | ^4.2.1 | CSS 框架 | +| class-variance-authority | ^0.7.1 | 类名管理 | +| tailwind-merge | ^3.4.0 | Tailwind 类合并 | +| date-fns | ^3.x | 日期处理 | + + +## 🤝 贡献指南 + +1. Fork 本仓库 +2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 开启 Pull Request + +### 代码规范 + +- 使用 TypeScript 严格模式 +- 遵循 ESLint 规则 +- 使用 Prettier 格式化代码 +- 组件使用函数式编写 +- 优先使用 Hooks +- 响应式设计优先(移动端适配) + +## 📄 开源协议 + +本项目基于 GPLv3 协议开源,详见 [LICENSE](./LICENSE) 文件。 + +## 👥 作者 + +**MotricSeven** - [GitHub](https://github.com/DrSmoothl) + +## 🙏 致谢 + +- [React](https://react.dev/) - UI 框架 +- [shadcn/ui](https://ui.shadcn.com/) - 组件库 +- [Radix UI](https://www.radix-ui.com/) - 无障碍组件 +- [TanStack Router](https://tanstack.com/router) - 路由解决方案 +- [TanStack Virtual](https://tanstack.com/virtual) - 虚拟滚动 +- [Tailwind CSS](https://tailwindcss.com/) - CSS 框架 +- [ReactFlow](https://reactflow.dev/) - 流程图/知识图谱 +- [Recharts](https://recharts.org/) - React 图表库 + +--- + +
+Made with ❤️ by MotricSeven and Mai-with-u +
diff --git a/dashboard/bun.lock b/dashboard/bun.lock new file mode 100644 index 00000000..8f74c0a0 --- /dev/null +++ b/dashboard/bun.lock @@ -0,0 +1,2502 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "maibot-dashboard", + "dependencies": { + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/lint": "^6.9.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-tooltip": "^1.2.8", + "@react-spring/web": "10.0.3", + "@tanstack/react-router": "^1.140.0", + "@tanstack/react-virtual": "^3.13.13", + "@tanstack/router-devtools": "^1.140.0", + "@types/dagre": "^0.7.53", + "@uiw/react-codemirror": "^4.25.3", + "@uppy/core": "^5.2.0", + "@uppy/dashboard": "^5.1.0", + "@uppy/react": "^5.1.1", + "@uppy/xhr-upload": "^5.1.1", + "@use-gesture/react": "^10.3.1", + "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "dagre": "^0.8.5", + "date-fns": "^4.1.0", + "html-to-image": "^1.11.13", + "i18next": "^25.8.13", + "i18next-browser-languagedetector": "^8.2.1", + "idb": "^8.0.3", + "katex": "^0.16.27", + "lucide-react": "^0.556.0", + "motion": "^12.38.0", + "react": "^19.2.1", + "react-day-picker": "^9.12.0", + "react-dom": "^19.2.1", + "react-i18next": "^16.5.4", + "react-joyride": "3.0.0-7", + "react-markdown": "^10.1.0", + "reactflow": "^11.11.4", + "recharts": "3.5.1", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "smol-toml": "^1.5.2", + "tailwind-merge": "^3.4.0", + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.2.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.2", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "@vitest/ui": "^4.0.18", + "electron": "^40.6.1", + "electron-builder": "^26.8.1", + "electron-store": "11.0.2", + "electron-vite": "^5.0.0", + "eslint": "^9.39.1", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^28.1.0", + "prettier": "^3.7.4", + "prettier-plugin-tailwindcss": "^0.7.2", + "tailwindcss": "^4.2.1", + "typescript": "~5.9.3", + "typescript-eslint": "^8.49.0", + "vite": "^7.2.7", + "vitest": "^4.0.18", + }, + }, + }, + "packages": { + "7zip-bin": ["7zip-bin@5.2.0", "https://registry.npmmirror.com/7zip-bin/-/7zip-bin-5.2.0.tgz", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], + + "@acemir/cssom": ["@acemir/cssom@0.9.31", "https://registry.npmmirror.com/@acemir/cssom/-/cssom-0.9.31.tgz", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], + + "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "https://registry.npmmirror.com/@adobe/css-tools/-/css-tools-4.4.4.tgz", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.8.1", "https://registry.npmmirror.com/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.6" } }, "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "https://registry.npmmirror.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.6", "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.6.tgz", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + + "@babel/parser": ["@babel/parser@7.29.0", "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.27.1", "https://registry.npmmirror.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/runtime": ["@babel/runtime@7.28.6", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + + "@babel/template": ["@babel/template@7.28.6", "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@bramus/specificity": ["@bramus/specificity@2.4.2", "https://registry.npmmirror.com/@bramus/specificity/-/specificity-2.4.2.tgz", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.0", "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg=="], + + "@codemirror/commands": ["@codemirror/commands@6.10.2", "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.10.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ=="], + + "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "https://registry.npmmirror.com/@codemirror/lang-css/-/lang-css-6.3.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], + + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.4", "https://registry.npmmirror.com/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA=="], + + "@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "https://registry.npmmirror.com/@codemirror/lang-json/-/lang-json-6.0.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="], + + "@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "https://registry.npmmirror.com/@codemirror/lang-python/-/lang-python-6.2.1.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="], + + "@codemirror/language": ["@codemirror/language@6.12.1", "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.1.tgz", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ=="], + + "@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.2", "https://registry.npmmirror.com/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q=="], + + "@codemirror/lint": ["@codemirror/lint@6.9.3", "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.3.tgz", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw=="], + + "@codemirror/search": ["@codemirror/search@6.6.0", "https://registry.npmmirror.com/@codemirror/search/-/search-6.6.0.tgz", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw=="], + + "@codemirror/state": ["@codemirror/state@6.5.4", "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.4.tgz", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw=="], + + "@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "https://registry.npmmirror.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="], + + "@codemirror/view": ["@codemirror/view@6.39.13", "https://registry.npmmirror.com/@codemirror/view/-/view-6.39.13.tgz", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@6.0.1", "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", {}, "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.1.1", "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-3.1.1.tgz", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.1", "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", { "dependencies": { "@csstools/color-helpers": "^6.0.1", "@csstools/css-calc": "^3.0.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.27", "https://registry.npmmirror.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", {}, "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + + "@date-fns/tz": ["@date-fns/tz@1.4.1", "https://registry.npmmirror.com/@date-fns/tz/-/tz-1.4.1.tgz", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + + "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "https://registry.npmmirror.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], + + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + + "@electron/asar": ["@electron/asar@3.4.1", "https://registry.npmmirror.com/@electron/asar/-/asar-3.4.1.tgz", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], + + "@electron/fuses": ["@electron/fuses@1.8.0", "https://registry.npmmirror.com/@electron/fuses/-/fuses-1.8.0.tgz", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], + + "@electron/get": ["@electron/get@2.0.3", "https://registry.npmmirror.com/@electron/get/-/get-2.0.3.tgz", { "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" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], + + "@electron/notarize": ["@electron/notarize@2.5.0", "https://registry.npmmirror.com/@electron/notarize/-/notarize-2.5.0.tgz", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], + + "@electron/osx-sign": ["@electron/osx-sign@1.3.3", "https://registry.npmmirror.com/@electron/osx-sign/-/osx-sign-1.3.3.tgz", { "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" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="], + + "@electron/rebuild": ["@electron/rebuild@4.0.3", "https://registry.npmmirror.com/@electron/rebuild/-/rebuild-4.0.3.tgz", { "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" } }, "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA=="], + + "@electron/universal": ["@electron/universal@2.0.3", "https://registry.npmmirror.com/@electron/universal/-/universal-2.0.3.tgz", { "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" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="], + + "@electron/windows-sign": ["@electron/windows-sign@1.2.2", "https://registry.npmmirror.com/@electron/windows-sign/-/windows-sign-1.2.2.tgz", { "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", "fs-extra": "^11.1.1", "minimist": "^1.2.8", "postject": "^1.0.0-alpha.6" }, "bin": { "electron-windows-sign": "bin/electron-windows-sign.js" } }, "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.1", "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.21.1.tgz", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "https://registry.npmmirror.com/@eslint/core/-/core-0.17.0.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="], + + "@eslint/js": ["@eslint/js@9.39.2", "https://registry.npmmirror.com/@eslint/js/-/js-9.39.2.tgz", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.7.tgz", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@exodus/bytes": ["@exodus/bytes@1.14.1", "https://registry.npmmirror.com/@exodus/bytes/-/bytes-1.14.1.tgz", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.4", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.4.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.5", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.5.tgz", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + + "@gilbarbara/deep-equal": ["@gilbarbara/deep-equal@0.3.1", "https://registry.npmmirror.com/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz", {}, "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw=="], + + "@gilbarbara/hooks": ["@gilbarbara/hooks@0.8.2", "https://registry.npmmirror.com/@gilbarbara/hooks/-/hooks-0.8.2.tgz", { "dependencies": { "@gilbarbara/deep-equal": "^0.3.1" }, "peerDependencies": { "react": "16.8 - 18" } }, "sha512-aWXlJFCrqmasGaDd6IhSpqOFeOD4pSBpRtILKw0WxWQzWE+HYCA0adLf0P18BNztR/G0byWnpkGupeGx+NFnuw=="], + + "@gilbarbara/types": ["@gilbarbara/types@0.2.2", "https://registry.npmmirror.com/@gilbarbara/types/-/types-0.2.2.tgz", { "dependencies": { "type-fest": "^4.1.0" } }, "sha512-QuQDBRRcm1Q8AbSac2W1YElurOhprj3Iko/o+P1fJxUWS4rOGKMVli98OXS7uo4z+cKAif6a+L9bcZFSyauQpQ=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.7.tgz", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", { "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" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@lezer/common": ["@lezer/common@1.5.1", "https://registry.npmmirror.com/@lezer/common/-/common-1.5.1.tgz", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="], + + "@lezer/css": ["@lezer/css@1.3.1", "https://registry.npmmirror.com/@lezer/css/-/css-1.3.1.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.3", "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], + + "@lezer/javascript": ["@lezer/javascript@1.5.4", "https://registry.npmmirror.com/@lezer/javascript/-/javascript-1.5.4.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], + + "@lezer/json": ["@lezer/json@1.0.3", "https://registry.npmmirror.com/@lezer/json/-/json-1.0.3.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="], + + "@lezer/lr": ["@lezer/lr@1.4.8", "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.8.tgz", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA=="], + + "@lezer/python": ["@lezer/python@1.1.18", "https://registry.npmmirror.com/@lezer/python/-/python-1.1.18.tgz", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg=="], + + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "https://registry.npmmirror.com/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], + + "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "https://registry.npmmirror.com/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + + "@npmcli/agent": ["@npmcli/agent@3.0.0", "https://registry.npmmirror.com/@npmcli/agent/-/agent-3.0.0.tgz", { "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" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="], + + "@npmcli/fs": ["@npmcli/fs@4.0.0", "https://registry.npmmirror.com/@npmcli/fs/-/fs-4.0.0.tgz", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.29.tgz", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@popperjs/core": ["@popperjs/core@2.11.8", "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], + + "@radix-ui/number": ["@radix-ui/number@1.1.1", "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.1.tgz", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.3.tgz", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "https://registry.npmmirror.com/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="], + + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "https://registry.npmmirror.com/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "https://registry.npmmirror.com/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "https://registry.npmmirror.com/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "https://registry.npmmirror.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "https://registry.npmmirror.com/@radix-ui/react-label/-/react-label-2.1.8.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "https://registry.npmmirror.com/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "https://registry.npmmirror.com/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="], + + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "https://registry.npmmirror.com/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "https://registry.npmmirror.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "https://registry.npmmirror.com/@radix-ui/react-select/-/react-select-2.2.6.tgz", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "https://registry.npmmirror.com/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], + + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "https://registry.npmmirror.com/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "https://registry.npmmirror.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "https://registry.npmmirror.com/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + + "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "https://registry.npmmirror.com/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "https://registry.npmmirror.com/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.1.tgz", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + + "@react-spring/animated": ["@react-spring/animated@10.0.3", "https://registry.npmmirror.com/@react-spring/animated/-/animated-10.0.3.tgz", { "dependencies": { "@react-spring/shared": "~10.0.3", "@react-spring/types": "~10.0.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ=="], + + "@react-spring/core": ["@react-spring/core@10.0.3", "https://registry.npmmirror.com/@react-spring/core/-/core-10.0.3.tgz", { "dependencies": { "@react-spring/animated": "~10.0.3", "@react-spring/shared": "~10.0.3", "@react-spring/types": "~10.0.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ=="], + + "@react-spring/rafz": ["@react-spring/rafz@10.0.3", "https://registry.npmmirror.com/@react-spring/rafz/-/rafz-10.0.3.tgz", {}, "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg=="], + + "@react-spring/shared": ["@react-spring/shared@10.0.3", "https://registry.npmmirror.com/@react-spring/shared/-/shared-10.0.3.tgz", { "dependencies": { "@react-spring/rafz": "~10.0.3", "@react-spring/types": "~10.0.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q=="], + + "@react-spring/types": ["@react-spring/types@10.0.3", "https://registry.npmmirror.com/@react-spring/types/-/types-10.0.3.tgz", {}, "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ=="], + + "@react-spring/web": ["@react-spring/web@10.0.3", "https://registry.npmmirror.com/@react-spring/web/-/web-10.0.3.tgz", { "dependencies": { "@react-spring/animated": "~10.0.3", "@react-spring/core": "~10.0.3", "@react-spring/shared": "~10.0.3", "@react-spring/types": "~10.0.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A=="], + + "@reactflow/background": ["@reactflow/background@11.3.14", "https://registry.npmmirror.com/@reactflow/background/-/background-11.3.14.tgz", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA=="], + + "@reactflow/controls": ["@reactflow/controls@11.2.14", "https://registry.npmmirror.com/@reactflow/controls/-/controls-11.2.14.tgz", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw=="], + + "@reactflow/core": ["@reactflow/core@11.11.4", "https://registry.npmmirror.com/@reactflow/core/-/core-11.11.4.tgz", { "dependencies": { "@types/d3": "^7.4.0", "@types/d3-drag": "^3.0.1", "@types/d3-selection": "^3.0.3", "@types/d3-zoom": "^3.0.1", "classcat": "^5.0.3", "d3-drag": "^3.0.0", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q=="], + + "@reactflow/minimap": ["@reactflow/minimap@11.7.14", "https://registry.npmmirror.com/@reactflow/minimap/-/minimap-11.7.14.tgz", { "dependencies": { "@reactflow/core": "11.11.4", "@types/d3-selection": "^3.0.3", "@types/d3-zoom": "^3.0.1", "classcat": "^5.0.3", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ=="], + + "@reactflow/node-resizer": ["@reactflow/node-resizer@2.2.14", "https://registry.npmmirror.com/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.4", "d3-drag": "^3.0.0", "d3-selection": "^3.0.0", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA=="], + + "@reactflow/node-toolbar": ["@reactflow/node-toolbar@1.3.14", "https://registry.npmmirror.com/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ=="], + + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], + + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + + "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "https://registry.npmmirror.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.1.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.2.1.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.2.1.tgz", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], + + "@tanstack/history": ["@tanstack/history@1.154.14", "https://registry.npmmirror.com/@tanstack/history/-/history-1.154.14.tgz", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="], + + "@tanstack/react-router": ["@tanstack/react-router@1.159.4", "https://registry.npmmirror.com/@tanstack/react-router/-/react-router-1.159.4.tgz", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.159.4", "isbot": "^5.1.22", "srvx": "^0.11.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-z3DhNkRh/joky5b+X4jEYOn9q4Jieie6mVFP62wgwM9pVlNRYh6aIroiU95ZyOwDXDijItVEZtvHuipbLHy4jw=="], + + "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.159.4", "https://registry.npmmirror.com/@tanstack/react-router-devtools/-/react-router-devtools-1.159.4.tgz", { "dependencies": { "@tanstack/router-devtools-core": "1.159.4" }, "peerDependencies": { "@tanstack/react-router": "^1.159.4", "@tanstack/router-core": "^1.159.4", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-7HXV4b5WZMdWoP6HD+mURh4mq1ssRg0dfcVYx+AzhaLboFzy4LyzdUtMpmNgRFgz3mBXLBoo+gMbKSjKlmsZmw=="], + + "@tanstack/react-store": ["@tanstack/react-store@0.8.0", "https://registry.npmmirror.com/@tanstack/react-store/-/react-store-0.8.0.tgz", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="], + + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "https://registry.npmmirror.com/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="], + + "@tanstack/router-core": ["@tanstack/router-core@1.159.4", "https://registry.npmmirror.com/@tanstack/router-core/-/router-core-1.159.4.tgz", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-MFzPH39ijNO83qJN3pe7x4iAlhZyqgao3sJIzv3SJ4Pnk12xMnzuDzIAQT/1WV6JolPQEcw0Wr4L5agF8yxoeg=="], + + "@tanstack/router-devtools": ["@tanstack/router-devtools@1.159.4", "https://registry.npmmirror.com/@tanstack/router-devtools/-/router-devtools-1.159.4.tgz", { "dependencies": { "@tanstack/react-router-devtools": "1.159.4", "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/react-router": "^1.159.4", "csstype": "^3.0.10", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["csstype"] }, "sha512-2z54MX1K2BBfyFULeOHzjrYzRcncy/7ZWXmw1l8L2tjwveuZ0cxKgssZTEPZU7yBohcXmDMvFPxw7hAdgAcZsw=="], + + "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.159.4", "https://registry.npmmirror.com/@tanstack/router-devtools-core/-/router-devtools-core-1.159.4.tgz", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.159.4", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-qMUeIv+6n1mZOcO2raCIbdOeDeMpJEmgm6oMs/nWEG61lYrzJYaCcpBTviAX0nRhSiQSUCX9cHiosUEA0e2HAw=="], + + "@tanstack/store": ["@tanstack/store@0.8.0", "https://registry.npmmirror.com/@tanstack/store/-/store-0.8.0.tgz", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "https://registry.npmmirror.com/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "https://registry.npmmirror.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "https://registry.npmmirror.com/@testing-library/react/-/react-16.3.2.tgz", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "https://registry.npmmirror.com/@testing-library/user-event/-/user-event-14.6.1.tgz", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + + "@transloadit/prettier-bytes": ["@transloadit/prettier-bytes@0.3.5", "https://registry.npmmirror.com/@transloadit/prettier-bytes/-/prettier-bytes-0.3.5.tgz", {}, "sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], + + "@types/chai": ["@types/chai@5.2.3", "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/d3": ["@types/d3@7.4.3", "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-axis": ["@types/d3-axis@3.0.6", "https://registry.npmmirror.com/@types/d3-axis/-/d3-axis-3.0.6.tgz", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "https://registry.npmmirror.com/@types/d3-brush/-/d3-brush-3.0.6.tgz", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "https://registry.npmmirror.com/@types/d3-chord/-/d3-chord-3.0.6.tgz", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-contour": ["@types/d3-contour@3.0.6", "https://registry.npmmirror.com/@types/d3-contour/-/d3-contour-3.0.6.tgz", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "https://registry.npmmirror.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.10.tgz", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.0.tgz", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "https://registry.npmmirror.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.3", "https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.3.tgz", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "https://registry.npmmirror.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "https://registry.npmmirror.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + + "@types/dagre": ["@types/dagre@0.7.53", "https://registry.npmmirror.com/@types/dagre/-/dagre-0.7.53.tgz", {}, "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ=="], + + "@types/debug": ["@types/debug@4.1.12", "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/fs-extra": ["@types/fs-extra@9.0.13", "https://registry.npmmirror.com/@types/fs-extra/-/fs-extra-9.0.13.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], + + "@types/geojson": ["@types/geojson@7946.0.16", "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + + "@types/hast": ["@types/hast@3.0.4", "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "https://registry.npmmirror.com/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/katex": ["@types/katex@0.16.8", "https://registry.npmmirror.com/@types/katex/-/katex-0.16.8.tgz", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], + + "@types/keyv": ["@types/keyv@3.1.4", "https://registry.npmmirror.com/@types/keyv/-/keyv-3.1.4.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], + + "@types/mdast": ["@types/mdast@4.0.4", "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/node": ["@types/node@24.10.12", "https://registry.npmmirror.com/@types/node/-/node-24.10.12.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw=="], + + "@types/plist": ["@types/plist@3.0.5", "https://registry.npmmirror.com/@types/plist/-/plist-3.0.5.tgz", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], + + "@types/react": ["@types/react@19.2.13", "https://registry.npmmirror.com/@types/react/-/react-19.2.13.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/responselike": ["@types/responselike@1.0.3", "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], + + "@types/retry": ["@types/retry@0.12.2", "https://registry.npmmirror.com/@types/retry/-/retry-0.12.2.tgz", {}, "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="], + + "@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + + "@types/verror": ["@types/verror@1.10.11", "https://registry.npmmirror.com/@types/verror/-/verror-1.10.11.tgz", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="], + + "@types/yauzl": ["@types/yauzl@2.10.3", "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.54.0", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.54.0.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.54.0", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.54.0", "@typescript-eslint/types": "^8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.54.0", "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0" } }, "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.54.0", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.54.0", "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.54.0.tgz", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.54.0", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.54.0", "@typescript-eslint/tsconfig-utils": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.54.0", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.54.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="], + + "@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.4", "https://registry.npmmirror.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.4.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-YzNwkm0AbPv1EXhCHYR5v0nqfemG2jEB0Z3Att4rBYqKrlG7AA9Rhjc3IyBaOzsBu18wtrp9/+uhTyu7TXSRng=="], + + "@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.4", "https://registry.npmmirror.com/@uiw/react-codemirror/-/react-codemirror-4.25.4.tgz", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.4", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-ipO067oyfUw+DVaXhQCxkB0ZD9b7RnY+ByrprSYSKCHaULvJ3sqWYC/Zen6zVQ8/XC4o5EPBfatGiX20kC7XGA=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "@uppy/companion-client": ["@uppy/companion-client@5.1.1", "https://registry.npmmirror.com/@uppy/companion-client/-/companion-client-5.1.1.tgz", { "dependencies": { "@uppy/utils": "^7.1.1", "namespace-emitter": "^2.0.1", "p-retry": "^6.1.0" }, "peerDependencies": { "@uppy/core": "^5.1.1" } }, "sha512-DzrOWTbIZHvtgAFXBMYHk2wD27NjpBSVhY2tEiEIUhPd2CxbFRZjHM/N3HOt3VwZEAP471QWFLlJRWPcIY3A2Q=="], + + "@uppy/components": ["@uppy/components@1.2.0", "https://registry.npmmirror.com/@uppy/components/-/components-1.2.0.tgz", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "preact": "^10.26.10", "pretty-bytes": "^6.1.1" }, "peerDependencies": { "@uppy/core": "^5.2.0", "@uppy/image-editor": "^4.2.0", "@uppy/screen-capture": "^5.1.0", "@uppy/webcam": "^5.1.0" }, "optionalPeers": ["@uppy/image-editor", "@uppy/screen-capture", "@uppy/webcam"] }, "sha512-rtIr+77Rw/q5Vw++xazF1dCg2d4A4zT9CV+ZyN8Rsx8xiIr2CxCR4TaHHBy+WeC0b7Mk6yNuJ0wUa34tFJ6pKg=="], + + "@uppy/core": ["@uppy/core@5.2.0", "https://registry.npmmirror.com/@uppy/core/-/core-5.2.0.tgz", { "dependencies": { "@transloadit/prettier-bytes": "^0.3.4", "@uppy/store-default": "^5.0.0", "@uppy/utils": "^7.1.4", "lodash": "^4.17.21", "mime-match": "^1.0.2", "namespace-emitter": "^2.0.1", "nanoid": "^5.0.9", "preact": "^10.5.13" } }, "sha512-uvfNyz4cnaplt7LYJmEZHuqOuav0tKp4a9WKJIaH6iIj7XiqYvS2J5SEByexAlUFlzefOAyjzj4Ja2dd/8aMrw=="], + + "@uppy/dashboard": ["@uppy/dashboard@5.1.1", "https://registry.npmmirror.com/@uppy/dashboard/-/dashboard-5.1.1.tgz", { "dependencies": { "@transloadit/prettier-bytes": "^0.3.4", "@uppy/provider-views": "^5.2.2", "@uppy/thumbnail-generator": "^5.1.0", "@uppy/utils": "^7.1.5", "classnames": "^2.2.6", "lodash": "^4.17.23", "nanoid": "^5.0.9", "preact": "^10.26.10", "shallow-equal": "^3.0.0" }, "peerDependencies": { "@uppy/core": "^5.2.0" } }, "sha512-6H/xVvhhdfwp1+FRMp2C+tudyaedqD5+LMDB8Iw20k9+QCL1eGzOh4wXm6MCqJtNfQ1tLaprGMG1jlo7yS/uyw=="], + + "@uppy/provider-views": ["@uppy/provider-views@5.2.2", "https://registry.npmmirror.com/@uppy/provider-views/-/provider-views-5.2.2.tgz", { "dependencies": { "@uppy/utils": "^7.1.5", "classnames": "^2.2.6", "lodash": "^4.17.21", "nanoid": "^5.0.9", "p-queue": "^8.0.0", "preact": "^10.5.13" }, "peerDependencies": { "@uppy/core": "^5.2.0" } }, "sha512-NAazIJ5sjrAc6++CeJ/u9dB5gDaaAOLHrYeEmWs/HqLlftlIinRZOybnyzJRXwI8jWI/FK5moluzt2HXu6dPQQ=="], + + "@uppy/react": ["@uppy/react@5.2.0", "https://registry.npmmirror.com/@uppy/react/-/react-5.2.0.tgz", { "dependencies": { "@uppy/components": "^1.2.0", "preact": "^10.26.10", "use-sync-external-store": "^1.3.0" }, "peerDependencies": { "@uppy/core": "^5.2.0", "@uppy/dashboard": "^5.1.1", "@uppy/screen-capture": "^5.1.0", "@uppy/status-bar": "^5.1.0", "@uppy/webcam": "^5.1.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@uppy/dashboard", "@uppy/screen-capture", "@uppy/status-bar", "@uppy/webcam"] }, "sha512-6lzPutg2XGavs7P6ALmqOBPitd/Jqi3r1jCJQD5nx8xtNlBRwvlBR6hrZgo8XOI9cR+OaNDrJ0vEFxXDWb04Ag=="], + + "@uppy/store-default": ["@uppy/store-default@5.0.0", "https://registry.npmmirror.com/@uppy/store-default/-/store-default-5.0.0.tgz", {}, "sha512-hQtCSQ1yGiaval/wVYUWquYGDJ+bpQ7e4FhUUAsRQz1x1K+o7NBtjfp63O9I4Ks1WRoKunpkarZ+as09l02cPw=="], + + "@uppy/thumbnail-generator": ["@uppy/thumbnail-generator@5.1.0", "https://registry.npmmirror.com/@uppy/thumbnail-generator/-/thumbnail-generator-5.1.0.tgz", { "dependencies": { "@uppy/utils": "^7.1.4", "exifr": "^7.0.0" }, "peerDependencies": { "@uppy/core": "^5.2.0" } }, "sha512-QAKJHHkMrD/30GOyUb5U9HyJ7Ie3jiMLp4pVdw27PoA4pNV7fDQz0tyDeRPj2H+BWPEB1NsTSSfHI2pjHNI+OQ=="], + + "@uppy/utils": ["@uppy/utils@7.1.5", "https://registry.npmmirror.com/@uppy/utils/-/utils-7.1.5.tgz", { "dependencies": { "lodash": "^4.17.21", "preact": "^10.5.13" } }, "sha512-Vz4WGTjef6WebECGur4clWjpkET4o3bdvPMj1m2sD5cL+dTt69m+FIE5h5JD3HBMLEPTXPVkrXGMIFcbOYC12Q=="], + + "@uppy/xhr-upload": ["@uppy/xhr-upload@5.1.1", "https://registry.npmmirror.com/@uppy/xhr-upload/-/xhr-upload-5.1.1.tgz", { "dependencies": { "@uppy/companion-client": "^5.1.1", "@uppy/utils": "^7.1.5" }, "peerDependencies": { "@uppy/core": "^5.2.0" } }, "sha512-Vp0HWVA8o+niC2uISxPt0pZ+95bHHkk9HzNaUTrff/vq+20Ln68BS2auJhc9ecJzI6SKAlGZ342dcTQ/onw0nA=="], + + "@use-gesture/core": ["@use-gesture/core@10.3.1", "https://registry.npmmirror.com/@use-gesture/core/-/core-10.3.1.tgz", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="], + + "@use-gesture/react": ["@use-gesture/react@10.3.1", "https://registry.npmmirror.com/@use-gesture/react/-/react-10.3.1.tgz", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.3", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.2", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg=="], + + "@vitest/expect": ["@vitest/expect@4.0.18", "https://registry.npmmirror.com/@vitest/expect/-/expect-4.0.18.tgz", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], + + "@vitest/mocker": ["@vitest/mocker@4.0.18", "https://registry.npmmirror.com/@vitest/mocker/-/mocker-4.0.18.tgz", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="], + + "@vitest/runner": ["@vitest/runner@4.0.18", "https://registry.npmmirror.com/@vitest/runner/-/runner-4.0.18.tgz", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.0.18", "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-4.0.18.tgz", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="], + + "@vitest/spy": ["@vitest/spy@4.0.18", "https://registry.npmmirror.com/@vitest/spy/-/spy-4.0.18.tgz", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="], + + "@vitest/ui": ["@vitest/ui@4.0.18", "https://registry.npmmirror.com/@vitest/ui/-/ui-4.0.18.tgz", { "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.0.18" } }, "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ=="], + + "@vitest/utils": ["@vitest/utils@4.0.18", "https://registry.npmmirror.com/@vitest/utils/-/utils-4.0.18.tgz", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], + + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], + + "abbrev": ["abbrev@3.0.1", "https://registry.npmmirror.com/abbrev/-/abbrev-3.0.1.tgz", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], + + "acorn": ["acorn@8.15.0", "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@6.12.6", "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", { "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" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ajv-keywords": ["ajv-keywords@3.5.2", "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "https://registry.npmmirror.com/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="], + + "app-builder-lib": ["app-builder-lib@26.8.1", "https://registry.npmmirror.com/app-builder-lib/-/app-builder-lib-26.8.1.tgz", { "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" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="], + + "argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "aria-query": ["aria-query@5.3.0", "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "https://registry.npmmirror.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "https://registry.npmmirror.com/array-includes/-/array-includes-3.1.9.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "https://registry.npmmirror.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "https://registry.npmmirror.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "https://registry.npmmirror.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + + "assert-plus": ["assert-plus@1.0.0", "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], + + "assertion-error": ["assertion-error@2.0.1", "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "ast-types-flow": ["ast-types-flow@0.0.8", "https://registry.npmmirror.com/ast-types-flow/-/ast-types-flow-0.0.8.tgz", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], + + "astral-regex": ["astral-regex@2.0.0", "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], + + "async": ["async@3.2.6", "https://registry.npmmirror.com/async/-/async-3.2.6.tgz", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "async-exit-hook": ["async-exit-hook@2.0.1", "https://registry.npmmirror.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], + + "async-function": ["async-function@1.0.0", "https://registry.npmmirror.com/async-function/-/async-function-1.0.0.tgz", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + + "asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "at-least-node": ["at-least-node@1.0.0", "https://registry.npmmirror.com/at-least-node/-/at-least-node-1.0.0.tgz", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], + + "atomically": ["atomically@2.1.1", "https://registry.npmmirror.com/atomically/-/atomically-2.1.1.tgz", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "axe-core": ["axe-core@4.11.1", "https://registry.npmmirror.com/axe-core/-/axe-core-4.11.1.tgz", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="], + + "axios": ["axios@1.13.5", "https://registry.npmmirror.com/axios/-/axios-1.13.5.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], + + "axobject-query": ["axobject-query@4.1.0", "https://registry.npmmirror.com/axobject-query/-/axobject-query-4.1.0.tgz", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "bail": ["bail@2.0.2", "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-js": ["base64-js@1.5.1", "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], + + "bidi-js": ["bidi-js@1.0.3", "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + + "bl": ["bl@4.1.0", "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "boolean": ["boolean@3.2.0", "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], + + "brace-expansion": ["brace-expansion@1.1.12", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "browserslist": ["browserslist@4.28.1", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "buffer": ["buffer@5.7.1", "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "buffer-from": ["buffer-from@1.1.2", "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "builder-util": ["builder-util@26.8.1", "https://registry.npmmirror.com/builder-util/-/builder-util-26.8.1.tgz", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "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" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="], + + "builder-util-runtime": ["builder-util-runtime@9.5.1", "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], + + "cac": ["cac@6.7.14", "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "cacache": ["cacache@19.0.1", "https://registry.npmmirror.com/cacache/-/cacache-19.0.1.tgz", { "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" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], + + "cacheable-lookup": ["cacheable-lookup@5.0.4", "https://registry.npmmirror.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], + + "cacheable-request": ["cacheable-request@7.0.4", "https://registry.npmmirror.com/cacheable-request/-/cacheable-request-7.0.4.tgz", { "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" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], + + "call-bind": ["call-bind@1.0.8", "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.8.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001769", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], + + "ccount": ["ccount@2.0.1", "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "chai": ["chai@6.2.2", "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "character-entities": ["character-entities@2.0.2", "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "chownr": ["chownr@3.0.0", "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "https://registry.npmmirror.com/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], + + "ci-info": ["ci-info@4.4.0", "https://registry.npmmirror.com/ci-info/-/ci-info-4.4.0.tgz", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "classcat": ["classcat@5.0.5", "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + + "classnames": ["classnames@2.5.1", "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + + "cli-cursor": ["cli-cursor@3.1.0", "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], + + "cli-spinners": ["cli-spinners@2.9.2", "https://registry.npmmirror.com/cli-spinners/-/cli-spinners-2.9.2.tgz", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cli-truncate": ["cli-truncate@2.1.0", "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-2.1.0.tgz", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], + + "cliui": ["cliui@8.0.1", "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clone": ["clone@1.0.4", "https://registry.npmmirror.com/clone/-/clone-1.0.4.tgz", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + + "clone-response": ["clone-response@1.0.3", "https://registry.npmmirror.com/clone-response/-/clone-response-1.0.3.tgz", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], + + "clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "cmdk": ["cmdk@1.1.1", "https://registry.npmmirror.com/cmdk/-/cmdk-1.1.1.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + + "codemirror": ["codemirror@6.0.2", "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], + + "color-convert": ["color-convert@2.0.1", "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "commander": ["commander@8.3.0", "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "compare-version": ["compare-version@0.1.2", "https://registry.npmmirror.com/compare-version/-/compare-version-0.1.2.tgz", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="], + + "concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "conf": ["conf@15.1.0", "https://registry.npmmirror.com/conf/-/conf-15.1.0.tgz", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="], + + "convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie-es": ["cookie-es@2.0.0", "https://registry.npmmirror.com/cookie-es/-/cookie-es-2.0.0.tgz", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + + "core-util-is": ["core-util-is@1.0.2", "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], + + "crc": ["crc@3.8.0", "https://registry.npmmirror.com/crc/-/crc-3.8.0.tgz", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], + + "crelt": ["crelt@1.0.6", "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + + "cross-dirname": ["cross-dirname@0.1.0", "https://registry.npmmirror.com/cross-dirname/-/cross-dirname-0.1.0.tgz", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], + + "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-tree": ["css-tree@3.1.0", "https://registry.npmmirror.com/css-tree/-/css-tree-3.1.0.tgz", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], + + "css.escape": ["css.escape@1.5.1", "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "cssstyle": ["cssstyle@6.0.1", "https://registry.npmmirror.com/cssstyle/-/cssstyle-6.0.1.tgz", { "dependencies": { "@asamuzakjp/css-color": "^4.1.2", "@csstools/css-syntax-patches-for-csstree": "^1.0.26", "css-tree": "^3.1.0", "lru-cache": "^11.2.5" } }, "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog=="], + + "csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "d3-array": ["d3-array@3.2.4", "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-ease": ["d3-ease@3.0.1", "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-selection": ["d3-selection@3.0.0", "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-shape": ["d3-shape@3.2.0", "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre": ["dagre@0.8.5", "https://registry.npmmirror.com/dagre/-/dagre-0.8.5.tgz", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="], + + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "https://registry.npmmirror.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], + + "data-urls": ["data-urls@7.0.0", "https://registry.npmmirror.com/data-urls/-/data-urls-7.0.0.tgz", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "https://registry.npmmirror.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "https://registry.npmmirror.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "date-fns": ["date-fns@4.1.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "https://registry.npmmirror.com/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], + + "debounce-fn": ["debounce-fn@6.0.0", "https://registry.npmmirror.com/debounce-fn/-/debounce-fn-6.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="], + + "debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "decompress-response": ["decompress-response@6.0.0", "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-is": ["deep-is@0.1.4", "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge": ["deepmerge@4.3.1", "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "deepmerge-ts": ["deepmerge-ts@7.1.5", "https://registry.npmmirror.com/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + + "defaults": ["defaults@1.0.4", "https://registry.npmmirror.com/defaults/-/defaults-1.0.4.tgz", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], + + "defer-to-connect": ["defer-to-connect@2.0.1", "https://registry.npmmirror.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], + + "define-data-property": ["define-data-property@1.1.4", "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "dequal": ["dequal@2.0.3", "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node": ["detect-node@2.1.0", "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + + "detect-node-es": ["detect-node-es@1.1.0", "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "devlop": ["devlop@1.1.0", "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "dir-compare": ["dir-compare@4.2.0", "https://registry.npmmirror.com/dir-compare/-/dir-compare-4.2.0.tgz", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], + + "dmg-builder": ["dmg-builder@26.8.1", "https://registry.npmmirror.com/dmg-builder/-/dmg-builder-26.8.1.tgz", { "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" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="], + + "dmg-license": ["dmg-license@1.0.11", "https://registry.npmmirror.com/dmg-license/-/dmg-license-1.0.11.tgz", { "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" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "dot-prop": ["dot-prop@10.1.0", "https://registry.npmmirror.com/dot-prop/-/dot-prop-10.1.0.tgz", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], + + "dotenv": ["dotenv@16.6.1", "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "dotenv-expand": ["dotenv-expand@11.0.7", "https://registry.npmmirror.com/dotenv-expand/-/dotenv-expand-11.0.7.tgz", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], + + "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ejs": ["ejs@3.1.10", "https://registry.npmmirror.com/ejs/-/ejs-3.1.10.tgz", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], + + "electron": ["electron@40.6.1", "https://registry.npmmirror.com/electron/-/electron-40.6.1.tgz", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-u9YfoixttdauciHV9Ut9Zf3YipJoU093kR1GSYTTXTAXqhiXI0G1A0NnL/f0O2m2UULCXaXMf2W71PloR6V9pQ=="], + + "electron-builder": ["electron-builder@26.8.1", "https://registry.npmmirror.com/electron-builder/-/electron-builder-26.8.1.tgz", { "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" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="], + + "electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.1", "https://registry.npmmirror.com/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.8.1.tgz", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="], + + "electron-publish": ["electron-publish@26.8.1", "https://registry.npmmirror.com/electron-publish/-/electron-publish-26.8.1.tgz", { "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" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="], + + "electron-store": ["electron-store@11.0.2", "https://registry.npmmirror.com/electron-store/-/electron-store-11.0.2.tgz", { "dependencies": { "conf": "^15.0.2", "type-fest": "^5.0.1" } }, "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.286", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], + + "electron-vite": ["electron-vite@5.0.0", "https://registry.npmmirror.com/electron-vite/-/electron-vite-5.0.0.tgz", { "dependencies": { "@babel/core": "^7.28.4", "@babel/plugin-transform-arrow-functions": "^7.27.1", "cac": "^6.7.14", "esbuild": "^0.25.11", "magic-string": "^0.30.19", "picocolors": "^1.1.1" }, "peerDependencies": { "@swc/core": "^1.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@swc/core"], "bin": { "electron-vite": "bin/electron-vite.js" } }, "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ=="], + + "electron-winstaller": ["electron-winstaller@5.4.0", "https://registry.npmmirror.com/electron-winstaller/-/electron-winstaller-5.4.0.tgz", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], + + "emoji-regex": ["emoji-regex@9.2.2", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "encoding": ["encoding@0.1.13", "https://registry.npmmirror.com/encoding/-/encoding-0.1.13.tgz", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.0", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], + + "entities": ["entities@6.0.1", "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "env-paths": ["env-paths@2.2.1", "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "err-code": ["err-code@2.0.3", "https://registry.npmmirror.com/err-code/-/err-code-2.0.3.tgz", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + + "es-abstract": ["es-abstract@1.24.1", "https://registry.npmmirror.com/es-abstract/-/es-abstract-1.24.1.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="], + + "es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "https://registry.npmmirror.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.0", "https://registry.npmmirror.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + + "es-toolkit": ["es-toolkit@1.44.0", "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.44.0.tgz", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="], + + "es6-error": ["es6-error@4.1.1", "https://registry.npmmirror.com/es6-error/-/es6-error-4.1.1.tgz", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + + "esbuild": ["esbuild@0.25.12", "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.2", "https://registry.npmmirror.com/eslint/-/eslint-9.39.2.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="], + + "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "https://registry.npmmirror.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "https://registry.npmmirror.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], + + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "https://registry.npmmirror.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="], + + "eslint-scope": ["eslint-scope@8.4.0", "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.7.0", "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "estree-walker": ["estree-walker@3.0.3", "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "exifr": ["exifr@7.1.3", "https://registry.npmmirror.com/exifr/-/exifr-7.1.3.tgz", {}, "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="], + + "expect-type": ["expect-type@1.3.0", "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "exponential-backoff": ["exponential-backoff@3.1.3", "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + + "extend": ["extend@3.0.2", "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "extract-zip": ["extract-zip@2.0.1", "https://registry.npmmirror.com/extract-zip/-/extract-zip-2.0.1.tgz", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + + "extsprintf": ["extsprintf@1.4.1", "https://registry.npmmirror.com/extsprintf/-/extsprintf-1.4.1.tgz", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-uri": ["fast-uri@3.1.0", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fd-slicer": ["fd-slicer@1.1.0", "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + + "fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fflate": ["fflate@0.8.2", "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "filelist": ["filelist@1.0.6", "https://registry.npmmirror.com/filelist/-/filelist-1.0.6.tgz", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], + + "find-up": ["find-up@5.0.0", "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "follow-redirects": ["follow-redirects@1.15.11", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "for-each": ["for-each@0.3.5", "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "foreground-child": ["foreground-child@3.3.1", "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "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" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "framer-motion": ["framer-motion@12.38.0", "https://registry.npmmirror.com/framer-motion/-/framer-motion-12.38.0.tgz", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], + + "fs-extra": ["fs-extra@10.1.0", "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + + "fs-minipass": ["fs-minipass@3.0.3", "https://registry.npmmirror.com/fs-minipass/-/fs-minipass-3.0.3.tgz", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], + + "fs.realpath": ["fs.realpath@1.0.0", "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.1.8", "https://registry.npmmirror.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + + "functions-have-names": ["functions-have-names@1.2.3", "https://registry.npmmirror.com/functions-have-names/-/functions-have-names-1.2.3.tgz", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "generator-function": ["generator-function@2.0.1", "https://registry.npmmirror.com/generator-function/-/generator-function-2.0.1.tgz", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + + "gensync": ["gensync@1.0.0-beta.2", "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-nonce": ["get-nonce@1.0.1", "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-stream": ["get-stream@5.2.0", "https://registry.npmmirror.com/get-stream/-/get-stream-5.2.0.tgz", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "https://registry.npmmirror.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "glob": ["glob@7.2.3", "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "glob-parent": ["glob-parent@6.0.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "global-agent": ["global-agent@3.0.0", "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz", { "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" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + + "globals": ["globals@16.5.0", "https://registry.npmmirror.com/globals/-/globals-16.5.0.tgz", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + + "globalthis": ["globalthis@1.0.4", "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "goober": ["goober@2.1.18", "https://registry.npmmirror.com/goober/-/goober-2.1.18.tgz", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="], + + "gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "got": ["got@11.8.6", "https://registry.npmmirror.com/got/-/got-11.8.6.tgz", { "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" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="], + + "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphlib": ["graphlib@2.1.8", "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="], + + "has-bigints": ["has-bigints@1.1.0", "https://registry.npmmirror.com/has-bigints/-/has-bigints-1.1.0.tgz", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-flag": ["has-flag@4.0.0", "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "https://registry.npmmirror.com/has-proto/-/has-proto-1.2.0.tgz", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "https://registry.npmmirror.com/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], + + "hast-util-from-html": ["hast-util-from-html@2.0.3", "https://registry.npmmirror.com/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + + "hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "https://registry.npmmirror.com/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "https://registry.npmmirror.com/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-is-element": ["hast-util-is-element@3.0.0", "https://registry.npmmirror.com/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "https://registry.npmmirror.com/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-to-text": ["hast-util-to-text@4.0.2", "https://registry.npmmirror.com/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@9.0.1", "https://registry.npmmirror.com/hastscript/-/hastscript-9.0.1.tgz", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + + "hermes-estree": ["hermes-estree@0.25.1", "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "hosted-git-info": ["hosted-git-info@4.1.0", "https://registry.npmmirror.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + + "html-parse-stringify": ["html-parse-stringify@3.0.1", "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + + "html-to-image": ["html-to-image@1.11.13", "https://registry.npmmirror.com/html-to-image/-/html-to-image-1.11.13.tgz", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="], + + "html-url-attributes": ["html-url-attributes@3.0.1", "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "http2-wrapper": ["http2-wrapper@1.0.3", "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "i18next": ["i18next@25.8.14", "https://registry.npmmirror.com/i18next/-/i18next-25.8.14.tgz", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA=="], + + "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "https://registry.npmmirror.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="], + + "iconv-corefoundation": ["iconv-corefoundation@1.1.7", "https://registry.npmmirror.com/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="], + + "iconv-lite": ["iconv-lite@0.6.3", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "idb": ["idb@8.0.3", "https://registry.npmmirror.com/idb/-/idb-8.0.3.tgz", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], + + "ieee754": ["ieee754@1.2.1", "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "immer": ["immer@10.2.0", "https://registry.npmmirror.com/immer/-/immer-10.2.0.tgz", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + + "import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "indent-string": ["indent-string@4.0.0", "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "inflight": ["inflight@1.0.6", "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "internal-slot": ["internal-slot@1.1.0", "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "internmap": ["internmap@2.0.3", "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "ip-address": ["ip-address@10.1.0", "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-async-function": ["is-async-function@2.1.1", "https://registry.npmmirror.com/is-async-function/-/is-async-function-2.1.1.tgz", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "https://registry.npmmirror.com/is-bigint/-/is-bigint-1.1.0.tgz", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "https://registry.npmmirror.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-callable": ["is-callable@1.2.7", "https://registry.npmmirror.com/is-callable/-/is-callable-1.2.7.tgz", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-data-view": ["is-data-view@1.0.2", "https://registry.npmmirror.com/is-data-view/-/is-data-view-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "https://registry.npmmirror.com/is-date-object/-/is-date-object-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-decimal": ["is-decimal@2.0.1", "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "https://registry.npmmirror.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-generator-function": ["is-generator-function@1.1.2", "https://registry.npmmirror.com/is-generator-function/-/is-generator-function-1.1.2.tgz", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + + "is-glob": ["is-glob@4.0.3", "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-interactive": ["is-interactive@1.0.0", "https://registry.npmmirror.com/is-interactive/-/is-interactive-1.0.0.tgz", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], + + "is-lite": ["is-lite@1.2.1", "https://registry.npmmirror.com/is-lite/-/is-lite-1.2.1.tgz", {}, "sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw=="], + + "is-map": ["is-map@2.0.3", "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "https://registry.npmmirror.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-network-error": ["is-network-error@1.3.0", "https://registry.npmmirror.com/is-network-error/-/is-network-error-1.3.0.tgz", {}, "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw=="], + + "is-number-object": ["is-number-object@1.1.1", "https://registry.npmmirror.com/is-number-object/-/is-number-object-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "is-regex": ["is-regex@1.2.1", "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "https://registry.npmmirror.com/is-set/-/is-set-2.0.3.tgz", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "https://registry.npmmirror.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-string": ["is-string@1.1.1", "https://registry.npmmirror.com/is-string/-/is-string-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "https://registry.npmmirror.com/is-symbol/-/is-symbol-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "https://registry.npmmirror.com/is-typed-array/-/is-typed-array-1.1.15.tgz", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-unicode-supported": ["is-unicode-supported@0.1.0", "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], + + "is-weakmap": ["is-weakmap@2.0.2", "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "https://registry.npmmirror.com/is-weakref/-/is-weakref-1.1.1.tgz", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "https://registry.npmmirror.com/is-weakset/-/is-weakset-2.0.4.tgz", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "isbinaryfile": ["isbinaryfile@5.0.7", "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-5.0.7.tgz", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], + + "isbot": ["isbot@5.1.34", "https://registry.npmmirror.com/isbot/-/isbot-5.1.34.tgz", {}, "sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A=="], + + "isexe": ["isexe@3.1.5", "https://registry.npmmirror.com/isexe/-/isexe-3.1.5.tgz", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + + "jackspeak": ["jackspeak@3.4.3", "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "jake": ["jake@10.9.4", "https://registry.npmmirror.com/jake/-/jake-10.9.4.tgz", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], + + "jiti": ["jiti@1.21.7", "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "jsdom": ["jsdom@28.1.0", "https://registry.npmmirror.com/jsdom/-/jsdom-28.1.0.tgz", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", "@bramus/specificity": "^2.4.2", "@exodus/bytes": "^1.11.0", "cssstyle": "^6.0.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug=="], + + "jsesc": ["jsesc@3.1.0", "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json-stringify-safe": ["json-stringify-safe@5.0.1", "https://registry.npmmirror.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + + "json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonfile": ["jsonfile@6.2.0", "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "https://registry.npmmirror.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + + "katex": ["katex@0.16.28", "https://registry.npmmirror.com/katex/-/katex-0.16.28.tgz", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg=="], + + "keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "language-subtag-registry": ["language-subtag-registry@0.3.23", "https://registry.npmmirror.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], + + "language-tags": ["language-tags@1.0.9", "https://registry.npmmirror.com/language-tags/-/language-tags-1.0.9.tgz", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + + "lazy-val": ["lazy-val@1.0.5", "https://registry.npmmirror.com/lazy-val/-/lazy-val-1.0.5.tgz", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], + + "levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.31.1", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.31.1.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + + "locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash": ["lodash@4.17.23", "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + + "lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "log-symbols": ["log-symbols@4.1.0", "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + + "longest-streak": ["longest-streak@3.1.0", "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "lowercase-keys": ["lowercase-keys@2.0.0", "https://registry.npmmirror.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], + + "lru-cache": ["lru-cache@11.2.6", "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.6.tgz", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + + "lucide-react": ["lucide-react@0.556.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.556.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A=="], + + "lz-string": ["lz-string@1.5.0", "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "make-fetch-happen": ["make-fetch-happen@14.0.3", "https://registry.npmmirror.com/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", { "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" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="], + + "markdown-table": ["markdown-table@3.0.4", "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "matcher": ["matcher@3.0.0", "https://registry.npmmirror.com/matcher/-/matcher-3.0.0.tgz", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "https://registry.npmmirror.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "https://registry.npmmirror.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "https://registry.npmmirror.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "https://registry.npmmirror.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "https://registry.npmmirror.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "https://registry.npmmirror.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "https://registry.npmmirror.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-math": ["mdast-util-math@3.0.0", "https://registry.npmmirror.com/mdast-util-math/-/mdast-util-math-3.0.0.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "mdn-data": ["mdn-data@2.12.2", "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.12.2.tgz", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], + + "micromark": ["micromark@4.0.2", "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "https://registry.npmmirror.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "https://registry.npmmirror.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "https://registry.npmmirror.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "https://registry.npmmirror.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "https://registry.npmmirror.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "https://registry.npmmirror.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "https://registry.npmmirror.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-extension-math": ["micromark-extension-math@3.1.0", "https://registry.npmmirror.com/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "mime": ["mime@2.6.0", "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + + "mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-match": ["mime-match@1.0.2", "https://registry.npmmirror.com/mime-match/-/mime-match-1.0.2.tgz", { "dependencies": { "wildcard": "^1.1.0" } }, "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg=="], + + "mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimic-fn": ["mimic-fn@2.1.0", "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "mimic-function": ["mimic-function@5.0.1", "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "mimic-response": ["mimic-response@3.1.0", "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "min-indent": ["min-indent@1.0.1", "https://registry.npmmirror.com/min-indent/-/min-indent-1.0.1.tgz", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "minimatch": ["minimatch@3.1.2", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "minimist": ["minimist@1.2.8", "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@7.1.3", "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minipass-collect": ["minipass-collect@2.0.1", "https://registry.npmmirror.com/minipass-collect/-/minipass-collect-2.0.1.tgz", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], + + "minipass-fetch": ["minipass-fetch@4.0.1", "https://registry.npmmirror.com/minipass-fetch/-/minipass-fetch-4.0.1.tgz", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="], + + "minipass-flush": ["minipass-flush@1.0.5", "https://registry.npmmirror.com/minipass-flush/-/minipass-flush-1.0.5.tgz", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "https://registry.npmmirror.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@1.0.3", "https://registry.npmmirror.com/minipass-sized/-/minipass-sized-1.0.3.tgz", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], + + "minizlib": ["minizlib@3.1.0", "https://registry.npmmirror.com/minizlib/-/minizlib-3.1.0.tgz", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "mkdirp": ["mkdirp@0.5.6", "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + + "motion": ["motion@12.38.0", "https://registry.npmmirror.com/motion/-/motion-12.38.0.tgz", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="], + + "motion-dom": ["motion-dom@12.38.0", "https://registry.npmmirror.com/motion-dom/-/motion-dom-12.38.0.tgz", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], + + "motion-utils": ["motion-utils@12.36.0", "https://registry.npmmirror.com/motion-utils/-/motion-utils-12.36.0.tgz", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], + + "mrmime": ["mrmime@2.0.1", "https://registry.npmmirror.com/mrmime/-/mrmime-2.0.1.tgz", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "namespace-emitter": ["namespace-emitter@2.0.1", "https://registry.npmmirror.com/namespace-emitter/-/namespace-emitter-2.0.1.tgz", {}, "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g=="], + + "nanoid": ["nanoid@5.1.6", "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.6.tgz", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + + "natural-compare": ["natural-compare@1.4.0", "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "negotiator": ["negotiator@1.0.0", "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-abi": ["node-abi@4.26.0", "https://registry.npmmirror.com/node-abi/-/node-abi-4.26.0.tgz", { "dependencies": { "semver": "^7.6.3" } }, "sha512-8QwIZqikRvDIkXS2S93LjzhsSPJuIbfaMETWH+Bx8oOT9Sa9UsUtBFQlc3gBNd1+QINjaTloitXr1W3dQLi9Iw=="], + + "node-addon-api": ["node-addon-api@1.7.2", "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-1.7.2.tgz", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], + + "node-api-version": ["node-api-version@0.2.1", "https://registry.npmmirror.com/node-api-version/-/node-api-version-0.2.1.tgz", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], + + "node-gyp": ["node-gyp@11.5.0", "https://registry.npmmirror.com/node-gyp/-/node-gyp-11.5.0.tgz", { "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" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="], + + "node-releases": ["node-releases@2.0.27", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "nopt": ["nopt@8.1.0", "https://registry.npmmirror.com/nopt/-/nopt-8.1.0.tgz", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], + + "normalize-url": ["normalize-url@6.1.0", "https://registry.npmmirror.com/normalize-url/-/normalize-url-6.1.0.tgz", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], + + "object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "https://registry.npmmirror.com/object.assign/-/object.assign-4.1.7.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "https://registry.npmmirror.com/object.fromentries/-/object.fromentries-2.0.8.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.values": ["object.values@1.2.1", "https://registry.npmmirror.com/object.values/-/object.values-1.2.1.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + + "obug": ["obug@2.1.1", "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@5.1.2", "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "ora": ["ora@5.4.1", "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz", { "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" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], + + "own-keys": ["own-keys@1.0.1", "https://registry.npmmirror.com/own-keys/-/own-keys-1.0.1.tgz", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + + "p-cancelable": ["p-cancelable@2.1.1", "https://registry.npmmirror.com/p-cancelable/-/p-cancelable-2.1.1.tgz", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], + + "p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "p-map": ["p-map@7.0.4", "https://registry.npmmirror.com/p-map/-/p-map-7.0.4.tgz", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], + + "p-queue": ["p-queue@8.1.1", "https://registry.npmmirror.com/p-queue/-/p-queue-8.1.1.tgz", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^6.1.2" } }, "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ=="], + + "p-retry": ["p-retry@6.2.1", "https://registry.npmmirror.com/p-retry/-/p-retry-6.2.1.tgz", { "dependencies": { "@types/retry": "0.12.2", "is-network-error": "^1.0.0", "retry": "^0.13.1" } }, "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ=="], + + "p-timeout": ["p-timeout@6.1.4", "https://registry.npmmirror.com/p-timeout/-/p-timeout-6.1.4.tgz", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-entities": ["parse-entities@4.0.2", "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse5": ["parse5@8.0.0", "https://registry.npmmirror.com/parse5/-/parse5-8.0.0.tgz", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], + + "path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@1.11.1", "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pe-library": ["pe-library@0.4.1", "https://registry.npmmirror.com/pe-library/-/pe-library-0.4.1.tgz", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], + + "pend": ["pend@1.2.0", "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + + "picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "plist": ["plist@3.1.0", "https://registry.npmmirror.com/plist/-/plist-3.1.0.tgz", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "postcss": ["postcss@8.5.6", "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "postject": ["postject@1.0.0-alpha.6", "https://registry.npmmirror.com/postject/-/postject-1.0.0-alpha.6.tgz", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], + + "preact": ["preact@10.28.3", "https://registry.npmmirror.com/preact/-/preact-10.28.3.tgz", {}, "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA=="], + + "prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.8.1", "https://registry.npmmirror.com/prettier/-/prettier-3.8.1.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + + "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="], + + "pretty-bytes": ["pretty-bytes@6.1.1", "https://registry.npmmirror.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], + + "pretty-format": ["pretty-format@27.5.1", "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "proc-log": ["proc-log@5.0.0", "https://registry.npmmirror.com/proc-log/-/proc-log-5.0.0.tgz", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], + + "progress": ["progress@2.0.3", "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "promise-retry": ["promise-retry@2.0.1", "https://registry.npmmirror.com/promise-retry/-/promise-retry-2.0.1.tgz", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "https://registry.npmmirror.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "property-information": ["property-information@7.1.0", "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "pump": ["pump@3.0.4", "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "quick-lru": ["quick-lru@5.1.1", "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + + "react": ["react@19.2.4", "https://registry.npmmirror.com/react/-/react-19.2.4.tgz", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-day-picker": ["react-day-picker@9.13.1", "https://registry.npmmirror.com/react-day-picker/-/react-day-picker-9.13.1.tgz", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-9nx2lBBJ0VZw5jJekId3DishwnJLiqY1Me1JvCrIyqbWwcflBTVaEkiK+w1bre5oMNWYo722eu+8UAMXWMqktw=="], + + "react-dom": ["react-dom@19.2.4", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + + "react-floater": ["react-floater@0.9.5-5", "https://registry.npmmirror.com/react-floater/-/react-floater-0.9.5-5.tgz", { "dependencies": { "@popperjs/core": "^2.11.8", "deepmerge-ts": "^7.1.5", "is-lite": "^2.0.0", "tree-changes-hook": "^0.11.3" }, "peerDependencies": { "react": "16.8 - 19", "react-dom": "16.8 - 19" } }, "sha512-9EFcYn8YOfqBoy9mAX0nnaG0KVvlTqOUCGMZgdJmI0ddvmZfnutlAo9s2RI89Up5ZN/pYuae8OObwZ5TBV91AQ=="], + + "react-i18next": ["react-i18next@16.5.4", "https://registry.npmmirror.com/react-i18next/-/react-i18next-16.5.4.tgz", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g=="], + + "react-innertext": ["react-innertext@1.1.5", "https://registry.npmmirror.com/react-innertext/-/react-innertext-1.1.5.tgz", { "peerDependencies": { "@types/react": ">=0.0.0 <=99", "react": ">=0.0.0 <=99" } }, "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q=="], + + "react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "react-joyride": ["react-joyride@3.0.0-7", "https://registry.npmmirror.com/react-joyride/-/react-joyride-3.0.0-7.tgz", { "dependencies": { "@gilbarbara/deep-equal": "^0.3.1", "@gilbarbara/hooks": "^0.8.2", "@gilbarbara/types": "^0.2.2", "deepmerge": "^4.3.1", "is-lite": "^1.2.1", "react-floater": "^0.9.5-4", "react-innertext": "^1.1.5", "scroll": "^3.0.1", "scrollparent": "^2.1.0", "tree-changes-hook": "^0.11.2" }, "peerDependencies": { "react": "16.8 - 19", "react-dom": "16.8 - 19" } }, "sha512-NBgtdm8QehHEVI/Qkakb4EJ/WjKN7bQaZgZmO/01v1p2yBlzAcXyKM36FeS1YZaywX8v8R79bF5Z0OcV5BK1og=="], + + "react-markdown": ["react-markdown@10.1.0", "https://registry.npmmirror.com/react-markdown/-/react-markdown-10.1.0.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + + "react-redux": ["react-redux@9.2.0", "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], + + "react-refresh": ["react-refresh@0.18.0", "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "reactflow": ["reactflow@11.11.4", "https://registry.npmmirror.com/reactflow/-/reactflow-11.11.4.tgz", { "dependencies": { "@reactflow/background": "11.3.14", "@reactflow/controls": "11.2.14", "@reactflow/core": "11.11.4", "@reactflow/minimap": "11.7.14", "@reactflow/node-resizer": "2.2.14", "@reactflow/node-toolbar": "1.3.14" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og=="], + + "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], + + "readable-stream": ["readable-stream@3.6.2", "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "recharts": ["recharts@3.5.1", "https://registry.npmmirror.com/recharts/-/recharts-3.5.1.tgz", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA=="], + + "redent": ["redent@3.0.0", "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "redux": ["redux@5.0.1", "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "https://registry.npmmirror.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "rehype-katex": ["rehype-katex@7.0.1", "https://registry.npmmirror.com/rehype-katex/-/rehype-katex-7.0.1.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], + + "remark-gfm": ["remark-gfm@4.0.1", "https://registry.npmmirror.com/remark-gfm/-/remark-gfm-4.0.1.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-math": ["remark-math@6.0.0", "https://registry.npmmirror.com/remark-math/-/remark-math-6.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="], + + "remark-parse": ["remark-parse@11.0.0", "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "https://registry.npmmirror.com/remark-rehype/-/remark-rehype-11.1.2.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "https://registry.npmmirror.com/remark-stringify/-/remark-stringify-11.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "require-directory": ["require-directory@2.1.1", "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resedit": ["resedit@1.7.2", "https://registry.npmmirror.com/resedit/-/resedit-1.7.2.tgz", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], + + "reselect": ["reselect@5.1.1", "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + + "resolve-alpn": ["resolve-alpn@1.2.1", "https://registry.npmmirror.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], + + "resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "responselike": ["responselike@2.0.1", "https://registry.npmmirror.com/responselike/-/responselike-2.0.1.tgz", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], + + "restore-cursor": ["restore-cursor@3.1.0", "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], + + "retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "rimraf": ["rimraf@2.6.3", "https://registry.npmmirror.com/rimraf/-/rimraf-2.6.3.tgz", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="], + + "roarr": ["roarr@2.15.4", "https://registry.npmmirror.com/roarr/-/roarr-2.15.4.tgz", { "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" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + + "rollup": ["rollup@4.57.1", "https://registry.npmmirror.com/rollup/-/rollup-4.57.1.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], + + "safe-array-concat": ["safe-array-concat@1.1.3", "https://registry.npmmirror.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "https://registry.npmmirror.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sanitize-filename": ["sanitize-filename@1.6.3", "https://registry.npmmirror.com/sanitize-filename/-/sanitize-filename-1.6.3.tgz", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg=="], + + "sax": ["sax@1.5.0", "https://registry.npmmirror.com/sax/-/sax-1.5.0.tgz", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], + + "saxes": ["saxes@6.0.0", "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "scroll": ["scroll@3.0.1", "https://registry.npmmirror.com/scroll/-/scroll-3.0.1.tgz", {}, "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg=="], + + "scrollparent": ["scrollparent@2.1.0", "https://registry.npmmirror.com/scrollparent/-/scrollparent-2.1.0.tgz", {}, "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="], + + "semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "semver-compare": ["semver-compare@1.0.0", "https://registry.npmmirror.com/semver-compare/-/semver-compare-1.0.0.tgz", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + + "serialize-error": ["serialize-error@7.0.1", "https://registry.npmmirror.com/serialize-error/-/serialize-error-7.0.1.tgz", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + + "seroval": ["seroval@1.5.0", "https://registry.npmmirror.com/seroval/-/seroval-1.5.0.tgz", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], + + "seroval-plugins": ["seroval-plugins@1.5.0", "https://registry.npmmirror.com/seroval-plugins/-/seroval-plugins-1.5.0.tgz", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], + + "set-function-length": ["set-function-length@1.2.2", "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "https://registry.npmmirror.com/set-function-name/-/set-function-name-2.0.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "https://registry.npmmirror.com/set-proto/-/set-proto-1.0.0.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "shallow-equal": ["shallow-equal@3.1.0", "https://registry.npmmirror.com/shallow-equal/-/shallow-equal-3.1.0.tgz", {}, "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg=="], + + "shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "siginfo": ["siginfo@2.0.0", "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "signal-exit": ["signal-exit@3.0.7", "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "simple-update-notifier": ["simple-update-notifier@2.0.0", "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], + + "sirv": ["sirv@3.0.2", "https://registry.npmmirror.com/sirv/-/sirv-3.0.2.tgz", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "slice-ansi": ["slice-ansi@3.0.0", "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-3.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], + + "smart-buffer": ["smart-buffer@4.2.0", "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "smol-toml": ["smol-toml@1.6.0", "https://registry.npmmirror.com/smol-toml/-/smol-toml-1.6.0.tgz", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], + + "socks": ["socks@2.8.7", "https://registry.npmmirror.com/socks/-/socks-2.8.7.tgz", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "https://registry.npmmirror.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "source-map": ["source-map@0.6.1", "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "source-map-support": ["source-map-support@0.5.21", "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "sprintf-js": ["sprintf-js@1.1.3", "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "srvx": ["srvx@0.11.2", "https://registry.npmmirror.com/srvx/-/srvx-0.11.2.tgz", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-u6NbjE84IJwm1XUnJ53WqylLTQ3BdWRw03lcjBNNeMBD+EFjkl0Cnw1RVaGSqRAo38pOHOPXJH30M6cuTINUxw=="], + + "ssri": ["ssri@12.0.0", "https://registry.npmmirror.com/ssri/-/ssri-12.0.0.tgz", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="], + + "stackback": ["stackback@0.0.2", "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "stat-mode": ["stat-mode@1.0.0", "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], + + "std-env": ["std-env@3.10.0", "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string-width-cjs": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string.prototype.includes": ["string.prototype.includes@2.0.1", "https://registry.npmmirror.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.10", "https://registry.npmmirror.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "https://registry.npmmirror.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "https://registry.npmmirror.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "string_decoder": ["string_decoder@1.3.0", "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-indent": ["strip-indent@3.0.0", "https://registry.npmmirror.com/strip-indent/-/strip-indent-3.0.0.tgz", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "stubborn-fs": ["stubborn-fs@2.0.0", "https://registry.npmmirror.com/stubborn-fs/-/stubborn-fs-2.0.0.tgz", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="], + + "stubborn-utils": ["stubborn-utils@1.0.2", "https://registry.npmmirror.com/stubborn-utils/-/stubborn-utils-1.0.2.tgz", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], + + "style-mod": ["style-mod@4.1.3", "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + + "style-to-js": ["style-to-js@1.1.21", "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "sumchecker": ["sumchecker@3.0.1", "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], + + "supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "symbol-tree": ["symbol-tree@3.2.4", "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tagged-tag": ["tagged-tag@1.0.0", "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "tailwind-merge": ["tailwind-merge@3.4.0", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + + "tailwindcss": ["tailwindcss@4.2.1", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.1.tgz", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + + "tapable": ["tapable@2.3.0", "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tar": ["tar@7.5.9", "https://registry.npmmirror.com/tar/-/tar-7.5.9.tgz", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="], + + "temp": ["temp@0.9.4", "https://registry.npmmirror.com/temp/-/temp-0.9.4.tgz", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="], + + "temp-file": ["temp-file@3.4.0", "https://registry.npmmirror.com/temp-file/-/temp-file-3.4.0.tgz", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], + + "tiny-async-pool": ["tiny-async-pool@1.3.0", "https://registry.npmmirror.com/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tiny-warning": ["tiny-warning@1.0.3", "https://registry.npmmirror.com/tiny-warning/-/tiny-warning-1.0.3.tgz", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], + + "tinybench": ["tinybench@2.9.0", "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.0.2", "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.0.2.tgz", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinyrainbow": ["tinyrainbow@3.0.3", "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-3.0.3.tgz", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + + "tldts": ["tldts@7.0.23", "https://registry.npmmirror.com/tldts/-/tldts-7.0.23.tgz", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="], + + "tldts-core": ["tldts-core@7.0.23", "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.0.23.tgz", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="], + + "tmp": ["tmp@0.2.5", "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + + "tmp-promise": ["tmp-promise@3.0.3", "https://registry.npmmirror.com/tmp-promise/-/tmp-promise-3.0.3.tgz", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], + + "totalist": ["totalist@3.0.1", "https://registry.npmmirror.com/totalist/-/totalist-3.0.1.tgz", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "tough-cookie": ["tough-cookie@6.0.0", "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.0.tgz", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + + "tr46": ["tr46@6.0.0", "https://registry.npmmirror.com/tr46/-/tr46-6.0.0.tgz", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + + "tree-changes": ["tree-changes@0.11.3", "https://registry.npmmirror.com/tree-changes/-/tree-changes-0.11.3.tgz", { "dependencies": { "@gilbarbara/deep-equal": "^0.3.1", "is-lite": "^1.2.1" } }, "sha512-r14mvDZ6tqz8PRQmlFKjhUVngu4VZ9d92ON3tp0EGpFBE6PAHOq8Bx8m8ahbNoGE3uI/npjYcJiqVydyOiYXag=="], + + "tree-changes-hook": ["tree-changes-hook@0.11.3", "https://registry.npmmirror.com/tree-changes-hook/-/tree-changes-hook-0.11.3.tgz", { "dependencies": { "@gilbarbara/deep-equal": "^0.3.1", "tree-changes": "0.11.3" }, "peerDependencies": { "react": "16.8 - 19" } }, "sha512-cNHPuFc5Qbi2B74VqSqL/Ee/l4n0SFfzYKTnXYViJW1yCFZ0bl97QsgUIw9vdQtqpWDwo83mpNkGUvcjeQc0Xw=="], + + "trim-lines": ["trim-lines@3.0.1", "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "https://registry.npmmirror.com/trough/-/trough-2.2.0.tgz", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "https://registry.npmmirror.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], + + "ts-api-utils": ["ts-api-utils@2.4.0", "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.4.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + + "tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-check": ["type-check@0.4.0", "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-fest": ["type-fest@5.4.4", "https://registry.npmmirror.com/type-fest/-/type-fest-5.4.4.tgz", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "https://registry.npmmirror.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "https://registry.npmmirror.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "https://registry.npmmirror.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.7", "https://registry.npmmirror.com/typed-array-length/-/typed-array-length-1.0.7.tgz", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + + "typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-eslint": ["typescript-eslint@8.54.0", "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.54.0.tgz", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.54.0", "@typescript-eslint/parser": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "https://registry.npmmirror.com/uint8array-extras/-/uint8array-extras-1.5.0.tgz", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + + "unbox-primitive": ["unbox-primitive@1.1.0", "https://registry.npmmirror.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + + "undici": ["undici@7.22.0", "https://registry.npmmirror.com/undici/-/undici-7.22.0.tgz", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], + + "undici-types": ["undici-types@7.16.0", "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "unified": ["unified@11.0.5", "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unique-filename": ["unique-filename@4.0.0", "https://registry.npmmirror.com/unique-filename/-/unique-filename-4.0.0.tgz", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="], + + "unique-slug": ["unique-slug@5.0.0", "https://registry.npmmirror.com/unique-slug/-/unique-slug-5.0.0.tgz", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg=="], + + "unist-util-find-after": ["unist-util-find-after@5.0.0", "https://registry.npmmirror.com/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], + + "unist-util-is": ["unist-util-is@6.0.1", "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "https://registry.npmmirror.com/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "universalify": ["universalify@2.0.1", "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "utf8-byte-length": ["utf8-byte-length@1.0.5", "https://registry.npmmirror.com/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], + + "util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "verror": ["verror@1.10.1", "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], + + "vfile": ["vfile@6.0.3", "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "https://registry.npmmirror.com/vfile-location/-/vfile-location-5.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.3", "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "victory-vendor": ["victory-vendor@37.3.6", "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + + "vite": ["vite@7.3.1", "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "vitest": ["vitest@4.0.18", "https://registry.npmmirror.com/vitest/-/vitest-4.0.18.tgz", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + + "void-elements": ["void-elements@3.1.0", "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + + "w3c-keyname": ["w3c-keyname@2.2.8", "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "wcwidth": ["wcwidth@1.0.1", "https://registry.npmmirror.com/wcwidth/-/wcwidth-1.0.1.tgz", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + + "web-namespaces": ["web-namespaces@2.0.1", "https://registry.npmmirror.com/web-namespaces/-/web-namespaces-2.0.1.tgz", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + + "webidl-conversions": ["webidl-conversions@8.0.1", "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@16.0.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-16.0.0.tgz", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ=="], + + "when-exit": ["when-exit@2.1.5", "https://registry.npmmirror.com/when-exit/-/when-exit-2.1.5.tgz", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], + + "which": ["which@5.0.0", "https://registry.npmmirror.com/which/-/which-5.0.0.tgz", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "https://registry.npmmirror.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "https://registry.npmmirror.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "https://registry.npmmirror.com/which-collection/-/which-collection-1.0.2.tgz", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.20", "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "wildcard": ["wildcard@1.1.2", "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz", {}, "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng=="], + + "word-wrap": ["word-wrap@1.2.5", "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlbuilder": ["xmlbuilder@15.1.1", "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + + "xmlchars": ["xmlchars@2.2.0", "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "y18n": ["y18n@5.0.8", "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@5.0.0", "https://registry.npmmirror.com/yallist/-/yallist-5.0.0.tgz", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "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" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yauzl": ["yauzl@2.10.0", "https://registry.npmmirror.com/yauzl/-/yauzl-2.10.0.tgz", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + + "yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@4.3.6", "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "zustand": ["zustand@4.5.7", "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + + "zwitch": ["zwitch@2.0.4", "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@electron/asar/commander": ["commander@5.1.0", "https://registry.npmmirror.com/commander/-/commander-5.1.0.tgz", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + + "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@electron/get/fs-extra": ["fs-extra@8.1.0", "https://registry.npmmirror.com/fs-extra/-/fs-extra-8.1.0.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + + "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], + + "@electron/rebuild/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@electron/universal/fs-extra": ["fs-extra@11.3.3", "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.3.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + + "@electron/universal/minimatch": ["minimatch@9.0.5", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@electron/windows-sign/fs-extra": ["fs-extra@11.3.3", "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.3.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@gilbarbara/types/type-fest": ["type-fest@4.41.0", "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "@isaacs/cliui/string-width": ["string-width@5.1.2", "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "@npmcli/fs/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.3.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + + "@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.3.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + + "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@reduxjs/toolkit/immer": ["immer@11.1.3", "https://registry.npmmirror.com/immer/-/immer-11.1.3.tgz", {}, "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="], + + "@tailwindcss/node/jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.0.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.2.tgz", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "ajv-formats/ajv": ["ajv@8.18.0", "https://registry.npmmirror.com/ajv/-/ajv-8.18.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "https://registry.npmmirror.com/@electron/get/-/get-3.1.0.tgz", { "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" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], + + "app-builder-lib/ci-info": ["ci-info@4.3.1", "https://registry.npmmirror.com/ci-info/-/ci-info-4.3.1.tgz", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + + "app-builder-lib/jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "app-builder-lib/minimatch": ["minimatch@10.2.4", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.4.tgz", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "app-builder-lib/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "cacache/glob": ["glob@10.5.0", "https://registry.npmmirror.com/glob/-/glob-10.5.0.tgz", { "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" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "cacache/lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "chalk/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "clone-response/mimic-response": ["mimic-response@1.0.1", "https://registry.npmmirror.com/mimic-response/-/mimic-response-1.0.1.tgz", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], + + "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "conf/ajv": ["ajv@8.18.0", "https://registry.npmmirror.com/ajv/-/ajv-8.18.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "conf/env-paths": ["env-paths@3.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-3.0.0.tgz", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + + "conf/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "cross-spawn/which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "https://registry.npmmirror.com/fs-extra/-/fs-extra-7.0.1.tgz", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + + "eslint-plugin-jsx-a11y/aria-query": ["aria-query@5.3.2", "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.2.tgz", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "filelist/minimatch": ["minimatch@5.1.9", "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.9.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + + "foreground-child/signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "global-agent/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "hast-util-from-html/parse5": ["parse5@7.3.0", "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-sized/minipass": ["minipass@3.3.6", "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "node-abi/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "node-api-version/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "node-gyp/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "p-retry/retry": ["retry@0.13.1", "https://registry.npmmirror.com/retry/-/retry-0.13.1.tgz", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "path-scurry/lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "postcss/nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "postject/commander": ["commander@9.5.0", "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + + "pretty-format/react-is": ["react-is@17.0.2", "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "react-floater/is-lite": ["is-lite@2.0.0", "https://registry.npmmirror.com/is-lite/-/is-lite-2.0.0.tgz", {}, "sha512-70f2BMIQlbSUXVKaZUd9a9fJH3IH1PDckV0m4BIIO4LjnNYvOh4Ng7vXIXEwpA0KDZknRq+7fHwGTu0jIdx28g=="], + + "serialize-error/type-fest": ["type-fest@0.13.1", "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + + "simple-update-notifier/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "slice-ansi/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "string-width/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "tiny-async-pool/semver": ["semver@5.7.2", "https://registry.npmmirror.com/semver/-/semver-5.7.2.tgz", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + + "vite/esbuild": ["esbuild@0.27.3", "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + + "@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.0.2", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "https://registry.npmmirror.com/fs-extra/-/fs-extra-8.1.0.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + + "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "app-builder-lib/minimatch/brace-expansion": ["brace-expansion@5.0.4", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.4.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + + "cacache/glob/minimatch": ["minimatch@9.0.5", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "cross-spawn/which/isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + + "filelist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-sized/minipass/yallist": ["yallist@4.0.0", "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + + "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + + "app-builder-lib/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + } +} diff --git a/dashboard/bunfig.toml b/dashboard/bunfig.toml new file mode 100644 index 00000000..43b70d4c --- /dev/null +++ b/dashboard/bunfig.toml @@ -0,0 +1,6 @@ +[install] +registry = "https://mirrors.cloud.tencent.com/npm/" +linker = "hoisted" + +[install.cache] +disableManifest = true diff --git a/dashboard/components.json b/dashboard/components.json new file mode 100644 index 00000000..106236b1 --- /dev/null +++ b/dashboard/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "src/components", + "utils": "src/lib/utils", + "ui": "src/components/ui", + "lib": "src/lib", + "hooks": "src/hooks" + } +} diff --git a/dashboard/docs/Caddyfile.docker.example b/dashboard/docs/Caddyfile.docker.example new file mode 100644 index 00000000..759300b8 --- /dev/null +++ b/dashboard/docs/Caddyfile.docker.example @@ -0,0 +1,12 @@ +maibot.example.com { + encode zstd gzip + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + reverse_proxy core:8001 +} \ No newline at end of file diff --git a/dashboard/docs/Caddyfile.host.example b/dashboard/docs/Caddyfile.host.example new file mode 100644 index 00000000..d18d75d3 --- /dev/null +++ b/dashboard/docs/Caddyfile.host.example @@ -0,0 +1,12 @@ +maibot.example.com { + encode zstd gzip + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + reverse_proxy 127.0.0.1:8001 +} \ No newline at end of file diff --git a/dashboard/docs/main.png b/dashboard/docs/main.png new file mode 100644 index 0000000000000000000000000000000000000000..5a0feed3f94e1d36e814f68ce8c1f22562a831f4 GIT binary patch literal 422633 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

YxIHaMcAiVBDmd@wPi)Q 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

_CkeAvlQ3hUB~#FWk!;(Nx^`Oey+&30o|g)V12 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

UF{1Yiq8 zrE}2GD=vNr|EbEPZt?s4cZ3=28myEF?QPgyZS>Hk$#8h+({(L$L1xo@95 zt*-ro1Tf|OQv@j(to*$ndlNt{0h*)~?Kqls7!8@F%Q8*s^8Ipw9)lyh|lSZX1>KEZKb= zvlc**FbTLU?g~@&krInl79eBQOBJ_!!lXPtLW2O=kbT4$DN^A8Ap$K2wqP7y3ML*f zd_D!{vIej!NdwC|*H@2P=Mn=(hR+6ivi>p)3r(X9nr<8rDx{H2;ZfJmj;YEG)@6>< zQ`-IxVNw=ET3&P@HN|K1AA5d5z$@qlNG#9)LK&^V^)|WkInP{hoLiK*19pYoyDpuVgJn zL@<(%lWj_%7ibD(l2f%GttS_2FL6}ihIz9-m(Q>fp(1>x;d8y}sLbyxt*yhNsc7xPY zWxzKq^)kiaYY><(c}3sHtGaVVVk_G@2t^d1P(pAqWbW=n&{%%8!2sjX`v-GzuMK{Pu;d7ub>uL8q?0s)%jzy*jXee9J z2h5&CZ($P;6S9hcGqn0;F8Z9LxkzwxaCV%fIwGnoBIy#91}An7MeS_MBGV9W>(BzA z1a5mtOG~fM5iX=HbUxd@vo$@P!0@l+xLnZUy8!Z2R9J+K>mJrVCmGkjL9Piy0T=eFl-1B@G=VR_iPcb0B$ zHWDtz@DAf}y8pS`Dc5nH-Q>0` zCGDvGIIr}Qaa8CCtE2n2rZcFggeX9c7=>-}aou<%02~kvmU6 z8OhH-@Kaw(idhd@9>8c^9ZmQk0T^Zf<^X+>fhZY~%ib{)Yrs&BQdpUG(-qwi=-%!6 z+@d$L!r*vRt>vp1Wl1{lr+f;wiR!6wQGO0WzN4lPi|OENvlwCQBK#q1F7 ztc!ns$QMVm@PEpA8-WBN$XQ!^U+kiv2&6^lIpa;KyJl0k zt!hfkHCI4;i;REz-hyn@u}P3n4v})~Q*PUCi{T{E|ZtOzBHW zp~coEH6BQ0T7*M|{HuBwLK;fumL00!o%K)CJi**M+Pt|w55@AOEQ*b%1gpZe&ugOY zh1ujx(yw0HFg9jiBc0?#{T8KpGNyg!-|WMj#kXQI-rL_BPQ0j`O}4ksd7kXNw=h}T zOKE^BFmep;wzp{!WASv=dwNkr-7yXk2*bhxJ2E2dg@>j*bfq@QCvjB6G!Lp`Ja5jL zV#X1NpbMKHws^m+)BBQWv0t7RC!XzvoLM{pY&i-?Tt|iwM+Lz_y)b-gSPVI6&0~fU zVxLR0ThS|V7J`LK@Jk(;OC*pSsp)8x7SE=pO6EX8p+qIQyc*!6xpC%2aJR`aiQ30L zYkaB6Cm<1g-67C&F_ zP>YO?(JeF9+Hvn*coA#C7>;$!*2HFZvQ#P;z=yA}|AtTO|AgqIWZGe&MKjX6bNc

3-~ZO6AL#sC~z{ z`pM{5jiMZ*+L2QSPO6AW>?U+SY5Ii^V$w!pvE7XoT8gHmM5lt^MpL#aW6@^a%MNYp z9S7vpcU#hjuY0k8AGbiNGtWFLq(D|}Hmz_Jke^l8Ia6916P+3t+`7MEt^VT%JDakr zGm>0!K`FUzM`5hdn%6Tr=5X9^F~S$nW9Q7W!>j#uILEQT{l6HATo)T}9Fa;AB;+Rf#&4E?Lpy%ZtV`ku9^w|(EB z3{Tttdq6g;@TN}(Gg5x?lM4nX#j+RUem~<}o#>m}cxq!)N`O3()6Dko0dx(Y*8x+) z9Vwmjg6iYz8h686MaF!gqfO&QT|LPpSE8!Fieu21Ss7gY?6DXbNX1zRD9nge@T~71 z8!=y#w*u^wc@~=qv{O>jn-_u|R(E;E%DKY<-C9>IaS=|9Go0i)90=du&i*oSoKKM& z&_LQ)MkSt@{bhu3P)Y3Nxjp53+}&G_1a>IUUi#A#E#O~nkOg2F9;2P8ivuGYN?c64 zApCn<&G{4O*{Q6CeFDl$shO+gI9O@arDJJYt63M!5C z<_Z2X&WLo}mBndPIULZPQ$Db!t{W08JP$#eoWZTfnP(M!Zg|ztFnzH7^?VF5!0^hg z{A!BaY6WkYDV9h|Rdi2>eO;j! zP#q`n#(=oF@@2TlAfg)DsE<>x~Ni8>C z$E>Y^*j$Law5}!>jXeul(V*l;^UuXzpuXfPH!#-*z+ zNZBIZoRwWj^(!0s}GqUC+1C1c$R!_S}&DOmWw1V|P9`&**IcLCQaBM z3Z)FwkRSqNf77kIl9L{SS+QnZfLgyOoWSt}IjJhIw!gYoPf`zX7#kGIqXhj1^r)T| zxZ}(`LG>x0b(bq)Ihz>Sau2h4dRAZCB1#g>9ZK-BvO?Zex}=H47)_ez`diiockB!) zu}9e~V^0&(WGH- zqxR)n52P6SRifJqqVBsj@mrxaK44PDE-N;X?~=$irnl_X zfKR`&C74}X_Fd>dt2W=p)%ZY>zGro`Hy9ISO^ma8I?QAt2OE_ zztS-SFves~nW{vN$e^f>i0Hhip7i>tc{!)6xONCG58B*!)XfHN6Se(I09!h!$;I=_ zi_jQ32IaN%klWNImz~TAo6U!XwcKoD?W-lx8iV7BEkdB_3?gTJT<|XQ%RHwC!8%0e z&RN=49KgFgD_vLzC8UIc3C<6qx5ec4H>uzSc~ZI$&qPV7*PM2yfKXXYqhY8(fD&wnTNFtU`D-I4o4s7~Kc1T}>ODRW(%#jwX<$XJUXS>E7ZJ^UUb>X%A z4JJQ^+DL_LLRbIkadsI2$|1E!*D;*ChY(4RVyGz=N{~!E+uFN%qYZ0wvuhRuCABH3 zUu&IBviT0BWw{XmFuWOJ!QsTCAA&AYZQyX^!005=JIn1_L{a4mEN=z>imwViULJGf zr=k4A>1lk5{*f3Y8MNub1N5zNis%|#P-IJ=EYVxMwzJqJySeZtQf_QCQCO)#*d*QC z*!Q1mhUAHqB|Qg1N&v7Xnlbjld0}=B4MH@~=R8y*^WrIHYd~45r=w?XP@&G|&NnSD zS6P5Nz}`qAd!0f@z%p*7wX3JPBmEQLq>`jV!GA-JfRaGjl)X*#aBB)BGJ3T|XsfZS z9An81YV6AuHxu&jL9_*h4OWNog?EoSoNI?+28T6JehNl7kk`Htd4Ex*eZAVL%&yZL zP&F(M={wpq6pxbC7}fFh8BtDoS%58OzYw^f6Hnf7u|Z{+l-eh;DAvzBVe{~WGyz>M z6LzMNV$PSu$WUVNh^dDf4`Ey4HDGpqx`}g9idG=*m-9?E+9*I&P7!%o8AQpe%jGp6 zjLLhfJw@;HGhTDvU3TpvaO$IMdhuvc<9C!NHoS{6laKAG-TIfkO@k^-yVMrdM9phV z)`c{+ts0aQ%zi*^@kjaZT-qrJB+01Q_Cy6_XP$oGEbSmwl*49ws0Ri@)ug}tjTbaO z?ynh<=k6S|P^EdL{N;}^Hmqe5Gk#tg)_gdM2kj^(s{Fj}1-^GSxqfKjej~oke=%$R zJ+rfZ^7LHav4w$Zipw%vui-YE9o7R4MG|Mr_f{5pY$}}qr7xt%u$mnUeWhzZ(74!D z5PcNgwFA#pWOLhBCK-+)2gg~v%uESjR&VB2UUMRqjm0wVj>|q??l|A*9qg8ld?f8; zyZ-L_r#3#cYHTt=o0bPz!NeN}Xw_uACabZT1L>(=E3E|8({Qou9LuPNi_mMJ^#@zG zV*U~V?kkPzYgZTJ^{d!tM?CwKTG%xJZo`Ar+rxG260`Sc*qA3hX6Zbcme!`G>^S1(&V zKRH>uGmOacZY>cODN}MH%Id6@Ji&wqtkL&s-Jko~EiMqMsFp4FEVq1{q1UrkSMPIK zMNFQ(KKfT$x{al>)ac;h2TN3j3^h3JQur@+pbzGRN?RhSHi9M7F~2`*T<%V^F)QcP z2F`Z$5KT{r!9AgT9c$eE%3KuLjmQc_;S!`_<;DQ>{2Y(oiPq!x_x3!PR4Gp4v)lc> z%U?HUVc_@iU~7d}Tk_mV!RK^IKu290T(tJ$GS9|q$i4UbNchr89FxJ$hQe=E8{hW5 z5vm?@-6&sYAepFRFNgm`1F7O@YeqQLsIsCM;JM{7=##DunW{U{F5v6QBrH`Qmr5|p z&Q_#zVfXzPxUGy|)IkG)!Oyx{$Hm(H_$1MK*nXjkr=ov#=a(A^#m@r$ybsY^Ug9@a zd&m;*D(Jl58dM+;ilv{0y2E`a9SmbgN)b?XLUn;N(imaIZ^awE3ah34b1aWQwngpp zegK_z2jo5PR%axjDyQ%~iob)nE}lR#7fo9zwPz62=Cb ztA#+FfSC9kOvJMjF{1EHlpfqvXLPk-dFu{BLhr9fnyv>KA%?QXk{Q{TGPSoBB_}&W z*hp^8rq?>_ZR_0JEH20o>I+~=d)P#Gkahyabny{9@Tw30G9p9|77f<`&QP>`DAa{m zW;A2W>sS+{$=<_HnKy>PVlxOMJaBs(Uh{~_(St*VEb6wEKPl3byhci@|3!Byg+C+PVcA}j_!Oqv5DHx-G`8DnkBIX^9 zB0wkwE+1SEn0O;K9gihe^u_#-C0RtOKAr+Z0Efj9u2G%gBpC_ap1A5v=O8K#{{Oo9CrDNN{@Gla9BRz*ZVh@Rrl&wk8<%> z2j;)VX{$g8D{e4j@^8P@Mv>4%_urY!dgFi2K+SFaEu}wRSVgkGplLj!S;b_9OwBNTM&!=AKT* z__~a>xq`?RAJ*2~{Px9nGU}Pi`BUi4^r-y}tdyK{rX-GX&|wq6=yymuJX%G~?Bqit zbDXo!8~}${7?I73f!^yS~u?+4{11M zwLv#Vzv(kQNHRCmY*ca1uJ`Nba=V?ofQ(|)hVfP9cJxH+!^*nY`<*+_eK6ftR3|z# zp~A-GcXK)NO}ROz+cr~sNLi;TqIRSEB#(363UIRDH7lM@iIjP1pcw1oyXcls0I4{M z-$M;psHCin_`y)ylvo0_)SwqgSL^fh_7>7vHA*u#n*$qHmLxQHtjP-Bpq>+d9$?K2 zsG{`J0>7V%=czRQiTV5C3MEpY_4Z0aDl-tWWOq=I>5-{6G2^OSa?HcBgX=^58-$pciCiN;66R^P0^aC9J zeC%AsL)Y(2b2{8ho6F5X_C%1Cx3-r5@J?m6yH0wNNZwj-p@rEHW0|B*X(lpRrlj5$ColS({i2V|xS>*uns|5M zJdB>tD(*M7(e7sN(f6p_1vyyW_1dJIGauth;*5OE?MQx_*wCwM?sMK4`nRiWI^6p` z;B5(!I$K$Pa$S(BcJ|)i$fV<4CPLvAb;INkrF+Iv_2*(JiRwB0o=e^gxvsL3fI`J! z?ETbljdV{NrIY}IPt;*^{lnbv=1nxkFY!_Jp^g!<=ugOoBMg$qPo5ACu=k~VSG1>h z`PKwi-vdA@ygeEbXjZDD#zqoz9*~M5nk3+%LY#s$;t}9$Dy!>46l%0vI}tAnB5RdU zi6HVa;BXRmmO`5uL|V|0jgu~}JSzSmYBB{HMy`%IyPwb;Zw7)0sEso8q0P&7O0Y`t zDhK&LRBXyh*V#O868Qr=k-TutCBfeVf-eJ>?4pO0G=hBPcF|wlOe1Nnq zD)l4grPKxgW(+vay2gSf)`E06ZS;U{9P~~M6=v?|+~t=lKd_Cj1Ze2|(x%qpr zyfIfEA33dt1-dV!E#QA)C+(UE{B_(86;4l9)J|6=Z13(HNiS}S4iV^y!?B@eXSIop zRkcC3)3C=3jm_K>o?SQf9i+1^tXHHx1; zw#zw>ZSzPK8{c%&RQr_=g`!2?vgAAWqj^}@>uLfm{J>{N|F>+~nV~F?go1Inhc7)|{x)}t8 z{gn;mYi9ewD@-s*XpG!8EH5{2mR~SiGrM^eowh(!sbqUJ(T~{(yIsd`Q-0rDW?oKM zUTWE*%x`u}YJj%2dsb|I0#nrGw>N=Q-(v~&=dGMh<;bf;u~{iR3s0;4!|Za~sg`C0 z;Dss-%;m4e_Lc>`RpV3_N8#7>YS4o#_8}&Lh-|igHAbGVLhMxO5WNkD+IDLY?{4S| zurPlHTHiUeZ|9#CGe2VkMJb7(4WuxZqpli0sp)jH=G|s)sxde|jmTdh%)BLMJN04PbZbi(YoEvG z$lJYD081-KNY^qwKCO=BrgaI!wnQ~p5kqEU;c-Nvfqr6=5(Y)3J1q}58EOZFPlsV= zT$@hizPKZ8->w=HH()vu?rqo&<`ZfzSrx11d`!Q84Ok<}Q#cRvp^2J#{YVaazh>SpZX@hp3Wb~z8?^quvN>oU>~uW8-c<5 zT!D@20_>06wrkm69j;+{M%JX3jSZdmYIq;6y~GS&B@snqJN_Dk!om}kVj0IIjLIyQ zOux6QCu#^r&1g<$2cRcj9!oqeZS1M4hE8}OcaS#1uKgaU9Xv+DDn!{$l;O4)WV3;< zRmu%-M{S6WLaeqDVm%<$LVaz)AkyR$%YXtBI+fCg1>Z)ee=6hAKGb;=X zgRhqV%oc({f?87AIaLs+Mi^Nttgh_JOA2WB+wY==*|z*I`%AktdJddA82UJ^)W^Dj z9VJihO3E!uIV9KJc}HJ#(J1dUo6+NV5QApP7>cU`7TfbyyYs==JuiG2d}{Eq0s__{ z983B%1md<^`i<{2)qJWeoQnv?3%`DA+y3$Yu=Xx+F{W+&_%j`mYOy3ys4eSI38mw- z%Ze2(mXMkVIb~AOxfv{NNNh{!=lA)%_MK*?W}f@G?(4n|-|PE5oZg_#wjE7Xam#{hd{|pYU!#U;Ng53Vl_wVV z?bZsai0SLUf-ycN_mzK;e>Yh&DxyGEKd6F?OS5j)4kx169T44Q+ObWTV;#cC^GF9K z@dgC=5DZ9D^K6c67?i_uGtTQ8TPfEEMyf>s{U5@~+a&?Ly*GGxDM~sV7l;ZOCLfsx zJo5gfHv-L{$~0j$8kM4_!pMxjvoa=~ypcU46?Mg>S0De|e37`?uF`f4Oxq;RP{te$ z-0lFnF!#@TorOwgcDGEQ;I7S}`7&p%%1kZ)s4QN)TKHrQG_}UMGX-Gcp|+AF2W)(3 zo4}SUY4B@P!5($(#()=dHK3N~@-C0YE}K6)5Bju~omZSTDq}CL;}+~xW<1j-F*J#x z4jTA*LJzMF4hsa`*dxus&B3JllpiqaGRVMm_R&6lxTQiHcsHR*1zR>Hrj8uQdI}jE z{1Yc=Be|-qyF1@nE}Mx>pR%L$@19##>*Xa$Sw0jVRi%qf7Vqd+z%Lv$%iw7##$YW1^ zCMp4yMX+?`&ulqQ)$-`Q(QB?j4HdjIt0?fI*R8Z7Tn}!tVwVyeFh<@MF+el4Jo;;b zF{2B93z?fh>OHBMmj<$O8xJh>{zu92wlf+p$cTR1OK{xw?vxfb{$;){7yQ!f8sF*= zh44ti9829R#b|=8!EG|z{u$>3`yIFk;i?R@iSq}Q0!c3>fbr2`NDFNP5v8L%*`w@|1-5XSVCS8_zBSJiAX*>jngBuh zsVx#6)+1QR+>g#14k=;#>aU!9K!9SYx47dM132S~bC1DacTt-_tj!~3pB}eNP#Vw5 z-^=dS8lUcN3s{YyR@T*igjSF0+8}<8yC?x73}Vx)*_F3DFtPu z!OoXP!@QfmaGtup*iC;nh8=SfG}RXSb9$=f38bt7m$YgD{lTeY820Pr4`VPFs98R& z2s^9C@`vWWqadkNFdK7J0g0uk=w5E8&Ual+d;-SLXkY?>GdW|2Hhk*l^YVJw z|KRD`bAaO}81TnDyRsLpYNMCs{t&t-ASrzsLzz?%|>}S z^rLwea79ZDi*uXNYPFo`=|f`%1r7`gOSkHLW0dX<&@jY9Q=-8DyR=u7aGGvDRUr_S%KfnDf;|tEOPoF%7Gr z5wXhMU*5fJEma)yolBUwffT4qkyPK_u4OYCdq$~qqyLThK0d44)>pk_K=i2RPlEBX zCTK9|8ese#J=Xy9JbM{5=egorN{)5EY3H5(33KNDUQmu1i3jfUG9Hb^#(ET4(buc~I}PTLwb{$T03F9sTj{s9B=770HyNTowb`tanQ^hQuoGBh|^V z$OIWOh;Gzyw+3=59p-t;(E+Peshe@35^?fN4Vh>+jHjZ>Ss8vD^R5H~-@=?!<@NwP2{r4p zPgleGN?jiMt;d74?}(o3*~Z8F@6?q$EmZntZ)qU2{yt?BGYM=)`OJ#gn=8AvZ?`b_ z>mv7YQ`Wp2s1?~sv>D9w`W&`>ymC5Q4V*v^c&l3S$25iJ2|xK>=RBhKTQcd{= z;@CyfA8YqkX~J3Icr7Q`W0vQHCu3I4z*;l+2myLt^}yU0UK2 zZchPC%HfsgEsbC|_fQ(Z?Q{iXcCv31N^xO8g`t})^%g>q*0f6km-1s{wDh#19t$g{k#N8TywCmiz+pxkjbsCa0iS8wmM96PKq+t-NE^e0y3No zpMgd(q?i_w2`JEaynS|6^QO!__InSl$CjIS5Lm)E*jC_-w8M@DGS#3ASMEYP8t2dp z*)sHi6NixeI^JbP#KrSLci8!ov)Z%f{q^}kUG|MKV?Eg> zX;UP-s4Z$t1N_edGx~k@pC+^4%yxqFr8PI8VVhQ1bq@wIJ>^Z8I%Y)VT^>hW+I7UB zy%|z$7bK%yb*L}zSp?*L6ePIDDb(phjXl37i_52Nk=$Q!@gf0x7QUsYq};kb zry2{gcqsBKla#WpB?eg?oJS7N#+}){z4j)#)(S0*w=z~!DLe=v#C?lF{6=%4hF(Ur(6BC~BN0`p4An z4~Xbc2}gQhjvqEPg%3N!=;ILMK@=M8L~`2lq2M4WiBH1N}cntV(t!OjFIFdC^mp%VRn;tFXS=|(zD&ki^ot` zm_J)WBqk|q!#{8ujO+2No&#m1^*92oJDHY$0AtYXf(qFJ?h7cBIAwn}G2otBAbEat z9Oihb>^mpI8O~vzUlC-5JeND%JL|#5KRev%FDbU};P`BxLcsbfJ14-ISZsaI+j5?L z=I-oW_Sk4kQS|wwsyF>Dt=F)Y(puH0buJxtv)z3U33zjW|4EKWMZI=E_OM$*^(ywtQ;f`gsHpj9g*oBurNm} zXrJv)+x1~uVdd-Cc7YE%_xlX_*xt9C5;xpwQFqK3t-Nddk^PV_=1rElJN5~U;n8$Y#YLxi}1 ze6~;E4>de-dGYr*QhLGt6B-W#-g=QJ!z`TTSFxQ;A;!OJSg7dVtaokXqg1q?us z#f(36Hn?^{g=H(hBq0ak>~TR$dUD}jT8V6`Wi(Q$1?AX(a&i4{DKUf9Aza{+`K_zn zlYW?{YJ;}W-aWSXp?gM7ZP5Ue=6Ds_bG&@MJi=1OT7gu|$aAdY%0E+uU&qv-zD;(L zCG^_-7$!u(j0Zgfs!(>054FLTpXvq~){awS?UfkU{jU5NeD%ldvDoNm6Q50(_zVar zMzov7i7Zi!L3`s4DE}R^ccny6&`BzG0u4RyZKXf)I4O{??)tn@?Y};n6IuO96{RFx zf}>K|^|XoD_`K=}xf|N|`U}#^2Ue9uF=d;|;80vKRg~QW)k=La;vckQF7enx^9ow8=Ov69e&wJplfp|C z2(;;i&?1|vwd=Mo*Qc34)hb&YVx!Q2lX!zX`Kpy}dKen~vzu5P2IKKTa0B~!rVfrlJW&25x&5Fiql#U}%4&7(G7EX__o%!aX0Tw*W7YDiES zPdsa?Ah46lWopQ^Sb-uw0pf#9nAei{D5KIJq!oTz9+gk~)y-i3T%O4a zJ3(7Me>#Yd%D7@E#Nx37=g=@Jd}G-lypW_49RS(ic=N<#?$J<&o@nN|!vxA0+Twa9 zRHHMZ(^+}?RFzEax_L}hs>#tgEbzMrthsbv5U`naZBd0IC*i<13#JgJ-wiEigHEvT zOiD!458E;7ygPz-7%){S+S7D=UGIjDYw7?I+k>NfY!4%lBn>ixS$e@;avj z%sYSW27Rbhb8ZAIQewmsOgg!7`b*WYO21z6!_n00%17#8a>t5uzon|AFFtV$O19NO zpWqz8a}Cp#3l%9t^<9FU4DxJ+%e&9R0ln~NUYW!AJlkhb*IjL=g?ZY?xmTz=9!bpd ztAZWJ^{rb2HHi>PkAp)H5rJz5?w$;){$n8w9QIP;T|CsV=Rvw=wj%^wP(Z~*#7RY6 zNz7Dw=PI1EW&5;4A772t(gL(B?y!I!Vl0M~Tv2YgS5;fa_tA*7x3QoLNc>VdObj#< z^9{mh475rsw~r4S2Jxg)@@`2_d!hS<>^7kqFH~@|p-?&0^FqWbKQ{WnNCcFe$K&=5 zR6O5x%#O&13}BCOUNbUEJi(w;1D8rJsvzS^?>FUB^B`ZJbNVujaSe%&;}OF1kow*} zFy7MZil?_!*d5iwg=B`yey>&)%u&5KyskH_|I`h0;VhOY6s1EggMs~6NzK6s6bMHVK1rsCkIG* zQ9}>!$nM|RrBRlog^gtuxsU?&U=Xp@iS!Ys#<1{Pc@cWp{YO*E!8J^6pF*?qvWJ=; z&!~*v7h>AClDGrIo^^{Gcw2mXATTtem~=1)5rFb?+r6?-)><0^_Scz2(SGqEZgdTW z8~x@{Z75S6OX+IrB7YRyqJL(M#byL7NR$3(|HNKWKi*r$zhf{@qGn~+|I_Mb73{AJ z{&|>(4eI^L%1`nW)PexIz=c>gS5Qvm9A8A|v@~H1j?X~%!7Kts;o4^HQ=a}R__IlN z?cVg-+$<>AI8bIn6I^h%RM65hxI?}`=R7boPt0=fF}KaKbFO=*T?Rz`8WB&*=4r=P z2ET2-pz5e@7Wx4q%o>3>uB#;e!K<8}4GzI9r|b#Xu8?ZCU^vVEBunP5gn2|Xwe~Xa zPr|4APL2Jl)ALV_2@Lxjp4-vNyY^0xEuMC#QW^7|W?;viGawTCbhkBK3uK1ekyHsU zguV%^9P4P9E^G_kq@oZENs@J`q6T+^zFFQ02ek^6m1_^b{r3kW29&NDqmyMJ^xR@fHgn4G_ZODk6F;mtji?;K-w&a zAOb0(Ex-c$hi}<^EhYs0hOFdpb?>RU7iw}{JKZ22%Ul3HH@vnVIuf_)t?O5j>a(<= zFbQprH!|FT!E3;{Sl=<#WYB)8$bYtuRFF#HD@Sz6Ffote^8mX3*|u*sc4m5c{^OBv zASM@tyvBZE6$z5<5H>&7(}o+~JUc>kA{eT8@7#+6#qcC&7SuSgiOT|EvbM>lL+kh* z;Y_l_u0MNeO?U@~lgG}}q~^&(&?nv!g~80K*n^Kz`SwhTe}~;9)1+e7q~}l<_jue< z{2Wyw=O!fY{Ah-qVUV}i-j(5D0P@lIB9-)PYdO?gmg)B`N`@+t(bIs8_BR#nO|qsb zj*H1i0@Oy<0kH`aaL0~yDHWpg@oG?@wr-h6`e;SMYe`r`PlDtaGZThjrD4xB9Dp^A ztRx3u-81=PckAV}%%e+P;WT@?@b%lOAFie?(sZ2Z&q@{aAI7kl_8aBeJDxq(>9`?; z%t5C;(nWu)W8HC*_`=(9*gmm1{UL$s^swF-Vm%0kd(wz^vq+-P>9>-gbJdncD>!-D z=FjgJ#4Xs-I6tAYjd)iVTg`?5`4aWVdNwFaEoP_@a8C$bM2_OgQwqlD=; zu@^C{`))%d6bJIxlwQHwND=}Ela`YPpdK1fZ*&iTN^1Fhb zs@`9pLk^vvE`rJX&4LqVeudYpI^(V8-hgICZW249Q@N&FtO2N)2}^P;?>4^z7}%?1 ze|`W)wP^SO!qEc-Ci{+RkbR0=P@b^}Y_(;bqsJP5KN z?;fPuBOsVnnVNuRXx?8d^gf9FlGgl>i+}Sag9FPZU`D5d@-<-iV{y|iDAfFnF=ADB zKhh&yo^)|XZd**Ce{<@2NI?}~emnp-%s0iu3-c$ZeYD93nojZr7%uDe0m@n3NTD z7pk9e3)VLaFSmu53z`b7?qy{gt&g3*!R>ejT?q52ih(4$QPn1O|&ASAE=&RJ;;e8y;OLfoFnVoOCg)@W-5JO;!MEaPDt9sXaZb z)6SzmC9wSx%3*Vxq7&}=jhVNSS$|E(N7<8X*Y2J~53tOx_b7pj(Hd_S-Zt)^x%M$J zL-!imhQpaXQNCO#pR0j+H|T{tw__a=7d$E0Tb13gb^K9LUduqSn>8RMj2GE+omy%{ zaN6Cv;oDUDNeQN;enA5jolEOp&&wriVT@N{d0BCf+amGbwGAYHr%yYcO!tIDT<$=m z!KVLVLZ6Ee#tmKSPOKDbN#k2lP6wlBOM4tW_94drHCcn~+5`7*fiBNHn1D41OJFMj zh_5$A^VX*ONsH1fGj@D~$;wSD2D%Pm5B=&bG5!XR9xGbsdf;kNuJF$%i6Om$3Vm@w zN_8I>ev*ulTbCj{lqW54tznol^)xo3>Bx;B|1IO!Z z>6iswFw3iQapJ^Y{RkEB zux{Qy0??D`Ke&ZAK%gY52gOs=`WVDXwh+Ht@U9rTBb)q14FexT16QznSR$SiEI4NFRrJ=m5H2n7AAN1cK?be-T7m85%Y~;^KG(M)EJNq;$)FNpTCwNWA`EFJN43ut7u_#${42Io|o=X48^!RBuN6LTZz#&C!$P`*rz!@`BB85PU3D&R$o5Zya@<^ ztR+xC8|fz~hreQfLdh68STSr-47WNkM(GU7{4W{q+F{BSBYHE`p@!0fiyy#^PD*R0 zh$%|eFpAzmt}U0Bx1>GD>G`r|Qxwpd#ZFZQCW0ZtR(L8DFR!XXYhW*w1O{oWHsj>S z_||NpmnfEtTdEk!I_)RdoBN8AgX-1k`Soa7&Rp)bap=z{x96!ts*_jI@k%@5s8vL} zUT()x9bcWH*U^+EJg?NIgc-Ne>MTt4unq3TX#Mu&;R}s@a;T3U7Y+ ztF{B^S0VdTj<@gckruO(h&v7W3FMWxo7SSu^go|}se8e^H zfHgKZR3ka!2V1yB(vnVfV3vQI6H^I8O&8MgQWac1^Tg3Ie^}xJTTB~!0|UZ-FXr>S$z%^CaG<*Ukz(}vL>SAZF8k2hRi(d z^VVH^ZK=#~5&mn_VSSUISa??5Gw3M|%Tl4n`TMxDX>lJ|(cyLZ~ zXe&?xPI)MHfm_Y?B$Ed?$q)A_$T{AVOjKyk$!|%E#1?_XB{Bm?tkRQq60p%g8EK+5 zaHLs%ighCZBN4y?nx0J3Q#(LR`_5-mBrcVJ2`yZ5D>d=X4q*8+Z4}}OdeDW?ic_!O zTTY;EiZn|(SbiTEvml`p+`pBacf<=jKdq$hxaJlKkY#aBQ2a_Ow`@ov;OySQrbm3J z_X0C{VD4dQqYLN!W|fG-gz|bUd~LDb7d6Nv&5VHWy)*`6fu0~!xC zFsgD;MFo+;B!(?4hv^2pj3lS`TAUhX?6UCG(M^^aQ8^)p)!%h3uO=jfVpu@(0rOHd z%LA@v?F`khzk59+#I;t?5Gm+&=<^r9(y;R-zzXL%QzYs^6$A{+=__`n=l4SJs4|t2 z;}ACf1BS^TFs?tCKq_L=#RNXTE1iCd`;P*#1|KGhM)su!&m-z8N&{*iMg@lRwAA2x zZ|Q)+ny$#hYxWCRz{3ojr!1&vA`b69*Tr6{T{zV(A}`I?(Ay;8K+jR*pYr=-FT7vf zoD%sEf{w_hY1H#KL0#=2DziXih5CXhQO)vIfu1+nI;$1BF5=`2U1{iaPcjMV18m9; z`+mqx>s^lYcNkb!*7UyM!QrOdS}<-di5RuRnX5^XU_?p3dY_=eWgDQz(_7l6-S832 zd_=%LZ4arQReh+SjkV_ed9$l}76NP5TNt>Lhs8f=XVH!&_oE#LFT%6vzF<*@n;W7PK2dA>u$b^y0D$kPrT2DVGZ z{q#lzYlG&jxo|t;r%$+hBp74dy4eco8|06~gYZluMR}l6>X_W=hh0U|o%US^u&2iO z$H^?B)eH^frAI)SOx{assZxToPU^00^tbBK`|vAHuOIiI^G*`cApqV>MWGS`60(R>5ZJf2U^d8oPKgsuZ~a31(fSKqN%hP==k z73e>f9ki&Cy1nH0g7luQ_Icc{N*46XkO5ZvrA>Wjt^Ax z3PNh)X%Bi%zy9!68FM%3Xo$-WS2Cmx1+sbS1cyWhsHmpt7Wojn9bmQI2-z zsZ|2#yc`kH_PNKY$LRD_Z>(@ zPPR87I}%>ZugSmR3SLIRZV|-HhXd5JQj$sV_&D*Tv>PWZ_(E)3vN~p<3E$n*JBo-t zM-Qv_y}_Mi(6pGbc8pZ%A%?}IqO4 zIlzFOlptHWm8%V8i*ly&-)pxca}5`u!&B;B$R86-p|ItP@j^Bk#)Ws` zca*bEo7w0OZUx zhofTy{&}g1W%L;#|CEtsH5cuzh@7Ybk8oMYLK7q3)wOu+!{!S3wS;1(>mBa@xKH#% zLM0mn7?4B^Qpf4&;Yt3Cp@`FraOIqQm+;f;%7RRroYhA9F!^@7m#Q=r{Yx_~#>A|G zy|~l>o=}a^4?|KD06njB*S^ESZ;LF0a;}pd5P9K}F`j#DZ)GQfl821HAK^!ERKRdv zR>b@6JdDg0oy4fQe|ZuvE`tEYRtz$21+PSUpNn5Iwe%Yw_tMdhliLy+o0Me*u^uAO zq^-H@G?;c*QO38_8IVM3$brKaNI$yH1>&3wD)tFowc71>lwsei!*W6#2yUu<1xzlF z)><5c(g(O3wXvVnFqHq|o2NHOE4lem9Ln6nK8IXjX@2m|J&WEsl`o5TdiAL}`Zdek zTutD#Jgk9!>xVj>v?)z(pKBAek-wwfne@n2meYx@@-RSlFXq@qhyhI}k1eG4LR<~4 zk)|_-r|Zf|Vl^bt1M))$*dGFY-|o*CbP z_@QE#^aAK@p+U~sY8oby-$wF)9dKdeupvmc;6CB^fK#$~=MLMnwy?=miWSbUi|!5r ziwxcYctYWpcGbub&{dRY&vncD-k7k46uu<8Ulv#!i1A=_d2Ta?^#xkD+(&V$1O+*f zZIIBS8v&MX<+oY~GlG0d>e_O&s0AKE0)9qM_0=a*AgwaO*R4nU3^+e#y*cnAskh&O zt%`2Qn|?tgy3Gnp>N*)(sYps{4yo68-#$S(IU?VLB{4-JOTJY^@z1#KQgV<47)Qww zhoikb1dptcz?6*abwpbPDqAg-60q)}=%w-Alk^C_JNS~X1q2sMlYS>u8X)zkq~0U` zRk^#B5vzGNsLTMjUe5lUV<5q_07)?+0V-T%DrwcAbW+evY4mDht%gxEgSgT|9cJYb z{WdPPa|ZXDvgc(&0Wc=am~a@qT^6<7AJk|vA1#|J2K6PF7X|EBja5VpZ0D?&Qh-yS zDv{zf%RL3U{F0E7)ISQx0ZL=}=|~;ne`ql|Tb9o85H6#298EqORJfPD+&EqpM4B#; zBz@|qqEB}Ybb0)26HDM!S=_&UHvjNENSS1UQ;B1MkriCT>fQESGI)w?JxdAi3VCb$Dzw&%l|F*W?CBe4?5||)2HTG zR0WjmriOfcwU71i@RKV}`Y)QR3p>c9gfAgo* zKy}Q>)0&%Prz5*UT;d9YMJ2r~n@1PeLD@i>yTa^`kn(sZ24krWq3CkM76Bg1N_?&d zy3YUaM=yt!h(|tbDd`6WlMzRq(ZdFusYJ8r^JW{ywq4?6AzS=DMvbz4XbkF>{ z@19?@HK0C81X;7n%2{)?t-qyuD?h)UM%Y}vQz^sD*(dc}c7pJZtfCk@HM3>;ByAjV z7GGmw2{KJW*+2o3uEM2wV2VZ5?V#j4IPO=9G_TNUggCY3IT2ur@WKoLDE?m#?tN`r z-ZQEHzzdr2Z=61j_e@HGp5Vp;-IP`F`TdL1unWJkUjm}?MLfXKs_*Gi`;V((#Re0fg>(K=J*Q9@L{u{ktLN5cpWJk?sgU9PXi9%m_;zD= zO|&rPKl8LWr(FE@SI>f}EF6N5qr5WBWJnblk(ZYRk-`gJFS+n!P{9hvmEdz63YD3W zg-cP+7bPdf#c8=|j!9OmTD1xr+uf^dzMFfx6`OjX`@llWMX`%JFVtg|*E)xQ^eqo}o6do%`R0s-I}g@u_yA+ysQAY9n+F*yswzO{a2}MXPW5CeW6P&z z-b-|g@SnE?V2gN_9T)fkg#$#!poT4Xln+KpYt=dy+Avx1|74O%aa#=f9LY1q@t|S; zTlz3*jB-2sGM%hlvE{(`8k5XwyGRnPD8b3|zAqZ$Ub3^)sTB3aC(mU%WjDM&3IA+& zSql(pg)vS>Af?)GO$hR{#ZmUfw-QPL%DEGt(5Nijx%2f~7FnV{Sq%x7QB`6Feq%!B)~z&%rf+oz?KqkA1Vk{h2}m@fC>IurND3ub ziewS|2(j3&?z$*p+%Ze)9{_kdAM!MHwm$(mCGJngY%K^;8UXwlK2f#so z9T>Fvt!@{yeCK1Kp-BC$Af2+TE??M207a`wraOiF+rI+jI>{96k}?+K7$j@dC-X?+ zIZGmGhoPdzxQ+$|S=EQ;;U$sRE<8hAH&5pu|59?q~5z85ZIQ9-;HPkKGfb*7x=zIp3=;{5EzaMjndh182{b|^_2vFQs#yA>`)z@ z&PfiUBZu}2UZ8ru-8%fJ(WoW!>R1KzVkAR3WFV-C2Tt}zV8Q(KV2w;53y@1$+9k>J zGvIj+u0WIN%oJ%HgG$&VWG7u?`@|C!8b%{8KGYV()7nAU8+Z#xzB0IwElr>VyW5KG zJ8!~-db?f$_w}1M&zrpMlNXfPL_XOH@e0dB{NCyh5YlT5W>Ut%yK2G;?t?n7G}1*^ z%t{l$7y0ERVwDiTV|EAH2i}{MnXE#_-=p5$4g_fP~vuH;JCnsfYZJh!~B1|_TIjDUb zbB@E4UFgN*MaJTY{N$T|mcmRhf;r~~*3O2Z4BQiEm_bPv9i@mPM!LQZ-Z*Q&u*}SS{LBYDjAS>AOI_gDt*@T4KNM&pkK<8piPvJKR8WFu4mvV{9ye*t$Iv@^M9 za)z|77KCBy|Dd}+y;bGEd;k6)KovSqQ%lR!E~c?@(#{u2rAX}oG-foZsr6DRL%b}Q zG)f+qKyiOLqT6ZFLyb=S`00RbR{E3_`6@5)ouQ(Udi1uzxxzgM3c0eV*>k+~m+`K` zPO=nDJ1CzBJxQr%O^Z(#lU<0M76R1b#Z1Ku8+J&YUkvDs;9gtysIQ)5d}jm{24#|l zUV6XofvF3;+w5rVFL)iFt!T%%30*&qPmic~>w64YO-puH#vXwvJ)#KY5EK}RhJ7e{ z*g{_;Q?UY5{wkTBX=nNwk*i|hDYyJk2w;M2GSenRGQ^Z+SvAOW8OUdWu@ zngddxl-TvK(nz&0G*9r|bgELjGj;Hl76}fvxwVBR7h$iGc+XT~l+T!{So08)jAvp4 zIbaaC*P8l+qC;m7GyH@$t?NZtN34NyM}8pfVmhU5-4_4C8|a`ymM1bhSwETCrFbOp z=}&M~an8lxhrcYM-1QTzWr@nHvwBpDieIkPru$Ce|_DT7XeQ$eLEXtE*=v&HnkkB^%<34Q^B#24_e`F#jQ}r;r^(nBMJDG;f3mnZcd&sd^X1~W?G~G!HOz$1&fa1aTQYQ! z90l2BnEQD4cGS_CGr@1qJo3Z$u7--W0+AU!IrhZwK({Dk~+t-0~V$3A|y=MpYH6x6*I3Zduf@o%};h zlDI?P(ZauI;czWdp=9oZXAECL@|l3yjNb62zo{_R&6sHv#x_YD(>_{vvgtQ&ibXe= z(yyGYT%a2EG${~YZ3W$vjp z7bYuns(!sN`P#R zJx}HLhWlN_qSIP*r>6d|)m;1K?@3JM(3NsWY_fsw&TRn~PJh4dCV7wA7n?yiVN}nm z7Xi)}pFR0xUT;`{QL*-Y7{N1a`-P}iTWwODZ_sY)4vt|-Uf@-4e%GLsDPQt-+@~X4O_Yv$D?0o&^_0Ps}1zll7J3mYA@MIhHp2THa+Mly$Eb;p3P>b%4 zfPF`(V+8w71lX+lMrE=W7GycuYt#`c{OAxr69D9uDz^Mn77I6A|8;cK?Jv9oamIt6 zZ;pqTw-+a^SypU3>aR1*l;x@ADnC%?t3l0MX1qd;QP`SqEf!`((h{qqe3phUF`Tw8 z#p2+*eJN#^PL4q0HG;A;yl#o*Br<0Q-dHVs4R2iM{bJ|mTiLSOz1S_RDUGx(cWia7xc%lC z7roD&)~}lu{_u_GsUMau$LuD|{PFSRCEuuAFW(KXbnZUta_sRF6?lJ3n}L@wX^lc~ zB{D(J`)*HwH#tn~+`Yuz!O14kc$5;d?$K0GKbG|`C%{j$#^@U8UZDq?kvhZU1f77y>qf(9To&QEB>#AB&upeF7=hHlO&)ySwe- zodr@{FrV$&lzTJ5zyHC&ermclT*>&u8|zx%uL+uC7iF$tL*Mg>4m-6bXkC zT>rlD{9wS#{{c0NN>U{y~~kMrMYX@<$n*cUrj zq?TV_`@J2rVDi!5(U*|{_V{cS6@}FoPGE>tj!2CVcp6QJ;s`D4PZ-$8;I~|R^V?YX z`R1=DrjL?(_aBuw>=jFwPxflJi) zUD3lTWP+g8Iu!EmeWj1wNk$raRf|n{n_IT;>o~9EJ2%lq2(x z{{~(bnfE~If8u1%$);as%#=642pPmE#aqiap^8cr&po-($TNxbT%c{<{x9s^FtW5; z@qkvSB-}Icg9hXkfM_5rV~y14 zQMknraErc(2Nb^Y1G6ut85C}~f0cMUCXNs2b-ps`s~HNOPY_%G1M&ZShcFhv2EVwv z7N^p>4?6T$nXPGFN9wmZc<|uA-#_sLmguzAf#L06g?nQW*Gg97(DQ3lPwv{wm1!=c}Kf)Gb&-amLYt@l+X2BrE=DUsob>NW6ybIu^Cfoe}*T?yDE z`|*has1$v@mL$r8qP&k^wjCTgk;7m(ujSthcon56Jm6!I99gfyW};7}K9O?-0NWkj zG5~I-4wwDALuW_}M5;p&An6-6O0uMcikqRpLE5B^W$ZJ~xX>#`5bu4~Qo>PH|HJay z7*G_{R54_nxxk<(9h-%sH7VP7g1j!&g-^7@h^&VCY0i3J-*r4PL@Kq%I9%Aa2Bu;* z*+HZ69WiJ=P{4S1l*vh$hgFD1JY=5#zc4Z+KWA>Sa1;ajvu?D`4+due+(pq~JbgJG zj!A$EPw-YAWB^7))015hv5e$7b}CQpDO*peBx|`_XDLRi^@6i@h5ffqeCE?vl@b0$F(Fz2FH%VSeDlx;l#ae23L_Cr(Q8nGx;N z9_(YOFfwZ|aP@y^#z`~La(Rl_(%uBFA>8N6*+!ETG5s}Q;qErUc`>xGgYV??k##Vx zBZ@1N2hkQmy;d^73~Fv1?x_&}2Dlj7bDM$fW#JX5HS`w(-ZQgJ3`*u=)M2v<%^Lg>Wa7Oh0oT3i=2It%7d8 zSSDeQNU(7zB+L+GcKQN{$LZd^L1^@H^C9|5z$QVnh(QMoE7`N-61h_}2Ln6{>d5Fr zx$}KlV>tp6qh=8*agOlygFzX3*4_;P!~VT7!hQDDAs`uo8wIp38|kAl{yUpc(;Cq#oHXF;O%(pV z{{QTuPz;qb=u;1UoS$&uoi+C6#I@f+I0%H)FD;3&Q=_IfOUtx4;S2lY`T2uxJTMRS z1u>VOGrw2+jcvbEeAF@J)hOQhf50v_4f-e)F9k zplUO>wzrWxtCETTqGNK;yZj46AQTOa*pZUkP($Tc#pEV$HV7(?UmnIcM4Ua$ zZxK?VF%KtnvYRIFiM|Oz<2fO~RmovY;>_JpcKqEEHWNP>Mlx{%4T==*NPTjY6oD7| z!6Ts~Knkqn*(oEEZmBn`O6Dnfl7>w~g-glir)7>e9q4SujF2x9h!YVxPtaCFFhKm^ zMbuPy4?+&G1mcxr@e24}c_@S~yejkd&AVq^!s|p|59=f^kiG`g^sVcInYh9=l1B0W zG|mYqW+U|veCut;6JX94EfW_c^@c&6UxQJ;s5#nX0lAzc;D2aM5JF@VG#datP= zdvG8Amnu?*$6L=geWEc&VVFeLl8%C-OosLd#TQAAg_AJ|BKQLNb!aDYbU20bekhd}&u`4cA)6)lcI|%n^ymx>O*N1JZ@siRqye&>~{@ zwe0I=d2|5St!NV$Ge7t~plosTR91&rJN88b7W6k~Zc^y18o#DOm=!X6c%v1Rhas{E zvUtSmvuEXGB!?OtmeRU6Lcocm(dZ3GlfY5weED0;XMfvzcb0+Mk>RS)8KpUdkO8p@ zpnG)2BVB2~*1;xLMhS!)RC^Fau+X%n0zd~jEBH&Bxn*k9&8LPl8Ou7qCMVJPX?8&) z{!YVd0x(nz2Gi2GM><^-esR(LX=;annc`p8_SY}CGZeQhu>N?%xbdY+mqxGs9*o1a z3vFMpqPIra=4u=4uea~t5g4e+;~+fSiw{pA28ADH|Fmx10-#mG5p6$iZWF5l3_uEA z-WX)$?kM+cG9*8MGtc+De7xoKZZsc2(P6?+P%ydrz6^een{zA*kfj4+2?s2TGfspW zWMAOXarc?N6S`<4xyGGCD&i2~R%0?Az`Zr1+51|cY+waonDA5~!wU7x&2|BBjDb)- zQncu84J)8!!-4R4_s`B9H&eh(edu<@4~4%9Hxg}OL}%|FH^ZaKZkTVCZfpc8-+%M=f6#m0X@7K;W^a9Zm#fKR@SrZe=nJYPU>Qj zBYvw$wu_TRZ0iXA*7DY6ERJp;Jvf_$R0PInraVwaU=*p<21qgI(s3KaiYDf2D`!~Y z2h%)uDC)?9<3N!jqLI|^kHD$3@}R@IcfU7uWzEn4$W-am7ESU$z=0mbi`%XL^g4gX ze)+q>b!h5*Wuf@I&J%HYXH>hmilxt?W5<5eyV+Z}fB)x_pWlw6O1_ox`QmP}vKj?> zcuZl0!-aDmwvWR&2;vRjS;`KCqk39OrA8!~Gtdd>NHluI8v2Dwy?uit5_t17{T9gh z0vV?UO(b0?Y-m;QmnCXqE-FI+VUEX7@M0bZtv*5lZaoakaTvn_fsoiCe~PAh>l#c`TR+_D_^GbBEyt#Yy&N(2W#e>eN*PDKE1s$L4g>G>6qY!M*v90H5 z?$x@}rd;2A`|6nC#Tv=UU$1PwZDF%;Ir~oQZ(Ao0Tc3NPE2*g`|NV9{Q}%qT+|JvM z@gqOv99o<9>s&yC-q>~){h)!y3aTWR0xUHcM(cMxOpn`he|WpRN@DVM&$(Kf$FCzk zRq~HnM1R4XinnqK_~B8Hv|3~zFI@}imle8n4nVH+xAUp!Uv7_RAqypc!L`k|RQh|k zL5fmyJ3)7sb9BSk%PaQPx7K6gM0tPFBno&XRezpPKjQCDHe8zQG(1bZC;2TY$rKnh zT;;91FTcAeT_(o(fJpsgRKe^KHzWDj)``E2AMwkg-{FR#c92=Iq}fZ{{wBZY)K3Tlq`kaz!vT%tTE z=gT$SrFTq}eiR98M-NQKmgCa6hYX2oK(-?lU>3{=U*u%9wkCqWQycMZ-56U|2ltu; zmz;&kBd?&6VvkY+2(_n+jm)gagP!?VGeM<^;AOEx3Cp4TZiOFK-2O*QUd*~i%pX|$ z*rG6?IYEhnWUoUvEo&XgRYq1TGP}xKM(bzfp&s zbJiZbIe&{#k0nWsl(&R;%Db*QvOCklt}RZtKVjV1@flRJksfl+Sp$%ikTTsz2xeWn zP@GnL=w9<{vTq?DbhYPegQ5p0Q-6QD^?DG`O8On|d5@7%LWEkHbAjs5EBWIR1KXq~ z=Y~IH!vD|?Z;4H?P)#wAtbmnr>AVt1OxE9DYewp~qXgYzeDb0+?v*~qfM*aMF4C5= zhlwi^0|wR@NpDLFW#Y8rt_~*cq_Tkszv`B3zE!TIoPF(`keGK^nJ6@+3916m1{L^G zhZbuyzCQ8>1H{;^E-x7+WQq<*TIE#6k!ilOfwyyO8>yO6Vq&I_h6#g<8TTpKXaS8j zmUT@c*Y3raQ(7JunT=31m8A8(ipCc%^m~nqXsz%iq>Gfw+H#{~EO9B{8iRKQy9_|A zL@L_+(IG_7@D?{qbKR%lHuc3`z>`JUsNF1f1_k46;w`z<8OjG*=$d+xG8cMxFpYmh zuc}Dvj^H&{@IKCgxyW{FHH`b9g(C^u*j1p516x5TxkbBknn?kka{Q93Bfnf<<9nnY@`DFPKTCCASf!H^&jCr~>?pa#)3+R%p5>9Nsq^2qPqAa^DL#<#8O# zK2hx}#K(In{h`kN@8@}$_nDJ~-MvAQRJv2$$%;0$%UvHU+B+XaW`Eo&GYZL1!y7Je z`^dIQxGFPcr)IOj2kA3Ywl+GQJ`#C{n}g*Zo0DV}vnVd1I1uLY=QdrA3eFALXHpmn z%#(N5*HCt;8^TQLu%KT^KEOyLmP9)6*J?d2=&r{fZeDFI3=jL{RlFeaWjHL$;R zDGdB&7VJr?vWG}&g(toXo)VY)Co<05Z7cLsrZVAAtzd;6k|CVp@_JUKrwpFkLee2g zGObuh4>SygLfVN)Ns*8!vS&#OB6X*PT}Th_4@I_U*ipDQaJUQRv(@w)|8V1cjEz%% z8Qi(IuhE+giydlelJ)qi4(%iv=8oR<_mzBrsj0B495CLmEZ^H0-iH+jU`3Fow{&gp zy7+CW4-pG$k}*cL%XZbQj_NFe;}L@o%hCI|2hAUcL{ys}tI03+_bg~Ir=LynB0Y{F zU^b~;ZG-Ml#zBibEkBvhAQPDtWt2bHc*?A6Q4HZ)jOb!|cvIJ#{`Z0yi!qp8do{D` zzM1Hv`k0K?BJ?mkiDwT*Zda{973R?>_vv9EC@M<-dV>Dq1o z+{^(jVH58x7?Rn?_djeDRY-l@3Z8@&-2z;dMV&hBFJRN7jrxx%cS&+MzH@^FN+XRS zK$5(FJ9B>Ofo1A7l2rI3$VGC*ME9qnlgtLqQo) zD`~>-oY(6LD9_%v>O`(s{_%Jy)IuLICoUxQygQnD2ISW3u35!c_;rCIyYq}*^`M^0 z)kq%%c}a4ZYgl3+G`jE5U3X)l5;pp@-ErlhKjX>jKzFP` z5xmZNxYJPT21sj3uElQb?maT!DbcVw&8<%k*YpmU%hi;v;c{>XTtB}#b3CDJHo_69d<>a;Kj}TT&!Kk8wAHEMJLn*en#kj~BYHK!rKK19_%xudUbz z?GgtmjYhOuC8ZHzyw!)?`zK+>9S`bZ$**i^({2X2MyrPuuy!+ppUNMF1~;?)_uY2ACP?XGl9wenDXvaa37 z@Tt`*iZ+iQYe5e|ZrED~aO+8#>|*3D@H=sOY=#DIjC^+6ZF0yWjYg&n0wt!)P9iw! zIVGL~4e9P@(-O|VYY6+4$*YTpdl6+dH_|@b>*c#2O|YD*1z6&=4q$AKL$;^swyLmFwowFP7E9wkQbmht6WoRSfZP9dox zr_}_cp@b{IY_la!Y>pTY0xWL!odC%zj`Gw>^JB1NlJLsD-A%-y8H-n%wNUqGNVr9OhZi7Bt00ya3Iz zZ52d%p}{h(+85qTRZsLo1+6=HJW1F(Pin*D!1f>J1T~a;$lB;4ijt^h>ORk(bzTO7 zW~21*0c#AOQK9wtGd&^(8KDtNlBWkkJVtGF{#Kh9V$x~|lgeoGH`}})aj=n{P?!~| z?cYk0S~RUPPz8kqeT^EaKQ{Y0bXNs-bgI7ed!CKfy{^GW+rOkh7PU(ZhIjApN!GVd z+Ii^5ita27gkX?2sAXh@Qpf$t3=05i7leW0Wa#{CURrN{vHI0cb#6)d!;d%M+)v*AIuaf8*nY_( zlJGV6X3wy-Ti2W_slmO=J;~1Wpel_`<`h%;`n(Xuv|ZlOp=3j#nly{|X8Cefo=(j~ zk$S~62zKJ|gJRhRv&Cl?*kD+HmG!9MzEKiw;}W3>_M@@*j4bv<=MS7Y(Y71^39!cQ z2lbJ2B`(?^-762zW%1-lRxe2!E4=CA+%3J{ew0sMwFAI$$_{Va1_wx`+y3{#VLDKa z1{b%OzhZ-3cQD$4&ULNjO;cLEG>4~@eD;xQ$_so$L5PjDa1MWILed;r2fS6oIkV6i z@`cc}doJ3mx7at>3iw0v4e%JJiMOU1clbwWdfD4rCs&Bu;mkB^KaL;F;ntzzetYr5 zL$l2&ReqT956bIC(1WzFHH#kdBc(^Kuv1KLgQzpysXMZQFx$N+gA@_g3`m7qHq}B&(PT4-=og)DURLSXQzF;Tj zqTSiLJT~V;+?}h7AH**0co~Y$3vM=%zJ?Y}8m@7!Gz&~eE?XVdm4YWM zMoGnmu>0tZmnab@S1vw@;zT2d?l!LfV`IvKi$UjkI~Y@89xwVB~GVKxP0x{>M4r_tq`GmGVjxCQLqAs za`dhJi6w`pjrhtuHO+(pKAZt?FTw@jy?|m5OfYUnSs(r(qypVM3Pd}!Gl|7?2&47^ zd5Ut}>K&iXYXn2|UJQqZ*Q?hJ*vqW*LiMe$G*EOZG-YK>m9KZy2*-(nA07W9Z2}hY zShpPaa1%Auo?*!|DDW|Z*|yAf zWwi?Gh#;^0ZFza&P^k80J)_Y=M~>f3UOK!j*CJUkZn$R*HHNZ+_E4okUJx1HSW=Rs zL}LxE5C2`ztkGUO{65*cr-xN(SP!nWw3ssTPu`K^8%hQJ2ltPhbaOB|o(?4R4%imO3eWM~L%*GoI5(CvA9K*wvr^_k> z(j`kep`FKT{nz3hAeWW_cZDp0=O_RA{W)y;DC+!$_X8C_B3b1J$919VlVJdQSUt+6 za1M$8Bg$%|o8y1nERZQnf^m(Z`rSm6BEs_NN~3Q6dg(#i_b@3oPK7XY#15t}S2;5M z@|C+$@H&9t9UX_GvG19rK}MfNYkE8U?u@(-5}&m+bp}0Fx~TVH+Vh6<)_vbnfBPK+ zL}q`H@%u-qFBkc!eJD?nVRrFskN(%*_haFuU)Ytu%~$(o>4=X_ty{vQ=z$;mmycbg z#OA=nER4uVHzVbJyC?D7+Hvr~(??B)d4!^%$xM^2r(&cySF~DH5~X^9(WwseB5KDs zD&vMLzqZ_5+|+k%F*i7Mc>X-xc%kra)nUKHvH|M8pz4cW+D8GH@=xeuRb~PV`iBcf zgJ4p4S!DLj|6%Xj<8r+J|F4Q-NuOneRP%A#h9VLwnQaaWZ8OWc2szY>Q0Z`IhM7ZX zRHAG-pHht^y6=Qggh(mf6%{&{?hbeNef?gq_jO(Oq4UQ++djYVWB+V*>%Q;ndSCDN z>-~DaUeCji(*U-r)M^R+x#7zA33RY_TVK=F`!l0bAP%#tR!+P=1|=tWPrJY85dQh% zp0hi5`dqOTVW+N0hRRHM1GYo9Pz7&T8l*Xmqp zkZ$!p+xVc($3deh_5^=x;MJ;|{YH1D21mY~zNqT%(~5|zFW(kW(^r`n-n{y9zj5J_ zj{*F3&r)Ivzd4%du26XWs~sOFRb|`8Z|!kFb)xF=wGUz=@&*+dSc4n9tjVcxWMqG| zISN1s7axfmC>gYI(`OF=B5UL~AF0gkdl2~6UIukqyn2aCMwXo~&kCwZ^Jdf!+p{cZ zJA7q?x0bkSpPTT(i5#0>Qv0di2fb#WiU9kg*MLT)t^oHFzry z>E|{q)rb9-JENkhbp{oE$!PCO1KiQ6YQLuy@Km;5yY_Ow#-pxa(${dgww9k~%%|bb z0ySMgo+LxhI&hS+8aEC>>s}8xC%1be^RdcdjYD_3QSvthoPYjJQ(~ObL>Das-1k-S zVHb|tr5rLysLTX5JAG8kR=Ra{%rAaA#SyfYnUkuYrFP?1exuavaYOQcZ2(QBs=OC=F{gDKMen*vEc4DC^mfCw^$|jwco~GMsNJ81{>r}U$nL?@^@e- zZ^%hG#=f&4+da9yv7k0E@pVMrkH-LQlegb^`K{yC^~vSNj%sbrsDPIA%#@=&J|)pj zsxjh~MJ~(+X3VZ~b3ml#NuK|G%L8Muxa(R$W-H1Iz6t1*n4Oc2j9CB zVG$6E%maqa48S44~IK z@qp(LSXj%|1bo#&>2|c9bgg9YU~cf|r3Y@3ieYGYiFQU;!PAo3;40k0PMEc&2Xww# z;?$eU@EjE=m6|QsnB1MRKU8WPntCjz#NNIrG21;)Qu6EOEwDM zHSjT=?`Fxa;vx5scW0i?{vj~Ye>c2sy{H~n$*5pWApYo`Z^Qsl0D{6g6(h`Gc9Hv< zvvIKTxHV(e3qmz~6YYF)obv8<0}~PT@5kXBTsuZ78EJO)bx%^`t=m#RAR<q9@mG7%l)H7`|oSK?Xaef1wX!@~8 z1N%b{4+DDi)9sOL1EpESCRY)~D`=O8VdQ0^FiF44xtBiQs3=FOQ*7wj8z=|8`kD+% zx2hU3;>Mgll*pn&4@XTc=1?lxj>yF^aUp1}Jk-O6Vp45#p=eWbCM8%q)1%-{N-`Ck zdB{88>5bogwMB^ur4yHr84@-&Wc-ou3-930JECAts2BWmZ^v~--*z8g%WPe1s#1nM zNJxO`WbDaoBPpNGQasi)Njrjf5SC=Ro6VHjYm@@NLPV14QYAK!6s%0#&qNMhY{hbQ zKP`w8KIWv$`hA_GK0Bs|y+-ZAI_4RJ$voO=<&n*ae@F`gp~8e%{7B0mz#u7Fuy2vs zWFlQ#ka(ebxsgPITLT1g^ULMeg%yE9daDnFuhgRW!)=9jQ_QeRlf(YK+d<*Nm41XH zWBNRu(?4JAHG7asMHrjnLi@q+=t-AVzg;`Ct?c!v;PI=w1NZPIB-71DqlEW7Fuq~X zmwQvo5>#9I0~}l+Cg&ums)c_s`Ihy!iCtC?KmDa@t`(QWOA9qVp7+fB^{e0?94&4o zO$O5AX8jPeNQY{5rHhqKPidh3@9PX)d}}m=(=&f+JsUlL=3MAjZ}pK|6CRpu4*%lx z;vd=jl;zlWpFT}9UEg={oMlhbhwtpB^^H-l?Jv4~)yn=NwKZ*Oo7nUMOL&_ce{>pu zBz8jbFT2%EUi(S+>b%%uvZi17>4B8=Ia{GDHLjU4MEsNe!+wpvxci$YCgZ=pHE+l1eewh6C99Xk#diwydcS)9#l?AwM<0bL z51I*x??)O=>k&vAc+A^>_kr=J_nLft*8W`QT;VBeMD1#_n$A1H?3@ zt?l4<4Lv5WaK1Zo(a$Hp{p{?;lb=uuJm|g$=k?Y%_-)>~R_42i2DU>xxg}^Q+^t=4 z)_T;}??tBnb{e%GWSE-Ge$$PT>TWAlSQRESEyN9@?Tk-MAMYR5f9Ee>ntnce8i1E% zAAYy6`h%n-swqdEeT*>r9ii`u=&yn8x?mS`GuTDvd6kF?92DRQ^FD{ z#|9A5I-la=u<%ontpX)s0NVc$FsL^!EGatNhJ0U?jBl>^Tok6A*J<`7!+CTo#5nH9 zC>dA3)ek}GiL|z;VL;tCYv=iVa^E)xgbx+06lHstY#f2R3}^JTXV27`wv6IL;Vu`e z6X<-^rY;l+p3j2hYe#ze7}UzDLN*c67MnLetChGwMlDKGik}O7-Bv;qUPUJFXOM}8 zPx7c=Exe7cz1_{LR;X)eWL(&wwN2l1ba`Fw6l7eqQO1G8V<2db?8C&njVURHl94#A z4_Ly7N$;ACE4dwh>f28m3mvWUqYXW+h>IzB$fZ^YKxch@ck(y#?+x8a8hqD;z>Xr2 zhti{C3G6P^xuVzkjQD{3j8D#cm_v_w_<~3ufV3RWrU!tIF54Dly8M#59Dt;Kx8`P3 z!&-KhNz8Ho2g`xhk+p>NZO=oEH=vaa#w#6=_!)|Fts8%@dXa|#VWEKEam6(k?_euw zLlK3XCU5W}K6>#YNKR=I@ma#zUCYsUb%1mO_&=f^iUgYxU1fh{^CQCnB=n^uHO2!Q z^xfoSxRPWU0~ZEng16Iv6srL20jF*?6vn~0Fc1dAsjO9 z{QF1tzKFky)2xi|-ZLUpduPtADcFy%voPDGx-+V1B*lJopucmcBd;@T2yBlgxJS<# zZ@e zO?y{YKOFJK%BNNgp9d$WhW^^#+YCIrB^_S-2^BgrW#NT?`JH$Z zl?=%S^p}U!RwL_I#;TmrP}bAmM0Er`{k%sB;+KMc5x;WYNnHpPGML6`8u40Sp;*_| zaeBygm!~Rc9a9}#RHzApO@MVW1;@R~DwAVhhr`bcx>xI|^!9wUF&XXt8<_>WrtUh8KoM z+TQELn^iLGq@Hy#YgKuN*v!OG)4DB<<1PEv( z;O|tpwhUFgVZmVdkdcCO;(cmA;#h$tr3>xigV<+Q>o#32#VY|AYi7dh!&tTNoG15d zh~6Ke@Zf4Wb5lF@MOd-@J;Pqml;tN#2sC%#*Ns(Qk5(DsZ|e_{7dqf;N+$cJFhHru z!zHCps#TadpEWHXelA>-9cVQT|E3$%Yw;0=6&-(A(^w4+^W3Cu_torQl1_Pr2ZO;H zuS>mhNY-xo1S2s=EsmF$9sJv>$4Zw_qQLFj*TmQEGOkXtqx;F?4w?9$`H%1G_f6z& z^b&Tos`3@R1treRD9%nL0EZ2N2695dV+=Wgz|I+x3g+EWF|PR~a;Z^RC${O)n>0B; z8&t`ZQFIk(;&hMWzHgC3FE?NLrkJ(Yi@E{svDY`bsMS##%;f*PEIekUs(_#?p~n?Afij2^C*YknDGP1V8uGr8jHyVIh!6l=+y`tguIdEKJsE-POT=htbaz?(-<> zGXi+oxj~!EFE8sxE!U6l%@E+g8kT*-{8iV0vCIieo~l6s40}6vx&z~t6($$PZT=M1 z9*79=+cE+d{esI3X6nQlNz`qfgot(x_}Om4&x0urpKfyMvP3oK-u%l;#4~@+GR}Pt z{FCE7hg5wJJ5QFe{nLz@W%T|ehfr&+Sto0<)>C`x z%G`7V0G~C*#h|nO%fi%v)nh|V1LJqR=nK}_;dK?qTs9>&^ahv%zz%<|^;J`#r^wSs z(RAXB%%+2UgZIfRh7~sNOY}P9wuwk?!Sl=><`SoHTA{U z-kwf9ZLjK6gCAc0B~93!!hFsLAg;ZSt!^+QsGFck|9)EGm`<^Kta+UpsOf3O^;LQG zT+{>LOrOL2(Gc=#ir1A6oT{97qbPa? zfVC`YtDg0)z?l7%1~wsj2{GPGC7^#GMMl|nZf7)kO`OSsl4CiM zVefL3D*z92h`xHcX5eZ9!S{|Eqt(zadacoB!t%pysid-z%l_i^1IS@(t+Gj2Tf=dlwu>|(DD?y<}zLX!6?~y$CaYN-bLa9hUQ>4ZLxFKsf zD;@>gEI@0@*Q9-Pp)9pu-I&~MMGw0{?yj$U3K%^CLZ}?|dKCi`w^@465OGA4&iUt9yayD-A%{jf zzZdH-S|iGsxyaIx(n;ZceA6~X4f6?bPTmGxtkDKH0|_l9rh>&{pTnTriJ=(i#BR2t zC>N`}J$FqkT9=~*4&KaSpTt1u6k7N4tj;E>U6G#!BB20;tw5fZdhqC3cY#!$Q@L)m zs8zkNgXKQEtWE9>I{nj~S9e0u9lI_(mZFM+Egt2+!U6_Qz+e#l&@7CSx}a|k1@ik0 zNXRmDOGE}nzKdklc)cjqy|9|WH}|0SK%z7zof>g>hT+1LK31K;BI1>^_wS3}vrLtA z1^y+6ZetVXBb6V;TVO^!Q?#MAQ-h^``-(q6L)ik9T|I-|n7YE=d=N?dROn9pAD(he zAh^L+4FxL|d7^_vTNJqfZZhORNF4_~QR4r{GRhB`d;K!oeL^?lGz7}<)ErT-MeA2s z!D_9}zK+%?#zzZ4q5;kos47mh`ti|;JklG1DYNzC#|^LUg#!SR91^BgCQo???Wd0h zgj3X0AUuT4{cJ#zk0&`A=paH@b@bZo)!ORzI^LVb$kWN1cFs0cLm7UWdRB#EKYjXL z)5c&^iX0YNo-t3FIps9pgXzI=0C0w3GxU}uchm0X3Pk-1EDDez6cwQM2xjDX?!<#A z$%Rfhu>0wx!d>W40C;MdSOF|5vsl=EL$}K$2ugM-ux6304s=@;zj=g2jVxR|hcZJ6 zY8NS-OjCVM+-^V9m}rJdFY@RWzXVU>j~l8YcTod9#GOkv-n0N!b0*`z5X373d4F$! zHBU&L5l&8{Ibto!-smt$F*JG(>%{txPhzs3VTC&lRVSWvx7#>aQ)RNtix`(yc2K8w zy+WX*5(2`&i(wG)h`wM=r4@y#dfi*w_&eF1HC3j@iad7+;Io1X_YxH)Sfxj+b|9Msu*|GJrJ+)IJ@}mmcL@WN zB_F5y{w?Q*C-R|o_h}oLiAymX)4rO!#t^+o*HS&R7Fk zTK2;h9PK(TQ(-Igwz=InDa?8zv3%9cH55*k0m)kPQHwWP@d{_I#4}9agQ|#me+%7UTS+qQW!(rxx zYb8~GM;%FzOP%v&4*}uPH4$JTt4_2O%Ry(tcp5EcY78iZ-UY4XF$$>|-rnuyZUDO* zb9P}O9yQKiep$|pYi2-atGt~N*|)kNU2Vzzy8hJTjkU+ld<0dP$~W~?aea{+59T;a zhZtPYT}n~@MRBPMQ>M5qO8v~Xv6CPBZ=pLEB-#bVVyVk!ZNHexhcF7q4+GVW^3Y^6 z-R*b;09GL8Uah1=Q5~p;(vv|a?~83swRJnnM2DugBch*o08b<_9w}>`Ng@==kfFEDQ<-$K$p`C@1*&Q&*O;V+3WD?j zP$Z;mbeT#p{v1Grn#$LdnE<5&U6F~(px-=@WtkwkRX~;O8a3e#dj<4+tcBV_b)wo* z4F@_U_Xl+UMV%NmzafnMFLNP7REc^a8kMlxkS#mI(f537qRSt(-Djaq*(oixn$IaS z`glGSj$wStd7Gh7fG+3E6$+V?RbweB4ucmNDtX8pQaB|mC;;?J@M7MF?siKHRj5=2 z?Km)VBegXF|GMn_>NhRAyInV8X5lvv6ePbJ?rnuaR*YIEf7zp^wlS=^SMK(dXO%WN zHD+ULiP_hYUMU6HY${3&s;!bnQ-U6ujn#Ipno}xHL+JwEvhl^86utTA+zY~gsHKVW zYRzks0Df&E=H5K^z;fKdE=LJ(OnGO<_D+oEGM$g~0$~=Hh`A|eazHAx6Dz_<1c{hK z8D=Wz;Ych#gxLx9iA%aKQl|n)&iqlg-713U#hl_{PR%v*-km7ve&&?>L+k@tDUwI} z4`X)}5g|E7L<|h_Kx_-3FclKsvR4nH#}%@6pwrNaw|CrT7gmjkC{c#amoEY^YVnm$ zDtvewZC=WKo`Q3UW}&J~7KXGzI>}{Zpuox4f=yJFoBE+zD9Q{TA$pQ15d|lP+Oh6^ zC;=nGX(vX*V6PV-bDHzn;uRV#Bt25Sly)8YKtzTWt0nwMFPTuk!UePV^=VhH_P&Sr<;MhO~sQ&=0iw(U9?T6PUl@w*1P#dlJ&)}db?h0D{Nd#8e#IJmiHH`Q#QR;`zF9fXmcgLp1iPq?yM{oFmbEMWE5@ROL@tb zv(P3~+0!ThTa`q+vvgAc({+KOGRO(vAC*hGaNzv5RVuRCFig#Fn!HPZBB^^kN3TtZ zW384^;BFd|6@cRx7I*Aj$Vpt!1LR22%$*4^GxQ)tuS!@@cn{pxT)Y*`XyxT)RFLWb zF@qk@JubV43XvWI%8DXT(h;uy4Wo7%0rlZw5LQy%(3L&^F*lyifA=BjJN7LIWR#Ab3N>Gu$0I5=w_h%+03 zn`ZVU^Ewf=0p2?8moRhdG}-*x*Y|cX&R5T;_stF)d=N8KURmfXP6iWd3!{ z2^eO2EEZ`JAw1NWMy)(ifvhF1cDcIxYO#ynk-RRUUh2>}l-K@XQ`9x_S5oE%ur0c8 z(bJxqF^y2juP1Em#cqTJxJXe_j}eouf6t&XN55MOXno+=Iu<9?7N@v*fy^L#`8j>iC14r1|tE zne*%3vaR9oCe-1B;71uSa((5`KTSsM<#>`;cO~MDG7oo@r0!F2pq$wprZ+%i6XDDi zGN0pw+~>lVsFY7SK^Hb@^1VUNoJh};Za>awG)Tq%9>%`rR5NK>yCTXEp^RlY?M(#> zSWF!$9nfG8IXkZo6cW!5=-rToYN{tYLpkzg>{*)p=3z0+eBVtiSo0xa7y5wWD{Ro- zA_;B?6%mYw8+Zo7D!;1R$f`*%oC0!n79qzknW~cX44RY*p3tBUO;KEiYFZJ;W>2_z zp1g@Vt}r0NMbWH`d^h&cpp?jB#Tud|kv3;6@qCl?P^>-q1i#)Arnayu=hAA3HRvQK zhSTg z8UXMB$`8+qsD|mD7;9w}k^_MiV_g6lP2vh~uOr{1R{5X`Y@Pc&?ilBeSqKOpIB-(m zI*4z8bL`qH&+JzuB~k-nv{77o+?w35Y)y9dA5SOUbv25tVUQUm=F_>`v1kf%o#eSO za+574uVCDFvNO>^S6Sh_qLW|d(6Q>2#}#xeMi*k)IiD+;z*IZhJ~VoPh-L}vQMAh# zn$bpvE5#gl%mrU3qoUC4)EE{H0AA06Gl0p!15l9Z#lw)d>*`7yA*y*v4%M-ewL_|K zDzxeih+^L07{DC(nI@uUFav>t?yJ1OEGs)JORoFwUYQ}-4APrNl@AR%>@j*DOgsqt z*$Ky>NImSt8RuW%YFRyG5KI;VI*i8s9ln#vedjR~hg>{lCJtvV*S-&oJB8#GtN=nlV8@q%_NuvgA6uhkL5;n7^0fbjfxN(;~Cl7?#Pf8XUx)?=W4l4Fd7fum8 zr(gDJ0i7lagWAGvM9;TXAmTXyCM5mRhF(T<)hC_{j$R%BIuNdROy*^=+AUEhenwl- z9NIIU#+;OF-fTS|$_&Z6(U>q`HDBRr6>i6W0npke+DZ0f5AUt|{ zelkH^6G1Xla?FRo&j_Z1zbrfUvXht}>F<#KY5<2FnsJUu<#M5e+xei#FfFX%hInqQ z_s7kbkSw*7@?kGX&NSXQ(#0!PgF0FB=;_r>Ambi$tw`;Vl!boWX_L)w zagOg%^h^dG%4AOawV4zAK&<2p9P7}z8cy4fhT=H@QX#At_>g1)8g~b=909;4K=%E@ zc_ZU=a?J~r&0J!IGQBVB4pO)fz%&W+qZ#p-V5=LXIM=*Z#ZDCoFQ#4AKrCoXs8tB_ ze=j<8bBdLC(6eXe)4bnC+u;kH1<~?c@EzcZb?z%H-%3br!mE7z59wP6c$71V5X3~8 zYYC|+wQ&#WY#|*om9}87bTGiXD|3BnNz3b3jj};&u=j-q*twZ&lbO-wkYe0+e-u%Z zGEbIrtQz7*8yh`k?)sucs$X^XfECWNS_D{B$SPmMw?m0nRvOkUybkBcoX_-DayN_` z>w*`t9Gc>yA`B1;J!B(o5#)=Tp_qQv#L`o?thv<(k66kdMTxEZeevy?*_4jgMt8TH zH|g9!tA+{NQOO)$01<`%5}=NPGYQzX6XpJJblSn&q2w!-9FZxRop4F%HP)&EbE+It zeQdG8j-`AlKe&BTZlNmlGaE&^R(aN84PB^mT8i-eKxPmR<*2qpdP($rOB6lK2 zKXHOhNfGF0o2XMvSs6Vj{CLM!uQEpw^o%Sni-wqu9z<+%V|TAPbdzl&$Rn|=6;VQm z*hxy_+Exij>8x(gdsP-Z>9_Fc9S}@FS$~XMudaUcR@++MDm!eh;xQO2iNQ$=dR1#q z@G9Et-2i1hESU(!&EO}~or{aE8w$xp-N%JDE$c8V+S}!vEWN|;+*f>{@68s zTb9$6VEoL#kL&E`6U<-H6&VLEKB;2Qo5eXd`Zyj2yGJnpMrSHKkOXy-1q{WB2`)1r ze7S)QN~;DlWf2Z)gx2pQosj#bFMH&r?c9km440B>=s>RQyUY#-1wB^USYYk4yr7k_ zgp#IRAMp~w(>DQrI@16Vosf$^k!x@6JOI%NN!M(B5Ost?JO!-;NChPfwZqfK}=> zsw5w)Juv9t)1eMM9(g%%dIOdnN_L$p{T)=~pz=gIM$7HklW^-7ZUw&DIX9ri+%mF+ zDQun}NztXg;vHcX1vb2Hey0AGcLS{?BMp3W7g3>F3UxdP&upy&VcM_-fy%3@yrMA} zfvuEi48(lj> zkg3$;dDR&Mx^;isX2mPK1Bc_ns2|(Nk|2}qfDTY_^58bDJv*cWl(H^MF>z^7Wg8J#b|?@#evatlYP#tu(bT%14fG zkkvipv-}dDouBP3Pba%JN4L~C!na@t;#k_e@1)k4!DRI~@j4)vQW!O)Et@tNmJXH5*$%$*g?#yqSassJj&o%# zb}5xf`pK{6Z0aO9`Y=*8fEv7OcTZUME7zSB4D_28?$CL#%JjbaLq~MAe*}L8kzjJt zd(%~eMvwo_Ugw{;{4-+zOqhSx%m1lxQ9-j%3v>WJJ|ir_Hokhmecy+kANuEmf z*593dIm^&n8(T23Z=!aLt34L6^x4}!qfJe#51L)uHQRpzP}%BMoxf7DWRyF zZ-%N|7K`S8Jbh>Miq~rkhs~L~XXcv3C$_4twWTrN1od`A74m^tNGoxcJTv|F%bt^` zOqoi-2ByClcdLi*lilo}U*rR#^TGl6Z}Q*&lmYT~`UUfgKX3ReBfr)O4z zvUTfz{Bv*QPaLDa;-mYIoG=Df@t^xupeQ)4jVI00>G$5+Y60o2>6n+nc#7?x3hC@F zEl-%=tDJ6+>T3Uu!)Kx7U|R+0?Bk3*ju0S}-tw!F&2N$YwVS;94j}zh%h7uL3pNe@uVm4*R~T8u&-1NPFM?Rp5|^8GHWBo6uVC*;nt+ zy@?i>ng0hj<-8@^IKgNUr@K zeuP2}%zJbur(*dc(dnm^Cxnup&lvMN`Qw}qBM3GZRM8nRH(w$$eyt0=50 z$KQ8fx~O{&EizjxTj+{~jt$G-s7P<>;1JLUVG@#N!9Vk8}*xZn%eA~G{SUlxH_M_`x@EY~0i)HbW z@2KvURV$eP;IBFqsF#gq{^Kt69ZDHx37Z{j{^$#W=yR;&0(IGG-C2c`37oZ{(zOR4 zY#LU?T09jsJMa8wA5lT?0y!}v>xeaXRLLtzj6b^HK23FvTRjcwg zhc)&?G;#2{H^He6OP2;E8`o=`uq+yeNQJferyMIn!X!VzaY-l8u1b5R*ZsK!4s~Nz zYA%>Cy$|cOrEa1~_^KoQsL-yUy^kt?aADDlOZ%cKLP_cI)#ufZ#7FK8@r0_-m`I+Fz$h{|Jw#dtL^JX>F$WXXu)WZybY?Z8PI0W@4>;I3wP~n6cpJ|3PMX=h#AP4x1 z0t=}hKy0d`Z&8ggVia8f9zQ}zh)ktL4+g7`bEoR__*%mK@~}X-FCh$D^pyQrqA6FL zwCE!g0|RX!2YR$V2hcPYTntEHpeVN*3`&AGqM{_o9CmH1{bs2L@WQ$GNuJZxn(!GYmTaBMRMYl}iBP6Y1^SPmy28OM!Ulzc=4VM%!&{9GRlw+v#fsm|7qXC98 zp_}>LP=NgrxgDLI&l84+Fzj0(&$b@NEPD_^rS^L3jsXBjHDz>@?xHZJ-Oe%8#_A1C_bjpe`Y`rV)bh(guEZ;A;# z7Ge0LO6%GK$U;CbflVeJ`DXSUTh7=nMFzSz%I?JR*rH2fx5ZmeafL5_36K;3;cw&-4|utez1F+O3?4fJ-NPV zfJa#$iUO*6`MD*PafH!%l%wVXD)5@U5yb{W0y(#rJyH>a*`An>0=SxH;3}VKOad#Y z*D{(*C+_Blx6q#v{nzHXP7KDEv{wE{g|=4p{X@o_ zbtw3B+X#yh6eKt1+hR3)sa80lO2Jcx8WZnb-q}qhXmoMK&ELP@_!ID=e1c6c>Z@;v z?301a*7HEtWo8Ew4aL_n{b9DwYchU5K>G>y_~a9y4k8yuhOF(x$vZ&ozly-xmhqT? zy&G}{4t#c!M)Q_VLhOkZmO}_Ehe*#C8{?~=7*}DBE|r&`$Vr-a0#y&WZicuD#0)~fTcApQnr-yh>n5cbePAnL-p)t zoP&*-=n71~!ckuU*o$dLS0*<>hE$0bVZf0O+Kx(lgssm~n}OU%D5Zo?$Iv_5+BhUb zv6h9B7mCib+_aHN^w3>_dNGIpy(wkPE7e1h_M$szvCu`(u)Zy;T2a{J_IZu5B~~}7 z5^cTo^Zg%Yr{4k=Kw`j1_k>AhS0vwo=$x8tE1 z`oNsT?YbZ(Lw7*|@-LdcYxi!fw9eZ5{h==t-zKiGlTJErP;H>&7ADr}#m_e$=2;&$ zuA($%U-7(Yceg#&*QNG&JYS>ovEkZsNoTQ<`iy^#zPp(3-KY zZ++ZllIai9HJv|vXm$SE0ss1OTx$I%){80~aU37*n$_$vt?SUS0F}HICQkVtctGMR zU6Y!i+6ylWc!fXtI~NLKgx7y}$eE#f#N^86$aC(G=nA_`-(>e`PM2S*ooz_8_9)Rd z&XXD%BnrZLopUwmvsJe@Sm(q4RY$~E8#EY7hr;ulZeI9&nCDHqAaS+}d`r`^Z`6r5 zvb4QPB|#xzWGY>6=qZAlI{&5F%w=yzmgjL7n_c9)d1cbokga{TseRSwd4gzuy!DbN zhKsICtSq)BN$b8Dv!Fp`Lw1$Ej>Nxl|3hc@k(Wx1J_$F^0Hx%Qw44j$?;VJd9-ovC z|9|vgdAdZh(CPA9wQM@kAp>vQZ04^%^_p|5ezqC~!vwgf6X6^R>s@2cKn0Pl)Z>3k z5lwMk22W*26dwJgCC^5dtFwEcw2G%vkb*J3|~G}>C0iwPsJGl{gu-@%k1&l!2Ioe!~v>Q8&kZRW-|xmhpZM@ z-E3S_`erth5ztrI??7;rS2~ZcTESn%J_-ya zy9`Z4?OBkb_%_q&Ry*-)aSMh`^%OjEc-#fBw|zzrfW3|0 zi~*!H#yb#JV&Q~xz0uRHx;1Hs4I*kPS68TX_I`4jUFF8!hlPu*}1rrrCw*|as=$weHLQiyRoK=JF1Tt4WE=9|fre|b)uA0mL zn%uM05jPO()}@>~>|U(lM|a+}YsNq-^wix~-675Sl){w+p#?YUV0E5 zTv9g#binyMQzj9rv|{ zCm`~Cw)P_<8W>47tec~7m1qkq^i&HPm5x0WRKhLxa?%>iwi`t+YkeMhSwLb8XQ0in z595$y{@Qfqtmijmy2F;sK@FOPIs6_e3g>f*I8fb1FVF3q#LB4}1-BW^V6HI@qSgXX zL&A0}_Mb+NzD{H+p$!lAGHh!bglkz1U7}`Up~tC0z5#t>7>oI+&F?v|Hl8LrDr`wZ zVtlZbc_dh7GZ})to>4tTL4q|EY8DFdh1`j)LMcHZb7}IUR2S+XktM^u8(#VvT}6#9 z`pfb=sHed+e~3s>>POIjf8yu@04$=In-Xm( zbK)eiP}dq!JDl3vy)7Zi`pdV#W$HPv0PCr&C>>k;3EfSSN^v6Yws0!b^TYf`%=75x zkX5FdOYb26Om~Ohmau2H*i^APbsqRe%G{-9WTw_$a9qNfjM>{YVuRjL--lBV95}K5!Ua7xTuUN4 zBD*8v-=!}9gyNn#7lUSl&QlCU5gk~`BC8?NMz2G0UN)kC_0a;cE0HLM|mMp3{KVU^?aG)i|pUWhhb={JWNzS4`xTv#S znVpCWp(Gh1h7igi9vrxcOXa~Gk8px`lb}Wx^|B{V#onnHtf<-jmbN2x&N}zLu=}zH z)D)jGGs^WQ?n=zbiWEWta@xTcEcH1(@7(Q#KEkET$n^VfA(Y$QmJrD5dIvk4MVFo@ zK!+I>(;4|J@TZdjaX?GU4K$3+d;?}{3R4L_CSYtMWChUM{sde<8e-oPwPA15uxr&* zSzUVyBpG#6w!n6uHYDb=&#a!(&T2$4iP5^s&I|{`PrrFE0VMrKRoFtu50rmAE|f}8 z8azXf#Re(?g1}0oe4|ywMtHXDRH91@C)DZGeBCF?y-S_hWO(R^O71?NumhM5#m zE1Rgws6xhqsB|gYV&?_q1TZ?0V0Yq)6M0s#f`5au5GZ{P4li@7s;tuD8@Etc`NB#a zvOTwPM+iLnw6FutKMX4uRRt7FaY|!|gD>IPQ_`ou|9(*gvUW+?;_p(V8U6=U3oXRuKlp;!h!Qgf0aZywwD>J2^~| zhKwk(s09NP4fyPOalV0E@CI7FhMjWPWr zW6%l0(^DC>F%6VRNsS9aQ zhYq)1By}VeXL;Vi)TOOXL0wILVsfD>XmXT#w@s^$Y|}dR*O!*L9TJaB6=9>tj-9BQ z3p#`eSTW%C12ka7?t|8-?i!>#fK7qiD9K7VMRtP8x43y>&xz-S;C8UlZrW>8JnDc9 zBvy0K7*Gct^;-DC6Hv&<;lFk1Il%fvF%XGxTOu{2t4(W2r8ZcZYqysRs>cE;23R>b zq?nX@EPCK0XNy;Op24JEunZ-hrUJ-BqHXs%9CYC5M^+mx9R1Gt@E%xi<$8D)v%``8 z4~fN}PO{-*ovd)J1YXFL9$*WJ&<&hLsy`D18PKqD1uh6P@+~exNnS@NGg7{ZY>k05 z88#xz-1sxuvJ z?J**aDTQ37wmoiqwt<@UU1uCFyzuz}pwk2WU7lXrn8JJ~6g#cXWf5d+q4nu(9H z?WVk(O9umV$qJ52YoT8LE9@3Kov#Jpp%vzw9kY+~pf|9EDSay9A4{zAI-rIOwg56^ z5FGlIutfURiRM$7G5Sj?5tzff13ehEiKIpE+gm;na5Dl{S^H+caDEcV+YekTh>wLy zOj9VlC#G>1pmXh7jA-@Jy*7F@%Z=`qOuA`IMI0T8WJiCTlV4e`T%m*jm0WscAd# zP?272X9{&ZxNYc3AA`Poli(}WC#`b;fVG(tfR?bVGE-vJADTwk=Aj(sm%oIz@COh( zjL+;WTMG3J6?^D+H{rQ@UFry!BG_6X5%xMxR$B?S=%AalUg3_oDrO88Yw;1Y9brlu z$l_!=emn%-6$V;!)Wt{QhKUi-(f&tTIMU~E<|@Wq8`|4|8n6S*htvv@Bh;%o(n<22 z5o2AHV=~%#-uKPvBVQ3MG)-13jIr2#9-dpc8nC}HFu7>qPUpBC@P*hv0w72eso#=d z>`Cc#5UtzT^LYTsLi{NS!{fLVwf8cTd6%kAoF8af8U?jyqNLo z+`5l1=|btG9Z$_2Dy)AWJ300Eu1~k=L$H_Xk>z{xB4BEcL$HE6Y6ZY3U_e5*GvzgB z(yW1fdNTI%XrL)K5%}rw3$iUZ>u)ZtG2<%jfwD@m#y*2=7j76SQ&Fn+EPC+x@$`IC zmyLR7419iqeEP|P^_e#hi67aTfKcVZg9knDh2-2iJ;e|>SKg-h=A!tw`=^IYfJZ7Z zI|%JIEJage7F4M~bb$SwwuR!^TdPJ}+z~f4YtDzBN5ngJJDP+LrD~IjEw{&J@~;1O zEE&{?+(2%K(HuJWU2wF0|Lq?h&fj__qEl{JmdmjbBk#js;me98 zC5OJS4pf6ge+>2y0m#)w0H}qoO!7qwo%sMMfR(Wm8oo_{rk1n0+*tvU(|n4`v}48A zpX~qb;>BGzDjxhRE5^CAJ+OwiU+)BrdZ6--v>1Iu@P|u5Qs(ujU2)KcrSJSaavLoDOJx~}u=MA9^7`R;GWam^zx5UG zcx6g&>mOMkX`ac{nzCIk{4ZTv$H!Xfj{bSuADFfOw?`7s+|cfKxb=7@9`QzA#IxP< z>7|(1_L*glr@4Rr>7UW~XKwtn9R3Loe|0=m5b2kkSm0AXh{05$6ymp^+yn9A|Enp&Q7XmI7N7d}*#WiphGqt79O6GZ8taR3Z6;M2UPm8Pwh%lqP; z`)e`+e`6d7=>NqcGyqTnvHuTM2yTyQC6{U&Z>&F)ab;N#s1g@I>AG|7g8}~P%zxn5 zK`Nvq%KQf_-cY#0-0Hokdhh*$H-3}I5aZ{;isL?&Y!_wihxx!nhs zaeDZW(2eT+`b>W)Xb)afKZwpnqhajK%(STK=35#3E$i7wMQaoo!vtq>hvL4R2GPHufzR5 zxd#Y&OCwLjdFujS^e+R`H@M!Gu%d|QxxRw5;J z2su%*owF8!&?8!k)J?)(&Jl9%%MMvrCzma=s(3d&*vfnE@9G;+x}V z4^)EF2J9^N5H~7^D0!J2xZ0RX z&?s<^x;VI}6b3goRidu^0Pl3{0p3vrW!S9@^6iaqJbQ%T!8-K1LI9G`5^ti{gMO>+ z`B|ncv)QW9=B4kFxQUc$K)X>Fl@^zP{teK7BV}wcz|{zkzoVjqfF8n->cv@qxnK11)=Un;$?Q zEkhB%f<#no#lz=CIU?$-qUu>uv(;Zy$JhP5;##ZqE=_2vGEyg?D(BQ<8I_5F6pGWjwBS&NGa63`8Jwcxm#YsenIJAt*7}KSO6g$mLK*O~J+@ zdImgjd>#smMJX0DX&vax=UQ$5IH@YzHhyd1)hg4H2|c9v`SnZ>oSBAtUYiq zWs>I)dyhF*?ZZDhG&W?sUy9RA--6X1cZS*}S}*b(>;_Iz+=XsOWS*Z^pf3s5LPFm} z;B8a6jVRg%)j*bg!(Gy#zj7@#jBUN z)FqeOrn%+^r*x)toRfzA7NApXo0z8|xCZ}TD^h#Aro5b&Xy;4o>A+tXI8m}M^ST0a zPjBhL*VA8lCN(F%BUKl=NEf|4-+6Bv?OFt8)5{(o#Q5n8;DSbv~K>u*R$0)1s*>8If`*e zG`%+@II$-Trw*aMGOJ#?sOs+1Juua@f7I|L5Au7D52&C=pBo&JOq?0owb(^?W9I_D28&65J>HP!^4%`*<*+6)O)C^&}&0v9aaZb%{v#53_ zX6lbHF|%9?a0=oS;|(uPrba{-%!9Qfyx{W&*2n8)m8lo1mm5hW7tb22NzTOjXRdRU zuO6GiT+uM}EV4Y$^&)yqyQjYQYVU~g$2Uf>!u3Nwg$JaPVYs%%FRs+R3rZwm116LYEc_>YQ(Ya7V@VY{VOP&5T+;>Dy>!`O0`9f zf+WI|JKo8&H^t1FJ*1ap0w*iLUZZwlo!o=i{%)vw$t$H$D z&wKaINbu2>;*gmQG`hA-!#O(cRQQdu8Rnp*Uoa3Y};zthxd-a0n<=z7*u%c+{X84yKdx-VIiF>e7AF>!_{XC@r zCf{J^h1a?+4^6SYZ+T!0^`Q#JOtOb-4M|OzM6XhscRf(#jqKMb@B;g_2<2ro5T17w zfTBustAuw`CbiE#N%@pin0c1-fr9cVJIyXJq0}c^jN73`ZZ2%fo3l1mbsA7&pZhQ* z_HFIcsL;$q-VJMm)(%VcA-nX6C<)AhXTt}5e}6ES;Wy&CvD|V-GyfwC4x`9tulgMvhrfj|oL`CROpO0B2^^-xmVznQAb%?>82kz+Pw{Zddr0e&N| zb90D0eXm26jgm|m?$pTbYjKn=qO5_|@*0rrI1^|s?#$MVFq+8=?+Du)H(iCqe>4)| zFW_ONVx5vyxE~!Mqe5W3sbe^v1p3KZskWv*AU9iOXrV$UPK_X!ckh4X)P`hSHsxe! zVjxq5DJFu34>bi1N6~}jRsr0OrVtOfA4TN~f^_%rL_ASAKo{}Hk{FQ5S&9lO_8=3Z z%ubTdM}|HWuv?5Z>}N|4E7S{Cg1jeSu;C+*5V5Z5O-^g&pjh+;!#ZQu+>h_P=5`@5 z=}9UdxVop?W)5|@_JLJ7@R9sayug%dT}K@c;f(jlolKn*n|?}#|wPt1*gR_0W}1)?%!J<;m(3$`g=T@VW|10&vT4jB0h?YVv5`( zf3oTjsS^an<-44iK98g`GOoCub$7|=Mc13rizMdfI-rXUIi3c`FLHx&7>W&2n43Uy z4EkY8DYM@Wju%8WRF6wan;;XaI^^G!5vEAtq{IGtV~iGhlCdRpG?wO%l;;s%pEQs; zZ+`(bfe{#nWXls7V!Um({HmM%+)MVhy%&k(#xTGMf_hjN0i-B>h$0${_Z5;~0~mPc>{lk&0R3sV@L-6NC|QOp-gGO?zLpn4sp zb2OKkaK*e#sp85z=};d_VH76kaU00@2zij~KJblP0Dy9~X-BN4V6&79(u?VnvUOeO z-bBjE8tSDShww29fkglMt_3~bGw#S%NB{K(l(=={Sy-u`Lkm$gmkcZ+&f&9Tl}=Sq zsR@c*%9eOGNpZr;!R=BrA=#0q5mNqZIb9!hAe$5IfcUVka=P!6#=!mOsD}+=a~4+^pJ0QwfsTcS;02* z$N>POIO^!C9D!3d=uEsXE4$(P7u({_ggLzIx?4KKl<@b0*(V5nqR+~0RI|yc+2mjK zI>I{uoQlyKg;Lkhn8V+o1vZjbi`5JVRpP&WzIwGDMH>zC(hQSw+KA}e7QonBqS2Dh zp_}GBDKYx<3L)lrjJKEE67z9G4sm3XS^Su=ZkkLKdMZoo1iVR23R*czr&NzC%Da10 z`xQnHvL)tkW1P>M9_1&9ML~NUdF@#`k)`nDjA*K4W+6kDd}+@MwohP$ylt z*Uw#3R_9zF20Dr8yW|ovH^qxz-)p%FCHDuN<}EJ!ni7ris5K*Ie{u2d8k^-Bmm%3E z>YVFZHCi~ar>`ftJAoJv;}C_V3#L8x0rq*6CVxpK9(}QbC0|o5&&~ZXHw%sC+(wNV z;$DN6QaA873!%fEs{I2ePu>=rzmH!{tfqWlN{CnwsM352^Am=uQ|@bGyNFch?e0G1 z-PKh*vr{=HnJ$`$H@{1UpE-1_hR}We&H! z8d6ae3MuMM#lSLia0wHCZ7%e*JWRpR2?>d_?@z%kgTvXrf}wSdPvetAg!PMb!suu2 zbECXFQvrp;=X4QN2q?-%H#z6}2_Eq7Yx~wc2Rlb*rm((&-r%CDvd1slqwH7LXp) z3j_QMcNSS1QmM5NadW2b`EpG{n6LG}ryj#!e=72#&{=sD_)?)yG|>}V=Xz@hnOr@O z$YvMXjCws?@|{mP$l`(X)}FD2;l@V`%IsY81~AUH@wlo{-EY{U>KUN!M}&`wXtKRpQM=$;Y4YYxaea{;ef^x-0-O9Wo)AongqiOT(j$|3O2@T-${{Fgw6B6FjE0_FQFKp$yPN}2jZycOL+Arwsvh0lZu(HY%N6Xs zhDquZse!#SZY~G2N!A&Z8l12e=J$|sg+Hgdih#F9^>+SIJBBPz{0UE3kJt(XZCCV6iPycR2$mcKe z(qqggS?Gj*R+I+#2VsvqU_@&pj*%b+!^33yH(<_#ryqB!E|y!6ygWV4iZzc|M+}o3 zT#GK_8Q;8nw|mdTtjSeiWSj*&{=9y$foc9$8AsxM@VjtFH;8p@Z)OI#@hrQFB0DiZ z>$<3G(LH=Ntd*Z=Tl5gN#Q|&)F&Lq)f?9U}m`)_OR&VHLtqOsF2&B$HNjZ(yOB6HO zS_y(D8I0?}9ql7&h!sV&{E`Mqc!ovPQ9wBlG?{W%yeXuNojWl`2I<6^5g5hD`Vi6N zMq`x>STSG82|Lmr*;&W3*VBg3r#NF4@lk6^ii zlUouRws4tM!BHD)hM^9ewFy{Bg@3JcElpEHw4g`*zJkUw0q-vEh|-iLB8X@Mr9+O(`8q=J{JQZYL_wH$$lnG3w424KXU)#f%K}U} z49*u=sUboLCC;#ZIsEtC#uBPH8&Np(%zZwZjgph+dmBbjA)6u~W*z5Uv3NfU^X8H! z7CzSAZXhK+J0|zmfdLK$Zkjk}M{o|~c2Nh54m2Yh$-E$uNlZGARKE<^`AMkccb78U zi9>1XRtY#S4Ddovcx)8RCY&crh^ff-<(s49hX6LfpQH^GrS{|0obN%0NQz+07GX3Q zI_+xgh>Ql%Y!ZdnT@Gw6syqlh+3A3=0eP80&}T*rcLGo*N(fgs5PdH14P5wS%R$$< z$HHUNt}C&83OV(kr9L7D)^i4Z;-5~Q`$ zKULi;MWKlKIjboPO5cy+*PP|jJ3ws0QAjc*@P~Xb-8Ax92PeO-qu1Zb3q_$bu{`{p zSpopv9L>vL1jQ|XfdS43W-dH?K_198D-c@@S+J=>;)GUHu;v>JMnp33fzd?#m_ykp zIw>qGETfW48CWnD*T7snyEM%GZBLxp+sS}9}M;Y z(Sa9bZzQw@Oe2!WtPNI9v+SApHd+tno=xg-@NC~vn4=#>(-%c=>u2iaIJ~ve*yrq? z663WEuc)Oq=dh)-qp#O77XT&5?Yu$#wb;4Ar`?7r@}Jd%6CmN$MwKtLgHzGyDdTs* zs|_)kyjkzeB+lIykqv7#3pBd+twcRuk-T3`X)Y?L-GkeToMB*rRfch4lTNf< z{z6kz?Fgr={58#QXA|9!*VDAhU;8JV-j-zp6p(=OzSmi8(Dn@kbz!mZB0XocT#eWa zPeZ!<8)oPvZhjigNXgygf^W&$UPqHdG8f4F8U5Z?B%>hu+PB*G= zh%)?4GYl;XIhnf9m>aVFv-%Pu>D&lQMLO^DDu8I(ZMd!*ujCHNn4`yz<|gdaB#jx9 zwD{X`K(2TZLh0g*qd*w?B|d&UY0#0r$hFAi-Gtg^v<$)=GGySNPv5+$C28g8VzR?L zk{Wlf&783c{hU`{-XmGrlt)23WXNfd|MNwz**i<=C>rT-@-!V++8by2(rinfz7BGr~X4uXI>*R!;?b60|Y`4uq?m_ z)SX}6&Zr;DPhBg3Pyl-nSeRrW3i_Y?Wq$R&rV0|f=$wx(=lTyNaydVfuDsLRb|KOS zxNS+}pk!ro-wi7OB?I21fAE*)0Vmb|(J$3kJGku@}PFQ!TI(*B%eLdY%v6AxEzr=(WWmP|J-`@K-;KjM;qKsUX%lsoQhlVP-L&Wl~ zasT_;SG{JH9ACuEEX?1It4f2fmt%x*+iioN~f(egj03kQ=07BsjncjwvV`ZH$d(2NB_A zBM~Y3*ucsC_apzV7__1ND=`0_mw(m8-`19F3e4x_XQFt0hv(cJ<+pH8wr6IKB82>+ z!j7e?IGu%x<-t&vr$3DU1Ag`EnRwAyWdQ#N%=77&$g?@A?L`t*CREdxl=|FOZaMYm z^eez62pvV(weJrkC*_89!-@6^2>_ZC(ydwXk|XCh>VQQzA;V?LBC~p|IxwRaPqd)C zEJHUM{$}3^`QhS{#zou*+h{X9u~g%{<2xmGxsB1L&6}ZWpC)2pap&oDz^VlWZwio^ zs}|>Vp5JC4;Xh4vK!-dzxWHYve>w+>nyH{c2LnoBHwuE<|1nO?M4oPMLH3Ig9O;_B z+ORPwrU8ozl3)5;$<8WCh^sB)*ipt{{-O1;bKxT#m^2@kIi6Qz_XexbRGnz~1ftCg zzJo()p*jGzoj+QPmE~ogkR(pG>3)#U>`QISbNDFVRk^GXr-pP{*rQ5glCWfVdX3_5mHNh0E!;vQ{YI3Wu_cPdj)|FK;jOgJDiF#!bu2SStZ zs!=ey3>MO)J$(W}``2X;&Dp7<-mFNQMIVGlxjy1Jh@b5EOP$w{f`8GyuaKM^$sJB? zqnPalvYoovAlSmI9||=`*c>DWNgik=67{q|j?`JFJ0rpECFcP0P7(zb!XmtC>T;;U z^X3#^g+!bWjF~=d#t!H}ngR4_-I|1#W??N@Owe zdqZzG@-{&2PsV9~9<&j2G^J|;Vs+cs!}2rZzX{5IF$CB75{@?T_2^*xyQpe8j}iy< z`3GJ%D&v%I!9-~s;E5IBlH~>_somNw)hV%I6$;Tq;bu#zZxd`t`PL$ zW3325h$oTb4m#ewR{YqflGoV?9n3@nBsWlcbAjV^L5Uo8Mj}fU^mT1~lEzz16m^9W zvzvfZA(F%5P! zf?D79eT*F|lgg(^s9fJ|$VVqSsFXb5Fm$YzCvOy%!Z^`4bZDBhwc_Vwy>vUw@aw3A z*FKckRNx~WOt1hXQsRi&kHjj%{CPBw#0#{QP;!r@x=?nn1r_&@VlEJg?#+*wU5Rh= z$6_63tO=mGP{jko`@W2N6+2Na38B9@6kyzbf<}~Mq<<7P7_{!%#^Y2pI=;KkBUyr= zl*m303+RWC*GblvNE$fdJAEY#-9%!-x}Xmgn;-?VH?cydev_I41ZKpM|gK zBivpVtJ5Slb~J7*w7JUEQ#VMrs8QkIEGmPuh=Ym+C59k&fjEnn=&X?bIRp;dkH3C%j|m@7vrV;C6V0ozE(v7F9$IQHwjUhKv+5pC zgGDr1=sT82a>51G3Pc?nQani`USOJlt=Rw^!NtV%Z_&i>?=QipNo-11cT3M`*APO& zz8asQpN%3#q%|bHCq|4{S%tV}qx?FYS!AieA@(G2D}BaBF#98^MSJ&&ry9>vwkx@7 z?cJV=`2(#SnJRW{^6BqF0Fb383Aw)o@yTmmk~Kjwhn3-H;!9}huC=-_AzDJ=b0WIO z<`64MB+Uo`a-~z-&h;Y@ai-Oj4s~Zht53{T$9{R?CJTIjFjbGyox?r?Jk0EfZc{GW zBnjIuet-GdEUzGmp+=HqGpaFZSCOB-g#{cd4}{8E5c3N693!804PueJUPwNKoVx9( zJVeF$V$Dt!Q&Z&!Bj>Zko||BdEmWI)n>ZeABAR}zO75`lG2PvF3I6FCGPDX2PRY8|Sj{!0?oAzLu^A$1nipYP_$dv65#fL1@iSn|<`_D3|TEiGk67PQv8LlkUHArMGAd|l(#g&`TRMvFv*n}zhr0tv|LL<7`;1faSTE&@`Ep(YaP z09*tDOtVRo`W1Y%)5`~|1+w_>Fi@&Hm^yrItjlguI!v$&KXY?d_0AG!y?UM$ilyz!zQ z5TZpilDEH|L9Ub|+5-d~C`E8lB;`AdifqG*Yujc<(U_x~EL~@KVLusBCc=nGSn4j_|@16QfDP(A2Gry`34osT7T=c_aK-LFQp4!xgDQiM2)t4l0s=O&aoY5iQ3-9$y#;V06(nkKeojc#FXK z=cx(^sGO?3$z>QHL+F$S+Z*b|$3f_Kk%9(%K8%mZ8$so95pTHI$D+1tu@vOtt_(BH-J`hi zE2F#PdX8*3yyJ(5LtT2n*+@hrPO3WRE$J4)5Wss{VrxQ)!7SROdo{@CPdKCIc3eXe zC04uShMnJhc3Ea0_m>n386!&ZoT$xDG`SBCK@J!r4Y;ZKk`AsdrCW2OAe|H^p*9LI zo)xH#%~Fv;1oSSO@3L-gjkx%ndhVjEng%Cqtt)rOP{(Rjej1MNf?*saur=rrqupHt zSm|!SxDfVF7id4kn6c4RRQcFRFp@0dd_&$*L22S=Lm=9@qm&TP;sjO>`c$}q9;Z&~ z?%~mBZwl*F7M5^>gP%Np6i7O|Qh-TVQ(VS_m++&|OA+iUa(xOG3nNvZv#i&4xOzxF zEpi44M3w!ng5I7M?*m&ENzs)MZ&U0$6}6~=31JKw(zXCXJ4ikW{T88pB(e#D{UZsY zXVC^Gr|KY3xkYjlk-S3qXQ#wE5QCve$PjPEkPy16PYa2b*p!cXKl>R? znMJ65tG85KF6UvxZY|) zlmJnpa`Y*zValQ~c}C3g33otV=vm?iTR?!tlA9LS=M1>_Fa4D%LvjxmoT z#Q6wxD3Bn`Q$utYZJ`Ik`o=D{+6ccPRU+{hiCF|JI|Si`NcP7B;E4!oWYnQD=I6Nu zhMMpch4V|p+>@#KEeX1c#*rYCvGkZ<0eo)4+LI>rtm%`h}|MNl^9`t zJTnZlZ$$?$k2)J^RQUo*{Yd(i$S(@-T)4_=02fbIL#w19kVvS%z>@~|OaugD45I^t zjt{Oku4?_tk8pjP-4K$Ev(-NzC1{aUis7$`NshKv>qWa99Ructc>H9Lu|%P%7H%aX zJSc8LQ~`22WXyT5X>2sYf~P+vY#}Wg6WiUH#G1Z^by%z(^Rk6<2L8W@{Gj>?pq!g+ z^Vf?oN5OeO>=`?g1#?+(@&@9*cnk;x8b%&shAY?Hm9}u*>V%)>z`sIs&3lU4fe6h+ zISvV}E&k7ZD{-7cixxSoT%+NgxNO397H55kgz=V9tb|HXc*`=9VfZG9$46mqdTof? z83;G^)*IgsPJW)lz5p)_L3pYIwWT1?Wb$?i30{hfJ|Cl%D6`F`i1UKZ(ZWJJ=-uJ_ zXpuJNP+NuP3-twN*zx$m?}Ob111d33IHEp#c;!sysw?_st#Zz4D}tWyuMc}Wqgs^d zXOx*4v9vxq*2^N=NDLbVErEkfu*@TojOd0aA?=|6kzWdOzWJODsXkWmsuNFXyh5<^ z@?q|ohb{XGfDc-@Tbc_>Z#@xRCj90_t&-!dt?F?DV^{X!1Nu_up`DX=(8j05B_5{okT%| z)(cl9&ig~lO+|nsL@wyo z2pzDdy~Xae%_0a6(P{flSPhDz$OtK9aY>0b+#!vnRX->Mx0?4MJ+eW|gkdFOQvwMg z*rBj2>?2qPCq_Id*T5Wg4nN}P41y9>i{wQ&F;u58-&!e=H7W51qbFOgo|nBo6BE`1 z=jnKKgfJxHYgb15K|Lbin#mKbgv6ItIaYAL9=6ep~mqhVB&L_q{HB~eoV*UH^f z)iNI(lpgMLV7&#`AB(jq82?Ryi9ix1Wn3dYF}aWwoLUT70|mP{Y#o5AhhJlHDr0`~=7kwAYVZC)whSXg#cRTbQ$hzbq(hltL{&zHw)m$E1A?H=sxX zi$Jjq2F@rL+ayqAvM3NAW2rn4UaU+B+5_DI(=$oOA z6eJ9QOj(d>`vA&mm!Rh8N|;hpa9?{swp;AJ!lY!u5k^P^{73Y#04hv}D(V4zv(1T^ z4i?*~MUVbbh^9tDmgXQ_f|+Reev(#)NhF|n+DT7H%mSn`2FR`pCKH<3|UWf-+hpJk-#{zj^anJ4$PlEj@a$d#cJoAL!tRdrn__%V*?; zlA~b}J&S7d?XFImoO$VLT0qo>QQyx@8DM7BHxZi67H-^he(oaw9=E7dmm0~6D$6L6IDG3h48&EY<@XHxWm1qJ1$&*wt$J&op<1i zf`(ZpNUz5%m#1VRIbUxUi&;Q+>o?2HM4rTY{xYja;-J<-prS8CqTykI!vYMg$Mtnz zkY$s;8Ctt5wYN?Ia+mi?+5tTY=<8eE^>b}}Y4C!~*{QCN7DHqBIrs!LKracN>UIuW!{(!eD=pASQen{~Pa86&{ zvzCW*`j@`v;~%?a?O+7@;v@I-@$ZrP7~+rBf{0Ec)Xl&nHP|@90@qb6sh~p_qG%>!V zV82`)g*6a|?JRf* zrYJvp*G7d|1grU-cf;VtA7Hhj!_Cnm!TZr7?W`Jtw;R2`Kg!C7pH958(>w+@{eSgi zKDl>Xhqqs`PxZ`k(RO1%m3-=zU!NayDi=1ii-!_KtC0|`mQ=+3xQ08d`n^5r|0)fz zkRWlWEkE@mewi}q5ukmop+Wxs1tn9x1*FC2Cf~uZYwO|~bXwsxpkdnf=*3bvZy)?= z!Qz@-5TKk0y?|ku^>%eQjTvWOVZ5e&?EnUP|InXa)E=RT!2`A(ejKP^zwBoy}LFUtSlSnJS&8a0sP zp8wd=)qaO+cBzA@`LwHc?Ldt0*~xbRG5KfJ zaQi2bW%4qGHBO9U!*3>>9(iFCjz#DWeQP53Pb^iy76JFLZYK8F>VTd`e!vG+&y!Bf z-imrs9;!(fpV?KY63 z!HiaM3*HT>MF9L>MTr^ZiCnn-2T*o99#YOwKY;bZM7$V9So0(Q$7>9+y;AR*D+b)}5 z9%-{3=sO5jfenx)-AZIh355`JoX`DJxPBR>7q*-(jq{T+g$F>Har8hhd$ zH&%lh3+6KAe%x*=tm_k3YKguC6+$G>TbLmR)jv--dr)Tg+VE~B_<0$S|8)XVGJIUP z1uSUAf~|V`E~y7Qk#gAKe(A{(4%Mfi-s0J3YwQxWw@XsX7$}|0@|Nt%#3iCYl{itt zASk+qCXHa|4i(V)bhi|Pmo7THjLk&G9~8H!P#C)N(1*fWX+%+w3K25V0U$nn{k=-) zFbNBJr5=CSPMUZ-c?*Tx$*%yuBi-^5C5)1lb?gXG7*O2hiDX8|--l(QSW=Woxh$O= zj+G^9`sGrUzAR=auAiuYT_pK=lywq?Je7~e|3NnL4+XA0q5$LOSrJLBbek!BwU^j( zQV60LD68x*uHlHLO0d?pCrK6gzg=(V6h(=m%K6}o^>rs252ItcdpN^T*ApaIsOt%{ z%{7-^TA!Saf_@9HPc7jGi=CSh@1dsE5cm3NQFpfQ5U=qc5kFiJ+I&ktAlkU$Vi~8Pl6wZWe2dago}=kHkRlS#wyA* zY_5JuqY6r%gbvP!W^9V6P#tF2cId6fDWAyJf%RwndebB5CPS+Op^I5o%^)PfA17R} zKPC368VE^5lH#JsRBfqLY#)%D7V(gy3-6+X2`MsaKkrlI>{1?L7qkNvJ-$nyZS1Gi zHkW*VsE_K{1dS^VAZ|NwVrME@oz2wT6+NeCAx|(bB8FJ|G>2>E(R3J#^PW zY6<2Yv>@)#;s0oyU}%pdfhun)a;RVr4QY4AmvuaGp+i4d=u60M2Qij1y|5Ja zkRVDFpUx@Jd@3&edn)vyf%ZV^;B2n4*w_2Q!pWL&H{j)H?|v|N-?xtu(4iE@2sI{x zjl3Lsd!X25`B1$gRv^JByE+W^^2yXC?YoPhGI}l$?_eVfcK^Y#B*+Jaoba|3ryRY) z$K9w`)yfYPY1$jBOHT-S462`Wm+2pgKPA*dEoJS|X+ZTJ(i=tlmMM z(e{PmTZk@XA@x3n3E-G7A;O3}%JT>}#(J=uq9v5xpqIXdCH8y5{D4?g#EZ-KY&n!@ z5ta*ki0lAj!@R4TZGh=pyi-LHk4~tV1fcrKNBZdr>PLd8aGjtGUeMdH5qcYlGWhy^ z(p^|b9T?|*632Rz?%r7a`FQ<>&G3@FKsup&+Jq`f`%w9$z0O{?>WNe$g-`HQW#5>{wW@gIvV z;iyd@`Uk|v_vJ?jfp8`Q!P;-K898d9lC-D0030E3S@p|Of!##y$>LEBu3uPaseoXC zX#R@eu<5inDp*er_H#I)V`<6VVoEAcP*F@MBS;X<^Jf)lDH(HVeuOH$|fXD_(^vK8vTPP4Q z=rL)0zK%cN_abFL^7K={?+($E#X8SdeCw>`(1=#pUt-@Hr=7 zRA|tWc8+n;6O8FZKnUa;Y#;OIkA6POE5QjLh7FfPVStVZAON##a_m)~lNu|>yj3+L zDR4>`PLs9$`4!QV->aLgd$H94qTW44;m96|&MipeZ2B={3(2K8c$G@tt_AS#DAJ?; z;*NF%X~ltrbdOv3YM!>3k#@VdmDt3rd+-s^t>s4-G6yW29T;mDRTfSS^|@$2W(MzTM=d+Vm%FY3XhH{a=V+7QhRn(@Ph$(?PR!H(62a^P(PztSWN1Nz)

jGwW6sr$piJ|iKMs{C(nbrM_6!U+1)f1Qr664?3wf&GlnLR@ zP85om`*R0!YY1F-TJlHOp+r2t0wR^)SAnt24UkH zNY(l;s}Flg(upL}TW97ElCr?{g@q^BG{M>Y860ha=Zg*S0_Gl|q3U)tLmMZ8a+d%S zPjIwLfFQOMR5h59Ln;M9Rt}v%p;H9!W!dS9oLTAl8_}ylJ4LyL(~K?rUVGp`u({sO z%6^c#`QQZ{uysK=GYvCK1XniR7t&|(DJEF$$vhbhVe_p8WAW#sPm#vqG`0h0Dsfgg zx{$7t9DG68ZX~kUjKqJqSBOsfqU+R+VjIAozZS238<1XgR5IK^37ajz|FCCNs8(hR zF3HV?bUJ@}l~A9H9RtbJoktZLcH}1(<0}TH?arVWy7~ai61-4U2^>1Y#Xt&KoF{;l z65`=DI_)s)apLiD2IgND-R5zG^O5XIOL}51irCA zxUF+r~J2>5aMW_3x5A#K^Jw~WbkV;cP28|~OIq}*n zO)ckZ!-l&{DAsb zX7|tfrTK&5p!k1L30)2)rxOQ?XDq=HgPairZJdCpir<+JQI!UNqC=*xfDxmULM1%E zqPc6;ytsX(Qzcm_`8i%Xl}rZCfEeS1_WxIm^?ugl<*b_D?G|DhZ40lbJM;XFqaPa zcqEC*gqBrAdqRYPz>+PRtVJNOFnmW?jrb3Ig#;fc(5Rq=Y)^PmJ|zbfEgo?p5j$G1 z{z#%J80bJu*AUPnPq@9ebwwek8~e|Y6Ys(eF$gWOcM~K>{b8M>z#9f&Jwxcg2?{G; zbel^wU4rD1L&+Ai1a+2N`Y<~swCL2e0QDh-AkvLux=PH|6-y_^3xGMAjf~(-JHco$ zS~OM`6D3wtbw1JTS|P?wi0SMgqJpz)|3QLXhZj5z>p6%ly2$4R!<}j>j!MpTvKQjH z8eD2Or)t4&_zIl}4y60hEBJO5ooi3p?RD^Cg071T_i?i?C|A zqY7eB-(FlYkb3XloD%rA{h#xA!^Bc~6DQcfnLO4m`o=@r^$Ya`yO(eTnFm(q@+B9` zzNP?IKOq~)=q$|F^M2-kqjR)q>fMIt{~R`_Y}gVaTj93t=Lm~sBlpz$&ttw0?uXFe z#=pnxB+0u&EdaJ6=2KuTLT;0U)}&zjKQ|ha>gRmBW$K-t?FnfxQEhWc08Wbz4e&Ax zoFrhr&lF6&=o&;aUTxU~{g#Re zZuzciLxNcL@9z2cfUs0FGEPk;Pod#Vo%fzx)*`jSM$TmT9Vt7r#+Z-BU%z&ycqE%A z7(IgDNfr#-gPRu-zizA}pK?rVNp;-32-YEJiMOo>{yM#yGgiT_l{*xE2Wu2nd1~T7 z{Poy<>YS2^bk$P$9j21#7(|XGe!bP=KR>>VWvw4M7B};nar7Mgbp(T};1T0OIRU@p z2E2cK<`?`msnBXP-fhFlb=V_O7^jXOwq^U5sB|KKbT^9`!Z4FPkd; zb51cCf$wCPDSlhH2h0Of?Q>rFP_|UeN-rc?G##s2G@`Bc`@*)%8XmLh29H4izOa>G zQ^fRO8RZk!%=cNkC+Ve`?CzGDm=$M-+B|z+MtX6*$}%%DhkdwhZR(hL-yF*AmuA@{ zFWB~c31?GsmXyF@CEN$eLF~W*3&#?Iwx>w942lge+PR7g>3DY_m$0#w1TPj;PW5n41_85te`MfI%J0YfsKL*to(Yp4 z3ZxFCEE#`omR~jSP%`{08ceefr_?O`8*{1t<|;jcMHE9dr9 zfS+Y^t%l-@D1;z%CHQOGvFpFgDuJKzZ~KoCNa9hHMEKQ$w`}mh`S8AOme$nW7G^J3<1MgCK@Gx2QxFj-7K zqO0$_^?c)kZ;I0gtK_(<%`Sm|f0@+$)&bmz5Fx1|X>nWe*ty?27BAnX!dJH7rHN z(B@NCCX*`ttW>XN_f=IIH<>q}$BUW8W0Dph1VIjXPyCg5AADl*y&XH}hl8H)>%PwP zmA(pXV}Dr{T7R#nw_lK$lB8cC%yY`;&WPPv+4ZAZFu z;OpcU#5Yh2R$t3*A!|*k977HIEnAanxG;Q>;grSUt#(5L^*&>DnX9`g_QqR~jkXtnFR;zT!fX63y9A(q{(xx`y1@^GE1sO)=SJ z-Im*HszIaS%ovKN)864x?-+lcQj$Tw*f{|8V^ygFM!&?s5BuOI82G4iqK`{ay8EwW zHN)Lo-TO?X=>?22ya)e}R`NF(Y-+hz_gI3_X72FbXZLXquR4)vW3}8^;~Rxw*SX5` zirVDD(kV%(lq`4~29K%FGkbBW-_ObLo?`>=avx|k(I??*<`}I>>Epr}y#I>qs0Mh? z#-2ARx1NF;1qUaoS$_3oS^T=tS@ZEdLl0G{-k1VX2RpR;i?}3X)hN{asZCEKyc-=+ z1{n;{+AmORj}vO`p0mPF1pKzzm!5Xz=8N76+Ee6LRQ(?6J*hI@&wF{5t9tmTh784s z%h{}@>oQ7b*p*r92VK5qm|nPa%_LGH%gyjX4q4;0Bb}yBn!fP?$#{t?9L&O}sZ6s% zhv(ZF4B=;Ihc~S@oEHus{UKZ_KrY~g->X^SZSDRUWR z#E}A1lTBAtoxZEwCJd6)&C9~C%6r_KYdlOtLvhZ%6j*}7<=fjHuW@Q9iO@4 zkPow3^W7v_*upI!1mH>*!=?{CL`%5jwQ{5yzUM?DY+(R=4zwU{`n;m3h~LKy?&AUP z`Fad)VL5dpT4dPt;{);Q=J)!c1m2U>2c*UijyP)8zj(<3@~f8ja_8+AVv=BC9C9F~ z$)Im9{OJf0c}%r?@Bk7%y0P+(L(veHS>vhq-n?mHLOQBRmvkK0WrNI zR@|?!$0PKUoZ-An2Wd7+38i6b*a?E}mIv3E+wVFmj}_w|{i@zBZ-)8D8gd6U=n&+0 zIa@B+F5vwWMX%*mo)xvD`EsC0E0`N@lCb;40#%oUu?;t)>|Y#wuzcG%<>-;WW&f%( zTegT^c4hA25i@uE{OqT)3bP>#P7IlFEOS^fyhvilE!n31Vy5l)v%(ce_+876%gmfy zd6*XSqU}xp)Zd#^>>ZX@1(GdOuaJY{Jny?U9~&zTW210 zsF5AHCuJ<9;^`vp1Lm2)Wk>8RR92k)HCb0ne)*AxiMo5r#`21eFXDPZd{UKja}>Tg z6@tOP|KBDl8Bmj(?3%+qI!%}Tb28>*(KEhMJRs*{Kl)w$E5pWnz+GJGmsAx|=0&EJ zc#$(6LP5Ch^8<@kQ;k4Ye_|Lq^VTdbL(A4I3zI+fbwmy>-vhLFjMF@MZhb>6o52}yK6;-|vK^hCsA_2FUvXit8ZY>Q^Q*lK##8OB z+Lb41l|>ElO*clc8j0^yRQ2yw)Wb-%qS0AH&9J^{56N<`#ZkNzlA*+@99ilYfsa&8`C&K)p)XeX+sLj$-+>4ELb+ z6=fL17uV3zMkQL=h23KBJgf$9=_OOhjUR#5H=g4(qbIX!=ioEcoj%F%mXV41!TvC5 zlXf)5h{ekcOrYIt2)f#T}_X_ZqpGbId)YSOBvh3#Q zera@_`6cj{Z;IgJ7=|z za_9hb{!o>_%pJdU{gnA7@GpMJ!MY~<5tC9Yo`nyrYptB_VH`6Lf4xtmeTu99qf5%B zY!H@F@G|DjI~vPAP1*g*KC{Hxh&NF^=wNSZYo7N|C8gfKWhd7jb9$p&F)5*S-U|(n zh78@t$99<;H?<^*>SQqST}{aozA>J=4&GSYoFjf?^%DU@ePPeUgb9I__RrMMP2V%O z->UiN?~YEH8~o#@yu~m<-C+2cjQ6o`2Q7hDk7%eZwK!FB$zuU_ouG05!7UHB5X4#HMRkpYH^u9xuD zWk-@dG*{r4z;$FmOgH8&4)!#BhOhqqLrL=B@%SZh9Td4+w%2HIXZY&MQF}^`e~VuN z*TJ2K%T~ah;j7nu;6aY#m%w#UzB_+BSfmB6tC?xW=a{*R8Yfha_rZVJWn-4Su<7;R z^7LFIVP#w-?|>xVV1{vFlMWFxCoHaZJ8R5iuN$W7s_KFNG46noC?8Igw{wlo=5i+O z&~h1b(mTOB`CNbcV-O$M5-~kN&|_FoBPW|4Rz`Np9>MWZhXjki4i;a%&bFwkB797P zuY(S36j8Sb`^J8AVSo!Ke!rSqV`gu`9&dv^#!p8C$9N^16y_xg_Gw~ZWfOe5zPTy; z6!VGRaDgaYgR9`D6Wj`CD&XY>;_wHE1AaP(W^P;pm*2pb*g&{SD}0*S=2SHwFE5a~ zEU7mSRSR=R*Pru^imyF;LCw;B?~sA0Ex({!)jK8YRSwEk9Z|Aq(s=gRrKT= z{wS6?BSGa+)Cck*?Vf}ytMHLOp$}@XYBV2!??br3YW#Ul9{e<=_)p^Yec&>{sa(H@*xZ?YGQ!n(vDIw@q5LYy)`*jTe zicTRT77$nb$G=LV;I%{Lpc&3lsPv||ccZ^GkD}KuMC9;kMt6I!0wxIBxE+3JH}Ct(QyM~C-VLXg*UIJmMg&-! z!}sRhacC?ibAL5)EzNn&DH^sC=O72(Sfv%mSZc^l32qFvW1ovtwK#koOZPf(#pQ&@Q!7x(6U6Xj;(&B*IKVgc^Z$rojDA?Z zUKv&~I=()IY*YAf?s;CERny}hE%xXnU8v*r*}s23N_yjGzJUfDiiQ21?&_uQ0&9y8 zrDivsF}sj5`IuIzHzn?9Ft1^X#!KibGBCfHP^qHgXlr2S^m6S8`?piL%$Oa_R#o!l z6$4O^%KrWMf92Bo-q~VIrq19k46=V-9N%~(r19;!i7v%)RO_4|8IpxK`=|P5uhZ7_ zptO@YmQQBOzfPM=Y3*UuTBy5ir(MNe$zgHGcZ<6YpER_cyBdt#kFwW*o3=$#yuE zxfv{|wqIIVz+`7FT}$V&@&>!fikvce_8VvVl{@z@%Qm`hm;)3uGH( zJ0sk`OWy%NNLxAWtF`wxYT8^l_-)j+{q|39%QZc7NI0*2pTjO^YkFJ)vKRx?e{bl& zZH1I}=-65pzpfl{AaV2=t@A0Vh0S}*GtND2p)@-%-pltWk`?-9EZ%P`H568BE)JLp z8SbO|ceKKUpC4?@6v=L_VFL%Eto^!h?!)az#+%xZd~$E?nn-1(n7pliSCT@eOe1_L zj}@Ww*Mv{n^6o2=!tm_yG6yQN?}hd2d>vu2j3R`s|eb zy-6Mes4=~_6zvDl@>}Pr{lQi=7#)5TjEuO)D=DkeuwBl}` z(-TB^(A5>EZmrw+^o0^hP3MMrTAf?NpqX*^92?TCPNLVhKr~RwWOWhiis|_1y6PSy zpS>)6m~Z9!wfTYfd!I~;xs1+NKp&UjeHtl@3$7%!d#}tLSz)w|XH{A8gQ} zI^U&Na}%QE-RGD&_qU@o&#=vVmNqu$wHM)vjjnfn9{O#*14G8=?RLB3nh3*V6ZH

rzbt7! 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%0Dw=84V*AYS-R*<%$ofCf-DEV zaKsy2#70-Tt) zap)8QBX3mO>eKK!?he7RGhhf4J}7a;!QOpS)|?B)_%OkHU*Q(UpFKqhCes%UQ31HN zLkI1{H930f=;**%+U;NI5pxx2h)bQFP;$x>MXWsNRCFDrX|bj7z(aK(mHrrHUah1u*q;- z18fV;wd%?&9+64N@R8zB&vzO8DDoESAW7XM;W>Ygu=QeOsf=z4xhD$!3YKU7O;fv7{XW2OAzv_F}x=2ibr4_jB- zWIH3=u|<%Rn3(-Wz9dQ%=y#$B2kQndvNTe?lMHAgIz6NV+w(1nrA0 zUv_(zKi%zx+KLbx`w&((*J9ao%_@Y|LQFqh#hJcydX({xa(h)1Ia3rQWN_vpT>w&- zuRC#trk|Kw#7_uOa{@m&H&LG!KSwH{kaXh2FnaB#$^#y9+rFqnOxMqufOYPQ-vjx# z2eQP^F6vQpq3#_`2UVs>+?GvZsakma3ZS#*nG2M(dy_19k*FWSoHM)!>Td>ijS4c% zV|r?8>R1kfU^j2xEdSsl21GDO;4jnm_fP(5Bj({R`=}*ED*>Q8qw|Y80sU6a&4RyP&J3vxr7yEl*#^?obFXt^dj%R;VaIZD zQ2yqHy=x%D$x)PS?wv{UJ;@uMmb?%F$26b`wq9}8H6ZsJg<4s0mPOb3t9xTlATZNF zR@C+P1a$i0gOFU@KXU>Ga*)@i@y`)zg@liBptS&!AW);z)0nA(RLBR(uGonX0MNNR zAZgaIo;~|#FEla~0uFXO#ZQf)inREC{{W6-|Ikr-yC$jI4l$5E_8Gxn>-2zkU%E!F zDnBB`W6M%@jHJ8${dK0!*YA<}S6nj-0f0Fl^^@%bL3Yf327j({?egu4-E$qgX;+#5 z2Dz?;`gWOzNz#yq8xoMY=CGwKKa~KEf!O__^X2zP^@4NejGf!p4NaF~&pmd?^$TvF zimyF)`SN(KD-Q$^sa9_mdloZsYQO4h$*EU?_wN0P^>h}_Q@4p+6%+8x z`1tW@PV#O{0=4jetH&hI*kdmA5caO;vHfJ)x$+gu5mS8n1Vk6X@L|h?GNok@XEuxG z*Jv!0T{DXQc=3~ISZ;X$G5VhYl16_}4WFKD{#iCgo2 z-tXI%Zw?`neRuvoUQ)LF@9*LJuM8hB+aR9Ap5pwj+9sJ}-_yTi4Vvra+a9LaR$tw2 zbkar%6Th$mEx-JM^O);W{iMGD58syGD5}Jh^qtLTlG(}oxq`#ZP43e&*<<|ThYIr53yY&O7xR# zEgXoK;puHn)Di;*62U`Z!{b47h4Zi8&l0%O_A$V{Mx>bIsWbZb{RcxV6cDM*7eAC; z^S-n=olUg}y*KB~BwU(vCKNq3=Y$~|63z+A-?tsqy89muvV=+5(x&RB+HsD zE|x@eO`8Top{;B1skRA05h4wIm-x^ap>^j z3l$af5SwUMfmhPdiUG{?a-iaGZk~piNKr`2wlpD0X=rOZ0kN2}7bbvbV%xTD%axVK zCXZ?qLW>qH5)c*THXK#nd#c18iz(rb)p4UJ-v5J?f1>97lck3K|G%O4JaN!fgLbG9 zCoyT7`4JXRyjI$%3#4T91<5*Z4L5+8QCDCL>&P`EsDl-cqO6gM@@Ati@loncw`j>%aiDr@Ps#Oyp%opijMuQUDG#i$i9F!C%ANpUsn19zti83wJhm6{KhaLrA zHjwA_R}csEoEakj@m~t$<*&%`=B@IP0bO@s(tg=M^cd(j6B%-HqS**SI+=CYB4&f* zV4cqx`a@4CO)FuaWF2hqey}W9%F6V32oML3PvoO8?*k{UqGFPgGt&IS8Z&E4thAMXHcPEzJBx`Z8K|KK{&}=N z8){kbq>&eCI+r1|FAfI+?LWUp&QUEF))X@X{J}fYY!n(l&1b@RoLEoC>1xIK19Bs5 zUtx$q%9~nPq_zneBHM@k?>yvRwfs0fjct)q*;@b>arnZJ_q1Qc$|)dvt-&`!Ok35h zK?$@w=3{?7y8bIUJTB=GXbmK_x0616`~M9!Kzwt@~Y+*xQ z2mJ{LgdD+q1?iLGh3=?Su!1>2~Pc)dZWAp1vJ zy0Gxk)BK%_uy)lE7b#-N*Zqwr;dqe@Bu;!+I4eSQBC7wpgBxt)CHqynt_mOlCNa#TO%!uC)zrp=ak{ic5 z;U)SHWW8HguFS;GgV-9@DUIAvCCk9FYDi^9Z|D zb`T#zxXD!c`PD`Je4g-+2PLU4oBqk@AZSa@d|R*vgOum65sU1d_4j@CrNcfqWCV;L z-#q{$rw2PtyQGtKiMw4>lYLrg1{Qr3C`nLP{KddVMywT_@2g#I z@+czilD6d35l?;$ZB^Y>Vs}F#etm<`lQU%%ch39nOtE8o1BvtS2A65Hj0 zNRK^-9KU6T=tQ;$U^fzJD;-mx^A+5x^J=x7nrK%hOmxEY^e+_JxEM}$O`2i!;}m&oInR%rn_G5kNckV&CAE#bDWe(D;8c04O#kINaQh?~wDs-# zz1KzTHwuab%)Pr-H9-8%_8DWj(giR^Y5&IhU1lz}e&^fw$)eq7Pf5h^w@>x31}TG& z`=b06O{9mtu|K5vc~vVHmv#BZK6fB3X8UlMF`yY;7*ER(5EcwxzT&*rCEVv7ex&Q$ zgC}0VV5HY_my`R6Qi>U4*K*HwTbsUVTJ$F zhM03x)k6#4>I}~|V&I0qxWCk{keS|jwW~t3-R4ecR_5y)KeYj(8MfaSbfXx}L}PLw z<(H3t#C_g8ab(2PvECCbMV&sexq;a=&3j@%J)Emhzis_k_Hkx6s>m%EZ2&~a=BG5k zR9Zpb2_PA4pNzOl?;;M*vXt3Hh6JM6UV7ve838O-*5NZ&6B$LY8Q9NAv89W}%;DKLJu$U4YxNB3s4MQj?bBS}o?85)?c(RaxPq7VBn z*f-D5PMkFTs%OwE%Y1ftK?SQ4Q1`@9iXl(p_9g8fM`O=jDf`2ut)WmDr<=;vih;pg+F5j>yz*A z_G_%wi731ux&UL$(Du0qxU97O8seTt9A3PGa5!w?z0kd&*fzw*7Gt|tW!fpXgxtVbzC za-zwyg5`&gB%~%Ezh3s5JDz7U>;2ixxj`++=6N!F>w3%dg5_`aUk&{W>(c6v;bVx@ zKizygwkw`{R4(Y%62aenw$U)7Mzi5l0vDUrk}hjGmM>d+D?jB$?nc&=m-s!4IX9rI@S5~b0Dr+5GAR89N&vsqbNYjJi z^ZaY1x#`R0_aC!(v3a%o^ZsFHZCzT_?sKmmZJ$5oNyFz?m#*LYGb>HpQkaW<6-F8} zo82=+>=z8IaTQV0gko!TD+k{tB2$m#ana{5OrLzTQhp;0r?QZ?&wAku_t{xLyl%XG zIOdV{s`m#73j=tY-7&WClx1s?y)!vquP44g1S>IsK;p5<@kizqUI;F@psR)TwoLy8 z3WzoCCz#~LtWSUiZXV%YRJ7-=f|ZIp)0wkny9lj2-w&=UgF=JgqsurT;T-7z(VWm@ zkH0nkW*YWDJKBBwjs9hOhc$kDFn*`0f#gkY3B#CgG2La)QO4a3HjllxD5#rRudV^_tV zS^kCSBJVlM6QE!Rx$D7s(+}j|A8^{;PaMd3OqPXCO3p{d6{5W^U4uVNQ$czD=$_Iq z4jiEI#llCzRY6y_u1#CSHSwmmDldjc^bX##@adcc@J@XoToyAD(Z3O#G{ib_a{5O0!h!4d-<7U};({jxJTPkZk9nY({|&7pvrLv@ zFu&f=m9f{v_32+*s2ryjueqpt#=f-ulbv8zP5a?P9fYsJyMQ>D{W%#Ts_m|w`9m`# zq!{h;85xF12Gdqj8*XelB_tUdx!Fdh{|$RhcAeJ}c4p4~;Bfv$u}igN-yb85OGX@D zJDSP`Ux$MJp0Y@&n@*Z6`#EwT8ekOJZhRh!29b48V0f!zBb`PF`&qVwa2KvV3xOn9 zlfpK|MCd#jIC!$T4q9(HQr(gAlVQIVTOb7w+R!_B^zEEcG$J&K>>b-@EbJ?Qb^i|H zb{(#Pb2j_7h2Qu0voF2rqb`-m-lKp2gx*UYJ$~#Up8Wfn$6gM>A7l3FzF?Rsb~^eb z4f$7w+UPm92K)yUd3h>zZ00nmgTu%ma=OSn$kW+&Dv|$23bfaF^w5hQPAwoMF{xrk zH<}^;+Dl;dpbTG79mKB)ls0UiX046E*I*eTnbSoMv+tn;1~p_v`~Doq>1De9cA8td zKXc#FD`-x1(l=6C;#vCEi_2*&&Y6~Rc9(ETCS8r&$#_P>C8?OEhw?FQQf0i@MBB-_ zrsOS_rV^dK6R?izQom>S=(!6RCW@zxt)?xO+n5(+L(LH4Jhy2;A!SYuPndRSS zKQ}RzNV6w$VQR0gSAN>=*6BFLDY!DD)A6sWIb4PnQs`~AR5HXsS#an`#>B#T47t=H zo}k0;9a(j}Ovxob2O(Vy99XmP!OoRkZC79I?f!0t9cby6K4>WjQsgz}Vkvp%=3~l3 zoXl|b<#fP?Y=qJDUf+_v>BHD$<%23Q-tf%R(<1yLr(t`$ z-0sZGIQ!S)GTPLx4<7L6%8;(=2gkW@_U>|nDM~slisj&!q($7>_NsRiMlNSW!1q-% zEj=&~;m&IKdyi#r;uG6;oZ$~S)z8Z>9Y~?XotSBOL&b|VI!VwPjcH~${#E(Oqo-@r z8Z^^SC}UQg{^DnY#_4cTkooZayqAoKOhL|ZNQSNB$ZNTzGZtu?Bq~LEjO4~(y~}RO z-sz-A`e_)9f1=iLI;s78Y|9t5*8~IWDdf)+u;`Z!W;ygv=l0+mcJz(mtnj8c*=KiV zXNzX>P*(|UO~g4?0BUoD$)K4ZI)q_7TJwsQUs~VzAa(_J@;S#6^qw=P$6Z#78jtO5 zb6N6r&ZBE@MS8ewOIqO1GJ` zL88b)+1vr$LY1WX0DJJ|$A=|f8P7!r!U6Oqbi6gl+LjlDwZ zkdIbKOTsK{b25+o^Fxzh_JzCTu$!qR!TIB!HxA zkO+p>`wc}Xkh31#<|FYEFFkdSfb-Me)_M>xf@vLTi}iY)efD}Y`ajd26&g|cw#CxZ z4|WJ7Me}2ZLS)64up};ZcGnv+T`1U4#Zj*cJt6RKWtaU_E}NT;b=rbfn3}S&V$>hp z>K7F9?Z`6rc0UsHxoj2X@HBzH>#p3zN;iYIXL{HEg`<}?%bdq4HC{%4gC%WTf)Hl= zs?SyJ5~eN4Sj|1)Nm|O{Zm%#yWBsxFyCSw3*BN&;bM>~02Wz~zm!EEjRDs3LZt zTkaY5%uvw4ATp3onz<p+H@{~5t;w6TDh(!PR%lM#UNkX zl+dNJy6&nP8-sqRVJKmBt0q^i?|05M*Ang0)`sXq*tNElueWL>D!~42(aD*k^ZfPp zZ}m-9{N*YuzD@1lyv}|s7CO4*P>0*5PFnua5L?o)&fvJa?}Ee!%83}(s;a?iHo(?{ zIcO?`Id(vrDJ{UKlKk)>M~)}2O~>whT#Z$>F#;oH9ftWQSE^Tkr`;4&u%Hq1q1LCS zG5kssPPF8C7~0#*P`qE{-jTt4+&tMYkixiKIG33IErwlgO!Dx6FSTvg!*KP{k~AX8 zYsm80!K8Ahr3!pK$ZhBd|3u^t*$+WhmC3D0ZAm)52dm^f`%|9c6DNb+M*G)(s{heo zqGe`sY5iLb8Inl|GAUF$dA_mG+)7!Z1ov3LRPzkYJfsDY!F`xZIQQ1SHV-Y%ZKNND zvC8IvwRQt5oEK_bx3Mm-)Z0IJ&Y#;UCA2sK(bPW#sh?P-y4>KoD>%Q7N!{q#beGu4 z=tdyVW|f(@vm$84!hCr9~iBI1$|7-(s(MbAsdvM zl}Q2BsLh;2k;6Ew3&=U~E#(Zv$ZOSt!Nb#G4yg^6#cpP}s~p69=Ifqq$;(|}+dk9@ z!!loAYsvq?w&FYQkFO@nbbPg#!^^P|9L;gdzymCN(@0>{Z+?W&;dDyS51C*vW?px- zzjweoQ+`P%Nr{7D1M_#7f+qN&$iZrpwvE8u9fwSVq`}zh3-uF{OmQc2Pn^0bGUe;p zJJc{up7q8JIYwssUR&F5Mp)w}%;BYLXnYV{we?j-QM|Z>ad?U4_7gfH4l+B8W~yjA z02>&TabW~#hgMYzXE=7_!)jAV>6_+)TSCrpY<)Y2ryg#BZvANrzNw(yLoTRg%@C#* zhFn1SJE2eyq?E54nvunpoUN=T3G5HGV6I_9v7;)1Z2BCI5bu*zFK-5u;xG(%tZ~bM zHIkdaZFMxs$qn@1*l*6=@{3l;eZiU^m2LFAwPKK2CVL=e*)RY8h+O2OJ^8CJ(Eq}~ zbG&&2@3DCY7xl{$F*U;DadFH`6|w(Y7cNqS28)UPFppgzW(#yMpH%a-#-v0A*bDwq z#CKq9FD>@0MO(~NB{<7W=3414`+JH${hk$7`Zo^?bPpdvY+pB$fSH!g1vAc*P{t+Y zreT3KC5?j#Amd&q@8)PHT!Q`io6eT=W$72O*X}KsoMOK6`E^r! z88X{osC68&vbC1hKM%$I*4Ga#?c?e8OYEAZkN1h!X_`9q2v=*aX`oGShX7Wo|Wc*!n~dcX@1F zqAHfH9JIh`9HvWAABZ09)>ojJw%xMImd}iAA^zgvdJn`_aTh_2`ppmNEZtlSu{eYUG-qn$Dzv= zv1^{m{;ln4j7tkj!E|H?zR~b7MnRfzmIMa`cn*RoJbSL?dBw1aEPWKJRQj*|%Vi*@ z>Frr&ip_C1#B_b*>}in@lTtmuUU;oE4&p6v_~I(DlKKi1sqWYcii(lvH2KoNQRVEx z@&@>a{1E-ga9#?6mt&|O(TK;e5M%X+{mS&aG>zcb94*%=w{Q%BiN?TbqKJf2l+pY_ zut7AH$Hq@By>7Y;qJ}2D@)s?Oo&8y2E2a3h4g(!E!?c`_@%|-C@g+YgU@OH@d6eE7Zag zniwb^u3PnCH0S=aYDn{V9V@zX$lt)?8$*0|(roUmWFqS`A4Pr+X4JDg)am`jOn);~ zQf}(n+cgeDX$RV#$$+6+O-g}~C5Rc_ghE%1EsxH`HI(7u-3^^*Zi-PhGIfR_!PWQE zlFkribrQ>@G70T9F3JHLcrX-Owwyq$KNQ#S^~7am+xR)I05J5b=TzHU$HZrz2NsB@x^nTBYQ zrOS@mHj@R(Ffp0c&eiC003_}bi$*5RKXx~W)F{(aX(9UsSqAZ#oV!j!0#NkZMKeAz z6v^1Y817$P({%VY zW-4~$j0?5RCaI;7-at-jd2th)H>U9gy&*A_ds3_D70FhSHr%-6BDMIe6XxW33XJ$4oE=3 zaOb2~QX75l`SE}q*y2A2lZ&D>FbC3& zgA~su{4U0m)_hqoUa_*R@3V-sOe%-a+}s!y`alm-Kku6=Np+9xi5QLg13ssO71fx>M=)e0gG19;r+UGBkMVkg`&7xg7L&Nni#iR33^NUmr=`=#W3yJIy2$ z^GU3KY}s1X(r#Q|N-ui98oR+EMU~i*Jg0kjWYG<;d+nf1hy~an2 zx73cqZs^?1Z#6S#KPna#5;o;^*QlTJZbcLuBXZm2=OwI|dZSg-o71Fpq_u6Ir^e=i$Y_QuU3+an7+a4|>1ekM zCU1_1!MJJUMXK2Al?;M;Q!-4mlJ~B}#QI%X^#icF9~C{S=TElTu15F3z|z`To6=~7 zZe!#i`))_&$f0+ITV5|p9TVr`G`sSAZK(&5JB7|{8+xwU2=*`Bqf)Jt#SU9${3 zaArnkn%z3ul*QSd4$h}hQ9we=FQeWOL~RmRqiYbN7ib3i`+eHDgGwc78XSCqf<86| zF5LG}3uc)DhcVoCRpYS1SjfP4iVPQfE`CXNZYI@x7)Wow#H{)kFxP0=Ub`2`M->fa z93^@QU+XZmF`xX(*3_$WLR=mZV8&}Ed1vS>Ug+fBqWyQXPThHPiLuA=KU~e99scvz zWyW3mYuv=onM+E z2LbBSDRhtNu+JmHo2ujIs_><0%}WptZEepFE0eg7tQ#W@lTOd)OA0nBeiw;a&zBas z%xzPF$W`vSE1Sz;JmINfyqpAj0|`%3 z%g!B}+VbODIU|%rj*o1cx#<8CeiBb_6G8grP{xscYQ5@kT{E`+k){-2EXi#@sj8I5_#KSUJI)wZmU!47 z_CP{?u#&EB9TwWoFvP_-Kfg7(lDzv@ZDt92gOWis^3|cRaXEH)sX8(xCeX_#6yETx zc`#>*Cz51&*nm-?sVguVPf~ZG?bo~m{j9J^(gy{5qQA>McQN<9LuwZY56BMaFV-bF zk7zNM39hw$=#Vnhv}FiJHob8s(qPn>Y?+x;cxn#mK=9=rdU6jTMah~}nM2zH`m)xY zYneR+d%cVs$J+)EU||<-QrXo+;cc5!+X}{G z{-Rl^9Ihb(rFT6tqwfWYDwB_LZ1L48Dg}}B%{*)LRL7i5a79rZL&khNdqUdsW zBd{t3xw~}{bcS*SdOoyEMEsSGZQzolDM*ZKByY_ybJO0nVUv?SZCu>V^+QUon-2C# z)vr86Vg&6~u;n)HBaro$w9UtTkHk>w5w~Z>??L?{hb6(~JIvJT`Qxdz?YK5M^)W*q zJ9m5IcFC`UDK z`$O3wFt70$WrVRWydJrb7v&mowt!TRf8n8~!G2}%Zjq--?F* zP%psDTbde;hIAve5q!_KiaYM4+LJ=LQSa(?u{zzBNR>S~lZ@nm64bqn*_NM4Sx}g` zui2$Qm6$mNyeOHAAD6egAHdRu-(z zEYOADmD~Cd{3)c?^IO9<-l&)XYehQJ`fwr^)bFBgb2`s!dZ0=qtNH6bT}@}nkys4F zd0<&6g)s!|%1^lj&zedN2GTf03YTze>RCfio+gL)C@PujWO@ETgiYJ~Ec#<(J8X0I z1|lt95ra*Z|2WegcHo%J>O=}<61NphledFeyd`YXWdpGQWIs6TRFS4I_Z*hjomA%w zz2RwXKA3~pU%>zi?KEqAoL_P~HSx4-V*MM7dz*(t>TtZB0{bIa@7vigG96}{ z$W!YT#-ym(y7$L4d9g*yO>(-STUMZsoC7J&v+a%Ts+z zD6;W6*v3H*=Fi%l>D{&x8^7g|7F=#!$7rbmT_92^!Q@^AXj|7vlanWC3XU|}Kqrr7QB4-1C zvsLq+l99ho=nGleUwBl-8JXV{Q!c9RgyAH3rb7iPoKD-SxIFtYBai+8g)+Cb-}k#d z*nbtA_-{-1NLFRM+;{GLXg)aKg5V~}t7QUqTexnPBh%d1f(=QYkZ9sFcazhbY}>=e zw?YTgxx)=)Q$0V%dA9G;D0o#nW9NBT_{Hf>lv1Mlq&Gu`g!m!>BAXq zaQO%cmAI%AeOJ>2E}DgV%F1)qSpN1oN*FVSc_6egJbzakFNC|&U#TupiF1bXQ5?q6 z5m@n395GyyQIYfEb)#0lIjbc5OXW#yu}>AHRb65(s!h z{%q(r`Y=X)*`xLxI51jzQ_eZ_?eMBCvbt|G6|s}A-pI}N_uiA4 zRR6}4JBV>06L;g0|7QL6=AVP<$Uo=#_6G-R5O{82{GGa?@Uv5FTRGN^CiygEHGsIW z7Ay((WJ|1F8r*X~=1r>r{(l=qM?GVy7}lGBC=^ z%!>}OSnQj3D}#~uGupVy2dVd(S#mR$mB~T!gO{ej!!Sb)2cM0sLcRsRjXV3}lX**e zgJ%O=%-i4a6h38JqF*QlM3dm-RgQnjZFz|7KPpBC&$$;bYNkZff;^c87d7?TnZb?` zcUp#GS00W~iVm4`*sW$MX8OnGsdP$F>D@gy$u;K>U8B^k#D-OJoAH?sXsycBkaKuB zHPcMy?fZ;1kahsG%D-$)V!a1a(8V4gj&B!cZ7p~W@t&(%@^gC9lPP18Bl7Qz37T)6 zBB!1pgN1x=bg)$Y-GG@JW{<<3El-|`(O#iFQWgf!BPL(XU9zz`6q9?43e#$dSR{8i zZs|NL3Ua{0m2Me@{c zyu4-@Vvz=_6#N&{XM1WX)9F+h#^n4qe$t^f(LsBctc?$fy=Akw;M?$Y7zo6$(hPMZ zw<(Z`iYt!-;mT$;y4Is0`9Zw8ZF0-<=NTxfrc(>iw;L|KU5C;XI;-+=K_y@1Sxa6R z{-DJ>X{S7281>6RvF|^@OOD_nJ1={sW7;r0l0Gfag~H+4`PTgS!#?rcCsRZ{gdFZw z)MBI4=?!Ck*1C;l*yr zdH>AUovU6%Rj(*KLmT>l@JoqAf?xD!j8h@PiaHT%y8{z5Fn+Ap{qC z8c`c3dZ*w70lu@jmfswq%OFji3#ocIK# za~9D-1jnf&YX*N-g||cHYo=8FTLAASk7}d7e&d z$T{JfSae9-B8ab|$V^rdyOANS7{JZtvoj3;zQaLD3_Dq%RK?~lMhn$^OIPM%8%yk_ z=7ubNKN@(FZcaYEP8mCn4sCzHl&p5drZA_QqTrmQt{KUWO(6Em^#ZFcMc{KH+rPRR z)wxKin_{Vw3tbj(kiO1kHCI8@Y;?$YL~m3RohEsO=c8XTOd5?sl6?HSRM`AaG73;+ z>Y$9%SMu`tqM$=svFWD2fCOxGx2l?xD|xSW#r$?mcc;O+tLwjtehQ$%zLsn^#RQpc zC|T8)-Es8C%c-PV7s+Sq3wv?0-c4QqhWw_(hDeP_qvh#Bzf4S!j&;orb}Mr1IDyfM zVHdyF4~6O_aNOyvf=uJG8{^=RBHYQ`pO@ZfO9^#(zG5z>TkcnC7V5Rm*?HwK+Fy?z zjUP|2SpVh4mpS-^`{44z;75T;t=~f%5yCvb!y|(F>8{&$ji%`{|Dt1m_})&<(AKC#!&C?O2b-)T4<}bYaVs9&wNTe zYb5W4fl*4i|NQYN%(p4nweGN`XCR7dJ?k2YE7qB*^DNonY0!o0ODRCk)5 z+{~40SNSPnG z`Jq1CIfo7{L>Im>oCb}ENUyWAfAoTMHuunI)!=z>;Z@RFO+p?0N_4bu``$Uc5s$)! zztf-3lVMPQb#xpKUxigEMR_!(8L^)40vJ9RNoc<|$8mUn^o?LY{T6-=w}1&93q!4S zzAzK+d;!)q+E<2m0eXT%3%^ERh~&lKSB!T-KX`DsXrPZ%3S15~lOd7kk4{B;x-Ncj z)aAS9)qUoJZgwDVJA<$ynum?n4kU7_A0B@?V(*+stvMRn#V2%(PN2`mYew}FD zT2Im`*}v-~e%5K7#Q5F2dEWw+(JB{zkE|=AFMl>veo6s4o{sWN4rf&<1?qt*p%o#b zE^9h8rperk{}S#TunLdHL*)(nsYlSf82pOQ3wR*eKN4W#-~u#pa1lN)hewPGo&fd5 zYqwp)CocQPKheCvEW-?=i90+3pSa)&_-Np$IA6ec7krv!pm7c5_yB@fqBA}A1aN&c z`uPo!z0v++U-;e;Gwyj{LAShe7bmfcGxhcKhR`bJYFr+@xU8&(Fm?-&B|4ERvm(#` zc2#%Aj2~pc&ng>$J5`5qO;I+W$nl~ka-<<&H$3{AOb7%HDY)SQ$YcG14!=3h4uL=bs6!zTc%6O32?eTqO<{jc zH~6sb3RKo~dG}O5i382H3xOL4_G0*M2I}-gVdu3iWqN+jj)4@myTMNZ34^EacvtEl z-2E+k@AcxRy)Ito{8;*#>-2-gmO$)@MpsDwX?LnltzWmEQ5otvSi>VL?a5zOn8j(s z4HKg%<)<*slT%LrboaMQzenEiezoZI=!zvtWCJW!w$aA*gF^;Z8U-%GSVGeDDIn$=Uj)q%zRMmvphHqsZorIww7AkBSi-LDC$i&v9;l)LC{pHQkiYm_(dl`hPSOUuR{EcICGC~O`Rr!qgu?uH zPc-dyvGhL;WD<6*^fTA#2lLd6geUs4DTFHc%cc;RsqQufgF8uZV#XxhSX;jJw^^r? z^5OxDe(&QKQwhUc7nn!vZT=3~ssnXvU6aMcrwp zi>&7kp0ZE@TcyA5^ytd$9245}IwL!^DYvFg$Bv}`wQl`2N2glxZZjQ8d#;AyC?QGyC@!~k1w|$uBMT2K5xD`C|-hN0d7xq%K{44q! zOFUFU$^Zf}u9bs+nJrH9yTs2HM6_{ADomK8iEv!h>f$k&RbuZQ)Eg2s8vi-em zj>c(ATah-59x;-NNka~6a7r|6x8*GKtdWgcoqsJH+va*p=7vt58roOKBMo3mf3{Y| zn0l8Yx@A@n97pAF8uI6Zl&9~0-mjwU8c2eV=aSCqKs?F+b%D5D&!F( zp*l6k3PqfSuH2cfb0^tzKiI5Yw;r%4XnluW+PY`!rJonLm|^iuIl?>$cAuV@I3>La z^YdHu%S3Bz!ih=nmo7Z4&(RkHe=Z(`!5#3|N_C8OUacGx34 z-0J9g7{e|&mvZ>r-Aj88a#~HW^=jL)hk6#r6HDOTu`k}gy#Eu0rG_yVKqdg+*C?N0 z+q?}j4=7K75(@l0f$&p2Ez!#lcO#4yP9Z|pgNy<{Fy}VeiSz+1s|?(j~q9{V;KB|3t6Z z*XRkdKB^?j{>Z~4OVUq2ZFqQ-fJjs^pky8BQ+&`V0PE?cEK*FoOb9=qN2!gDc)j~) z?MR{gr*Cu~g{LY+#Btx-w+5$AFKcCJ5%EL5@n_vj8X*pP_im1r)nE4aG}OHh@Mmlwwhu-hEJ^g9 z6y@2epWvcv$0_g~jsrWJ7doYr7U5x5B<~djlE< z<-$DK0i^Sn!U;YP5|&!EB`fErBm7|!=Skf2m#;;!jUs2~Ib3T&(g$kFH*DJmYz~lw zdI8)8+8+1j%$>`VMYV5Gicj=Y$N={CL+UHkwznV63Xh5;8^Ye3pPT2AJ%z*-)AH}_ zZ10Evx0b!9ZzLPwK^2Q4kr08&MSME;Y?g^cgose5r35es*0Nf04s9Qv2M?1;{Q(d z3e&!#R)Uuf7s<_rk~T{f#`; z#^9<7V!e*234RYZ26~Q2@G(WSyV#!`qG*>_AHw^vsK} zCH`|Bg#J{QN7L}G*mcf*YV32Kqfi>kV%S@NyS)gBh;YDGV#;CP!CU7WS9qt~7TXhTpAvBI+lg z(_j86b|1MI+kh&OS1rdf!Nb6gGueM9=e&3cYQQ)MJ|+xS?^Kn4#(E@kFV77kNnX%N zy|5s*5(@^H_Bef>7^93RxLB~K_!?%9&`+&;GFq)8ut56&k30TBWWkSEI^Fp@;y?j+ zN5h8__8)L5faKinAEy8Q6RL!#;_A3U%4*Rys-oseZ#GT|84kpjmYa?!ZW6@%+lqdP z*wv98>dI^>AFYBXmo`IvP#hn;L7O3&nrJk6^@vZTL@TawLWm5))+n z_=wYuxrjq$NH{6Hh}Q4!&!a+77FpUV>I-3L_xF_t&;Ag4AfGz@FdQ{B#WPe~gah~| zX?lrr$~VAx#3`y~@R+%G#{d$?m*)JS_c!(&TTp^gsgsn|5_ct57&OyA@Tw&|rQkK! zc3FB~=r%O363sHxR$E$&CZYdHnppsc!^h1=9W(`2k4AYX8i7j`nO?XLJYx$dZ&gHq zo(ogfxNZA#Tgzy;lNL1tQVz>Y7P{Y-`=0%iA!NOnTP*oH^G_W(KN~anN(!ckb8(Vl$f~S zOcO+LlKT*dr>v?w8kv6Lz(Kd%n#j~f?XIgx|A~|&O0bKB5xr0|IQ&)^e|(x7d07(C zA1|2`-6=|O$n#vSUQ;v|R zG$_AGUpEQeo`(nr$qB&234jpTi@Fs&ko!MBytZdPbwY3&1~vVQ1JSbs5xTYFd#$<) z%BJ1YxQa%JRZlvMzsUP+EfmcIb*%`|o4BD6xft3hbR2*1J%~P30lA1xxO9JtB4B`& z{x$YTT%0 zwXF|aaclvq2ZTx!Y#x9@cDZ^M#^WV}s2*_kI7dtq6``O4M7-}|y>D%dccUUU3_d~y zhy@61fJRyhBVxXS!WPt`2aSYaCSu4u;kFgYxmMBPhn%wXE{I5Adud$OovKJHGH!ZS zx+VMQ+{SRFqqu5gtY0x_&R#;RCDdMjOJKmLeUw)1 zhyxr%LYuxqxCkOAwh5Z^!8-9rl@hDJLoMYPs5qyTVyi@hf=?pU?Ibkx4{Jc33CAfe zyYEl&m3 zbcH$BSE=!6OTr=F`~5KH%h#`;5#%+V76-C+!G~D$+xQL2qUav(Bt-Ynxo1f;wdClV z;8WAD^Dry`t{SCs)&Xj4n}T<|eEmx*0#F%o9*;Oa=?}oqJFzdHMQD1@dS?tFu8My? z0r>g(@F9AIr734#4nKbwZbcA$_^`;|mOz6c)~&zd2)9;c=ULwUt+;iZHFg7rKv;h2 zIxW$M6!8W{A*BXR$vZ3yEnDGVl58daUKsHBGgPg z<%_==-gx6j=9&aqP==K#M0 zo6r(;4pJ+s(s{!dJmYhvZbXJ^dzT{mgQjsVbCU(X=UQos-Khydt)JgQv>4Lgg`b?G-_g?|i+$0YN8=@K4^)RY5DR3R zSd_1KY}$Jr$L4}<&@(kqr18M;uJo~DVE80Z$UumK95n}$vb~qdxQpG+Dg3e{P|Esu z`-Q)TV*#${1ZxTl+Ra_uDKM|lf4I9P@9t+xl!AU=WT)GS?5r5R$c}V+GU&^u>RviO zvrPq+%1~F-f%#*So>RjD<4M#JKFpeXyUY^fm&7<5W4AZmY5#fWwgbk4N6H+5ru6pd z`H^>8HT(|y#cZq`iIn!ny!)VFa$`3kGrQ3y4w>25>o&-a<-VPQ3?^KNethiq!H@@& z<6TKP2$y0KElzRK6aE$~+&vUIeOVjYTyICRMCVP|ySKftEQv^>zU0j(_tJ z;1(`9)U0sHgu`Wi3+Lo=caLm%p+&LXhu5uoQL_Q*iVun(<3pzPV&7O4W|t z(!6f2jPC7tH6K(vtqDn9$R?gFamRMFD99XvB3ei}E<0UGfMg|<%VJPji_(^;5Evpx z$opb&2mA$*r>w)OZ99LvD>F0Pb_M)}R3D3S{*BBY6Yn|oe>Uc5402`pkrO1JYQ+)- z2N5}(%}~}}ccPXPv`f)s=b;T*M}EF!RQXdW0loh`=S_;Ezo`Y}B;l+B8(V)Nkb2<8 zZ>x*mO~+Va(@?qXW`Nj>A|(HL`E9nn5WamQ?h{l;35u_pYZp(nzQZU~H7FXpQ7u1C=T%ybcl!KC(3%J|Gj;7i_v>3Mq?J}yO{nUN6?PpSk%iWEdkg;l% ziT%fJfuj4mqps8w5C+NmKre^}bb^6S!Zah3Si>-}gm)EULN32a&1KB>t`yjS^*EOBqC#CSQRmxq_dz&wiwjO%!=OLUb{7Su-2pz~*oi7(;SH8qi2fWkqweh>yjoj`$tZmU zcBRA#iOI<^eTi2S;K_K6UGT~%e0Tuc{Lza;DR8e|d?M0n65Y+H#v&1%# zw#QXerg&4j9{m8G>_E!)JzkAoEQH@!zWE~hJr~4TF76G^kYgoT^xwwk*Uwq3Z7E96BB`cw3D-RosZaDTXu66=FB)pnFf>V@Nxf&Z0->S=E2q3`-mkCoZ}I zg(IG10^EeG>H+t9^fDnxq@=24T$|noO`pwV2BNYJW~;_ zAb#;n-^G07SSaH;j&mrPTJcg$Y_r~n$;$PpBPYjvxs+l#r%=6*YDy5B?#C|@MKwcm(pm7lGqk-xW zcw`Vg;JtD07H8{gbye+4U4Rom^rLldt%KZ{PvCR(Ro^atUm?jo5h_rvxU^-jxf-6C z*bPIvJ|^fGD$E1GF~vci4=&|ysMcw^I}P)Q$h<`8TEzl}XWhT;!}n6azsj-tq8-l}Hcd3-QqTH@oLYExQzirA|kAfdT|4Q)|H+@xN+PQ-cUX=CO_DSur z+g2pGH>J?cREa#Fy1l7{W=D1GM?q0rAE<*QR)rJP8$}QGVdJedQDtPG=h*R<2spHj zSBTm7d5AuG>Yi#@o4I-M^!o8gnM>#7eJ6DCI?R1DH>X#}3e-nJMI*=!oJwmB`+Hm8 z<3Yh~VFL_~fWWCQV|D?tlWlV-K0GQz`>(2X@37uXZbdJMg&`yKlf*^;71D}Q(j-HF zF>6r;&+Enu3Z8d{C*HuK7nST|>xIf)mDhMyiq7=XFYIyAHw0k6QH5t??E^-093 zTbPzP8V6d~Q2fy0aR~kl8Q8U`T{}0l2Y0_m6~OyY5dQdZ$A1Dxy=!t1-^QO`pY)y% z1**6Qtt^fIMC~mkt+mLq^Zw*&2o@hic4PaHq=vH^^}uPM;*+W``ch<#V-lxNdyUeD z;??bD!JHj!Q&3II*!qXHoIe4KM3YQy3N6(WiiXpRQSq?`i)9J?rLw)DHl2sq^O7^U zyAh8n8VNON@8;LCDZ&D6=4`+%g|hBjyD^#O7Asbc@$p)>etk)Red|OLg}(253qRQ# zL_gGO1I`AU!~Xe^MpjY<6zXRGDHWSWFLV~)YY;RM=V~L0SwM>6^~D0L2f$|#^IrpA zAPgMG2{5kOLaPR4dw0Jff0Ke$VoN0L43E1NdrXp@U$Ke36MK89$ z8v>ASFfvd$5)MEV0C9J`KxM$-iH;|*;?_DlBd$7hUvnJ(o&>UUD8Y>O4S%)fIqL8z z`YmE#Df%MX(5Jh>fV`{|ul(gj!Ly_rcIrSV>&t%ddYwKzHxxQ|`DJ21llLN9b!Nx3 zh(4qEgz-MH3QLF5RB49Ai?f_pR@_p-(VkESoBaSPGGIBN6r-CLRSM@iFNK5$hLOpr zxA(PEH?xx>{Gq%QFrTojkge~D8{HSK%5`m;irO1U#h^~Yz;ytOKj3}-jurUJbBGhp zf9CU%51c=>H=t?36q6k;`Abt9QQHl}gpbCER|#B(=y=jsz($p4IDm#1;5yht4+LOF zLhTF4-v-*cn0VL#Q!XH|akMcyAQsyv)$#3604#amAl@J7G(yw#;f{p+_}lgdwY%L5 zRr-q^n33jqocVWq95ryRQs52W<*7iF;A2z>1tUTs3r`+U!vNvy9;E2YbAa{|S?DkG z@nt^vRwr9ka+A1b>Usg!gIHwDRXkXd;ee>yCfl0^16Au=(!2S@?H$0CE-OH z_J{!OhK|*}cio{ieFX?ic2s=fG3w2@rVUxYeE-KN)O{Qb+_v3F;OEZ8ZQ{1w_0hkA z!_)Cv>_g=6Koo-<9=M&rXSr^U!`}l%riO}b#a_U0IHXJBhhkUu`h}T z|I^PQ(!~`*qc41}0t)CaB)@Q!fqTZ?Zi>x0)K`cwQ9bh$urLnGG%23f9P5xV4Omw3 zrY&9g#(N^WMdYEVlg>Z}^(V*K>U@InDiVwYA`gWXU6_x52@FF?0%=LZyB|nM#*2r6 zU--~FCi}DIL;tcV@SyofH%#&%Y2P8g7Q(j_6ezOqUaO ze1rBOSf`Kb?0Vv)i{b7-tyKYaD=!x7iyw$X^JYF|Mi{({s3dlrq$UvEjUdWbWw{v1 zGn~9SQ+f@a6`ZDPonh&Qq_AWGH!LH1X5X!RPnLvz=1$3PI&8T0ee_)E25~+n+%|n7;FNtQ z(gJ~4B59~k*hReeVjm$%#Zf3-E9GosiJzh#_uoE!PsDIcQk?)oD74Q4)!hlvh8u7Z z9xtKwj$z|HNK(~2G`nfv-pP(P_pONM2-&CVEc%okR-A*eOC=1BfBO8Y<9funGha!H z-nw1!SRJ{pE?q1&fvH9+1idPWXt=AY$SD%7h2pU{{Tr0KbC4hc7gT!mE$X#XjzpVj zi8s8#@ot}C9lT%?D2@^!?93Di9g;eAD-dznntzng{h*3i{U*wDDyn~z@C}`08)*^t zB;l^%Q_2G>4CzK_efAGporU=F=^Y7m&Y#ev{%?5a0jNvKtJm2BM1XkRig!tjR*D2j zD8~xt{RJVH=_>sqyG8J4PJj?T3W*k8y9ldL9om1XSj{do(|$?H{}Bd~OJP<`=9pm_?R*WC`o(H=k*+!%KeKu-JpcZou8ClRgN)tvLSNmaS%QuD1k(*3QW?d;C9|Y)4P~>AtY{b?!a2w~29j)tmA~y+AWnN&a1qUT=z4?^flk6#9W3=AKKx zeurZNe0V#wAA`PH>H>HFQbF4eR@i@56WBIKeZmvh-n#@{ zsxR<>8-l6tHKMdrI1YLQT#7l$cr;d3dRDadq6e4HDL|NQ=}5VnS@njG7leDGP+N4k z_~4Xmm)g*0=L#Z|Z2M1`{O;1y2AzFcf5I=6B3am}_P(cVdx$SeR!b-z%;6d?o1(iV zHS>FO@;8=qZ&lQ49#C5T_|;T+D|$G)s}a~BN%G6rgXy&R1b@vFVVjx$qo_3t zOx0d&q-##~&I=^Z7!=1HV3noVHNFrnYsS0Vq66|~>L#gWUl_a4IByYi7_tzEI;C&j zabAm9NYGXh`Q__yP{_UaSw!~yoT6km@6{GKj%1+H<*uvMVX_558Q8J2l9oIiEjen0 zBIO`?f)w4-@hmnW7NS1ZbXGy8aoLS=Saxnie%%^G#;8}0$^;iKOve%`6i6lL zssQu0JyAeMIfO>}nSV|PZRIMZuW${wObCT^u1p<$WnY%Fizo2~t!|k(#jB7sR+-9_ zhu-m+FZ467J8bD0h@cgS5q8DeRYQCbIEI|6m6V1`z|33QLUpI<$<15|beV{P7U!2$ z3vJb|G_1vXQ@7b_iRU;3vhz8FB3;f5nrjim=!X1{28+$wzp8aFinv=5bI`cuQq(Hg zBG~Ko8*3T)@taERNS@PuQFWl7-w@g7@A8uqUGp+<`pT}|yxCf6;JhMRCZ2TQhAJHL z+tReKrJ>0(VNb0W=PYKsg`J0?^46O~+ozN4H%}x253CVlc5RJQwu1Kmqg9F!Bdm22 z<9CEK+5BMEg4TMH&gI`DkD&jTApTiY_kv#lB(o}`RWAM>S%>QRhRRPVKs~*qJd?vw ziKyN*y*c5w1*z1;AVf}^3B{tz+jd&DVq5JuNEQNRMcHRRpai?t(Tr2&SPJr-wFU+{Yjk=1sZC{ZyS#ISAF3_c!fE%73Lt) zR=5=?T*GX`w*t#6hK5+EupQkB6t3S}UqwJK42D=}HCqje9lOc%R&mD(a2rh5bsIN2 zlGpBsF77hW#QxcjD|SYPs6)Zw^yyZCQAKw%DMRVZHd32_<$3M;^_R8nQ(Z#2-y+q~ zaH5vAf88r5tPa{s09X#q2qeCwzcKnH_BNj)|pC+d% zW2ltsr^Yo1*#v~Jt6qK&#IHFyCbSD~`tTY9ssz9Z&rT`Ql!_sC)M)jvh>LqpUY0`~ zvek1 zZ$y;lys)5~4&PwDh!m)*`oKYMa@pevz-x-iJbOdKiAy=ojtWu^^r!;PQ})J=Q6NFB zz+*z?j)bp(Qqb zr(N(UIn=bgIQk&k`MK%}yno>3(^>n}lC6`Nj*2s?2Edx0GVqeae3cVff9yUnQ8g>_ ze0caNl=M47evxMX(~|I*E!CriN6RjNYk?v%a*GMQ6PRsl7CWLXw>SWM|s8C z^H5@0?k55#p_g2 zD-s?zX*VxbyHE4d0&X|>>>?-M)hSjWxoJgt6y{xR_U3Ci1KrhY10tz#+w>bf%0?a^{j;Qjh?lo)bDe+IcpOwhnk>}aCbN#`j-*4^iy zF5k>YJqqd!n67Tj4H@|1gs(r9IRaA4|Dp0)mKKi0KvlL#1%I-W*Ip4tc97`L=7)uJf<`pc*Od6JlfGgxhqDR`dHhsX zapg+S;|nV_(BX_ntn<6NQG4pAE>_>%(Z!vI(Vzs7yx1;M7tuIV^f1d+&F|Zs=?SQ+ zt|}*XW8<Pl<8kG% zqdo-{3BQW67r&;14TSvdl!(Uo##^!X=boX(7{kIqs97p!5y)-l_- zgG#uY%$H|ioyr5Nhy@5#f*`!ePIR*K9iSA!HfEAmVj+<~>Dw4H?<%Za@7H|KZE|Mj zUsFB(_Euc9_0Du^d!4j<{il}h9^ex;E?4`Ei`U`6$W4szB7%)b z+x7FbE@MG0kH%!iQ+Hkdj(}MP4j3Rq8~$W+xKoB~>QtxnC(o9-4ARd!^XTLBumIy| zSs$mL-?@}E>CB~W{g#KDd5aA`0bO4V>6*5?H#VU*8cww|)6 z{Qk(`W#y623ZtjTH5La`Gjg@vbpp+{j)>coc#yuabsROZP5H06bLX9!?0tJ+F8hZo zAL`%c?^FBZ-m<}GT&jj(A>aQp3_BomZUQz4GyUW44kNaX%GNx&U%u}D_3wnWLDlCm zn*RAg2W0A^&S5?~C!TxWo-RA!fnMStz6!J~Bi$I6adJ7(sReUJ3_ zD|_uGCo^f!Fq?ws4;5e!Q@#Ee9BThMIcoXqAAcVfn^)9$cGu{(_6u_T$n*-CT06VK zyoj_j4JQWpjFordE^=eb-e~=yoS&#XPMWhdP}cntAsS*C5e_DrMlp{EXb|JmDJkxtB6S-&N@jtXe1 zo1u^OTX4FJ%(#0i%8jsb!}59TTK~CQ&9QJp?aQ0e)9UGW`e937{#-|!RPW6l^Wo#t z$M*J^u|~$ynICDYFTPX5ek7R&6`pSSWl5v?pe*XZjhV8#^(RVo@02)xo$q4mrR2HW z*HFE`INY$A*Fiv9E-#=kA17P4Eq!gvk(DPL#_mxWwOV(2@=i~~>kAK^F@2fw@rLiO zq@C8Q6dw)oTPZhps_eyiw+W}_eN$w-YMT6siLY`Mu*~s45rSUprC#v4FyTg>bK0}v zld(aqDn=Erc7_g}dCKkT^Lr0`WF|~muzT-Pbu50^b}iw`o*!gp zHmJ|Cl(X^kZ8<*hk!q@bzU!ShWA-~Psho~eAcV@Vic%2r4qIS4Qz z653Db$T95KeKFv0Ktvzq6BaQ}WA^ut=ocT;21Gp_^W)hj`T6Xc+0^6fhRExL2zDeNy6tOxKact!W2e{`g&x z$5olJ80`;|m3Q=nH7UfeiCU{n95U^udelVsGfr8jTa&i{p+nrx=FZ3ZA6AvAE(Vo9w2{Eu&9fev-l!RXJ{0^FFLf;m@k`>Cf(Pf4rQxRVM%VWJlt1%hv1( z<>PX7jYEA&W{+qWsQR8&tIKMJ<-Z*H?3&zHvwp;ac4GtF8+VWki08(v;6$Hf&_c%r z?|V_SX6&USRR=j1dG|?2+e;@_9iQ_!4Et*LGot!smlw)cm~9(7xd+^hh@1Uw7I7}P z)KWrHZB%^14o>{#_f>ax4hb~ANg2KI#Iz`tObnw2m9t8KJS$9=ToC=OXGK)f4lBM+{SSnn}LZNruq%= zqiNnB_M=<7oGccya3^-;%7(ZztS5saxCM=8e_G0ExG-&tN5}`wbTP4B(t` z$1FVdGHxhNc(~U76uD^Bw+Nbg*N@^fY1~G+^Ne&K{PokPI)=X*=BBP1GI#mw zhv5-UFI{$+T^&Trp!$t^jEVWv=<+DKX| z_Z)RBn-#gQnXOZamX+huaa>B#uELT_k&#P7E)%wp>*UrEV+_VHW@djcgD{G7*4f>2 zcK&{6&YbuC{hs%EzR&Obd5|Hkj(3eiS{g-xS6)JJ9mLS@^&o}U3qvIaxUt})@(|c7 ztdso~_auS|@;lKJ=kMeYcMD_=i#fZQ926wvbjUm?R9-KBeMqSR|I<1`8Pmxb?cbyA zW+J}s-~s4>py2lYjI6FuYYp8*UHoZ7Zxa;A3Xecd=c+5L8Vz+*#;g(0-l^9mP2ldnhr8l42)_R-vv!v^(WY+8q6Yq=MJYtdNs3 zrS%N=T_XHHOn(mtg!x2?a_9+Cb!^ zJ9W9~cYf{toQ`$V5K5YC+Kmy-53xnqm@u1!Zx#utJ-|~szOrFjZIpY8Aecmjw;uGM z3@ORc6yM`AcIkJHmt_=+gos1*2;|;P_*?j5as+7-62@NAdU~zn?nO%8Lwn}&dK?i+ zcouGRxTQorqA18h7P|n(QS4}lbYa&nRT1~0-Yk3N#EGdvw>IojX_8$q>)l=`n=dB|a$Ny- zQ6m9J^Z87&*pHM8R>Mtl*XjZ=4==`FF&!=T73;E2&EkZKayXil$RXPrv!F2uGAl3j zu>%bf4smb7qUnn)AJ8;zFKZ@a1XlPDLSgWujBL__oUH^!r_P$G#HhJ6>?HH@i&@y%gQ0Tf} zAgIadMG?&-(XilWV11r!YgknDGeRz3YVpr|&Gjw5EM&uYgBWQ7zUNt5Mp8h(iluCz zi0b2bI6j+M9dlJw7Q;qUU9$73{mRqROc+FX(3jv$XKw{|>4e|r=|^kGCgpY2W~OF> z`!RrX`S+eVA>d)40MV9tVUjW6SVuSVZ4b38^-SRInLab%r|!QwG+NHk-CZ4DKqG@` zm5U@^gE*LyqzR}8E0lOMKneZ!s#q(jLv2cv$gCWnctgeq19$T&_It&SK6fe3og3&v zvBm4idzFS=+ZX7r$;6XQad5w-I;CaDR)|psh{dW%gUYJqw2ui%<1uStcjM|4$ z%W%kBLteuw@Ch?mCnk5^jy~IcYUUz_{7J|&!2ZNbAePhgc}WX+w=B7kL8Q(RMfpVs zQ$k%{MGu4dCq;kjs~j!q6dVkgCZERik2wH4Pf$k$tygIh1Taz4Ji~8;D~& zeg2I>Q https://maibot.example.com + -> Caddy 容器 :80/:443 + -> core 容器 :8001 + -> MaiBot WebUI +``` + +这意味着: + +1. core 不再直接对公网暴露 8001 +2. Caddy 统一接管 80 和 443 +3. Caddy 通过 Docker 网络访问 core:8001 + +## 2. 仓库里已经补了什么 + +本仓库已补充以下内容: + +1. 根目录 docker-compose.yml 中新增了默认注释的 Caddy 示例块 +2. 根目录 docker-compose.yml 中新增了默认注释的 Caddy 数据卷定义 +3. dashboard/docs/Caddyfile.docker.example 提供了 Docker Compose 专用配置模板 +4. dashboard/docs/Caddyfile.host.example 提供了非 Docker 宿主机专用配置模板 + +## 3. 需要手动注释或启用的段落 + +本文档按默认保持注释状态进行说明,下面明确列出需要操作的段落。 + +### 3.1 需要注释掉的现有段落 + +启用 Caddy 以后,请注释掉根目录 docker-compose.yml 中 core 服务下这一段端口映射: + +```yaml +ports: + - "18001:8001" +``` + +原因很简单: + +1. 这段会把 WebUI 的明文 HTTP 直接暴露到宿主机 +2. 启用 HTTPS 以后,应由 Caddy 对外暴露 80 和 443 +3. 避免出现“HTTPS 入口和 HTTP 入口同时暴露”的混乱状态 + +### 3.2 需要取消注释并启用的段落 + +启用时,需要在根目录 docker-compose.yml 中取消注释这两部分: + +1. caddy 服务块 +2. volumes 里的 caddy_data 和 caddy_config + +## 4. 启用前需要准备什么 + +1. 域名已经解析到服务器公网 IP +2. 宿主机的 80 和 443 未被占用 +3. 防火墙和云安全组已放行 80 和 443 +4. WebUI 当前可以通过 compose 正常启动 +5. 已准备修改 dashboard/docs/Caddyfile.docker.example 里的域名 + +## 5. Caddy 配置文件如何写 + +Docker Compose 模式请使用:dashboard/docs/Caddyfile.docker.example + +非 Docker 宿主机模式请使用:dashboard/docs/Caddyfile.host.example + +最小可用配置如下: + +```caddyfile +maibot.example.com { + reverse_proxy core:8001 +} +``` + +建议至少做这两处修改: + +1. 把 maibot.example.com 改成实际使用的域名 +2. 如果有额外安全要求,再按需增加 header 配置 + +## 6. compose 启用步骤 + +### 6.1 修改 WebUI 配置 + +先在 config/bot_config.toml 中确认: + +```toml +[webui] +mode = "production" +secure_cookie = true +trust_xff = true +``` + +trusted_proxies 的建议值取决于实际网络环境。 + +如果 Caddy 和 core 在同一个 Docker 网络里,建议先按实际来源地址或网段填写。不要为了省事直接把范围开得过大。 + +### 6.2 修改 Caddyfile + +编辑 dashboard/docs/Caddyfile.docker.example,将域名替换为真实值。 + +### 6.3 修改 compose + +1. 注释掉 core 服务里对外暴露 WebUI 的 ports 段 +2. 取消注释 caddy 服务块 +3. 取消注释底部 volumes 里的 caddy_data 和 caddy_config + +### 6.4 启动服务 + +```bash +docker compose up -d +``` + +### 6.5 查看日志 + +```bash +docker compose logs -f caddy +docker compose logs -f core +``` + +## 7. Let's Encrypt 申请与续期 + +### 7.1 证书申请触发条件 + +Caddy 容器启动后,满足以下条件时会自动申请证书: + +1. 域名已解析到当前服务器 +2. 80 和 443 对公网开放 +3. Caddy 能成功接收到针对该域名的请求 + +### 7.2 自动续期说明 + +Caddy 会自动续期,通常不需要编写 crontab,也不需要手工执行 certbot。 + +只需要确保: + +1. caddy_data 卷被持久化 +2. 容器会长期运行 +3. 域名长期指向同一台服务器或新服务器已同步迁移数据 +4. 80 和 443 没被防火墙阻断 + +### 7.3 续期检查建议 + +建议定期执行: + +```bash +docker compose logs --tail=200 caddy +docker compose ps +``` + +重点关注: + +1. ACME 申请失败 +2. 证书续期失败 +3. 端口绑定失败 +4. 域名解析不一致 + +## 8. 常见错误与排查 + +### 8.1 证书申请失败 + +优先检查: + +1. 域名是否指向服务器公网 IP +2. 是否已经开启 CDN 代理但未正确放通验证流量 +3. 80 和 443 是否被云厂商安全组拦截 +4. 宿主机是否还有别的程序占用了 80 或 443 + +### 8.2 登录失败 + +优先检查: + +1. webui.secure_cookie 是否已启用 +2. 请求是否真正走 https:// 域名 +3. 代理是否正确传递了 X-Forwarded-Proto + +### 8.3 WebSocket 连接失败 + +优先检查: + +1. Caddy 是否已正确反向代理到 core:8001 +2. 页面是否通过 HTTPS 打开 +3. 浏览器开发者工具里是否出现混合内容报错 + +## 9. 迁移建议 + +如果当前已经在使用: + +```yaml +ports: + - "18001:8001" +``` + +那说明当前还是“宿主机明文 HTTP 暴露 WebUI”模式。迁移到 HTTPS 时建议: + +1. 先准备好域名 +2. 先改好 Caddyfile +3. 再切换 compose 暴露方式 +4. 切换后直接以 https://域名 访问,不再继续使用 http://服务器IP:18001 diff --git a/dashboard/docs/webui-tls-ssl.md b/dashboard/docs/webui-tls-ssl.md new file mode 100644 index 00000000..960714a6 --- /dev/null +++ b/dashboard/docs/webui-tls-ssl.md @@ -0,0 +1,465 @@ +# MaiBot WebUI TLS/SSL 配置指南 + +本文档基于当前仓库实现整理,目标是让 WebUI 通过 HTTPS 提供访问能力,并保持登录、Cookie、WebSocket 和 Let's Encrypt 续期正常工作。 + +## 1. 先说结论 + +MaiBot 当前最合适的 TLS/SSL 方案是让反向代理终止 HTTPS,然后把请求转发到 WebUI 的 HTTP 服务。 + +推荐顺序如下: + +1. Caddy 反向代理 + Let's Encrypt 自动签发与续期 +2. 宝塔面板反向代理 + Let's Encrypt +3. 1Panel 反向代理 + Let's Encrypt +4. 不建议直接让 WebUI 自己监听 HTTPS,当前仓库没有现成的 WebUI 原生 TLS 配置入口 + +## 2. 当前项目的部署特征 + +当前仓库里,WebUI 的前后端是同源部署思路: + +1. 后端是独立的 FastAPI WebUI 服务,默认监听 127.0.0.1:8001 +2. 前端构建产物由这个 FastAPI 服务直接托管 +3. 浏览器生产模式下默认按同源访问 API +4. 页面如果通过 HTTPS 打开,前端会自动把 WebSocket 协议切到 WSS + +这意味着最稳妥的方式是: + +1. MaiBot WebUI 继续在本机或容器内网跑 HTTP +2. 让 Caddy、宝塔 Nginx 或 1Panel OpenResty 对外暴露 443 +3. 由代理把所有请求和 WebSocket 都转发到 WebUI + +## 3. 配置前的准备工作 + +正式启用 HTTPS 之前,先确认下面几项: + +1. 已准备一个已经解析到服务器公网 IP 的域名,例如 maibot.example.com +2. 80 和 443 端口可以从公网访问 +3. 服务器没有其他程序占用 80 和 443 +4. WebUI 可以在本机正常打开,例如 http://127.0.0.1:8001 + +如果采用 Docker Compose 部署,还要确认: + +1. 容器已经能正常启动 +2. 根目录的 docker-compose.yml 当前可以正常运行 +3. HTTPS 入口将统一由反向代理接管 + +## 4. WebUI 自身配置 + +无论采用 Caddy、宝塔还是 1Panel,都建议先把 WebUI 配成生产模式。 + +修改 config/bot_config.toml 里的 webui 配置段,建议值如下: + +```toml +[webui] +enabled = true +mode = "production" +anti_crawler_mode = "loose" +allowed_ips = "127.0.0.1" +trusted_proxies = "127.0.0.1" +trust_xff = true +secure_cookie = true +enable_paragraph_content = false +``` + +各项的意义: + +1. mode = "production" + 让 WebUI 按生产环境运行,并倾向启用更严格的安全行为。 +2. secure_cookie = true + 让登录 Cookie 仅在 HTTPS 下传输。 +3. trust_xff = true + 允许从反向代理传入的 X-Forwarded-For 获取真实来源 IP。 +4. trusted_proxies = "127.0.0.1" + 表示只有来自本机反向代理的 X-Forwarded-For 才被信任。 + +注意: + +1. 如果使用 Docker 内部的反向代理,trusted_proxies 不应固定写 127.0.0.1,而应填写反向代理容器到 MaiBot 的实际来源地址或所在网段。 +2. 如果尚未切换到 HTTPS,不要提前开启 secure_cookie = true,否则可能出现登录 Cookie 不生效或握手异常的问题。 + +## 5. 直接部署方式如何配置 TLS/SSL + +这里的“直接部署”指的是: + +1. MaiBot 直接跑在宿主机上 +2. WebUI 监听本机 127.0.0.1:8001 +3. 宿主机安装 Caddy +4. 由 Caddy 负责申请证书和 HTTPS 反代 + +### 5.1 推荐的网络结构 + +```text +浏览器 + -> https://maibot.example.com + -> Caddy :443 + -> 127.0.0.1:8001 + -> MaiBot WebUI +``` + +### 5.2 宿主机直装 Caddy + +以 Debian 或 Ubuntu 为例,参考步骤如下: + +```bash +sudo apt update +sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list +sudo apt update +sudo apt install -y caddy +``` + +macOS Homebrew 参考: + +```bash +brew install caddy +``` + +### 5.3 Caddyfile 示例 + +仓库已提供两份可复制的示例文件,请按部署方式选择: + +1. 非 Docker 宿主机部署:dashboard/docs/Caddyfile.host.example +2. Docker Compose 部署:dashboard/docs/Caddyfile.docker.example + +宿主机直连部署可使用以下最简配置: + +```caddyfile +maibot.example.com { + reverse_proxy 127.0.0.1:8001 +} +``` + +如需显式添加安全头,可以使用增强版: + +```caddyfile +maibot.example.com { + encode zstd gzip + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + reverse_proxy 127.0.0.1:8001 +} +``` + +非 Docker 直接部署建议直接从 dashboard/docs/Caddyfile.host.example 开始修改域名并投入使用。 + +### 5.4 HSTS 是否启用 + +可以启用,而且当前推荐由反向代理统一下发 HSTS 响应头,而不是让 WebUI 自己在 FastAPI 层单独处理。 + +当前仓库提供的两份 Caddy 示例都已经带了 HSTS: + +1. dashboard/docs/Caddyfile.host.example +2. dashboard/docs/Caddyfile.docker.example + +示例配置中的这一行就是 HSTS: + +```caddyfile +Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" +``` + +这行配置的含义如下: + +1. max-age=31536000 + 浏览器在 1 年内记住该站点只能使用 HTTPS。 +2. includeSubDomains + 所有子域名也必须强制使用 HTTPS。 +3. preload + 表示该域名计划提交到浏览器内置的 HSTS preload 列表。 + +HSTS 建议按下面的节奏启用: + +1. 初次上线 HTTPS 时,可以先使用不带 preload 的版本。 +2. 确认主域名和所有相关子域名都长期稳定支持 HTTPS 后,再考虑是否加入 preload。 +3. 如果无法确认所有子域名都支持 HTTPS,不要轻易保留 includeSubDomains。 + +更稳妥的起步版本如下: + +```caddyfile +Strict-Transport-Security "max-age=31536000" +``` + +如果所有子域名都已经稳定支持 HTTPS,可以使用: + +```caddyfile +Strict-Transport-Security "max-age=31536000; includeSubDomains" +``` + +只有在满足下面条件时,才建议使用 preload: + +1. 主域名始终可通过 HTTPS 访问。 +2. 所有子域名都始终可通过 HTTPS 访问。 +3. 已明确理解 preload 是长期约束,而不是临时开关。 + +HSTS 的风险点主要有这些: + +1. 一旦浏览器记住该域名只能用 HTTPS,后续临时切回 HTTP 会直接失败。 +2. 如果开启 includeSubDomains,而某个子域名并没有部署 HTTPS,该子域名会被浏览器直接拦截。 +3. 如果开启 preload 并提交到浏览器列表,撤销成本会比较高,生效和移除都不是即时的。 + +因此,本文档里的 Caddy 示例更适合作为“完整增强版示例”参考。首次部署时,建议先按实际域名情况,将 HSTS 调整成更合适的版本后再正式上线。 + +### 5.5 启动与验证 + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl restart caddy +sudo systemctl status caddy +``` + +检查项: + +1. 浏览器访问 https://maibot.example.com 能正常打开登录页 +2. 登录后 Cookie 正常写入 +3. 日志页和聊天页的 WebSocket 可以正常连接 +4. 证书是 Let's Encrypt 或所选颁发机构签发的有效证书 + +### 5.6 直接部署方式的 Let's Encrypt 申请与续期 + +Caddy 默认会自动处理证书签发和续期,前提如下: + +1. 域名已正确解析到服务器 +2. 80 和 443 可从公网访问 +3. 没有 CDN、WAF 或安全组拦截 ACME 验证请求 + +Caddy 的自动续期通常无需手工干预,只需确保: + +1. 保持 Caddy 常驻运行 +2. 不要阻断 80 和 443 +3. 定期关注 Caddy 日志是否存在 ACME 失败记录 + +常用检查命令: + +```bash +sudo journalctl -u caddy -n 200 --no-pager +sudo journalctl -u caddy -f +``` + +如果续期失败,优先检查: + +1. 域名是否仍然解析到当前服务器 +2. 80 和 443 是否被防火墙、面板或云安全组拦截 +3. 是否存在另一个程序抢占了 80 或 443 + +## 6. 宝塔面板如何配置 SSL + +宝塔适合已经习惯图形化管理 Nginx 站点的部署方式。思路仍然是:由宝塔的站点反向代理到 MaiBot WebUI。 + +### 6.1 推荐网络结构 + +```text +浏览器 + -> 宝塔站点 HTTPS + -> 宝塔 Nginx/OpenResty 反向代理 + -> 127.0.0.1:8001 + -> MaiBot WebUI +``` + +### 6.2 宝塔站点创建步骤 + +1. 登录宝塔面板。 +2. 进入网站。 +3. 添加站点。 +4. 域名填写实际使用的 WebUI 域名,例如 maibot.example.com。 +5. PHP 版本可以选纯静态或关闭运行环境,重点是站点存在即可。 + +### 6.3 反向代理配置步骤 + +1. 进入对应站点。 +2. 打开反向代理。 +3. 新增反向代理。 +4. 目标 URL 填写 http://127.0.0.1:8001。 +5. 发送域名通常保持目标域或原域名即可。 + +如果使用的是宝塔站点配置文件,也可以手动补这一段: + +```nginx +location / { + proxy_pass http://127.0.0.1:8001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; +} +``` + +如果宝塔环境没有现成的 connection_upgrade 变量,可以改成: + +```nginx +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection "upgrade"; +``` + +### 6.4 宝塔中申请 Let's Encrypt 证书 + +1. 进入站点设置。 +2. 打开 SSL。 +3. 选择 Let's Encrypt。 +4. 勾选对应域名。 +5. 申请证书。 +6. 开启强制 HTTPS。 + +### 6.5 宝塔中续期证书 + +宝塔一般会自动续期,但仍然需要检查: + +1. 面板计划任务是否正常运行 +2. 80 端口是否在验证时可达 +3. 域名解析是否未被改动 + +建议定期查看: + +1. 宝塔站点 SSL 到期时间 +2. 宝塔计划任务执行日志 +3. 站点错误日志和 Nginx 错误日志 + +### 6.6 宝塔模式下 WebUI 配置建议 + +建议保持: + +```toml +[webui] +mode = "production" +secure_cookie = true +trust_xff = true +trusted_proxies = "127.0.0.1" +``` + +如果宝塔和 MaiBot 不在同一台机器上,trusted_proxies 需要换成宝塔所在服务器到 MaiBot 的来源地址。 + +## 7. 1Panel 如何配置 SSL + +1Panel 的逻辑和宝塔类似,本质上也是由面板管理的网关或站点反向代理到 MaiBot WebUI。 + +### 7.1 推荐网络结构 + +```text +浏览器 + -> 1Panel 网站/反向代理 HTTPS + -> OpenResty/Nginx 反向代理 + -> 127.0.0.1:8001 或 core:8001 + -> MaiBot WebUI +``` + +### 7.2 1Panel 配置步骤 + +1. 登录 1Panel。 +2. 打开网站或反向代理管理。 +3. 新建网站,域名填 maibot.example.com。 +4. 添加反向代理规则,目标地址指向 http://127.0.0.1:8001。 +5. 开启 WebSocket 支持。 +6. 保存并重载站点配置。 + +如果是在 Docker 环境里通过 1Panel 管理容器,目标地址也可以填写容器服务名,例如 http://core:8001,但前提是 1Panel 管理的网关容器与 MaiBot 在同一个 Docker 网络内。 + +### 7.3 在 1Panel 申请 Let's Encrypt 证书 + +1. 打开证书管理。 +2. 选择 Let's Encrypt。 +3. 绑定域名。 +4. 选择 HTTP-01 或面板默认验证方式。 +5. 完成签发后,把证书绑定到对应网站。 +6. 启用 HTTPS。 + +### 7.4 1Panel 中续期证书 + +1Panel 通常会自动续期,但需要确认: + +1. 自动续期开关处于启用状态 +2. 面板的任务调度正常 +3. 80 和 443 端口验证时不被拦截 +4. 域名始终指向正确服务器 + +### 7.5 1Panel 模式下的反代头 + +请确认面板生成的配置会向后端传递: + +1. Host +2. X-Real-IP +3. X-Forwarded-For +4. X-Forwarded-Proto +5. Upgrade +6. Connection + +缺少 X-Forwarded-Proto 时,WebUI 可能误判为 HTTP,进而影响 secure cookie 与登录行为。 + +## 8. Docker Compose 下如何配置 TLS/SSL + +根目录 docker-compose.yml 已补充默认注释的 Caddy 示例块,用于容器化部署时启用 HTTPS。 + +Docker 模式下请使用:dashboard/docs/Caddyfile.docker.example + +非 Docker 宿主机模式下请使用:dashboard/docs/Caddyfile.host.example + +详细步骤请看另一份专项文档:dashboard/docs/webui-tls-ssl-compose.md + +这里只先给结论: + +1. 启用 Caddy 反向代理时,不应再把 core 的 8001 直接映射到公网 +2. 应由 Caddy 容器暴露 80 和 443 +3. Caddy 通过容器网络访问 core:8001 + +## 9. 常见问题 + +### 9.1 开了 HTTPS 后无法登录 + +优先检查: + +1. webui.secure_cookie 是否在 HTTPS 环境下开启 +2. 代理是否正确传递 X-Forwarded-Proto +3. 浏览器访问的是否确实是 https:// 域名而不是 http:// IP +4. Cookie 是否被浏览器策略、扩展或跨站配置拦截 + +### 9.2 页面能打开,但日志页或聊天页 WebSocket 失败 + +优先检查: + +1. 代理是否支持 WebSocket Upgrade +2. 是否使用了 HTTPS 页面去连接 ws:// 明文地址 +3. Caddy、Nginx、宝塔、1Panel 是否有单独的 WebSocket 开关或升级头配置 + +### 9.3 Let's Encrypt 申请失败 + +优先检查: + +1. 域名解析是否正确 +2. 80 端口是否可访问 +3. 是否开启了 CDN 代理但没有正确放通验证流量 +4. 面板或防火墙是否拦截 ACME 请求 + +### 9.4 是否能直接用 IP 申请 Let's Encrypt + +不能。Let's Encrypt 只为域名签发公开可信证书,不为裸 IP 签发。 + +### 9.5 内网环境如何测试 HTTPS + +可以使用 Caddy 的 tls internal 进行测试,但客户端必须手工信任内部 CA 根证书。正式对外服务仍建议使用有效公网域名和 Let's Encrypt。 + +## 10. 推荐实践 + +普通 Linux 服务器部署的推荐顺序如下: + +1. 宿主机直装 Caddy +2. WebUI 绑定 127.0.0.1:8001 +3. 域名指向服务器 +4. 用 Caddy 反代并自动管理 Let's Encrypt + +如果已经使用面板管理服务器,则: + +1. 宝塔用户直接用宝塔反向代理和 Let's Encrypt +2. 1Panel 用户直接用 1Panel 网站或网关反代和证书管理 + +如果采用 Docker Compose 部署,则: + +1. 使用根目录 compose 中提供的默认注释 Caddy 示例块 +2. 注释掉 core 服务里直接暴露 WebUI 的端口映射 +3. 由 Caddy 统一对外暴露 80 和 443 diff --git a/dashboard/electron.vite.config.ts b/dashboard/electron.vite.config.ts new file mode 100644 index 00000000..95819f44 --- /dev/null +++ b/dashboard/electron.vite.config.ts @@ -0,0 +1,137 @@ +import tailwindcss from '@tailwindcss/vite' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'electron-vite' +import path from 'path' + +export default defineConfig({ + main: { + build: { + target: 'node18', + rollupOptions: { + input: { + index: path.resolve(__dirname, 'electron/main/index.ts'), + }, + output: { + format: 'cjs', + }, + external: ['electron', 'electron-store'], + }, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + }, + preload: { + build: { + target: 'node18', + rollupOptions: { + input: { + index: path.resolve(__dirname, 'electron/preload/index.ts'), + }, + output: { + entryFileNames: '[name].js', + format: 'cjs', + }, + }, + }, + }, + renderer: { + root: '.', + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + plugins: [tailwindcss(), react()], + server: { + port: 7999, + proxy: { + '/api': { + target: 'http://127.0.0.1:8001', + changeOrigin: true, + ws: true, + cookieDomainRewrite: '', + cookiePathRewrite: '/', + }, + }, + }, + build: { + rollupOptions: { + input: path.resolve(__dirname, 'index.html'), + output: { + manualChunks: { + 'react-vendor': ['react', 'react-dom', 'react/jsx-runtime'], + + router: ['@tanstack/react-router', '@tanstack/react-virtual'], + + radix: [ + '@radix-ui/react-dialog', + '@radix-ui/react-select', + '@radix-ui/react-checkbox', + '@radix-ui/react-label', + '@radix-ui/react-slot', + '@radix-ui/react-toast', + '@radix-ui/react-tooltip', + '@radix-ui/react-alert-dialog', + '@radix-ui/react-avatar', + '@radix-ui/react-collapsible', + '@radix-ui/react-context-menu', + '@radix-ui/react-popover', + '@radix-ui/react-progress', + '@radix-ui/react-scroll-area', + '@radix-ui/react-separator', + '@radix-ui/react-slider', + '@radix-ui/react-switch', + '@radix-ui/react-tabs', + ], + + icons: ['lucide-react'], + + charts: ['recharts'], + + codemirror: [ + '@uiw/react-codemirror', + '@codemirror/lang-javascript', + '@codemirror/lang-json', + '@codemirror/lang-python', + '@codemirror/lint', + '@codemirror/theme-one-dark', + ], + + reactflow: ['reactflow', 'dagre'], + + markdown: [ + 'react-markdown', + 'remark-gfm', + 'remark-math', + 'rehype-katex', + 'katex', + ], + + uppy: [ + '@uppy/core', + '@uppy/dashboard', + '@uppy/react', + '@uppy/xhr-upload', + ], + + dnd: ['@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/utilities'], + + utils: [ + 'date-fns', + 'clsx', + 'tailwind-merge', + 'class-variance-authority', + 'axios', + ], + + misc: ['react-joyride', 'react-day-picker', 'cmdk'], + }, + }, + }, + chunkSizeWarningLimit: 500, + }, + }, +}) diff --git a/dashboard/electron/main/index.ts b/dashboard/electron/main/index.ts new file mode 100644 index 00000000..03acc056 --- /dev/null +++ b/dashboard/electron/main/index.ts @@ -0,0 +1,203 @@ +import { app, BrowserWindow, ipcMain, protocol, session } from 'electron' +import path from 'path' +import { fileURLToPath } from 'url' + +import { registerAppProtocol } from './protocol' +import { + addBackend, + getActiveBackend, + getBackends, + getWindowBounds, + isFirstLaunch, + markFirstLaunchComplete, + removeBackend, + setActiveBackend, + setWindowBounds, + updateBackend, +} from './store' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +let mainWindow: BrowserWindow | null = null + +/** + * Register app:// custom protocol BEFORE app.whenReady() + * This is critical for electron-vite to work correctly + */ +function registerAppScheme() { + protocol.registerSchemesAsPrivileged([ + { + scheme: 'app', + privileges: { + corsEnabled: true, + secure: true, + allowServiceWorkers: true, + standard: true, + supportFetchAPI: true, + stream: true, + }, + }, + ]) +} + +/** + * Register all IPC handlers for window control and store CRUD + */ +function registerIpcHandlers() { + // ── Window control ─────────────────────────────────────────────────────── + ipcMain.handle('electron:minimize-window', () => mainWindow?.minimize()) + ipcMain.handle('electron:maximize-window', () => { + if (mainWindow?.isMaximized()) mainWindow.unmaximize() + else mainWindow?.maximize() + }) + ipcMain.handle('electron:close-window', () => mainWindow?.close()) + ipcMain.handle('electron:is-maximized', () => mainWindow?.isMaximized() ?? false) + + // ── Backend CRUD ───────────────────────────────────────────────────────── + ipcMain.handle('electron:get-backends', () => getBackends()) + ipcMain.handle('electron:add-backend', (_e, conn) => addBackend(conn)) + ipcMain.handle('electron:update-backend', (_e, id, patch) => updateBackend(id, patch)) + ipcMain.handle('electron:remove-backend', (_e, id) => removeBackend(id)) + ipcMain.handle('electron:set-active-backend', (_e, id) => { + setActiveBackend(id) + const backend = getActiveBackend() + mainWindow?.webContents.send('electron:backend-changed', backend) + }) + ipcMain.handle('electron:get-active-backend', () => getActiveBackend()) + ipcMain.handle('electron:get-active-url', () => getActiveBackend()?.url ?? null) + + // ── App state ──────────────────────────────────────────────────────────── + ipcMain.handle('electron:is-first-launch', () => isFirstLaunch()) + ipcMain.handle('electron:mark-first-launch-complete', () => markFirstLaunchComplete()) + ipcMain.handle('electron:get-app-version', () => app.getVersion()) +} + +/** + * Create the main application window + */ +function createWindow() { + const isMac = process.platform === 'darwin' + + // Restore window bounds from store + const bounds = getWindowBounds() + + mainWindow = new BrowserWindow({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + minWidth: 800, + minHeight: 600, + // macOS: hide native title bar but keep traffic light buttons + ...(isMac + ? { + titleBarStyle: 'hidden' as const, + trafficLightPosition: { x: 12, y: 8 }, + } + : {}), + // Windows/Linux: overlay title bar (custom title bar integrated) + ...(!isMac + ? { + titleBarOverlay: { + color: '#00000000', + symbolColor: '#ffffff', + height: 32, + }, + } + : {}), + webPreferences: { + preload: path.join(__dirname, '../preload/index.js'), + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }) + + // Load the app using app:// protocol + // electron-vite will handle serving the renderer from app://host/index.html + if (process.env.ELECTRON_RENDERER_URL) { + // Development: load from electron-vite dev server + mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) + } else { + // Production: load from bundled renderer + mainWindow.loadURL('app://host/index.html') + } + + // Persist window size/position on close + mainWindow.on('close', () => { + if (mainWindow) { + const { x, y, width, height } = mainWindow.getBounds() + setWindowBounds({ x, y, width, height }) + } + }) + + mainWindow.on('closed', () => { + mainWindow = null + }) + + // Push maximize/unmaximize events to renderer + mainWindow.on('maximize', () => { + mainWindow?.webContents.send('electron:window-maximized') + }) + mainWindow.on('unmaximize', () => { + mainWindow?.webContents.send('electron:window-unmaximized') + }) + + // 窗口获得焦点时确保焦点传递到 webContents,支持屏幕阅读器正确工作 + mainWindow.on('focus', () => { + mainWindow?.webContents.focus() + }) +} + +/** + * App event: when app is ready + */ +app.whenReady().then(() => { + // 确保 Chromium a11y tree 始终激活(供屏幕阅读器使用) + app.setAccessibilitySupportEnabled(true) + + registerAppProtocol() + + // Set Content Security Policy + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + 'Content-Security-Policy': [ + "default-src 'self' app:; " + + "script-src 'self' 'unsafe-inline' app:; " + + "style-src 'self' 'unsafe-inline' app:; " + + "img-src 'self' app: data: blob:; " + + "font-src 'self' app: data:; " + + "connect-src 'self' app: ws: wss: http: https:; " + + "worker-src 'self' blob:;" + ], + }, + }) + }) + + registerIpcHandlers() + createWindow() +}) + +/** + * App event: when all windows are closed (non-macOS behavior) + */ +app.on('window-all-closed', () => { + // On macOS, applications typically stay open until the user quits + if (process.platform !== 'darwin') { + app.quit() + } +}) + +/** + * App event: when app is activated (macOS) + */ +app.on('activate', () => { + if (mainWindow === null) { + createWindow() + } +}) + +registerAppScheme() diff --git a/dashboard/electron/main/protocol.ts b/dashboard/electron/main/protocol.ts new file mode 100644 index 00000000..0fb995a9 --- /dev/null +++ b/dashboard/electron/main/protocol.ts @@ -0,0 +1,89 @@ +import { net, protocol } from 'electron' +import { readFile } from 'fs/promises' +import { dirname, extname, join } from 'path' +import { fileURLToPath } from 'url' + +import { getActiveBackend } from './store' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const MIME_TYPES: Record = { + '.html': 'text/html', + '.js': 'application/javascript', + '.mjs': 'application/javascript', + '.cjs': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.txt': 'text/plain', + '.webp': 'image/webp', +} + +export function registerAppProtocol(): void { + protocol.handle('app', async (request) => { + const url = new URL(request.url) + const pathname = url.pathname + + if (pathname.startsWith('/api/')) { + const backend = getActiveBackend() + const targetUrl = backend + ? `${backend.url.replace(/\/$/, '')}${pathname}${url.search}` + : null + + if (!targetUrl) { + return new Response(JSON.stringify({ error: 'No backend configured' }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const headers = new Headers(request.headers) + headers.delete('host') + + return net.fetch(targetUrl, { + method: request.method, + headers, + body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body, + duplex: 'half', + }) + } + + // Dev mode: renderer is served by vite dev server, not app:// protocol + if (process.env.ELECTRON_RENDERER_URL) { + return new Response(null, { status: 204 }) + } + + const rendererDir = join(__dirname, '../renderer') + const safePath = decodeURIComponent(pathname) + .replace(/\.\./g, '') + .replace(/^\/+/, '') + + const resolvedPath = safePath === '' ? 'index.html' : safePath + const filePath = resolvedPath.endsWith('/') + ? join(rendererDir, resolvedPath, 'index.html') + : join(rendererDir, resolvedPath) + + const tryReadFile = async (path: string) => { + const ext = extname(path) + const mimeType = MIME_TYPES[ext] ?? 'application/octet-stream' + const data = await readFile(path) + return new Response(data, { headers: { 'Content-Type': mimeType } }) + } + + try { + return await tryReadFile(filePath) + } catch { + const indexPath = join(rendererDir, 'index.html') + return tryReadFile(indexPath) + } + }) +} diff --git a/dashboard/electron/main/store.ts b/dashboard/electron/main/store.ts new file mode 100644 index 00000000..73535c91 --- /dev/null +++ b/dashboard/electron/main/store.ts @@ -0,0 +1,215 @@ +import { randomUUID } from 'crypto' + +import Store, { type Schema } from 'electron-store' + +/** + * Backend connection data model + */ +export interface BackendConnection { + id: string + name: string + url: string + isDefault: boolean + lastConnected?: number +} + +/** + * Application settings data model + */ +export interface AppSettings { + backends: BackendConnection[] + activeBackendId: string | null + windowBounds: { + x: number + y: number + width: number + height: number + } + firstLaunchComplete: boolean +} + +/** + * JSON Schema for validating store contents + */ +const SCHEMA: Schema = { + backends: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + url: { type: 'string' }, + isDefault: { type: 'boolean' }, + lastConnected: { type: 'number' }, + }, + required: ['id', 'name', 'url', 'isDefault'], + }, + }, + activeBackendId: { type: ['string', 'null'] }, + windowBounds: { + type: 'object', + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + width: { type: 'number' }, + height: { type: 'number' }, + }, + required: ['x', 'y', 'width', 'height'], + }, + firstLaunchComplete: { type: 'boolean' }, +} + +/** + * Default settings + */ +const DEFAULTS: AppSettings = { + backends: [], + activeBackendId: null, + windowBounds: { + x: 100, + y: 100, + width: 1280, + height: 800, + }, + firstLaunchComplete: false, +} + +/** + * Initialize electron-store with encryption and schema validation + */ +const store = new Store({ + schema: SCHEMA, + defaults: DEFAULTS, + encryptionKey: process.env.MAIBOT_STORE_KEY, +}) + +/** + * Get all backends + */ +export function getBackends(): BackendConnection[] { + return store.get('backends', []) +} + +/** + * Add a new backend connection + * Generates UUID for new backend + */ +export function addBackend( + conn: Omit, +): BackendConnection { + const newBackend: BackendConnection = { + ...conn, + id: randomUUID(), + } + + const backends = getBackends() + backends.push(newBackend) + store.set('backends', backends) + + return newBackend +} + +/** + * Update an existing backend connection + */ +export function updateBackend( + id: string, + patch: Partial>, +): void { + const backends = getBackends() + const index = backends.findIndex((b) => b.id === id) + + if (index === -1) { + throw new Error(`Backend with id ${id} not found`) + } + + backends[index] = { + ...backends[index], + ...patch, + } + + store.set('backends', backends) +} + +/** + * Remove a backend connection by id + */ +export function removeBackend(id: string): void { + const backends = getBackends() + const filtered = backends.filter((b) => b.id !== id) + + store.set('backends', filtered) + + // Clear active backend if it was the removed one + if (store.get('activeBackendId') === id) { + store.set('activeBackendId', null) + } +} + +/** + * Set the active backend + */ +export function setActiveBackend(id: string): void { + const backends = getBackends() + + if (!backends.find((b) => b.id === id)) { + throw new Error(`Backend with id ${id} not found`) + } + + store.set('activeBackendId', id) +} + +/** + * Get the currently active backend connection + */ +export function getActiveBackend(): BackendConnection | null { + const activeId = store.get('activeBackendId') + + if (!activeId) { + return null + } + + const backends = getBackends() + return backends.find((b) => b.id === activeId) || null +} + +/** + * Get window bounds + */ +export function getWindowBounds(): AppSettings['windowBounds'] { + return store.get('windowBounds', DEFAULTS.windowBounds) +} + +/** + * Set window bounds + */ +export function setWindowBounds(bounds: AppSettings['windowBounds']): void { + store.set('windowBounds', bounds) +} + +/** + * Check if this is the first launch + */ +export function isFirstLaunch(): boolean { + return !store.get('firstLaunchComplete', false) +} + +/** + * Mark first launch as complete + */ +export function markFirstLaunchComplete(): void { + store.set('firstLaunchComplete', true) +} + +/** + * Get complete app settings + */ +export function getSettings(): AppSettings { + return { + backends: getBackends(), + activeBackendId: store.get('activeBackendId', null), + windowBounds: getWindowBounds(), + firstLaunchComplete: store.get('firstLaunchComplete', false), + } +} diff --git a/dashboard/electron/preload/index.ts b/dashboard/electron/preload/index.ts new file mode 100644 index 00000000..4f950e24 --- /dev/null +++ b/dashboard/electron/preload/index.ts @@ -0,0 +1,56 @@ +import { contextBridge, ipcRenderer } from 'electron' + +// Write __RUNTIME__ tag into the isolated world so renderer can detect Electron +contextBridge.exposeInMainWorld('__RUNTIME__', { + kind: 'electron' as const, + versions: process.versions as unknown as Record, + source: 'tag' as const, +}) + +// Expose the full ElectronAPI surface to the renderer process +contextBridge.exposeInMainWorld('electronAPI', { + // ── Platform detection ────────────────────────────────────────────────── + getPlatform: () => process.platform, + + // ── Window control ────────────────────────────────────────────────────── + minimizeWindow: () => ipcRenderer.invoke('electron:minimize-window'), + maximizeWindow: () => ipcRenderer.invoke('electron:maximize-window'), + closeWindow: () => ipcRenderer.invoke('electron:close-window'), + isMaximized: () => ipcRenderer.invoke('electron:is-maximized'), + + // ── Window event listeners ─────────────────────────────────────────────── + onWindowMaximized: (callback: () => void) => { + const listener = () => callback() + ipcRenderer.on('electron:window-maximized', listener) + return () => ipcRenderer.removeListener('electron:window-maximized', listener) + }, + onWindowUnmaximized: (callback: () => void) => { + const listener = () => callback() + ipcRenderer.on('electron:window-unmaximized', listener) + return () => ipcRenderer.removeListener('electron:window-unmaximized', listener) + }, + + // ── Backend CRUD ───────────────────────────────────────────────────────── + getBackends: () => ipcRenderer.invoke('electron:get-backends'), + addBackend: (conn: object) => ipcRenderer.invoke('electron:add-backend', conn), + updateBackend: (id: string, patch: object) => + ipcRenderer.invoke('electron:update-backend', id, patch), + removeBackend: (id: string) => ipcRenderer.invoke('electron:remove-backend', id), + setActiveBackend: (id: string) => + ipcRenderer.invoke('electron:set-active-backend', id), + getActiveBackend: () => ipcRenderer.invoke('electron:get-active-backend'), + getActiveBackendUrl: () => ipcRenderer.invoke('electron:get-active-url'), + + // ── App state ─────────────────────────────────────────────────────────── + isFirstLaunch: () => ipcRenderer.invoke('electron:is-first-launch'), + markFirstLaunchComplete: () => + ipcRenderer.invoke('electron:mark-first-launch-complete'), + getAppVersion: () => ipcRenderer.invoke('electron:get-app-version'), + + // ── Backend event listener ────────────────────────────────────────────── + onBackendChanged: (callback: (backend: { id: string; name: string; url: string; isDefault: boolean; lastConnected?: number } | null) => void) => { + const listener = (_event: unknown, backend: { id: string; name: string; url: string; isDefault: boolean; lastConnected?: number } | null) => callback(backend) + ipcRenderer.on('electron:backend-changed', listener) + return () => ipcRenderer.removeListener('electron:backend-changed', listener) + }, +}) diff --git a/dashboard/electron/resources/.gitkeep b/dashboard/electron/resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/dashboard/eslint.config.js b/dashboard/eslint.config.js new file mode 100644 index 00000000..26a7be20 --- /dev/null +++ b/dashboard/eslint.config.js @@ -0,0 +1,44 @@ +import js from '@eslint/js' +import globals from 'globals' +import jsxA11y from 'eslint-plugin-jsx-a11y' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist', 'out'] }, + jsxA11y.flatConfigs.recommended, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + // 将所有 React Hooks 推荐规则降级为警告 + ...Object.keys(reactHooks.configs.recommended.rules).reduce((acc, key) => { + acc[key] = 'warn' + return acc + }, {}), + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + // 关闭或降级其他规则 + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': 'warn', + // jsx-a11y: 降级为 warn 避免阻塞构建,后续 Task 17 逐步修复 + 'jsx-a11y/anchor-ambiguous-text': 'warn', + 'jsx-a11y/no-autofocus': 'warn', + }, + }, + { + files: ['**/*.d.ts'], + rules: { + // Ambient global declarations use `var` in TypeScript declaration files. + 'no-var': 'off', + }, + } +) diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 00000000..21c3be1a --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,30 @@ + + + + + + + + + + + + + MaiBot Dashboard + + + +

+ + + diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 00000000..77877140 --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,17778 @@ +{ + "name": "maibot-dashboard", + "version": "1.0.5", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "maibot-dashboard", + "version": "1.0.5", + "dependencies": { + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/lint": "^6.9.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-tooltip": "^1.2.8", + "@react-spring/web": "10.0.3", + "@tanstack/react-router": "^1.140.0", + "@tanstack/react-virtual": "^3.13.13", + "@tanstack/router-devtools": "^1.140.0", + "@types/dagre": "^0.7.53", + "@uiw/react-codemirror": "^4.25.3", + "@uppy/core": "^5.2.0", + "@uppy/dashboard": "^5.1.0", + "@uppy/react": "^5.1.1", + "@uppy/xhr-upload": "^5.1.1", + "@use-gesture/react": "^10.3.1", + "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "dagre": "^0.8.5", + "date-fns": "^4.1.0", + "html-to-image": "^1.11.13", + "i18next": "^25.8.13", + "i18next-browser-languagedetector": "^8.2.1", + "idb": "^8.0.3", + "katex": "^0.16.27", + "lucide-react": "^0.556.0", + "motion": "^12.38.0", + "react": "^19.2.1", + "react-day-picker": "^9.12.0", + "react-dom": "^19.2.1", + "react-i18next": "^16.5.4", + "react-joyride": "3.0.0-7", + "react-markdown": "^10.1.0", + "reactflow": "^11.11.4", + "recharts": "3.5.1", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "smol-toml": "^1.5.2", + "tailwind-merge": "^3.4.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.2.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.2", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "@vitest/ui": "^4.0.18", + "electron": "^40.6.1", + "electron-builder": "^26.8.1", + "electron-store": "11.0.2", + "electron-vite": "^5.0.0", + "eslint": "^9.39.1", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^28.1.0", + "prettier": "^3.7.4", + "prettier-plugin-tailwindcss": "^0.7.2", + "tailwindcss": "^4.2.1", + "typescript": "~5.9.3", + "typescript-eslint": "^8.49.0", + "vite": "^7.2.7", + "vitest": "^4.0.18" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.5.tgz", + "integrity": "sha512-8cMAA1bE66Mb/tfmkhcfJLjEPgyT7SSy6lW6id5XL113ai1ky76d/1L27sGnXCMsLfq66DInAU3OzuahB4lu9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz", + "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz", + "integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, + "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/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "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.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "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.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "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.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "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.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "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.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "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.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "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/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/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", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/windows-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", + "optional": true, + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/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/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/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/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/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/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/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/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@gilbarbara/deep-equal": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz", + "integrity": "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw==", + "license": "MIT" + }, + "node_modules/@gilbarbara/types": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@gilbarbara/types/-/types-0.2.2.tgz", + "integrity": "sha512-QuQDBRRcm1Q8AbSac2W1YElurOhprj3Iko/o+P1fJxUWS4rOGKMVli98OXS7uo4z+cKAif6a+L9bcZFSyauQpQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.1.0" + } + }, + "node_modules/@gilbarbara/types/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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/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/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz", + "integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", + "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.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.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "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/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, + "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/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@react-spring/animated": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz", + "integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz", + "integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.3.tgz", + "integrity": "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.3.tgz", + "integrity": "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.3.tgz", + "integrity": "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==", + "license": "MIT" + }, + "node_modules/@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@reactflow/background": { + "version": "11.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", + "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/controls": { + "version": "11.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", + "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/core": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", + "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", + "license": "MIT", + "dependencies": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/minimap": { + "version": "11.7.14", + "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", + "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-resizer": { + "version": "2.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", + "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-toolbar": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", + "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "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/@tabby_ai/hijri-converter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", + "integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/history": { + "version": "1.161.6", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz", + "integrity": "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.168.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.10.tgz", + "integrity": "sha512-/RmDlOwDkCug609KdPB3U+U1zmrtadJpvsmRg2zEn8TRCKRNri7dYZIjQZbNg8PgUiRL4T6njrZBV1ChzblNaA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.6", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.168.9", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-router-devtools": { + "version": "1.166.11", + "resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.166.11.tgz", + "integrity": "sha512-WYR3q4Xui5yPT/5PXtQh8i03iUA7q8dONBjWpV3nsGdM8Cs1FxpfhLstW0wZO1dOvSyElscwTRCJ6nO5N8r3Lg==", + "license": "MIT", + "dependencies": { + "@tanstack/router-devtools-core": "1.167.1" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-router": "^1.168.2", + "@tanstack/router-core": "^1.168.2", + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/router-core": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", + "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.168.9", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.9.tgz", + "integrity": "sha512-18oeEwEDyXOIuO1VBP9ACaK7tYHZUjynGDCoUh/5c/BNhia9vCJCp9O0LfhZXOorDc/PmLSgvmweFhVmIxF10g==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.6", + "cookie-es": "^2.0.0", + "seroval": "^1.4.2", + "seroval-plugins": "^1.4.2" + }, + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-devtools": { + "version": "1.166.11", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools/-/router-devtools-1.166.11.tgz", + "integrity": "sha512-jvFKr1fQ5pWMOZTILhitJc1kJt1wj8qqtRClVJvyD1AjHc1XINihkqK+R6+FmC8F2m+XOhKME4CSnTtJ6Nf34w==", + "license": "MIT", + "dependencies": { + "@tanstack/react-router-devtools": "1.166.11", + "clsx": "^2.1.1", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-router": "^1.168.2", + "csstype": "^3.0.10", + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + }, + "peerDependenciesMeta": { + "csstype": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-devtools-core": { + "version": "1.167.1", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.167.1.tgz", + "integrity": "sha512-ECMM47J4KmifUvJguGituSiBpfN8SyCUEoxQks5RY09hpIBfR2eswCv2e6cJimjkKwBQXOVTPkTUk/yRvER+9w==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/router-core": "^1.168.2", + "csstype": "^3.0.10" + }, + "peerDependenciesMeta": { + "csstype": { + "optional": true + } + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@transloadit/prettier-bytes": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.3.5.tgz", + "integrity": "sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA==", + "license": "MIT" + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "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/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "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/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "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/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "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==", + "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/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "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/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "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/@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/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/@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.9", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.9.tgz", + "integrity": "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.9", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.9.tgz", + "integrity": "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.9", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@uppy/companion-client": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-5.1.1.tgz", + "integrity": "sha512-DzrOWTbIZHvtgAFXBMYHk2wD27NjpBSVhY2tEiEIUhPd2CxbFRZjHM/N3HOt3VwZEAP471QWFLlJRWPcIY3A2Q==", + "license": "MIT", + "dependencies": { + "@uppy/utils": "^7.1.1", + "namespace-emitter": "^2.0.1", + "p-retry": "^6.1.0" + }, + "peerDependencies": { + "@uppy/core": "^5.1.1" + } + }, + "node_modules/@uppy/components": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@uppy/components/-/components-1.2.0.tgz", + "integrity": "sha512-rtIr+77Rw/q5Vw++xazF1dCg2d4A4zT9CV+ZyN8Rsx8xiIr2CxCR4TaHHBy+WeC0b7Mk6yNuJ0wUa34tFJ6pKg==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "dequal": "^2.0.3", + "preact": "^10.26.10", + "pretty-bytes": "^6.1.1" + }, + "peerDependencies": { + "@uppy/core": "^5.2.0", + "@uppy/image-editor": "^4.2.0", + "@uppy/screen-capture": "^5.1.0", + "@uppy/webcam": "^5.1.0" + }, + "peerDependenciesMeta": { + "@uppy/image-editor": { + "optional": true + }, + "@uppy/screen-capture": { + "optional": true + }, + "@uppy/webcam": { + "optional": true + } + } + }, + "node_modules/@uppy/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@uppy/core/-/core-5.2.0.tgz", + "integrity": "sha512-uvfNyz4cnaplt7LYJmEZHuqOuav0tKp4a9WKJIaH6iIj7XiqYvS2J5SEByexAlUFlzefOAyjzj4Ja2dd/8aMrw==", + "license": "MIT", + "dependencies": { + "@transloadit/prettier-bytes": "^0.3.4", + "@uppy/store-default": "^5.0.0", + "@uppy/utils": "^7.1.4", + "lodash": "^4.17.21", + "mime-match": "^1.0.2", + "namespace-emitter": "^2.0.1", + "nanoid": "^5.0.9", + "preact": "^10.5.13" + } + }, + "node_modules/@uppy/dashboard": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@uppy/dashboard/-/dashboard-5.1.1.tgz", + "integrity": "sha512-6H/xVvhhdfwp1+FRMp2C+tudyaedqD5+LMDB8Iw20k9+QCL1eGzOh4wXm6MCqJtNfQ1tLaprGMG1jlo7yS/uyw==", + "license": "MIT", + "dependencies": { + "@transloadit/prettier-bytes": "^0.3.4", + "@uppy/provider-views": "^5.2.2", + "@uppy/thumbnail-generator": "^5.1.0", + "@uppy/utils": "^7.1.5", + "classnames": "^2.2.6", + "lodash": "^4.17.23", + "nanoid": "^5.0.9", + "preact": "^10.26.10", + "shallow-equal": "^3.0.0" + }, + "peerDependencies": { + "@uppy/core": "^5.2.0" + } + }, + "node_modules/@uppy/provider-views": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@uppy/provider-views/-/provider-views-5.2.2.tgz", + "integrity": "sha512-NAazIJ5sjrAc6++CeJ/u9dB5gDaaAOLHrYeEmWs/HqLlftlIinRZOybnyzJRXwI8jWI/FK5moluzt2HXu6dPQQ==", + "license": "MIT", + "dependencies": { + "@uppy/utils": "^7.1.5", + "classnames": "^2.2.6", + "lodash": "^4.17.21", + "nanoid": "^5.0.9", + "p-queue": "^8.0.0", + "preact": "^10.5.13" + }, + "peerDependencies": { + "@uppy/core": "^5.2.0" + } + }, + "node_modules/@uppy/react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@uppy/react/-/react-5.2.0.tgz", + "integrity": "sha512-6lzPutg2XGavs7P6ALmqOBPitd/Jqi3r1jCJQD5nx8xtNlBRwvlBR6hrZgo8XOI9cR+OaNDrJ0vEFxXDWb04Ag==", + "license": "MIT", + "dependencies": { + "@uppy/components": "^1.2.0", + "preact": "^10.26.10", + "use-sync-external-store": "^1.3.0" + }, + "peerDependencies": { + "@uppy/core": "^5.2.0", + "@uppy/dashboard": "^5.1.1", + "@uppy/screen-capture": "^5.1.0", + "@uppy/status-bar": "^5.1.0", + "@uppy/webcam": "^5.1.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@uppy/dashboard": { + "optional": true + }, + "@uppy/screen-capture": { + "optional": true + }, + "@uppy/status-bar": { + "optional": true + }, + "@uppy/webcam": { + "optional": true + } + } + }, + "node_modules/@uppy/store-default": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-5.0.0.tgz", + "integrity": "sha512-hQtCSQ1yGiaval/wVYUWquYGDJ+bpQ7e4FhUUAsRQz1x1K+o7NBtjfp63O9I4Ks1WRoKunpkarZ+as09l02cPw==", + "license": "MIT" + }, + "node_modules/@uppy/thumbnail-generator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@uppy/thumbnail-generator/-/thumbnail-generator-5.1.0.tgz", + "integrity": "sha512-QAKJHHkMrD/30GOyUb5U9HyJ7Ie3jiMLp4pVdw27PoA4pNV7fDQz0tyDeRPj2H+BWPEB1NsTSSfHI2pjHNI+OQ==", + "license": "MIT", + "dependencies": { + "@uppy/utils": "^7.1.4", + "exifr": "^7.0.0" + }, + "peerDependencies": { + "@uppy/core": "^5.2.0" + } + }, + "node_modules/@uppy/utils": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-7.2.0.tgz", + "integrity": "sha512-6lC246qszMv6bTyl/+QyHwrudgeguWkA94ME1wHn+a6uRAvmtAEaUManIfGqTJfoKvWAiCJqdJPl5xRJjhAloQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.23", + "preact": "^10.26.10" + } + }, + "node_modules/@uppy/xhr-upload": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-5.2.0.tgz", + "integrity": "sha512-3LV/X5Of6BINnKplP+CwUJ0a4/7cRFfzxwGyXnW+uCrNQHoo09dttcz3begWHejGvzenQHuUnMO3Fxyc71Pryg==", + "license": "MIT", + "dependencies": { + "@uppy/companion-client": "^5.1.1", + "@uppy/utils": "^7.2.0" + }, + "peerDependencies": { + "@uppy/core": "^5.2.0" + } + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.2.tgz", + "integrity": "sha512-/irhyeAcKS2u6Zokagf9tqZJ0t8S6kMZq4ZG9BHZv7I+fkRrYfQX4w7geYeC2r6obThz39PDxvXQzZX+qXqGeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.2" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "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/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.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-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "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.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "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/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "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/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "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/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.2.tgz", + "integrity": "sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/baseline-browser-mapping": { + "version": "2.10.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", + "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "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/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "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/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "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": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001785", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", + "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "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/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/conf": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/conf/-/conf-15.1.0.tgz", + "integrity": "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "atomically": "^2.0.3", + "debounce-fn": "^6.0.0", + "dot-prop": "^10.0.0", + "env-paths": "^3.0.0", + "json-schema-typed": "^8.0.1", + "semver": "^7.7.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/conf/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/conf/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/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.1.tgz", + "integrity": "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==", + "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/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "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/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, + "node_modules/debounce-fn": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", + "integrity": "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "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", + "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", + "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==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "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.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "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/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dot-prop": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz", + "integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "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": "40.8.5", + "resolved": "https://registry.npmjs.org/electron/-/electron-40.8.5.tgz", + "integrity": "sha512-pgTY/VPQKaiU4sTjfU96iyxCXrFm4htVPCMRT4b7q9ijNTRgtLmLvcmzp2G4e7xDrq9p7OLHSmu1rBKFf6Y1/A==", + "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.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "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.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "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-store": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/electron-store/-/electron-store-11.0.2.tgz", + "integrity": "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "conf": "^15.0.2", + "type-fest": "^5.0.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron-vite": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-5.0.0.tgz", + "integrity": "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "cac": "^6.7.14", + "esbuild": "^0.25.11", + "magic-string": "^0.30.19", + "picocolors": "^1.1.1" + }, + "bin": { + "electron-vite": "bin/electron-vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@swc/core": "^1.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + } + } + }, + "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": "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/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/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "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-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "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==", + "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==", + "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/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "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/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eslint-plugin-jsx-a11y/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/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y/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/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/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/eslint/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/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/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "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/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "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/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "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.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "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/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "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-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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==", + "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/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "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/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "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/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "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==", + "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==", + "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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "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/hosted-git-info/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/hosted-git-info/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/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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/i18next": { + "version": "25.10.10", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz", + "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "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/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, + "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/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "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-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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-lite": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-lite/-/is-lite-1.2.1.tgz", + "integrity": "sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw==", + "license": "MIT" + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-network-error": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", + "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "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/isbot": { + "version": "5.1.37", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.37.tgz", + "integrity": "sha512-5bcicX81xf6NlTEV8rWdg7Pk01LFizDetuYGHx6d/f6y3lR2/oo8IfxjzJqn1UdDEyCcwT9e7NRloj8DwCYujQ==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "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-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "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/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "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-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "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/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/katex": { + "version": "0.16.44", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.44.tgz", + "integrity": "sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "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/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "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/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "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/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.556.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.556.0.tgz", + "integrity": "sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/mime-match/-/mime-match-1.0.2.tgz", + "integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==", + "license": "ISC", + "dependencies": { + "wildcard": "^1.1.0" + } + }, + "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==", + "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-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "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-flush/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/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-pipeline/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/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/minipass-sized/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/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/motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.38.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/namespace-emitter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz", + "integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", + "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "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/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "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-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "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/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "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/p-queue": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz", + "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "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/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "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/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "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/promise-retry/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/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/proper-lockfile/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/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-day-picker": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz", + "integrity": "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "@tabby_ai/hijri-converter": "1.0.5", + "date-fns": "^4.1.0", + "date-fns-jalali": "4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-floater": { + "version": "0.9.5-5", + "resolved": "https://registry.npmjs.org/react-floater/-/react-floater-0.9.5-5.tgz", + "integrity": "sha512-9EFcYn8YOfqBoy9mAX0nnaG0KVvlTqOUCGMZgdJmI0ddvmZfnutlAo9s2RI89Up5ZN/pYuae8OObwZ5TBV91AQ==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.11.8", + "deepmerge-ts": "^7.1.5", + "is-lite": "^2.0.0", + "tree-changes-hook": "^0.11.3" + }, + "peerDependencies": { + "react": "16.8 - 19", + "react-dom": "16.8 - 19" + } + }, + "node_modules/react-floater/node_modules/is-lite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-lite/-/is-lite-2.0.0.tgz", + "integrity": "sha512-70f2BMIQlbSUXVKaZUd9a9fJH3IH1PDckV0m4BIIO4LjnNYvOh4Ng7vXIXEwpA0KDZknRq+7fHwGTu0jIdx28g==", + "license": "MIT" + }, + "node_modules/react-i18next": { + "version": "16.6.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.6.tgz", + "integrity": "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.10.9", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-innertext": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/react-innertext/-/react-innertext-1.1.5.tgz", + "integrity": "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": ">=0.0.0 <=99", + "react": ">=0.0.0 <=99" + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-joyride": { + "version": "3.0.0-7", + "resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-3.0.0-7.tgz", + "integrity": "sha512-NBgtdm8QehHEVI/Qkakb4EJ/WjKN7bQaZgZmO/01v1p2yBlzAcXyKM36FeS1YZaywX8v8R79bF5Z0OcV5BK1og==", + "license": "MIT", + "dependencies": { + "@gilbarbara/deep-equal": "^0.3.1", + "@gilbarbara/hooks": "^0.8.2", + "@gilbarbara/types": "^0.2.2", + "deepmerge": "^4.3.1", + "is-lite": "^1.2.1", + "react-floater": "^0.9.5-4", + "react-innertext": "^1.1.5", + "scroll": "^3.0.1", + "scrollparent": "^2.1.0", + "tree-changes-hook": "^0.11.2" + }, + "peerDependencies": { + "react": "16.8 - 19", + "react-dom": "16.8 - 19" + } + }, + "node_modules/react-joyride/node_modules/@gilbarbara/hooks": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@gilbarbara/hooks/-/hooks-0.8.2.tgz", + "integrity": "sha512-aWXlJFCrqmasGaDd6IhSpqOFeOD4pSBpRtILKw0WxWQzWE+HYCA0adLf0P18BNztR/G0byWnpkGupeGx+NFnuw==", + "license": "MIT", + "dependencies": { + "@gilbarbara/deep-equal": "^0.3.1" + }, + "peerDependencies": { + "react": "16.8 - 18" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/reactflow": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", + "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", + "license": "MIT", + "dependencies": { + "@reactflow/background": "11.3.14", + "@reactflow/controls": "11.2.14", + "@reactflow/core": "11.11.4", + "@reactflow/minimap": "11.7.14", + "@reactflow/node-resizer": "2.2.14", + "@reactflow/node-toolbar": "1.3.14" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "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/recharts": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.1.tgz", + "integrity": "sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "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/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "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/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "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/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/scroll": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz", + "integrity": "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==", + "license": "MIT" + }, + "node_modules/scrollparent": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz", + "integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==", + "license": "ISC" + }, + "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/serialize-error/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/seroval": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.2.tgz", + "integrity": "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.2.tgz", + "integrity": "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shallow-equal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz", + "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==", + "license": "MIT" + }, + "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/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "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-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "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/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "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/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/string-width-cjs/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/string-width/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/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "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/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "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/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true, + "license": "MIT" + }, + "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/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-changes": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.11.3.tgz", + "integrity": "sha512-r14mvDZ6tqz8PRQmlFKjhUVngu4VZ9d92ON3tp0EGpFBE6PAHOq8Bx8m8ahbNoGE3uI/npjYcJiqVydyOiYXag==", + "license": "MIT", + "dependencies": { + "@gilbarbara/deep-equal": "^0.3.1", + "is-lite": "^1.2.1" + } + }, + "node_modules/tree-changes-hook": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/tree-changes-hook/-/tree-changes-hook-0.11.3.tgz", + "integrity": "sha512-cNHPuFc5Qbi2B74VqSqL/Ee/l4n0SFfzYKTnXYViJW1yCFZ0bl97QsgUIw9vdQtqpWDwo83mpNkGUvcjeQc0Xw==", + "license": "MIT", + "dependencies": { + "@gilbarbara/deep-equal": "^0.3.1", + "tree-changes": "0.11.3" + }, + "peerDependencies": { + "react": "16.8 - 19" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", + "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "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/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.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/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.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/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "dev": true, + "license": "MIT" + }, + "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/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wildcard": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz", + "integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==", + "license": "MIT" + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.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/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "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/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "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": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "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" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 00000000..0656c4a3 --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,192 @@ +{ + "name": "maibot-dashboard", + "private": true, + "version": "1.0.10", + "type": "module", + "main": "./out/main/index.js", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "test": "vitest", + "test:ui": "vitest --ui", + "electron:dev": "electron-vite dev", + "electron:build": "electron-vite build", + "electron:preview": "electron-vite preview", + "electron:dist": "electron-vite build && electron-builder", + "electron:dist:mac": "electron-vite build && electron-builder --mac", + "electron:dist:win": "electron-vite build && electron-builder --win", + "electron:dist:linux": "electron-vite build && electron-builder --linux" + }, + "build": { + "appId": "org.maibot.dashboard", + "productName": "MaiBot Dashboard", + "directories": { + "output": "dist-electron", + "buildResources": "electron/resources" + }, + "files": [ + "out/**/*", + "package.json" + ], + "mac": { + "category": "public.app-category.utilities", + "target": [ + { + "target": "dmg", + "arch": [ + "x64", + "arm64" + ] + } + ], + "icon": "electron/resources/icon.icns", + "darkModeSupport": true + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ], + "icon": "electron/resources/icon.ico" + }, + "linux": { + "target": [ + { + "target": "AppImage", + "arch": [ + "x64" + ] + } + ], + "icon": "electron/resources/icon.png", + "category": "Utility" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true + }, + "dmg": { + "contents": [ + { + "x": 130, + "y": 220 + }, + { + "x": 410, + "y": 220, + "type": "link", + "path": "/Applications" + } + ] + } + }, + "dependencies": { + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/lint": "^6.9.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-tooltip": "^1.2.8", + "@react-spring/web": "10.0.3", + "@tanstack/react-router": "^1.140.0", + "@tanstack/react-virtual": "^3.13.13", + "@tanstack/router-devtools": "^1.140.0", + "@types/dagre": "^0.7.53", + "@uiw/react-codemirror": "^4.25.3", + "@uppy/core": "^5.2.0", + "@uppy/dashboard": "^5.1.0", + "@uppy/react": "^5.1.1", + "@uppy/xhr-upload": "^5.1.1", + "@use-gesture/react": "^10.3.1", + "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "dagre": "^0.8.5", + "date-fns": "^4.1.0", + "html-to-image": "^1.11.13", + "i18next": "^25.8.13", + "i18next-browser-languagedetector": "^8.2.1", + "idb": "^8.0.3", + "katex": "^0.16.27", + "lucide-react": "^0.556.0", + "motion": "^12.38.0", + "react": "^19.2.1", + "react-day-picker": "^9.12.0", + "react-dom": "^19.2.1", + "react-i18next": "^16.5.4", + "react-joyride": "3.0.0-7", + "react-markdown": "^10.1.0", + "reactflow": "^11.11.4", + "recharts": "3.5.1", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "smol-toml": "^1.5.2", + "tailwind-merge": "^3.4.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.1", + "@eslint/js": "^9.39.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.2", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "@vitest/ui": "^4.0.18", + "electron": "^40.6.1", + "electron-builder": "^26.8.1", + "electron-store": "11.0.2", + "electron-vite": "^5.0.0", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^28.1.0", + "prettier": "^3.7.4", + "prettier-plugin-tailwindcss": "^0.7.2", + "tailwindcss": "^4.2.1", + "typescript": "~5.9.3", + "typescript-eslint": "^8.49.0", + "vite": "^7.2.7", + "vitest": "^4.0.18", + "eslint-plugin-jsx-a11y": "^6.10.2" + } +} diff --git a/dashboard/public/fonts/JetBrainsMono-Medium.ttf b/dashboard/public/fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..97671156df256e850498054fdebcd41d74a65d6b GIT binary patch literal 273860 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%0AmA2ptU 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 literal 0 HcmV?d00001 diff --git a/dashboard/public/maimai.ico b/dashboard/public/maimai.ico new file mode 100644 index 0000000000000000000000000000000000000000..3c1b131edae2059a73c04b9eee7a1bb9d527d28b GIT binary patch literal 119148 zcmV)WK(4<400962000000096X09Mff02TlM0EtjeM-2)Z3IG5A4M|8uQUCw}00001 z00;&E003NasAd2F00D1uPE-NUqIa4A0Du5VL_t(|+U$J=cwE=D^=MekJQ|HgBOWm` zGlOLY8R9TAGlQMjmJDHLa>I!^)Ff@vG}JT=G@ORPy#BrSxigX-JJj_3>3h-loli6x z&D?wT+G}k(I^F;BkM8as-5qy4s7p>xk?$SxDb~P%;99fUa*bZE|J+CC1A|_N0ADjw z?J;Pr%)_jnI?U^@$GidhJkWr-KXZ-H-_HF7fA?Em{C@&h2*V!otFIiHV82-#`ABeKa&Q$mfEB z0-eEV*45S38$v=tGc6|TNk+ZldD;}4jkG!Zt!5~P0ILZ`gU?Q*!SIFAXu99eFK|*!Ok6nck8lEj zqmDU7*VNRctF5j5-EjUleB|Zj=|<+x)rEwF$oDa^@o|2B0SoEoZ!q}izcZ2H2Utu< zwnd?~G#f)*^_Vr-hB<>RvVrFEX4e3KzaJU=9RU8jz=}bj3A5>b4t3U{qA(M-s1W#? z^u+dv@fl3-69c%?-!GscAwJ$$_DpV`E;1}!mynR~y8->TeoUV|TLXYXozY;_CA*S+ zgMxxvO(x4tMuX`yy^kJNqaOC?a8ws%VYssfv&mcV8lep{LZ5~5_t(mrH&FjO0Q{FN zzJK$C!8OSJWFVL|(2V}p3KYAO5E&9cAizMN-%gC+6^qGyYD7eM>I3)Q=VObv>Gs=i zKV5Ng@$bg;-}aH2lcVb#?AL_^hw1?6LPJBH7PI+8lfm$sQRlPMOgB9;Gzi5xX&7j$ zrcKeLy!}9(SL>_gq5f+9rvc!X>g!*Vy8owN!~Zk@%kwD@&L_Qc}|IhV@(j;6P4fRFp280DwQ@YzYDW z{(;?Qv-w`5UjGdTfr9B~XF6@@s4v5effn)ja|Y@)yJ3qAB}V82lU}#eXwbiIwOURf*=Xl|BeP3k${D zM@7f+XP@BU&@{8zdZxi(d_(W!v(sYmA?+86`jTu+>#ip+&@A474SU@4*L&C8JuM9g zek%a*f6b%++l%k_9s-54`Ww;LT!FmwB!mR`N+w9J_xZ|XGTrF!?_XI^P+;`+_0tVc z8rDTeN9#^K_0-=D?zi&6MjsOsqpPj2(;4+foimwJdx4!Mqv-*?kKsFmj}HRLr+5Ju0&EMVQ*Y8RdIf6VmzD5TBCjp@Trvret_GIJ|0HdGJ(KPVf!6sQF66Y9S z94Vh8k8SY&OpHZa>Sq(5`@bU%I5N2VJyiE~M2ic^eebW4_cMpC%e1~`)KwHBE;<}W zqnt zhwJsG*9|^;SoA)y6SZqB&B2Tw-r!nV&+FCeCVai;G?HhHZ~Xs(joj~vJ7}PwZRU|d z%@F%1E(Kg8{o3msYLdsXwBa=7^xg(c?y5t7YZba0%F$d^gj%{DRYZF$in35%kRhuy zKOJSX$_q15Q|d-bRW5p)N-^A7iy8gR^n5K692H%yf#%OK{9gkACO(G(OU;-Wum&1` z^d5SQUjZk-k2)+M0GQ8#-hl=!NZ3M-q5@ei8v?B+$rR`fhL6k^>!RS0u=GdPJm?c0 zqa;C6((kFl|BppI-Ad=j#_1k;^kJWnkkD+i$+Da%|7UFcfxZ^FlM~S2R6&N{q!7RV z*YVK1Z^U0cFgJk(cmZG{5XpNXFz6%C)yI!%6lTN5KG;@C#+#4wymaJeBq7}qhs2mL zM1=+lz2$(Huh|GIFN=vjo789d+6)VQ?`tt4$k&AMU|%HA-=@=bEXYbhOJzO*Y5h+I z!aZHD|7igDAxXrnZ+?HRH}I=~wuykC8Pn)}Ru{Pu6&3^|gMePY*=RKV!QVfiE-SOd z7#QTQ8yPu5mz|yayD|Pxe{e*0zx@u>Su7S^X=$l3C@7@CWU@R+lz%JVtZ3ToRYlpD z(%mRGb7VKV{O@eQIem4zZ0wPL^ZEAn_ukFj?}~A3eG$S%B7|Bit%1evU+0lV1Q>I|*)M=PLeJ1A2nTrJr7GmH14!{8i97OA2 z>`$Nf*=JvjjLf6Q%s_8%AF8Tr;7oQQG&mShApufQGqYc@j2|=j|1;?xK|+x`3<9)f(%xySD}ggU3KpZ(4eT%)jeqj<3!Gs~Nb={sSW;R-vNKs16cqHk zVg65i*ladkQBjf3-`}5gm|s9Zz;vU*_$LnZnT-ZGV#Cm0TSS|*S#IRWuSh?C%?hX>YR<(1dt#FI}$ zdTJ`th*1o8)k`Yvhpx}BeZBS!0F6Hj0DjKv8PQUPvuO_vwpAe~)jlo>^oF<1X5ZtZ zV{Fm9C)3i?b>ZRRzXu2ZlOJ($adMGeE|;q4i;0P_TCGPK4f;0?IvxCox@IINqQ9kr zZd#+%fzS0s^!w|+M_GSKwEfrK(0gS99IPYn!(M)#7XZd7DyOez^tGTcD;Y82Avo;N zLvYj0x8T`7J&zAQ{uG~l`UT#5|3kd__B;5?Uth*UkF3FUH{OEFMpxjh^De}xXPk|b zPd*(d9Dj#w^R_uv08))OFX-1IcoZ+ZsnHa&|+2{a}T4-?q< zqP4n!0O&s-*!%J8-MHKOdC#G|{q6xy&%POFTpU20)?1JA{B)90R_J{=OJMxmWVS8~ z4hfL}l+)?dku%a278d?)tiSfh(9n<=f3h=Kz7GuzjVJnlp+T?z7Z-2@`&ozrr(w9W zUTE>igq!Gz@Q-hpUk(7jR$A@HyyS=$t)J(qMwHE}0#uy0ZmljxNlpf$NuO4fmEz@> zU%~qyeu9@?c^yyx@lUw!x*Krp(MMzEjOplTZ$oim0bH(R#K*-VB0LOXA;Aa>4nk;P zpsetqAVh|S!X6g~cX~P+YN|19@?@NJ;>lS3hlj9s!_!ztjNtIY4o85+h>~mUT-+sSVQflY>Qj z+eE*n_TQ^#@}%IrKWoDDo<`J{<-=uOBFTs?Mk8j=nu8BN`WOEE{EJw=dI8F`I*Jz{B-H&hm3Mt_S0%Q&JtGN35iI0!WSVgT4%)c-#v1$HS|864l_ ztoQ3mvWe~olQ%EI!3P|O*)wJ$Fd!I*AAU3ez^8cPiKnsO!iB^Ng5h>1pe!Q^m03-OS38@^{mzch9FvqZg?s>EP&$OX>ica=cnmW%5Q1!cNQUNJN|L1cIU_j;a zGssJq6c^#9n{LI%jZfgnBacP^0l-Noorb@^@eUq+Y#pWyPl7!<6y@2;sLo16joY*A zyRAgj^2)Z$s-ab$Whd`Xj3Fin2{9pviy^5%5+l={i5YX|V$o$|xb60PP*z%wn1~Pz zv{%b@mq4rN=z3Xm2R-ze(`z-m6k92eyX$-PG+vENxJJ7g;@))n-T>j}2LAR8QhSpI zDlN;esTqTG4KkAL@HH8vdeCSzybur=G^L=h&=MIQA^Z1tIq(ZU%F8R{b68louC%Po z7#I-HY&00x>wSE7n2dTjY*FYU1fDg}^u3_YZ=vMWTY0t@0N53bb}L5eSHI)n-vTLC zX~Z=0wu!Of7#bMBCJD?vg@X@096`aMIQ{gq@WP9)VD)_up|ibR0HBP$eHM9jx3;ng z1Y|kJmjHlT%m}g)qfl9#i^C2(7^k0kCeA(Ye4Ksmxj5LUi{G;_}O{LRVK8 zf&;A*5Nod~LT^*K6bms(%^+DYv#(yo2ej%*S=##>k%lTyCZm458gbCmV^Kd&%Lf`J zR4X?8L;(2rBL6?D&wxQ`s0oug>yVwA2tSK~%ZFhynO_SC2%44ZbomAah3ZZ@ zTH5bM{c|)we?&w^>xznsO+kS{y=J578KaMGr?1(7G-o{e+G;R+s8vc>C+f60KiO0K z`#0aS_67hWKRBtTS*uTI)PO;GOs=q-&n#w;JSVLM^m7J)DYVJBk70Ol2y54DloTBU zKuBm9PCW4xYt{fCkOk2M z1Tn#WNTk=6o*0YVR6B~ZT&OC{LTha?2D|Dpjozm;T@h^-rZ%{HfOY}}r!_)rjs_fa z_twbs|GUY^bMK|Iuo=VM^(b_wAkfdE+KUawH!K$41)-slenz83cf$==>MANKemCwP z`|zYth}!B(N-ItN{=q{gqxl7{=l8c5k&~W;p^jQ@Gc``wNdL0{@KYqf57(%xmelcL zn)(SrOEQ3=T8vDpQ(k7E6@zW{hzSe9VE-W2Jh~q1H#~uZ4q*TY#}On2)~wrvd+&P? zO^uC6j0s068GZ!=K=uRxa4XMWD~s3w8Gm(l0{uRb0KkbXM=Yv}3ovia2u5bl!^o_; zm^FPiX3m(6NyF37NetkMtFObXIkVvBZ$z>!lKv*1fFlYH0swnNFk(Xj5bke5h}Dd+ z04w4NjMAJo6lJHNp&}psZB>}w*GLSZNy~IJcmcr^h#S#BU}SLD2Ce^*66{~h_*5T3 zXg+%Q865*aB?tQ4 zn&)Su7ww-wpA%50Y5!U+CQwaspgc1XZbvkdEf5L2@DjaZwQ%AyByS#@n!A z!;_dhZyvoCBeIgBi9INwDbGqmIsMPlOegY*1!N}1z!@6`TVxO-gM0}PObGEcBR(Ps znZ)3Vvr`EW3NX}OC-n@{XF#AX(q~ZJ*ZC7VGw1zAnSh7ADNt_~5GXHnBbY$IAZbFw zn-+^@VN6VnR1F?<&_N_`%YQfa-9Pr6Ms+w9{fAeivyZ#9rb=2_)4hq8o2Of;a)@{VYkFCYjDN_(1#Q@+U383`|qi- ztR_I=RYgF;Y@&*un-~B8iY$7b3uN^1amDZ?w;S z-VY6_)B=PvX#)+lSIK;Wyfg=C_i%*zSrJOc5fv1G#E3AY#zrE~l|cH^g~;$=Was4J z>~k)`wb$N;!w)-F0AT)tg(Lws2ng)6U;*MH!%$3Ysho_P;{cT;11dZ#$3>rA5+f)h zZ(W*^gyM7u3dzv(Q*ET-hA<3I27-f-EJs}Qnq2bQvXgC6mk=Koj0k$qA#|T(gZz+gi$)2(u9k{C zOy(}czD5B^#f}p%Ey-1`wNxTzdcy<|0J_P3z6B$s56pU45d1_E($asFJw&L25B z1-~0Ve-JY2Vq;@( z|5JJV?@?CGi_NCjH?^k*%{7I{%W@H24@EH1<51GfaUp?7ChDC-nz=NCJUIQnmJFvh z*NKW;mjnSF2~Hey)EPMUoF&+2pMwxe5@5!RS$J&idaU2{1WrBWRM?_Po6~b-C&vjD zPa&=DjE&GJeHh{*LWEIpNpD!7A3_6(qSHnV4)E7j078R;5g8In8a@U+J^i@j?)$KA z<1@rEHeqOJ5(0b;a3@82vkSF39%hk9^1>mj*6kSonm^|xsrZB2A*_St3Co9~)C78+ z97mwn73?Qnh(W}566jjwrQ6Y1o`XppHPV1IdqS3H-fsW^ICG)O&XueaIUswAK!BJ) zb#V@Y7z89RXn4zNweFLgoa`GC8YX+cq@?8kw(%$PthK~M-Qh){>VN10v(9&P|4c7(!%7B=+CuD4c!9WjNxn6Jd)d zZ`sn02OoL_8#XK;U2-xLfF&mvF15CjQlLKGcj93I^JLGq z2ooqLIS@orPE8;-yx|`ZIMjasJ6?uCsBR0(#bsHlDvv#)c*ZO?q$IWIXLamR831yUkrEe$VBY{to-_+* zoxT_+9(OJZ@+y&$o`V~2x)qN<@gzxs^|kPC4xyq^D;hjMxfOc}X+UV$iA~wxU*&Fc**am*sX$cz+X?Cm5;4B_utW?GQjH zr^n_xpvB7T21fhu&G-rIz-ymVnT3<5@@>2?2rpqaV3d6wvC9xM{5%gxLCzh(Rd z1x31{;VJTUNMK-+*<`%QsMl{}&u@ziCfb*$jqYk3A9Kr=hK-LnwYqN*Vz~3<3gz zU^3C>Gnh$_a!ICSLM;`Wb;VH^J~S`}lwlR`Dz|{fs9K z`Ey{83^gh&7&bE6Bxyv7LQZl#iZc?)yE_F0t7uirO7=<#z8>{%y8s}+cPU*arc|5d zqOVg?mhM89Jx&^(IELYG(hIYpg&8Q9KuE`A}b#@3w`9AcH(sNhW*x6!Lm5jpmQ%;nhm?M$4ydu))c#CRTa4< ztyMukugK4$$EA~2cM%{tk(-``tQ3wl#3Pv)KtfCe{l7q=yM87kthCWBvt3WgerND$gezmOloVMq0Y(z?T{e;mF%r4AMFi7^ z_La1ow4SQs67t3dF?ePK2}HwF9kIwsOC-ZgK~;Vx>PvIbT2+AVhB6rjI;o=;le_AL zit?I4-fvcKBWCtA&}Z_JoNl9^@n@c&sFpTWm^S+C-toncpUHLqM7Md(r01E@tIpq> zOf0Ccxl(!r8t64JFtU+mIuqa^7Ql?ij~IfQ>NJ8Ysp(;0CVg(GY74$e}7ktekh<$N$jz3)=u8GLcaH6%c0F&D) z>Gju==T|2^d4h*Ob-v?708kmJ?=yfs1HkwI)!D=rCwDa<%VmevNG!ldx(HW?goI>z z;$r_RPa@(W30wii=kzh!k&di3nM}`cP{f}$L>kdUHky{Abj<3k7NhqVzW_jA!vwAU zb0fDu7XZdfPd$fAinpgVOdGtuG#BaQ-6H~gNrxI}^ZEx;@!#6$ZypuS14T!OUCj}rsPld=*GRr6 zBjLrr@1fOOH~u*7@8|T^$x?qiZU}SO>(bxO?yHg1CkN`L&~x;(mZ7047bSVAaHrX2 z)?P#q(|CjT{c>6~Iy3-|m~aBZI1~^=;9zMbUq6ur1OPd4^f{iuK&v+(AgJP416}(< zVkT+CmSV$*iO}8(qUYukWiEBDE6zf1LoudxRw*#}84W|b1HjLb0G{-q8q?l{uDVib z+Ty9zdLN%Hna-!*R2nFM+ztu&aZyfj2GM4R4UXtYM27?s0J0wUk!3X+#Y-}U<>H`< z{B$%`lA)7Vok|{-L1>ob!<)2bqGmNZOfz;5Tl%qdsKWRdjCcW{P6GgC z1hWSeGZ^6^EV48@+@~Hlx37LeTW|etg;1&wf)C^1>dr1=DK#WBxZsQX>%)WnU^S^Y zgD)|i5I-y8hzTXzBBW5MT$od`s*%WXNSuJ{5NdPBvj{x%fJ>Wml44+w3KD7NXVfE{ zWF~`c6)}_U>OxFvF2&65>YoJw^RzuVPYX8g-S9n`0M97*T1gY~HJsW}LtvGHI08D8 zLCzoe(rh-L79AZE$On-8uZjn7%j&^BbKt$}d-|--+mbut?fI^gnRmTSWitMX15E5q*!UCa z>zw2Sl)1GamNeQZOjshG*U3FivzIS=ju*Cu*A z1FTvjMD5k8pYZi=KkHXAVYW7kTx5f+28rDWQ1sT1-^ad24ZJ-|#dm}f52{K(9d>KO6o_$U#nZA$ zX)>2J=cd@CNh-w8LZG5Yq`x21;$tP?+)|!}fyN@t=&JTsm(JVmu$7VBV*nHD0d_fc zsPTK{!s^hKdT;4EuUXx-Xf4Z@3e0G2>^QOEfBO1brxg_y8N#($0{<(z`&jpLfTJKk z&*bYLu)xP)_|U+kyht+_(2c3qYSG)uGDu(6{-T208?9P! z=l0h~_t`9Z%oN%PZPj_C9TO1~9w>S;)ZZ%lGT)UTQ+8^kV8^MYu@c6IY_Rs{2sizX zfsM;)?L1h6Xf7W|ZDyKlCQ3@rn@L)q58~*kFUI8VI!y0VM^t${hC0zlTcRITCE=V2 z2U`drvM2AWA2%+Ja!+79^Y${RWS$2AWGO%p8ZXpdoi{K?Gj7k%#oNoY;wFvqkIMli z59+)!V%{$4(z*$QSv9a7)lvR0@dB?^D-N?HNz;(4O|?ZezuE8!TZ-U-foxx=Kvk%w!lP6{REL6^|qP=~!`Mcz0|LrneZ^XZT zx08T)C`GO6J?GY2dJLzGCbd_ixiSygWSCrd;!l(@ObkDgklLXl`BKrAhz1ErX{jp7 zZ#yNBSC&PZ-kE?z0t;pI1_^d?z=vsU4(Zvt(j4@YcIO75*>X5()A)%+6XO&Edm0?N zyWSi>=4$sI1n)qOf%;vh?RY_9Lfb7rj@iX*x-PQ_6lcrVJlKOlu}=HG&inJ(+VrIb zY$PHRl-D2W8)pa;hqHJ*=$zd%Ii6$4G)6KqVF>`y`4hH z*7Z#5tpo@G1gg~;G{-#3)!%N)IEF?`mYeq{L3C`eUN=6c#lV)#Vw8;D-k zXT_n;ZIjntO%k zQNENPyibe>kj9@QyE)KZv&nOgM!N2jp-L;&y} z{Up8R6WfOCNbgmmx3O60B_A5fW1hL7B+SoDIz60d8GH0p3G9isXOA!W`$VmE)*%|d zfHq$;=e`5U+Z%m|p6U=1XhpI;R)#}#k)EH<2EhS4Eoh`#m^Gsw+{@`S-y115-d`XT z-y661{$Zsf+Cb4=n`I^*1lqLQaMbZQi$~Yb(G1WNwH}{fH2yx6r*7KSYkLkD?f?G4 zoO3->lsxZO^mN_0RG8TJ@2R!#uk*5o+1fzP5pS8VIv;_1@Ht4z^$J{a&5cf0Bg-wF zWE5-!4r#UsVsSQdI*Ae} zX@x}d`#pz)G?L-hqQ9Yp^qw1eDG7+D8_hN5T&~N};=I&2X-bhCzLeu~8ZTZxbC9Vb zm*^wC=Je!v#M6f1o^}o@vG<6N3`HKdvep)2D*d0?Uc>NE`-ufj6UShwL7Cq>$#xIL zPUAqKjDMb2Z%zpC4XU!_`F$MVYZBc)N6Yii?Bm*Wm9rnF*Em2LyRW$%U35*_YYNd; zRUoanvdXg2T$+u>vK%SRZX}k}T#=8q%6xQI7oe-I1bs~vqSL2z)?fx1$E=K&kdP zga`Ty7%(`8_?uy)dz|5jmY{59u1mTZYh{)}qR{^=y05Xs0s_bxaONO6CKN?!c9rGH zOF?T{76!?2d>;rJ_v(oDB;#iF)Jn{&I@|8;q~x;a(kv%z5kbskwwsNnRnbu~QNcmM zA_e|yK1%XSbbov8HC6E!6zZTia*x4Bx5L+BLYgB+tATfl_m_bb!~l9KbAED~@5fkx zC*Y?}u~o)5y|W6v^~Go`;mE5~YRW_D^#l{zCJ=4UNsg647?n9{R93B)!>@JQmq;_SI;WXIctK3+T;}(`M?l zr47#9#rSvKG~-8nt9}E|q+I@A5=Ccl>uZ!$;iS%5bT^ixp|Sv#MOi4Ojhj!`*`01j zMoK(f&KTHjQHW*c5)q29;6Ma(O#{*3AgfvG4gxL869#Zuuf+^Ms};T$qRAHZm|%L} zA^yH{K0z#38AeCINzch=6S|2Nl8~ZFzwqSycnC)o=_p%3#tqiEMAZL*j;S^(AeL0G9GvG*wrh8=) zhQ`5I)sPjAJi70dnKo2q*(G&YA!%BNoSP6GhsCbJum2uX1I^eX3Z7=8l`+s}@ z=TDLVgNlms^Udt4!azeIno2TdOja7{JI)2O&W{fAgPU$NPpx4pSexV6ZE~(t`qgt? zHW`t{Gw!7Rov39XONE3;R2FAqh`irS%`nGK6RSyg#>t7t%m}RXK7zC{Wf|m{N(kU9 z-Ksm2J$?}ZW^$a05Ab`4Cn>?hSt_Mno17PU>?V>2ot17(YA%s9qKcn<|GYv^P?x`E z_P~ErBhE%Gu3@OtBndl67yVyfmN7=-gCQZIPK(K+n=@<9Z{PTN{INUBt)l*ZLG>oR z@nx<9k0iw9;SNHta~vA-IlRR(eKmP|E%n{g47R-ugYX2&X3<3+B*Skl&5)a!lN_h0 z9o@(f+Kfr@ktoVg1ta5KXh~WfzKf`si~hzg<*A`e^QB5nkEkGjDOas2%)mgaYDJwb zo3l;}YH`lHNp$q?r{{VCUZTTy3-Ha?G&_%%nm4Gj6tntx)OIZf$;-47J+CaxLIHUp zK9$EohQu}geAKs9i-cQr;|3nUX;#|cF|^U`^qx~`qvwbCp(Z*6ZNy>*hy_e{MPYVo z6y|2cU}07)4$O|jA-QolEYF6+^Wt$>emoAzw_`z8Jo;jTuz;A_f#kIg%1yul?pVyv zipH$WXiQ0qLa#F%E%p#p#RMQf)QWU}6B4ZivSuFw5i&F%1vdQp%)0T$AwQB%!E}9N z2^_ei(9O+4M8)fhGDu<+VW_nX(+R{mJv^sxoDC^ptW(C;*QC`%Xi`fXEGpMh&LyZ; zDMz~QJiWJ(m=Vt<MTMvwuw6sMu5CKtoaDptUOK6%YrAeFzL+**oulDp#Vr882+lWM$0V=8S~ zlmQEQqyT28UT!sr{;^%Tl;+g*ptUsK|OWY#M}W-S*sS+<_rQ|_^A!~=pw_f$tCKW zo&-BlU2kxQZe%`b|4Qj$Px5x%)ksh;nG9dmt;a=#iFdHF_an+^Bk;&^#-gz}i_o5E zU$^IYZWWxGrRB2~>Z>TWdabn@6VM|3eX0;?wn%__?fo^0M&}y);f_jl)a8+9OhsOX z9jQdAVG&dVQ(HNJ_$e(P9h1U`jMbGGlQ_b}Wu7NWdB8 z4lJ%u#j4f}+|ZecTRJmvXIC0lcPHZywCY!A~hiv#hFe`(mJG)m1_^`2@F~Z47#gw z(O+MLNsT2KYA8Vuecza$qV<7{uj*{agf3A92xV@kuvM-Zv6#&FM@7Uq40@w({e}&? z{r21Mw+R53EnTWhN=nu>HZm_XL(X~GVZ%x zwWzXY?{2_AOEv1s@=@SUBmJC6z!Q!TU#6&LdJRT0biEiy7`>iY^5!lwoZ?7-^d^R4 zeo6!m&5p(?B?)BUDY&XV1Gn^Kl0jzR-riK)-WWvZlU7G8*3;wGb|v5;0)t!XY`Cc+29I^Ru(s2V^jAqufXayrDCiR+N0+*u_g3 zKCkrx0CBR`cd50G44T(^UL7&mL~A4cd`))(9_g~1UfdK)Z8b$0AciobgISsag_&Bm zfdjD%`fIesU(>y!`#MY>x`iYMGr&UH52?gBcvKr_cDMxDmn2FII|5>W6e&bUZ@h`8tKRoe^r6U-C#MSm!%>sN0T2M;9-wX^g4}h zT4b3S!AiI&ec*I_~OC zBiijGLw8_JZz9%^CtpJzeGPx#ortxx)^)Q7R$iP9O&MIQ`YfK^M#dK}9-SY*mW*$m z7`_4n@#Yg38UGUu0t^b>vGjl9=uoL5hJg2EdkA=#8w{eCHc|o!aW3R*R`eMqRIpunHP;2X;ElQ2t`I9 z86@qk5KRhk2`y{*&>(+F&2m7G`vY3bGSFQ^456U_Q(H?ghh)P%k`3zqaIHg&#ANvX zO>e6}XGJ#2MxLgum2op8B*W)80T&nrTMV$1e93l33nM6UyF{LZ`&*^sFp(Zxm}*x+ zLT%cU$GiWi@pB+LOU*v0A@Ja1-72+=1b0gs^gf^Z`C6x`8XMPdaJXl3xz1oTlJ}3b z8;zFx+52<-e157bldX|Kk$VCFht|cFD3MXlk9xZQJdxd92QUz=?y13WQxV!r(j<_` z6h4=XmUH?XRO0+?YJ3FBG8H0MyFInwbYqDLq$fm6P|_+9ea;VCi6tn*ug^;(t(J$W zZI!CS%?mo7ymYPdCagJ0v(IO$-^)!fjnWrC+*yt8#$q%QbI4CiMRHsmq6m=uX~Rk% zzs`Vg`k$$^nW`fKFq{;HL)`H=w~Rb{D|z+nEV zGVbo;7)CZ0*QMi_d>dvZN1&GUdn&gNb3|VQ1kTiO%p@i%guHnais+tmJgB)O3teJaachbKbwFcAv_pC1O#TSeLar8&rC`{u{#y+ zlq3maa_vA&kT2Xxs>z5mNRn098}-jh)DCb>QnM1XJV3y|Q=`-w1bFNj&&o8LjEkb8 zBYgt{1H30_{U$#6%(I*v)#q=q_)jP8|1Xa6I-8Sh3m*p6t;uocx74X-U`1N^&<;&GX_N70HT z1CR1bfr&t|vCFgK=rJ}i%(Wr|V&%1M@aTQg`s>KMGZ^sN$g76#mTI6FcPf#(-S@UW41Re?%czO^JB{lpH2TZ%EG zr&{EkryY7KG12zobkva%HfR%!D|sei5;>WuCWHQR+r%aav*=fnJ$3{vG z1y3?gAqT`PfeDP2s7J=NJVS6b-kOqqn96&84K}Gn|rAj1E=9C;YUW zIH%#_Ngq~5_+e^#B&HQOQ5X@3eds;jNwofvZU-JCI=_~vyli|4%(0h^7LU%~ZxU}v zbe|2OlNUXf0D(QU((`f3=sV&x+HX^fJnA~}& z=i>wnHgvFYCa6H57=OGLbBHHkv8mNQ4hEYvFyNo5pC`z3@KUciM&cA3J0pp4L`hsh zBt|+{D4hI1Z89x1;Jf8rXO|b)dZ;y2)(Y178b$2RmB!+NteF_fBi${M_I0}L+ zh$k=zkt_pwZUTdd0DqEs1Qbq^2|Q}7AQ`RYZuF9roZ40{gHO1zX?lBw?8Al}hm0eu z6Y0p!UMVub#U2?-QqBlpy3cI%d>BhciWA9p8#yCisT<$}Q+Y^?9M6`OxK{vRx?h#0 zPIKn?fy5B96TL^ar6fd1EeFYf_xyYg(vFdCZQqEDaKsQJ@#1)C=N;0ao~ zRZ?AO7oE@b-PukK)C9xdWE8{>^Rps3CKAPIieA+d1L-cy#B@UZ`C2>eTjsW$14xLT6Pjnu^j)IW7vQ6`nVtCPF`0G7JO$NP5^XltJuRql3#O?L*c&NiEUU35% z0efkY0A1sOI}Z!+EDzQ87`R6ssNi3`7X6PG8b6USDAo`s+I(Y|k{J9vEC)7n5RU-h z5n7K)<{(yLtz3)U7KM)X# zA)}9$&+-}>MCfl7u*6ABg8%m;Jr4YVB-E|Ms+QI#;SiDvU5Vjv2bdA5S4B@2?Zl~= zupqgQ#c5oR&yK)_E|Q@v1BQudPVJ~bA32@|PTdg*$czDQ0OP|{$zZdSY;v+uAZLxZ zILy}qTTC>Ph-pLw2g%;#fggpgcqJ*a#)rk!cmjnJ0H9J%a&mY9K%642EMl})3l9<1 zeeGwp%w=2a?(Y6=7=Kq+m(FgtvnDZ^&E{i#e0;v+)>l4}hK*G&oO>Mos(1S=0Kipv zxk=hsCpF-qN*f3%!yP=*fi4iY<=SGSWF8(5=SYnWL0Ni&Hjh480C+%$zw-OsavLv$ zBuoG16Iy1-*3w032hO)m?{a?Ufj6AjVOZgO<_^ zROTh4ATv>dOAcZj5x!PL;JTJnq0k%JHC^5r>ouZvTF{O`;R)8`Y*6&~tJ{;Yv^W;`wDI3MXtUeJ5I1-{ zc$X)*7UvE6DbGG0AMkkmb)NqxAfTiLFODs2=p0{c?2j`5XwS!h^SH2-1l-$}gwe7X z+$n&h*o$Hs9509?V6Y2~-{@f*-CD5GOY^mOhOif#WER%R|5X5RKNsKPpp8`j{+X;HZ2{gt^>ag2B$}eUHzdi1vimYxTMyB1GA&io)C%@VvE62 zPeD?USV1t!$HW+JBaT9eHnP1gKNTHi>F6dF&|H|VIk7lm0&%K8gqdb$3Mw;FBt{S? zSpy|41O5FF6%m1updk2~O^6H*K(@;+a-haLCQjSaqW9GdiE2$QRBI_k9%fQWdo@49 zEMlcyJ>|Cn0DP#Y#bVJ#Mn*;%jmCR88yFJki=qr|-n%voo-zCRJBbYqBSwneS zL@07xT;iD`y$22STwE)_lZR$@*J4^%H72!}la4P&Z&e;TOES@vpDf2_S7zE#kZhAd zAmJf_GVa=>RkTGCwag8%V$d0eBP6?;6r6R~kKDqWgDtxNxdF3OBW+$=`Z(x7zd*ythX6#SqzRcE`wC zM@z8<^?x>K|7Y!lwXSQ|*TY822E0MQAweX;U^QTL4+z_3AkViq2mqS!$!9_eyod1(TUcZA^TS|=W+|Dn9Tl0@r7 z=6F($9$8}ZUWc`sY*Ft&T3*|ubRF;SNyQCaSvaHGg{i4gCeOMXtkXFbm2Qt#hq#)IGUm(Qml&$mS4OFa z1o%pwNx5kKL>2MZ0H8*Kf4t|N+MrmEMCN^Km(!A>cxsSNx82udIxitA-VzlO{@VZm z_Wu0g=jT_W*X#emIa*G|lna_ANM+QI0f6TX*Jx+QdB#82NZ&vTO0rx?iH{af&N@3e zIvAydirhoZXk9|!kU?}TA}B!W&pF5!8{$uh9xo-mT+S(=LeJZr>qJj^F1jlV&{mOy z#?o|D7bZ&+5WhA?_6jom?37qJj*7v=!n5#6LvqEMlUPSfTqq9CiN|G)9EeJx&BY#4 z<=fZL27Ii`K>)zCHcB?`&8_4~BK>ecdW;0GHgzRv-YWKcYuo_G({HzSrQu*#Bt~nT zqI*>oKVH1^hTRNzo!6*iRXl+VV7&l9%&zG^kC9ARyVnIMkWiFDJoIx?5wG!vB3wZHs33q69-XN8`aMi7IlfZRSxjl3ITgj>;iVM4Rt%q50!^!U+~dS8BuD1iNcPymaD8irJon>V4?+rA@3hkG|AgN7A z&}sz;Ji494d@^tx8Q_5lzPPf^MnLP3=V4&s=X#{PLVP4YNZ zZ=hre2NCVU)Nbi?;q;nhEGS7uf2Iv(k^WLzZS_%fKAhx%ljLJTvYi;AOOudES}~cD zsP)=(s9KG!a@`3B|2Pv6L4U6%|LVYF@cgW*d$gEAAWLP(sx>xHqm7NP^#Xv3ljWw` zrRRh8<5sKLbV7-{z-W(&|Lrh>!e?y>H8 z*|@UFB_htc-maqD?RG5^A4eM2fz!xK6k7~9DW9owk`%ve^rqe7_PBVHKF(5r0N}3f zR2+~TiK8=O#Cv%*@`l}ld)I1NwIYu!kUV?(zJ)U6W$m>6f!07iS?9?j1+ z1Z>o6HBj29r32UYIB;8gGLCeGVs^A2E-XvHYPtS4?fuvkTk2FPPG(%{vjPECe_&IR zOX><}^{5&H0i{j{9_&rQC6zWDkQ#;yTijUEmX4zfV$mK?tiWnPpw2*h!GJJ}1xeu{ z1O&tevJ_R9{JorHWLI$k?VuNxzEe4TZtdkkBYYMD*T(QTJPSE1&K&V1Yzg?uvU^kI zb>4$w)Lv)SU^43Y@4xi5T4%A{Rh5bE7biU_RtkBzaf8b>ZQ+5) zP1fQ8+3JESM74VxEL6kKc=r|e_ZMa+A~`V{5gg4o8YF$kEv{Mge}^66IIYx)8`?97 zwx?iCFW1s5ip<*bv7RW=m+Q28comIK5CGs5)<$Llw28Tb@34$m?b2dFZ?r7*w%=`0+Y_ zt$0W33fM!6!O`lD)9M0ZBrCzo`#fD*fsg#0gL-6C#6<2NNXId`vFHu;#o}rw*7OrN zb;n8OK#GTY;^Z@5xAi^o<8g)wbqA_&C|1iFsKxAPlLmhLcel5>aA-;ddcv*5qGE6> zea~gY48oia6jq_tIZUjJSSP@u*1E3o_qstVreYv?tk;2CI$Ss`g=C#8T$-gGAW*o4 z{>N$MF7zcwBR#+@wHzEP2=X_>PL8L5J4rqH{hSFWBzwF1)B&dq1UzswmG&9O0gM_O z@F(Zuj15ykM=dKhv5$cFGCRSd^dv-v1gi4{_4+>t1qS7^-PP9D|Nl4s+S*!OaB#3L zCMG7kzWhE@Q7_N%$MF=)gvJc67~~UY=`|FQPIK8}L|2)){-1+-bW@6g zESQ}Zi;JpKaCb*K9_~sNZzmeLhp4I)=qN3_sXbQJ%6GEPkJ3_kiu!U;Y7>{h(gwY^ zkLYD;G~C1h&aX%m>bbEi=0_G6`Ph_HppE-zp92?^#9>mnA67J^iSemgfLN_|V8YUZ zervVwC4I)mEWt1Wt)6IE>w3wUd!w+SFIugBdJp|1V+Kge4@P3+B>LSX`X9seTIe&c zjg#pA4bf{Fr0)mm?}u%;WiSo1?131J^24$Q7apefyN<6_Z=|fXJ?ej|*QBL7yOnNN zsmT~`YEZEm1y~G73?`d2gS~@{lpBZIh;i)W2*t{VB>aK?*P{}DNKkPFFU^nE8krPW zGjQ?G9@k2bH)`3DHBvblE!lz<^xEgf2jQgL1l&(-h5`0b0&8xrI=40%)6*m24k1Qh z)WJfGG(5-`DKR7soN*FoQU}6l9j00VT8$RSlXwArpO0E4 zCs)9!^i{n2U8iSh1v4BmXK&VP!`=PKn3)odERraTYMmPWjMcpVxWVrZ0L%cyTQT7D z+i^`xGKNT>o|GRit-2g<@Bq(x4)pc#qK(a;`{+6PWAWr98=je#gg?(t#*6b)@yfm# zcI5H5f<;!$5o>Zl02l7Y{DNEBj~SrG;sDVO}bp zo8`n)Qxfp_AYGeY71!9%t%7uGB{<2{JyL?GDvJ=Wx)z0{sQAHSbRF*MPQmFV3Fr#5 zpfA!7rx)9CcPFP?lSQhi%!Rs!>pMM_i*Z`Ibv!^SX;Dpnc(Nue)fSc8FgGRumzFz7 z>e)rgtfBw=a8DBMAo+JOf$vBr$+<8;**DAx0?ASFXmUPtfU^KR=Dt#E3L5VPNRWM4 zK>$%m@*yrVSR5Ev_c8nAQ=d8T?1=@iEpT@#rzROJ$gyrWnasC^hJ~fd#I2H&-v<4! ztE-dGAt52z27}=xrYVVJm=&HNiB^s}|rJX@fe_x-ef`*bckief)&F7c&U=m7FZS>q> z2nn{rY*K?W_y$%)_~C#Y8|R=mVoExI`Pte$@u%hZZhg(ym@R98FL{%Ik^;{pH+iz zE@;H&OIopYaT~T>){gBsL&!Fqj6kx)_q!5hOIdLz6!(&<|K?NG)B}4FdN33*4GH`R_lqc9IwH5-R z@dPf31e^@4cAT6Qj=6DxxUShH@`Ax+lSrck3E&6VgN6)1n>YrwRSLr zCz#DooMss>m~d$4&D*`& zUkWR<13I|zCpjTXP93%yj1tx52{koQ0oXq$o(w+?_jIRAj(DvnIK>fiEs)1SzDHT- zcd2}{qSLVwINQCd%~L<`Eu-z?Vjl8PJ*gOQMxuzcsUDAvl%UZE* ztQ%WakWnvh21Z-xa~m0IySCcMV1K=}O%{Uy{a*r(_m)k?MU#tA6Jf@&4Jml#f+1{P zHH5A7m~Er2(JnO-Xa$ks9bSqru;YsR*v?bv!*8@{`w1z(@vh|kWb!G|Z5lIXn}B{QZ$Y z;J`s{W~t03d`(799g^oaJ{%MhSfV;^vvf&G>AKw9+}{QO^!4@W$n!G*IO+3K0s!`Vt<+9wUKADE4|4kv1U zd^!HMe-0k+CuY&hZL&!iYfeK8$$(?)Gs)nlVauvs>{u~Cs~g)_k}<7n!qydRpzO8V z#ONoky&3b4745sMp9%t7SG3~0m2L7rw-WHYwRjMxb>|SkT5(LB3(uX_jjzTgWAm~e zGWJ$6_^o3t*s{EZ9^Zs*vgo6SY+@7|9!}#fRrZY%v(E_XG!r7O%efDj#=NrnlziH}k>9Z?oC>*slB&iO+HEqOmRma6fH@n$ zqmm_`FKv@tHmRjxG?~F&ftAuDkR*pXM1}_Q7$wQ!#~O90j19rmymSmFhGUdy_+$O4 z63O1+4dij~j*EC=#Q0UzI0_H<+OfDe28-QfwC!n91}gz7_8gKHQ_U%xCgim@div|T zxpK~qdwSE+lNg55Pz$c=OTosev3PEFBL2ExI$l347w;TfjE_&P!q?|D5=|#VSV9z= zsPi^5gfGr+#Iw^}8vRYcog`EC&5S`yYyegt-HflV93aE*A_M9oFd*Y!RZB+HOvc$o zhSl+t0AP3H|LGvmDn`6zB^z@KJwJWDq6dFlGzBMjxKSEn#BgR5ZrZ;NA1omzK*qmw zSuIHjdY|OF!H3o%tfB<19$%-wD+VHRQ zn(^8xby$076)x$?!9WrLEg4c9>G;F*VzG*7{2#hg@d$wdw^nZuIv*uVf~Vc8-h!(x zIWE987Hf%({h`N+qcfwiAR!nxwWms;ktN|KkqsPZjMn~M3u1Dhv4&?Zzv=<(f2~Bo9&_^s{Fddf@<2bf3jsPMA$$@;5F}E}8zBHRH zV*$YtnN0iB$@9CMNx!`r0A%no1hlNI%t!!miC*uslP3Tbrm7xi1pwNy*ct#-YkCCF-E-w09pF!F`gTNu(g*+a% z5fK5anG1Lf2qisM9&W`!xv{vcrwGTFrC}diAa3bQ#$$avR3lz%wY5uU+uC+s3DRUD z)n#;ZZ|QPjzobx{nQbGs;FS7&j?QoFOjJQSPl>Bm>%LA)qpj;!_3xZ(zl&~qHvvFZ zOd!@B*^JM~lYMs)8SABF082W(T6_m-=^aFscaHIDRUVdp|F?s3RY4Fv7nd0?YfeF1 zq!mZkyYSBFBy7E+T|Dt-TH9AN5cMY`B8t6rdHW9nfXB!u8vM@%fo<}cM+qBR|90R~jX`D6K<68!VhK5Ql_vVDvnPp_4~Ue4dQYKwl(i;RE!ig7?7 z+M}+A#2olN5de4|OaFH#NePx0pD!K2A5ZJW(&?3$ok!9z*n$?i{|nP1a89L@JaiUT zlecEQzh0AQiqhM)dJIi=u>|Xj$2Bc3%#91cnRzjIpxY${T`I<-?k{JA9_xt~#;7V0 zZE}y+dFw1}nuFjnYA5|40wFFD?@PcMVu9-<14I9B$cas}+*os91I`<)Kt_lke0BPN z1X=?nz*bUFsY@i%dQ}1P>}9~GcJan%NKq+ zqWZ)_xb1d9PBg}v5{aH=VP$6_rV$G`Iz0lb`;&-ztH2UddZ{YoNVDkvcnS7#;OH@; zf@hZ6FdP+tW%a2NfO?`UOj=~6pr%V3#?iw8D^82W&&^+pPkJpop-6mT3qcbY;%%lX_NNdT<+}F?PuxPYL z_~Pz^>+$)OJ@|G-vpiNjAEZ&voxYiY#JRQJrp)v5sjUnli7gV7d}LSK#zgoPf1e(zs7lH%bakzl!_stz? zc!-R0W49_(=D7!d?1;qE9Z@9P;_yT_S6rsxjQj*FAhvY_F$r!;S}))qtIaI1X?+gy z0`%)VOY3V;tWBjMHxWp%BwR~UZcRTuW*{C@C9w{>M=zsv+Q2U)S0ZvMl)su1V#j`2!5*7J{RazF}TdK|c} zEd!Iu100wViF^7o=;pD92%{B6HUP?|+cl%zu5J9C%V=Kq#-@ZS%}mg1j{ zz2s>I6L1aD(gAwD1*9jRJFf>{kPP5J8%R3rT&BFFT0dO^{E|SR$_8{vkZ~u27J2Ti z^z-jlbmChAy4Nlm!s%Uk$O|$dJ;;Ln>awu*ydl9pO&!dX)uhXmX#RbiDkNdIp z@-BS0vK1e%9Kwxkbj`qnA7R}Z4Lru7LcBvPO{s4nvdSF z*-SK^C#*{6Q?}NvCq2&&l%zW(t;a`d#Y9CSC{Uf_#?w{h2quZ-bDh3Tdar9_smdr0 z81i}QsrDE-Q=R+#c{Fy4uMvZeP@Gd`C#{u+N4lLv&r}i4sTD4i2UsxQ6^45|(`3?& z)Wc6`!(~1Hc)J>E!K~mO(r|}l#i1qCim}=xJkd{HVkjO@&q~27`@8YxQTh1fv9&8g@E?EJN^3(PBcEtexaoH4HI8=b*P%~U+9lBB?uyS4% zUR^W^U#{xMW&(~aVPkTa|*Cw zS_;+<*|2&b31=3^VZX!>88-50Z-S&IHwg2K8z0F=_ho$_r!UEo48-B7VH=(wNg?Nu zjei_Y_viRxe99@=OX$5Wq1Q;Sdox|1H_xud-_NecH_Lkk=)PUig||ni;MlHggqsX7 z8muq+T7!D>Q(eZ0FlO5N-yR4YdE}A0hK2@J4-gvSHkizh`4A2Fvl^vJufmh=<1RZc ziA;!=(Op60{r!Ed@HHCXjHbtBxloszET^~DYO#V!8TqBoPtUMNBQYXG3WT`JiidBw zEPC|C2jDcK>Nj?!;y&{Fk7<>9kI>gkno`gl6^Mo~U!w79a3<@sHQlO8Z=E(phqe6U z(o*WceSPUTvx0z&HpqTikytm~jrR}D$G^zKe|1R@>GmG%Sk@u=Z{C2S+1VI`wz5HS z%Bur^J0w@w!d(OaQ&VG*6KKKe!-=Y|>``7`GoW3KbOHd3Y2Ix`ml*Y1M;BrfSK{>( zOX+vws*WtQldNc@b=P6__<}tk{ol>YyRn&GgVN3|yFC9+0ftsD8<5u^Ol0@(+qJKM z$oQ3EDx>SrJhP(X9LwO>%#{4wRXup`vOz4FQh>53E8-10w>$~A9omSumJHx) zVhmfz__s(xzz4{Ppk>{O!OAKTCIT{ zS?)q(WRx1bvvA?U|GZ(hw6w_J!GeMU-Q2lzW$o>wqoYHYo|dlbZ12$f`v>$GjK-Js zT&GW-Ii2*pCvRVp;gHiPis3lDPTUQeRr?Kxe;dD?5+(=d&YyVB2$Q%W8918F{0 zdKxd0ei?2l#XL;YqVNQv_oivtxV$GHt)v?}qXV$2gFOBrh1ju(3}=+1?LDG<+3>{! zjkS9XL1_E(c3}bAmv!T{gYvMxCqkyvjHJcFO|Rpo1L`GGKG7&Wbazi{JTB0VuP78W!D(}ulB*wqIMa$Q>i!pd~v!d4>+Ur$o zZ?EBp){h1Wg|@c}h`mdOw`^`Lilh7ytLMo{dO6?Vu)0({NCx)-v6!z395=7(kTl_T z^7x!F*e0@|UAyM(yZmnnLQ3$HGYK9ErKE#rO=JiBJsa6~1d?Bn*Zr7u_w(m;;re~6 za6)?qI+H_D5<(IoH3FATDa4=8?Z-RI=i!>^mAJS$3GZGufNw{;iSco!hMsRJz0Reb z>a~rIgZR#|iLcdj58DK^IJmiOc_UquHhi^W64oA6j_W3+5=izENHKHjqW?iZKdBq7 znbGhw7-!*AB;Mp|2hQozyyOC{zmfd zCNg%tNpkmAosUrbD7_C-tR~dNhGGseg9~e2xTQM__xGg9Ot>}Vxp{V+DzJ&e&4kwT z(qd3X0Ca>qTE+e9zUubbGfaPsP(xOPCPm* z8=ss}L)yAk1@2aK3N=;%s`l>zfby~(0syZbL{zdT8V~fPV&BX-xQP+1np;WUu3tR6 zsgYX{opJ+Rm3|BX>*1P#J;K-ZtMA3MFQr$xyTEw&=y zECIu2S=*N>rohj)nO@sBE4%OkY4HuGb>ZAe#poxAP#S2&tgL8Uw_h_JI%z7-Y)ix1 z6B_a5%0aCfvQ^j!By00S0NScjn_eJh_SYoo&+kYg{qvF`Ebq<4Lj(k0b2@hgv4NFs z`0VmYxc|g1w2|}hr~Pj<8K3j_5A4rK&o^8Bf^;LZXX_IF6LkS*vspK9zx{M3gF)wT zIIQvUwus{5Qe#wP%n$xuV`HQ03kVL;Wn`vWtp0w}eT=3T^+waqKwm#NgJy&efQFWO#J=m zVthi{?CT}Hc>cUeIG{Qi`F;i*l^u)KJt_F>LHXFaq*bEa7}LsO#pAT>g7CX*`Rz-I z0UTI>jRXJ>_ow2pTpO}Tt}G(2^4anMGKx;kLwQnQKMer3UDl1ajx3RCxcfStI4mm$ zX%-6#Vxr*=w&29>Y`nF23T>|5@r^guEQY8k^8}B$i!}Iw4ZM89`k4SA@c_yBcM2de z9e?w(Y4kb^;0myy-sQx875SLtj6{jQfh2-I_Nz(5m88X=J-r+6E}cSNl8ux#Grb34 z1LKCwM!wUV?HD(Fjuq@6FUy7|Srsm6O5{RZt z&+5b31BGa${l}@~0~>R3@<1`Jo|}(Pm-WcC*d_tbCSh4Wbd5asVXItoRYI-US*rlh zS1X6{z+ug}q|J^u&Tmogm1P2fz?UnBaNp5gXiSNgibAu&_>#rqHxeEd6=*d1>aw#- z$m!a3K0ZFb9spRaR$W|dtS-fsZ1%NS=a@~Fn*sxZ+UCxi>ywp{sdN885TdrWQVj_S z^wTA$XIL!0e(gq+%gqr*-#t0@P!_2%KG?hLGGb>ZS#2hOif z#09m9SlZ%}a?hK|pzkL7{-EUR)sa=>v*>LyDOdW*$;fY_$L^OIgK{$dIgTLQ(4K@x z`x7Lu`^RBBURjWaw~s8qXJ^)6^QDBqv;n>w8^DX_Ou|8R8OY+lOO3)UME#$foPW2$d6y6ba;nW<;N06yfYc?cgy?nmy4$2fZ8;;>3T0H zNXDw6BAiy^!X#HTN`kD&CI`@L55>VG50=g-z@x{v;_r(l;a{Ueq>=kXMzO45@4Z#( z4_d^dZq*o%R3-}ZY166>n#Edno8Vs*FbqK3z48JCE%_D=~o(ZXnVd{%JH@P7IHTic#lrxO5=_LBiU9m5;o< zJe{A#s*4Jbh&SmC53{#68BBMDhla)*jYi#>#~!6iOtkI&i~ublNGPSduY zd+^2i&3JmD8)sFKw4&F2bfFWY-K7{z3@7Sp#z~!d`12)`iBb*-y8rF9jB@5;$d}O`0(`iu2Tm6PtbK zq5*6smbg`_Ia}%dwBw_dQ*r-sT^P)Xm+S!9NbaoJv-H8i0VKV%#qQRv zTlen)Kzw|>&evqpg#`uWoArj*EP6Ry;ZyqAe%a0>Q+#BkZvXj*=sKG^zehbPD=WDy zSmp%S?FsU;*qE5`0JHhn03V;v;;aVjQ<;o4hd1MuBkS?%zBzbyW+K*2jl-kdD$2z+ zTFIr<+P9NNZzF2oPM*Gl!N873+vD+A2YFV>wW?84mp7$gN@Ap>#SSbYS+HLv-Z{A* zpPo-t>arf}TuK+6YtcDEzOqwNYn+RI^2~0`r`N+x95Y;D#2C2jmfL63@a6dp5=r)W zie2*0TI63X&VM&c1CE!D(*OJ0ewosu`@lddj;lyPBC(#CWhwZ_k}252^piBK0)V}( z)?JE&wtK4Qc(X3+#77r2;M(a~s3C(tFf9i64VL1n)+|i11&IfpSDT7WXAF@CnIV~h zZ4y0i*4T^!fbBo(3#&<3UUH zSY`k_wV)xh6V7_@YaQ)L#)hM+=-PD4p5xTu=H=}o3qD&hh>fTBV}Aljho6bhklbc5 z7&rP_&9lP70wdI1PG4aH+@{YBLoU}ZI6^EmG*o9H!w&SdHk0wc7Zn_cn6MD|ne=}N z@HO`r7Zpfpk}W<#mywpKE6Oj_l@^xh3i9%G_Jnv{OjMjaPj*hWDIh4c(CQnoG|*u9 z*hSj<$mUE!)JbH3LkgXjwBW0Y>ZI@d?c+-D#^L#R^}uXAzaRt8%u2=|r#bNSWILWB z`u^19L_9qu2~SUV;^{fbcxFKso;sk4JYODqWlOHa`D;)grH;e{9}DYH#=2H z>1d-E8&ksXR(9Z%%ctO>6MHd(w0W-8h}mgT(#*lN15XUcRw3+bZgwIboKubsgNf3a$*sMd&3S+r z)IEKvxVk+B=TTIz#ot#=%+={c*xv@JI}0OwCC zz~2{7!8fZ&1FvkR)j)5aC@BYYmJ%WoZQf3Ruw^OfBGTtyUebWiFRaFA=U3sg^Xg=M zaX}3}C&2h{aW7sxdkB|IDo33y42=mvxQaZ$M@xqYExRSi_U%}g1dg^WuP38#CIetv z*Mkq14&b^2YS2k^zl@OdFn0oO?aq?XP#cG0@y5})*u2a``P+o5?Od*kR5{YiL`{^yk)8JOd?6SFa+D%ua{4dvna3xPIHgzV7LgP!6WmDAs;jhQm;g9oDi4AZ`E_X{33mizG&jbv8NhGOKa9@9>RPbHioQm^E zUY$^sghO%@Fh3&}QweOlh%wZYfs_zy$o4lNm1JQOF))`|PXJ*>I|1apvSb|JTZGX$ zRd|H%$*YSe;Ul8ntjVRUnC{Pa9I0Q$0Xgon?7@p?4q=MhhOAICuGqhpq{pygh7*l_ zH`caoxkpZMoT6DAl5!d8Z zz4`X&WZXp@1LC*7a5jfi?)PC*o+ZzNv0HM zieGASia*)6A<*jo1BZNSrM(7|rG=Qld!>$WTyxTF9MoQf1gnh4{*pAvBUY2;<^R%!U) zJa3broGRR)jkb)wCvPoPVf5zZF)JB4J0O=wQvg!GD+wf47+u7u#96YF@6K;x92JQRof31c{|G!d(bZmuWVzmdE(8|~&r z&E6dUPAz)8oq%eqr(~C2-HuVD21$UwAC`?f`_eHx!w#Fj74dNq zNFa%F&Adv|mVFu>Y}qAyrvPZ$HAAVin_k{#ssr%u_!wv1m!v7bvgy za|45|UTNTA7+|FKNO1CzjwJ2V~=)jrP$r#BIg(|>6o>`fyl)+{!z`Fu=D^E~ zhE-jMw-%vo0$6(b6WYCjR}YZy*roGPvCAz4H2)$Geq3F;Gq zQ5#N_pFG`*XLaEV(stiy)Qu?{>$j~E>}e)M@5Gx+266oXwHVHcN1Cq*9kvi$(3pXH z2D7Aca6@l2UfIu$Z!S^+AvOJ`&6`73H>phk>YHU9c;|vPd~sQ;7X)^$Z1V!ZPKnlc z;PqpQG1`%i@~}WO6ENDg3&mE=G#;<;QzGyTc3-XH~iG^~^P0R(RSE+Q~4%uSC*A+dq-@Bkdukbwt}ZNvv-y%G;$FU&C!mIn-E z7;BKVlkUZ~74>xA>V>IEB;B*gwTi)CW3UL{+t-#%#r*12IWc+3j52&W)~DFV(jI(y zP6OULssJy{bz#$BoHSl>0~UAWtsaB*JZNRf-YQ8A1UR54_qJJT2X<;~PSrNF3me+DiuUTt zUaUW~6i*ykOkZ;Vx&tqtR!giP4;xM(>AGwP-*F6Rj9F9%zPY@gWXuqrJ-Zu=XO?3s zy`NNaZXsl!K|D*tpxY5((r>dE_1k$`mr-v3G5f{I2~KNxP}rUUptz_=XEg`uBE#bn zO$Og}k!B;#pH_}{m(Rlc%O>N|Q~Ge;lv3vq$1pW za6MkVs2`t6WR|@C7?-n>kuR$w%eAww1_A_dHU~`wcs1NDBnp5D(kmSG7h5qD<SymwKvMrA!Lpc#+=TbqE$Ur(sPA!T+XSxh)?+GL!0;C_hmwc-E*o{z?cy#O#C zM`)W+#nv&QOK>lS!x7@kd!!6FCCG{>y1~aa7vjl7TS>|!NuBySdVdcSZN8%~3+Gh1 zFi60cPjt8@HULL=q~ZSKn&`exAup^pF$cwVYVEobxfjE462sr(>EV~U{|-&p{+j^6 zDL66?4#-}2U>&l8OqfPY<+V!)ATK8nU!@ABm{EOqQ42mev0Tb<|2*QthDqG986`zg zo^uD*cc@d4AD3xNv3QdE|9d0xBmw-R11UJt9fPTK4X&d1&NBjd1RPInx~Vr4%bL@0 zLa_t0h#@r7>*$X5#VMU`Y&c^89~0~RhGPMAEj}kBz2SgrxI;`BDNDdVE~fpxvO}d5 zSG0`-tQzLQn0WAbdGlg}q9NjDfSz089@iDQ7mljRJokzFh{OJ`qq9Ye`>#{MaG8gW| zSTcT|EvWyN7{Ebo^$q&?nAkl7fZOfXMTXjR_P8{Ei_w3HpU!8;4AM)lE}BdR)-H?b z$A_as_&XWk3+MLX&u91G)eHIvAchH%CeeoKl{CgTOkb7{;!C3E-!1Espq~_`2>ox7 z<`pb$m(T2VH%k{;FVU|_cz^jYHk{dsOJZCERxYT-E3`pABR%^y>Dh0I zEqps#Pyef#zVD>fOImI+)|}pjlLqrqZx2VV)kG*CiSz4Iq%w~?zPJhK@%{+>ZT}Q} zdr_k_uc#ndt046_eQVLsoqj*sgVhIA;oPnie7vNW7)zJ10Eq)|RhW2Crq11X=A;(1 zyF%fPjKJ-upM-laI2-9P(P$%}eD$I}p_fWCwrJjBoE~r8y=rfdo|70ANt@^C`q$EX zGwTefPa&f}u^(@pSdTx?a1dpV!CIotkM%q7@L&pV=}y7PC3bYgh6?p9iw(q4t(mz0 z=q9|sbVzCkxRY*+mPeOf2bopSC9G|`XJ()$Zt!mdfT|JrjyC>lmkwZVSt9bt6W?+W zdBZCPvE_Pp`pS#}?ynhZW-G1M~30KAHFv0mC0>I`Gug zcsx0o?(a~PS_84f0vx!sE*Y)l1P&#ye>VXDPXrd`zyLz5>9K(%+)c7=5g!zi5{Y^O zf~gtNxN*N)yg%AYUYY1OUGtYO8ODqv2a3XtxP`!gJ-1><&AY}%Jk^4{2_ zm@TkftUsax>kluNj!wRxpV7Yh+Zi?ZiW`P#e}74K@W(TTuuowkx*ego`^a|bblk~( z612V*=G-P}PL`(MveTsBzq?{Ko?me=mK-_>1xYb5`uOZ525?_sK%i*+T(a{$e)RVC z>dFghq{}bJ8aT_M(|_g)^TQSUH{gpEg9_nR^56%JKGK`wl*m>sShSVrz7cwTv!a*i z(q!DWunM=0PMl>MV+gLq}pbgVyZ5LeAF$8jy`=t>Sp zQK%V%S+Q6&wSs_#z+~lgx=;i3#yOowZNIx0>le8)RaY#14ySQ2Ou-rh7O)OWHWJ1wav}-rDO#tl^ zGLkdfGI3Tmz98Y16H(%bV&D;Coaa>Ft-(NZfb1Ktd(i=%)=y4W--iJ%4;>&XzLs{h}004jh zNkle4EY{NXx`PW6mbeUeGk&>z64sp9g??h&bMj+|^7rE_ zlD*=Tq|TvDipn^(%%Hd6;GgQRgSlnSw7!i+^+ZAQ@qg%2jeuY#C8^qT$@T{`K-c z{A<-@Jav9Aj%?2$4Q#<#^!ei@L!^znNY{6XN0CY`Ley{Q27g2^c*WFQOwP2UJU$#5 zr0;8!V{t%J30Caeg{Lo=h4)s@C9gk&F2)pMAd~U06~lOcY^pGjJNB=`SwwlJx)YEe z;)hszy}5x_99d+?O&w$aeaSN1aeYsm)aO4v#fE<#U4ku_cFAO0iF&VS(Q4?Mv=UOS zrF4awNW--2&*uzaT3R%gOfALd%lahXs0_GCO|lU^N33VXRGinJkK_O|&X_*~Z{M^E zZ{4s8M-KMDZZ+WIsfGA#1<4oLBokALD)9DG7=T(Gq?d!d6HI4uPm`{LAeA(94<^f_^Ldm4@_Odv)Oj5L#8T9y}9IEZRi5)ky^ z3(~*etm>3B)sE#_Y+$TWfz`hu0ldRVG;hg3``xN8ymVn7hKV8N`CD-H!WMjaA_ns-cSW>SG#H5CXlO-XCZ9^+nuURmJ6sS8{2%$#IA+7m04rd(}!OsgBOpWlxU z&TPgzCsgB&!;10J{7n3Daw0bLb2n)`u|+$sYEHnu#N-C-LAc|9dg<2NMojSU7Y^c} zswC9M`Qna4n(@i1SrS}TLG3ng-+~9w+4$vtGrJt+fZr5cu}`BMFx!GGdFu*Q`}`^W?&bw$=%x$dAdln-^+#W67EYerfaUX=as9#u zT)&_iSI(=z*sKyPo?L)4d)+v+j@vn-P!(iBiLVhg(ZNKq9moz1!mQL7nW4ugz{tq1 z9`=AXJUcrHAD&Vst(t87+a~m~dyP2AtTv6j$rNEwTeZo$6HAG= z?bhSI*Is;j%oi(%uymvpHa`;zQ(VZ8jlj|EZoIR68r>t38U(Ie7kA>b(`yL`a`DF* ziP+d5g^k@xKi}W$#8m_Y#}p@`%NC3RKRqf*-|k1=@0x|>c#+=ACo5GQfrrv=AM>=` z{&E1=p+(2Hc~YmuD*tu)B%I!#1DipIKDQ0eoI5Cza5D#rrEX!jlIsbbe>=uHCX6ok z3@T9uxS7D=t>cP`1@VBGR7@v&UqxR1?xX2`T|NWbSE$$zmxq6RMlD`FxBwdo2p;K) z!~^6sZY0Tfd~OtuFNnsAr*}&^GxzXsJh>f{G9odM9*aL5*H6-OI@v^@7`(^$<^OY& z&&n=|6>zoU8<$PNm9(du9N|c`n9&dyi1nwmNkI@77i}R&$_{nAHYJH2(bsgJpF4jt zCXsxJHR+HPL|~s1jRPApaZ+DCPUz>`j;XMK0Di75jba3Fn2v z$O&y&IlmIebY!8K3^LBw0Dqkh5p*MuqM!YfP)gEh%j+biwo|K%W*R4KfMmg^M6aGa zs|%we6*!_P3zJEEHAMNNGT4M_T2-XSDguls=M74$CeVns=s?U)j>0k7He6ig#O2+& zIH4*HZ6sOFDo@5Ay3=IdJ6GgAF=WH5`{&}bbDJbRII&KBVri@jOtou<*e)!G0pKgr zYiAD?quUvdXV2=_+Irdhs2+8BECI?lD+cg~Lu*kGV!@o27X0PmDxN@WxFP}e*FUZr#>y;h`#Xz=eBf3<2t5uUh?Q(6m{m2kf#k|^8haUay zSET!IKdKd+d5R>5b1X5(cNXz`>2_C<^x@15UtTVKzGHZ=X7&TVhRoEw+tm5|BY&x=)+sntw*md% zB+H4%f^d_%&_4H25W|xi7B`KG$R}q~VosQQoAl-9xD{h=ug;hkk zmvrajyqZ*;U24ZA!zZ0wb(y@>LVT3%{^&J_wpVL8o(fIR7 zGTuA6nh@S|phfE*2V6|_t~A2>9~9w3IolErTdufYr@o=1T39X zf)_66!k1%x@_xCrTGjhE$@E^K<1%NUQw;mZ7=i>FSE~34?*;aruUtAEJ^6Myj@?CG zZgh4TKA>yAnL%Q-hOSMMmO1LsoJzBFXG(4V3I!-KYJ9n>(B#;G^Z_hw!>6ZK;hAX! zc7qu>EISSr#2m-wl#v{pta|0lanc4C9+r`5wxXLvqV?CO@X;fq0rCz3iSU?}vpV)zVTOhg$({M;% z9XLK`H1JVlhR-c}{T*hLk&;hz3vJUPpSw~x%n*B5XauTP6&w|FPn?44dK-LG0` zTjhp4M_#|f6^4WB((%#dv#^ykE2JO1S&Q(hp8Gd1>BrOp2YInj+;Y|_c=zrb@Bsn9 z`*+=dx9_m_JTv&mylgMT~x%yhg3ahrb?E)cEU*TJhd-m3U=g7M`AJ!`h)( zJUo<)y9csyYGn$FL;c9h_`u&sk3<53{_I$c&MU_&^gh2LaN&TjN_UO3ply;VXwpW6 z{csjQ^;T$%VI^znK^)hUjbOcx41lR}gyF7Zh`DebKc7KBvWX8I`IeZ^H<$Mc5cAJ? zZClx;?VlDEjALmwR!gs`3Ev9v_29iD3-QEo60Yyczz|Vy9wol!q;7mE6L|;#mNft} z?4!#&Rp_&`>hR~enRIQEv0-K!K02>OF)OJz>5>@%e>$%hOJ;7HQzAClACsX0TQV5DXqG-zPfDs>p& ztn9%%%ckR?nq<_I^S$|`xj1D?H8BA*%zDFz{$}%noXia2`BvW`U0j?^cf$>r=`u6j zzq~n!#}9GS4|fUbeRR6YhDLo@NN^Z83;CH$t$|icQ$%QJepqm@Eh@$qW-$4lXx8b! zsdGlt8|){K-mSFTsHga6Tx;*xZOSZP?C&mX!aq;W!}I$)uyHCq2OnD5qb9LF-jyha zZ9L91-g}ay;6ok65lyI`iepH357WiJVIUQ+99@ntFKCw3k#}w#d)v{bp9BD&@>rg= z_r=&SR?e-JrlFI&3-I}sv#?`jr&hJ6ChIW$F-^#e^hbMPKK^*^3cP#gwfOLk z>+!)I*WtZ;uEzsQ&O?#Yj>6bLJbGfgWEU`|dI`2^rMTmLuW`_-GPMQV59~av9LT5_a5GWcb83;!4~Qow@INFQ*uvD z!4F^O7Lix7=_Rw&fX`NS;PL~iXj7XI9U2K+U@!&>l5oSJZMf&)8r-_D4A<;ahU@k% z#ccaXsfg2$$& zVo^;ZYRO3VDRWBKAE&sL%-~p6w;FmXouJ+LzwX zvwQjCNqF?=MqE2mh|4DD;Czw=7tbic4f|H$iPO694zb6tmJbo&OvC8(3S<*=zHDl_ z1h++ou4q$EV0oLiFO+R;CGh)BY9@xUcv?QnLd;mPun8|LnTrD(GZ9V>*XrZ*$6%|q z?(r)w^T|v~(v=hx|2i$g0|NuPo}M1vLk~TwvnAMceioC?#3)0{mq5YKXw>Eagq9oh z=D$RnjW};|nN(77agPMaMz#6(W8-@9dp*>=iHO>La(*3NKPDe9?3<3Krzhd@!33hH z@j_D{r_WFHMoUC{Q-3s`9E!oSvr=*ItTGHIgyIl)IG#DI6kC>dYZ9Y*ypY8^x32Xk z0Dzh%!~Fy=T`+_xq=nmd7&y*OGT>kP6MMoGjp=AdMFb|mD!OTXhBU;9*$Ws3j<~O$Rhn&72`*M=*BZ=(uUSRX6tg* z?mmf-*$ zt!;8}&#(G%;)y5fT&@(IGdWS`XEjUrV0dVVF43M4 zpItJH>t@xVg*??owF!9hq$+jL#+W*FX3GjSDteoC=;e=>+BR#IiKLb3=3TX*7EV7C zqKtZ6OlZv!*&UJ^>kzN9P1EEbjZMNKt!~8lT5!pM`{1oxuf&J9UV)EpyH?f*^!1yx zEhP=xjK3$cHG zBGQBXar%N8c=GC{xR|6&lRE|J-# zW$!&Pz(qr)FWryI!CHvxq3+7_unKQ8Z zm;ut-e6U6lj%v!n^t=RgI>RtRAaQPgKCy%bIkxN@9_O{HpWgET-aMuP>xP}Uy3>Vj z($vKvzF0M{9`7wD5V(TnNRu|Yt67G4^4|RV{3bj-J05QxmoJsAp6e#5KAAVzruB=c zVC6QijO1>`@44;v=UqSz?hz~b)lLI@tjvn750I*H2U7L5Z^G;8!%+pp(`}p|_ z`lXp|UP(M&zjO$nlLUP5xDM3N&n$Z5cV?6CqPUp&Fq7V-yW*BxbX8SV|C!l=3GoTK ztn_sGI@sU0-B<7Pro*bo(peSwY~`>PCD#Hq9AMGVY)=57Xm7{(q#Y?f86U7HG)Nof zAY-SAa;acbKi*wB2S-(D=R(CL7q&&kJkaTkC&Emnv#s@x_zo-VCwPHhw=8k zH{c({KE@6|07cOe=uL~n1IN?mTQMvEyIs3CJ4f4g=>}AthHc)KV{Xj);*vHzc4#{W z(&EsPmW0Ph#=LdkEhJ|yz(GBo$PWueNtiEA>Tu)PbNi&Um#aE|=sidlfN1SkBoY5X z#(CYmD(qA0LT`=>!=-LiyKHp54d|-OK}%T z!IBvjxbA=!{NaR2SbzR}tT}%Ht~;dNPB^MKCKs%(__&T?u(TjPI6e)Ds8z-3-NdZ)=sVE ze7jaG_BP3`1r;f1aRlOzXY~-Ms4Uv@eH-Bl_ahK6eC%hj?&nOfnFD?O{xilO78a)K zY3o+K$B~f{z6SlZ{yw^$gE=vH@q!^4ys$$%rb;}Vobjg0YPYRu{Q&?NAGz(x5^UE- zReLiAD?1cQavj0SK0JCtH`-$ZaBxN_o?ciaBe_(6L8siP9lNx%w*G`^w#u+Mm2>s} zHOL9~N3P3-grGoNGOY~Xcn5E)qpbM#a4PAs6I+lU?T5jNa=duWDx&{a;_X{j;iCud zzz6r=CSSjI3$cLPuEWW*rXkkEz2#Ztb*8EawpKg7LwnCZ4gl@r!#7v<;Dz%BNCrfr zrzjWCUNwq$Z@n6?-FP`JK41azqQcRi86^R)FIG+wz}TkEB^;k;*sk3#t&d+T*kVKA zaa`B#Q;W(-KO8^YkJoR%9`D|DBmR2h6&OA0U^Hf=!f7^OdVT^PBB1$ng{l+TYatLV zp1AYWsVz9UAsH{~C=69(;Fy6j z9NUp22c_;?$p2C9j`kDSzre_b(+*i4s7D|#w5Ng{2&lnk2~5~o!? zfkyH0+&m>yjI|C|-gi7cp4ZYA`8ju{{$o)Oj&DdsDZLLq6L7;w7Ct+-PD-pLExr4E z$>j zc;hxQ5Rw3I-F_9mes(>+_~TlRuBNq)?QGb9Kd#1dAYJ`?{~H6L${&A_XRr{T#n`f&R} zHCQ$+4`+5|;IQfx%+8I)NKpcgYcIs5Gg`57-(j3G+=j^|ZnUMw;E;MZR!y(O>M4b| zcQ7Bvlq8~v_AsYVA3e1lU#uFGRNpqKJ80J0)my!EU$RA3I2od^sLnq=KgXJ%|2_SttrN;=1|e-oZED5i5TDv@W!yMDkSa?Ix4o)e#Y~Q6`hGuBo;2=cfUG=%cJm zMHnL@A`*;7%WBTs4&>R$`)iFZW2%4NGr&R&jJ>7I0@&?T8c&LFH?Ox#Z@Q;%-aA#M zQ-06ooU`3FOim2I?fnjXcv`hg^4UGw?CH!>+Fc8}tag{ZK{ zV36qNeaDg59!7C=7#eLsxOQP3KA=s{OhBDU(5ZQUH5uoN#ZCC|l6KPngOW<) zu0-x|{B~6@9yz`Rg^~W4UQM;RDWcjCG6g?^s$V5&x|c$ZMDU{LaxjS?u9=$k+9X z9Z11at;m6I>HfSoIu$GTsYhv)znoXNq$wTiC%N$9vE^D(Q@iRtS=s81Q>kuDZBWY! zHQbaX2iN*vIlBM_K}IZ|T0qxNmF0g&_wVx+eKH;`A%G9bHGk}D@f)eFEzvs?;&mm7 ziNB8V*Orv%VquFFc8V5^a8)jPad2Z@_(*o`b41I~tuKSbs7{c)O$r z-{UQ1X>?hErRRE)=I7$06)Ls4bETSDzzs&vpFfBWqWWcdnRx0hVjK_NEGbB?M|kzF z>oIoJ;iyiCMMr8F?l_{Eyz~@l*Or!N34Uq;&=rdMdx~@zBzS(npD!LlUrrpF=(=pU z^kTew)0KFSWWakjlia!OYW$Txj~;mdY8aJ*yYBj&Ssr7UKm1n2+wF|3hqQNqWV#L&9J03ry3!knWl%_Q9 zSlqc>)dYM;lI*+7dBV>yJ|~Iw=4Dgx(6KGJXi5R*mnWk=H6Hy1+33yBL06^=UGX6} z$Q_T%ddhKRWhROPEvSwU#p2nu_}k*iQv9@Cf^rR7%C=Q|FJ%17Yo%X-!GS@6rwV3%;fu5;p{0XzOQ--)j;)=H~Aa{*SijGvlJdtsZlFI7{T z8luDU?cPHgP#kK-8GUYgZN1tAqZR_s4*ZSY>+GsD`04d98%&Re1*s0e3N2puZ-4Z( zw5T{haEOh(|Gj*O$3Sj8ULftyG-TTz=G=|1wto`OWJlDwcWbg?7cRv>Z?mF^#)=sCy3HCGg|{9O98jJH9`jTLUgm zx4nLLEnd5TXyN6w=~vV#03bm4gqXx>{RM~%uwuzs$KmUjH{#vL?!a60zuvw3CXysK zWBE~slKhC0QP}l=jrI=n<13Q7h9HRJ7oYjM` zF7K6tTXt}j;);F&agmyGPrD_i!_|AAl4N|1B*yKBb>QT#Ld?u_U^pjY0u@tqPH&_ z#DmAQ;e;kPs(6rTTo9JlrQxwY7dB3g!JDTPNTZb)x0dzUv9if~GO}_`&0Yp8ER7AF z0N}~f`cM}ah~rw*L=tdDXN#OC&`#H42zMUUjx1st7QOx(e~bC>WQW5T6A`iZS%5#e zIRD=wzzGADSiVm)?mT`FYtNpJN6wmsMGFTAAi|Lo9fczXx^V1pH}>mm!L*JV ziDKs_$HVQ2!Q}cfj2&?R{(9{yynWxTcxd@WXe19*L^tym`oCYU?9v90woe$trGh`6 z`FdN(!>u{26w9V$;jLx;Qo_nL?B9(wON%snx`OB+OzCXK`!7F^uitnUZ?0WU0B|Ed zzWqA9cH4DWbo8Mpvc;n!J`h*!UneO{_WC^kPP+fJ^pl*pBL>^0hN_Zhz5iJ5J@Chz zwr0F^`%QS~w(IdBdH#=Yxk54@@7#GE-XMl^!zo7+Q*fYyuI1GS)Zr6JK{kr(L}so^}1#v{(PNVk%xZrw><;RN&y+bTp*I z$$=%@Njf7pl|Uc|EeVluTMTGT3d8A>O7Y-HU3hCLr{4PT6?xt-NBi*gXrBZ$zoPf> z8G+4f7xt3$sKp_5sltLviB0UA6^Uy*lCgFm1sevE@X8_i_(rsUvl@}->26%%2?Tql zLp5uCB8sg_MuGmS`;*tf=s zFuHIVwpcW!Cu3$=Dvp`lhzeH%`U=yq`rQ3-{h`A+cd!Ze`r4Ja@A}Je$2DWPdBr8T@!V7J^yQcRKla`Nu&Qj!7LB{R zySo!25O+5S?(XjHu5sL@1gda%cPAl%-~`wB_;anj6QKOwel73yukLlvAvsBo>^bHb zQ}OthO?dFr8cgrn3dYK^XiYlx_S|&NM82EB*Vv^fi~_47Z?~s^Go)CkprC6Lydn)) z%UZJOHGC-uL-~>v^b2!j!vOav4wd|A?ns3wdzz_NMhH7&Z(|t;`AR zRIsskGcpL$(yaZruwjQ_EN``C9=kF>LCjA_5}nRO%O+Ovv@yrQO)K%_&`vxeqkqP_ z9=8$*{IH77T0Hu34OWjI4sUBq1e>d2+khZ49AQ{%L#JCqCuBn{1@9>!04QD9lBhY! zBR_ZCJ97wc&L8E#@?=vHk2{nR2pn3v2yMb6;i#;DUQI1HoA8;FtS^HF`rPGZkxl?IE!Ym*Qyp<6#Tdskjc~KC z2R_YV&o4nJek%}C+rqF`AwI?GV5$gMYIt>GWMkJU8&Af-n9X+V8)S>Fe&%>U_xWhL z{0Ls}!mg1|#KhU`OxC9(BPsb&UO}P1zrVk!NHSg82sk@cVJsG5^@(f7?Cwb<}hmp5$g>c%(A|RAjM`t?+JX ziFeb28oSWG{@fqTY3BtVQJgYY@{2m0Bj^3xR+!yB5Vq=aaMV&lmu3zq>J^VO3%g_A zyk3a)c7UU<8qz&Xkl~<*KK|yImgGw8Cj{MMe4$5_bYMmbY3=oRacVzmpO>KSO*!gc zu=4=b6%SDR>H*%|Jd4uZo5(=Ob8cCMQ~9$H>+1q5k^+lc2jFFHL#{sNJDF0)Q>gR- z6960>8i}TC!mTZ^rQTw_aUy9G+*}k=S zOjP>G#&sy(v<|Dr4o3(Xa+rxKHuVifMPU|i(QepAR4;;1l{hV!U4*QvaB`L(j7etX z&6|Sv53b|s>AfibWgVVvU4`dcS+-*}zhAO-CFTt34HtbaB-?4@=omJ+DNekoW5nK! z22IVbok;$<70+*h7za~?`MTl$__bj?}GNWnr*8n#N(a8Z?ptCAFKWu;&xD@{^D8Ley# zFtw=-evT)@PqM;^M01=XA;n4$DMlJtmtc!a9js9~KU7@N{q4`j{4NerQy?t+U;x0@SHCXIz|nEh7#iaU z7ab+|nrLHj*H|2z(E(eAq+ooq4>G(gV6CqKGoo{&5`A!VB7H#g2ES$*fc$otkxiOZ zRtDo+wZuIFfY;}bpzg&z)K=W1+fv}!JpzGJ)W0f4^^==;a^e@D6#KSsBH($5dIF>`#mDh%|0a}gUPEU!?rmO$b>jpOh_qsbFj06@ z)PfiC)QZDV8-iZEPl2uX%1gxdVbQz=c|g~8c=w_NmCx_u^|?cM`pZT#{MB3vFsAW% z%W_;_mxocUGvJ`Ah~CXCac6!CpWE2b)%dk%K$ytHJQ!Qy9b#>O=wN@8-#81rEJNL+ zTf_to;r{mZcu2qZ8U2k1Bn?il%}4*FIJhV)qJ@hwc8y8Ii=s>(7_1iSPy~G(#Rt=J z%AGF^5UI`oS|M61vPaf_{B%(kb`Flh;O4eSr1yE4rwP`ldtp57$^v1X8A~HJ zWM4P{(EH1t=kvv>*w@V$59cNDEP_yR7%uE9V6D@{4su#~qosp35BydZCN(leA)p(9 z_r#Ld_YQ}RD$58+y^@!g&u&;J)>lR4Uo-xMs3-|%8%us)RZ$^BT1xhXnw%^qXZY}X z=Z3P$nm=5U@Yh*@zXE~ZubYa-CqnDBJp$pUEQg+sM)-*k_GV8<)Gmo=+;RV>SpacA z93Qff$Fx*37Pj_Aq>UyFWF$%F%cEqpzebNeze^Wrnu`J4jEs1G zzo=7Fyvs=w+GJTOY(*yDf8eSskD+}!>)hbz7CJcI3KZ~;6~=Hm*EUapZ4KFaMe;oPA6iFg_+!8Fxpx#j?of8CW;5vj*p9j zx1k!+;-m4j_zJymcY%uAsC#@1Z?BxhquoCdgCa>lEa1_$^*Fz_5dAZgh(#$7km+Dk zzc9SWP30AO!VZRpRPXoAXkQn-#0hgD`8u%#UY^V*{+84GyuNEQ^mWt2K+^H6<6W>T z*$#UX&9F1sl22aVnP!j8nN85e-W07(H1I>RBWZmTIz~9())Myyd*I#7P@yr6r~C*2 z3d4Ddvoc`Nwmq8L0{oQ0LCZn!*w7!zw^;$_F-JknnymQU{-8jIHSoX$*) z;{d=uAHF!1Brc1!u-bz=^XYT)wtyU)2`D48Qd?6)OI=+}LLxruUju-bCkiC=)U+im z^o%tXWaXAgOG(!ISZLzt_(Z-~kyG=+=*G&ahTQ$P)prdw0pD*?{Tk@vK_D?`!~4n` z7b--5U8F$Yf1RI>2?^ftCi*qg-vs*;Oi|L$T^JfE)*1-40x@DcWkU)@$fJIt58=g< zH2m5>9Nm3Q5l849U~djBqIJe9iU_nfMxv_~MkFU-%0w9^Q-fq}|=L)i5%_ z9dDMk;>N)!6&Eacji=*3q5CG}rO=2atP+NYJ8|%Yg){`Nv#JwNZ zVabqz@G;gyrmH@EuJOEYpi&%NUB`ogQ3AlQQDa3dF(bndPTI;?SFiw;Bz0E=gbtksFx)Q^94nQ!qyxz@cX7D0eO^m#Ep-@S;(hj#F|0IRKdMsn)nntTjvn+_Wd75M9`Ag5h( zJYJk3ED_BYDo1MzBE|7-4cC=W|G}vdL1poyuavWleB^jDB(xYlM@7m={Tmw7)0HNBZL9OrqsW#rc4bd)oAH?sHDX^2XE24~e8J0&SuN4kn;c;OPeIy+V~JylCU`ik32$KG#S-G1TP5bQYs3n`D(>|YaeG!G=4W^y(OMtvoB3gI zizN718bhBne6*)4CUt9vT?=R6E?vOoKX1Xui^uT(>Is}AFP-4-j8tbMKB}CLb|%n! zUDy&MNIsY;DIkCN5IiUSUqha%UNrnV?)mS1cj{i2p!(qzJU_Y(<%}+GT!FHUt5G;& zFr4&t5oDx_Ux(4fx1t5Fd}I}Lp!b+D0akikk)MT$2`-5DbjJK)0}*RyhW^2JC|=lx z3@eqh1s2@O?-Bxg4JC~1-3{+ZtJhZCp>vm*Krw2GQh&I53{UrL;q?i(wyej@o*m(( zqlOMX=D0A0^}46>ObM%WV-;_FofR8?QbYhs9*7m|4DQ|@Z=c;k-Rm-Lq_yQ2QE_A^ zNsP64yop{L%MQ>f-?{-y`}RW+NvY|nE_gv;Rm-(&q^NI&6z6-Q;JNMm__B);{k!7R zvtmI?vG?w!AfuR^sw};RXUFL85EFR1eiglkYjAnpGEC{!5w3>1aMDo5-nj8 zH!BJ+#s=VGOKTjCx56^I2BLLTF+9YXdvI1tU7r*Cmn+z6#9n{)H^By-@IYP+p3={c z3betdPJZ;a6U1797~Ypq&AK@0+IzSt8v}x^Fu$c2pD-+pX^RsAiL4ia16ndpOh|^m zxh`uLu2zti9_?snC#$QeDPd=4_s@(!B_&0|(9no=0m{qC%1x1xkgRdgRK(UnWKi_R z@F^|1;lJy%|Fc!ozXgDAC(SmXGavGpD-=gC%_iidSve$8pTXhq*Hl19J3aiGWQ*hJ zCU`li8ESLlgwjcltm5__PWeLloA)|DllPAI4zfm=jS<>M2BC9oD4Lj?z*JEVJ(8nw zIDZjJw{O6MZEH!Juf_8tJ5clR8ou1SjJvyjM(>nlII1aNb04Bud8wS5y~%Hd!4c%S zm6eD}kHVYFr?}q!Bg6j=6i7FJzHOq#MitTN8b+y~-ofXa7w}}?Hr)SdHLjDM9-o~FS2Y#%4|l|!1ugl7RF@OZ ztKX`)QLt&fNhn_08f|?o5#(r#vj=`5<182A951fpOX+z$-TxE4E+zq1-~~yIr$4U8 zg*D63F{CMCE!41gR17}xX;DI?UKrmMhL^d?7?a`-EqN)-nLG-WFPL;GL+$f>bX!W` zSq8j(K$7Y{KHtB_0k?GXI^0>Y3^!J-z^PS5STJG`n%G;zTuBDqo6+-|nu^cMTcU=| zNo4a4iRxoXBpF>)<9-Uhv@=hTB|PBDhkOeA|9IU3OZzQN!OMAce+hk^m7Fh%$DQu3 zI231wt%;6&L|ux74o*!@<@x%C!JdEKck){Rh~xn9Y)K}DHnBocOHX{@%Uz?sshAZq z7%%c#U|fs~`Vt7Rnu9v=`e47SUX*H#QL&MHeIi_-DkTMJY1zFB3d&{*3Q7{5o}T{< z0DOFW_^rHxf{T>2^m(@Dt^+-}`-@tN6Jf%IiLqh>PVL{Uo&N3l4gkav+rk)XVZ?Yf z57-DJr`XK%nHipNQINtoPcs}yw8q(XR`@Vi=m4w{K}bNuXkLrWejh`~T|;A#?P-Bv zI~yeUc_GZn2F9fM)#>$QHub`p!Ua4s&$Q*kpH|_;fuG3BoJZX&W+=Dt^yERz8PFG& z%8D49=!I8#t%Qj&d4$AaPOwx_#;VEV@wxaaQPt8vHGVD!UI-vi_u>xTUp|b-Ka-x_ zumVrEuEV8O`REcG0V_pW^rDORCV896Wvogpo@+m0P)Rh7j*Ui`g$5>fX@{#@HlTlU zJerxRV`aByc$t%l&p9!8lbePOJ)6NxTLJw#wZhYq%OnHtq52hpz?)kH0`xqeJ-`>D z)X(;B!NVU`;M}qTbcsoTvz96rwrYx3OVflP6r0FH@`I0sFJMDFvoMbU$C|)%=9ppl z{Irau4V@SCd!OGRa65@-dp2{4Q?Yq9p8v2K_X%9{NA!cYjtWM{I^lUvx)76LY>hdQ zoMw2qGz0CL*+ExL8C&SRsD1s24F5j;-utM2R*Fv#@8JIVJx>FbF%S>KDXDoDdGT-Xt8ljkeAg( zQNmun!U&OdMm0`1654;oHEMa{&ywtXFvtgo6HW1Jf+a?I8l#D_4Dve$@~%i>$;#g` z01!~)pjokcDE5oN53y(1a>P8N+{ z*lSrMuCRNbpNbWogJ7yG2Pr9qr*iVju|iEx6A7cgGybk!yYiLacdp%(l$Dd~E-5AX zNnep(V25DRh#BJK+z4S@G+!W*^vApAbEE$R0HPb||JM^Kj@pXjam2wPYx@ukMh+-p2czXRv=+4x*gwkYKNm zi__D&LB7h%!hlGo-IcI*_GHvNy8G7vK*o>fq?frSFCi*^9q&)=#ZxvjfDDD@&yVCT zKt@x4*pa3j6X$@kMXC6}OIwAy^xCCiD5LkHXRtN8Mg-y3_RToDJP%oZo(Lxp*wjCQ zUN>7Mm4y5A(g;Yb;bNwTjjM7{#lVMNSIz6&A`^K)hIBN5|!w#SqqJur)aW&V)9nBB80W_Ig@ zY27+vWb2k_-MkqbHPm3MDvv1E1Qghdp;3LH;N8+e6(IYePs2CT7<7uYkj7(5S`afJ#34@6}Jt@rQdHKHzde?~Ervd?X_&-M_z)c`=h^U;r%NH@LK?AU<47u3!uLtw7!PnOJNe}nItqbQb)3p27Zcj zyW4QiA7a2+HVc2rrsoBdc;chmdE~KojyhZCy>VpmjL%`edP*jS0+E)iAY3 zca&^gPBgWE3~(i$Y}<-YS5BjvjJ4_&^VD}a0MtCbhVtXPFt&9T?3HD)q(cyWI4!uA z9USWd6J>d$js>)cwz7s3R>`)YWfMe`vFt0DhbITOW7CxJXl8ARC>tFf zLwJ*)#TTdkFgOyvB=(l+9jyEdmhjCZ$`=1d~BF7 z5}_6*=L8`zF)d; z2QF_}i~0S#!_Ul^uW7edRYofx7YuIhh7qK*7n7GgIH(z(E=lH7mf5_AFYMa>ZZum| z*9CzJh{6jI^ZWV+|=uj85=C1?yoK zBb&kt{C3^6Sg?~T>{zdEh<@fp{~-W;F9C$+m-_sM1r&*5Y#@TSXRhoT3>RV`J#6)` zBf$#Cl8sS1&|CPs1wzvVtI1~Dg33upEo$u#Z%swGs4HS%gewkDXo0iydZ1&V1Ml4H zmz0QWD_BR{N<7+Bgz~K`@#gqm)IGX`>X)QFEADdxsUrZWeR&%nZk@sQ`P1QJVt{lH zGhCU?7BaNK$Ru|dDaoQ}-UNEwU(^4uhk-yD*R}P`2+OYG)zO_KKh_WstVQ`xYq5RC z1Y#_Xa8*|TZ$Oa0!1OE+lrB!?<)_TEe<^B#{UZ|*PBLKOh(36A_5kke_yKvNN1&;h zA(B{(WOy_l7i8gXPHU1a?mV?tIC~ti0Ae-tJV8c}=XdFOln@vRtfcDhX`=4yag((B z)UF-iL%=yT%^MGMTk{P#H4UrGSVbm*@}aS@1hAU$cC^I4ZL3MEKj5r_6+P9xBI;g# z6VH$CLFpEHUGyG5`C&P>&z=Y`BRzC#Vu44CGsHF6bm6b)fN@y?FjH5?_yN6;Gh;l4 zc4>ooqX%K{rWJT}^E^J1%&2={+r22bsPX< z#KEcp0K6dpm_WeVo8F%X3o{$X*|GPiiU+pCc(DbHWM3mz)6Slqrjv4Bio7GBF4 zbClTg!a6Qfk<-B+`U=u)i_|?8dAUGl*Wpc_{@VK+=o?5lJG;oyf0Jb-C2JkYkbdkJ zLG(X`2W=Xrmv94Y$W@C*&z5|?TNFwcYvk9}$$uaPzF#-huu>^ps6u0uhxK9?gyThF zCMG9)z)_C$zq>JZCz;|%iV+?U3lLf;3xrKREcg3haSP0C6$C#r{%8|*;02QRilOvaBN}X8OU90A=yaG4#07KMeYPdul60Ccrc^UBop6G__2d zmtIE2q3tODc?}+s$9%AL1J+NPfDk8pxNFN}ZVONT+A7(Gnu1i^U)mO(NH1rF1>ydw zgLr-UB<}9|5sS%qybW{_W21#FgTnEsupKVUYJs-ihKOn6jAMI#CSxxb3zcq@!QSSX zi7I*ytb(!X)&)E#MsjAuN_2<_fxo^o>DpkT%i;xHAk0JLdX*&5+?Ii`A&uEKB?_gN zkD>1E6I8!>NFcyo<4t@f`Sjq|AMlXgYc@mR!kRoJ2Q`7esTxjAh!!_QC*k6}R)}>s zL71Nhie^v1mZC+tdGY{0labfGEXQYhzaE!d!NDC{v3S;G3?A4E8EFadc5{ZQfgUu~ zRiUb)2vubTXwa>;iZTq;RAH&72{(I7INDo7Nl_6jj%BT-j6Sj6*g2^q{oNke(Jvfp zTDjn-&ThClkv#0u7@q#D`_^&scvQm?7swEMc{tP;XVNTiBGn2-~}63t2WWqDfQ z?yNK(;N%mF8dL94d_Td#aq;jnRE4aBobEupBWCZVRTVh)=n9XXb)Qi=n-ws_QR81dBY168c;8Omi(l?c>q zv^eWWzcn`-gTfr)tgVD`LwezP`E6bsP{U=$EndL$`us6G_-O;Zrd7DLX*qhOC%}e0 z*ZLko&RkOt6GjZc`-iuA0I=ruW4wBB8wUwMri>ki)P!hQnHoV=Q2{bC zQlLu}lH!q&{N_k}dnAP;DFZ1fSx8GtLry{pn$pq;u`$HdHqCHwS_^U#ZE<5}Jc_3_ z!-pl|JcV2%Hbx0q0>Q~OjJvB7qwN*rgK#O+45yPV@KdTg(#&*_X0MAI(^L55XQ3`8 z_M3{w7;%G9%-0wnxwy-j82-i3P)83TO(3tR z-a=YZ?zJwPWJ@%Zt%_o~VWD(UNWE1TMtsxQ!D^T@u%ojVPL2$~hoZ02fDJkL-8iss ztNz4ID0TeBr3i043+5Fi;`HPsgwTb~8Z*|!+u=}x5#QtXa&mKk#UTn~@pfevwhawK zTRRO*40pz*Dd~8Vn~c{vDcIC26n=CO`;t*?m^ccLw{6CAHrnbZ^0d1);Qh6usDFAB zsJO%P*~}p7UJ9r7B{Psaf}zl39-cppQJp)(RYL)jS_UDnUj}ToRWN>NUsOFK0Aazy zin}6{Xwdya^q)zJKg|xX*a0i8Exkmv`Byxo=l5vy8UlgU_<6<{vgWNs~0&SJiLx4rw?PvLfY zjRnQo02?+jYIwX0-|-+LrSzkrp{b6BhRz=we_ULgguaQngpG@ff}Dct92p6jIuH5- zdqzj$eNhTeUDV}7^Rz_6AP3&kn3I6dq)#u549D6`CzLOU5%t&MIi91gtGu%h(bnO`S1=-ZvPo60p4g%Mlqp%GCXa~(5FWSe0;=;fQq@7Z)5-! z4aQ%>4`%{@R2$HkDY%Q8@~e1xY9Gp(*W5^wVAC3$$eDuysi6q5Qp4yt2V9+%iDyM! zN$&WgiJb|ytY3jD=Fy*B#wXJ9k4bxPn>Q1gexC5qQbhL%Pb?csvZ1*P!riQ~V{IM* z)?I-GFu)SQkG&5}p0LAx%KirzhruioF3edWuSbScV zjgu2I(9*>au6i1nJh&$wUq8uTkHBt<@&5b~JlL{|&T=g7-xYR>@|czBkMb3rkw+54 zo@CX~0p0Pm{1(m~+=F3#yTj4i6pFG^WULb8y_wNB9OnK1>PY^9@qhhUcI4LEse{q0lRe>M{stc2_V@NDQv=uR-sWI-4 z3+9=$YCfnnj!S?qTrSY_%89|_oD7VLbtTY|MIo`=_bm2M&@kUJk_W_DJYrs(rqGsQ zaRB*CYN{HJDoSd66Zr4`n>}rsgo3h)gsP^7wX}@<2}OD{+XUF6EGL^AH235@2$&$W zAm$5TP`fk*_ou~SNsJY?wD-Y#R& zsggW~WRs^_U4TbhR`V9thdVdn&BcSLW5!=0H0*Fgd?^@k<&!&jTXqePubsukeZS(@ zRYh30a2A%$nuPh|M<6{S2&QsU2yw86gQYPN;v#VG(rHvbEfF&TVmv^k{{jmr`Rf{i zm!;z4i%~<*>n&;Or@wB+(~WEKXzLoBTfGERd$vWavkCeJo8iEOG@`HVk>qNM9<4I) ztoQ;kkUKoPQ1##%(fA`cke`Fjkv)sRH8XjoP_!aeMf+`I`+?%a&(iV}eZFv`x- zo)ruLSbDIOGoR{bH}K-dX>3_K7qQ+Ruve2u2X9ks?GuW!C0U$#yO< zMt@5c1KOqI%8_63g#{W}^<(ixJlVY!}c-PFhN;(Qq3K`S%7d`IijfD*y-vVmN<|K`k}~D;atgdG zI3eLT?ZK^Ex0VQrj1;2(N=hlxQqnJVNo!4S*#xf&T8d?p5yD)276=l{ZCRc8yLk!N z-p&i-ni%2Y?%7|`1z+(1Umh0Y5gGB$l?9kTY6wPjX^##GF-Q*yLW}SabWKaZ5TdWc zd$dKr&e=$zubZo@LPcH*h6dW`)4nwdrjNrx@^p`{o$U+rAa^2KGgin=RapHQ{S#0Shfvw1^AC#iP4W^|A~#EbaMP zU^}ehPGB*N1>NDm__5?BPHg)D{d#nSr?nY;^;FO)z#21KHN}rZV{mD9DvI;lW8a*P z=$RY@S2JD22K!*~tns*e;V`j@n|O8R03QCb37aO2f|rg4My3Q}XlfI<85?2Th_M*h zrz|<$=cI)rNz8&7z7+jU2`N!h z&|}$_UACTVcAEg)n3si*`N_QUmF7^*==Lh7}g zynGt7@$~fQKZyPt7)W?}dCAJj%Z-o@T-%4RS^1fbv-|l#O77W(R>i8NvA8}m z2vhwGu!OY6v;1^UnUE{2imDdp=>Jtpq+v>oP;e2&5%+UZ0#47&;4|XF^wqFF&K`%7 zjd3_m7dJb*qGn+dPuaX*6hkjEmhY7RyetiuXQ!cSkTon6WYD&GAWr5k;8kf)Hm)E; zT8S5je?>KUNW8j_FVF6w^x_F@Ta}O5BL<>Nax78Ie;G1t`+r>fACDc{3+s;l$x6oHYUG_WVd%`xejp z*L~yV@$#>Wg#_wcLPjaDf$Dpg@aDumJlVF1*uW}cF>A4Z$!zqAZ;ogiE%b=-L$bdM zy0=KgeFi*az(NguG56wC53k|nm6O=Lg4jT69PA8rp-JpTM@bf~(_(S%=x%&@c^}oU z=@1Y!6hX20M}37*FF?W`)dU1D9$mwZpI2aH|BeXrc7X)}pp%vg672~9!)!2=Xnpte zaCq5ULWNkHK6%58xKQlgScF&CPT|qMU$ABJczEe*AH+QHIU6lWg?<+|#pLX1sr>|GzbKiAG@f_WO;-&%P!t!2v*;{w+(7m1%e^|Gp3_ zkfmE=MOkzXw8Q?<33!=L(o!4>xG{N398~m8!fSec?WZM|PnT&CD z&tQ_xX=1i4L7YV(bU_MBR2u3F#0DYGloRkOzcoe>14%Jd#@aYroNHr?x6>ne$D&Y& z!8Tn-@{}KA5}%f(;?m4CWV`62nUNZ{4@<;5VlJ$~iOGadF1W*!RZIRtPI8Dobc<>l#T*OYe}UHzm`<93>00>B*;v5leC28c%UV@~Gs~DWTYu zWrsobnm9OsC4`c1(+322~3Qv1;nCK`YKD;S@T3-?a zas(dIP>>vveFptenL&RSIfp6IWd#nsMVD>XoQeRnv6lW6+0Yth_`d>yu znht$WqJbSq^RKfb$%BQmP$xhNvDP6r&;$o2CgQ^MG@O}|jw6$juzyq-^0VF1&&L?q zcG`&3Q$nnXHs2^UApZ-J^D zHbgfDWh9N;HnC%wfGPz=<^Ef@te13g`AsUYO`A3X{g+pWAX@Z@?HwPT;EC7yEx+09 z6Zv)14$Jq?iNNXZo|xdHk4b@+c(Sl1_r%p~%59!d>nyA``nyR$LiHdA0JhVfknmk$ z3YNC^gd9Ndm~RMPXQqQ^cxeSkMG*{CQYqKPM3B}K#C#1s~4 z$}m(>fUcqn2LW{g0428QLYl<@$Wu#`p^}`FXDL8gneT&oWULYrvUDp)M?pAbxZIef zq#I)g%=o1mXg&u3X-G4`Xh>K=C&WpdpngG-1SW5P4OQh6yqDa3Q)n-p?HS6KrrK&YS?i9HsPkYnL+P zZ^j2TUY?eLe&LSf`DKvq?S+Gj=kX~ukAGRmhj2bUv;#MO*^EUa`l4e(7{>JKg#DY= z;9comR8>62)6$#R`O_v$pEM5bTeX0fixV_eRM@5!Ay_0K@=e^284=OLJ|lp$iTm`JGX8G{7EjI|z|0;U5bkV`&RHo)j|xGr%ycw&w1Fvs zf`O6>bQR>GsUS^KKnn6?Sn8_k@bvOTY(g9yoE)L7qRML9>CopRtpgQ#GiWN?KwH%T zI;xKRR#VxUScEYdt`1RoRr;J1>G3iG17NH|)c@bf0x3}%h#p+fxMq5@Ev8FmGi1xzh-=xz zDnr)!x@SZTf^-$p&0Y`N<85&;!59}iSmDF0=HmX!=*EF9Y%k*{CVz{PaehV`v34`K ztI1+XwjZ9cy^Z8dpXOzwM<@q?S{Z4XNh->6vI_FDUje|1{!d+9OTs|UNSQ9?rS#(J zLM^p%aSAIzOyQM!_1rs)(_4tjJf9GNV=Wvo-rE?R?X_@Waw0wzB{fc8Y3S+a0FW2| zw|n^neIy1B3#0KUCj;Glt45c4Y^D#V#_)5$#)IV((5k^5a^(DZ7c6bPn(Q5!1(yMp|MV zObiVmOWs9FQu6n@@o$1nj9&7{GF>Qgq+?lST4Z=L%q9kL>C^#ye9pY)eKF785Ns1q zWB$FNa!@qTitq2&fAez<456N6N>$laUZnKu$_c!p+c!kDKeIgli<0ktRyf7q|7~_i z_t(E>Eal!;meNqT^bG^~cBa7}o!XaTM&UQlwE-{~m^{6F317)?=SfcC`dIh045=%z6A>*KERlQB#Tqf?^2YOg1w$HMu&T1TUvAckQt2j?xwIK z2fCtb0D0gP!NcdreVtb*HW}6O-GcFa7)is#qEGqBxH~@`tz8U|LYjVktOE|kS&;KH z#PwcI__!p7muB-tx_PmjE%T9KxuJMZwz_L%JYsBg;YZJjr3POt&BT*jVyz)gEcIDK z&w0F|x{8dtg8Vo7-`-t9Rz^WWLrvR8N=o(=L$VPc?PH1ngiD}Uec$U`^UkV#&O^m2%A{!_A{@vn_ucepKd?3T6=_!aY*FucG8a5?4 z;V5aL!wDul5O{xxA3iTl$HnQX=pJmtQ*zN{{M%1o$(<|Mvbq4>+P8v(jRh3tNb5=c*5H2=HU5WD=EjSL(MY;5RFxGG=~g`+Jvh=jbjzRru0@9VkA16zyUn;H<3;J9SN% zkzpFCtD~u>H--)vhAo?Z!lq4Ik(8VSB}EOW%3HxqCk#%e8D#Kn;AY;I48A=)_~}4~ z-w_^WozT>_2hxJZp?m6l^varxb_t^q=GO|=hQ3gc)`x{#1qo| z&^-vB3Ni#*lOH7p=HkQ(rSA}Y15PFy;~1Tz zX{IP06OQwfv(PEP2F9cpV?5mP%gjl5uz3|8)5Z00>q( zR!zFn1H|jkn^`cVCJ%asv&}(^?Z-x`yIq4%_gQ#Crf(d3GfqA0EeBgtzvO zM3|)(0(2BGG&KkzE>=W2*sGgEkzNS!#0vxbi-PWYyDGUji$ z-Uxxp-5YN+Jf((hl2tG)FrhhO)9q@oRz|vuEPr zCbp?&8Sn0Ux@!|Y-#o=T?mj;+$Kf5@(Y;f9m>L_Bk$z1VeMjpg{)qbj>p5ngwqm(! z9<`PdQcd)?ROq5|voyi(B-x}=S z1Hf-3$M=TL$vC0MK|!z5@Zy240on@wL~D7c%qfrn{Fcw2Ua7kPb8bLREC(CI;j-CNw79&TDM^4U4WM;L6 zuD%|Dz_$TIiN^blOpj05kz^~mp((F~X#b{IHhCgejvI{$o!X&&LJVy6b&=>yw0!}q zPl^9#$ZNzm>+8i7dEQq5V0Q4iFbRjpMxnW(GCJAlV`m~s;A9INOt!`92zw-`Dc`R=U-##c3DRw%1sI8W=669s%J}W9H^hr<8;2pvG zMkW$55n&P%%IfM8S_b;^(z0?hS<^tUi5gB$N}?x{*4O}1$*Pd(mzVVOq$gsAWihsh zA@4sm*@Kroe!GghreW<bK6{g!g^4M?u#}U;a1SFKO|r(>cnk9W zEZ0hJfTbp^6=l&eBoIdz&BLQD8}O73_1LrwFZOIj<;~Nmd3=XQ_a}@R3U?<5$T6+= zt-(r2eRCxF`KJc|XBB3RRIx!fvT8OutfWmQI$X_-FsMr#+&sBokOLJ$4}Jaf zf5rkDQ-ckUc~SfggZQoIF8)UV@ZEV?@^7Etf>6U8gm1xv*ErB?-nV`kf^96}q^XU> zfM!^mw-V=1UBdaZ*EsU-9s|f(sU}QF(mTNGg#Y&85NvG9H3~##vy)x$_$SnbweMUebm&w&&NbxA0*c!I19bd==KE-VD+3iDC+ z(z0vkwfr;_nEQIbKJjuIv(dE?5A404)@WT#=gR-jVcyZD|Y!an-@t$ZzEtRO{B zNUB~=R_=QmBbW(E9^~m#NHGaEFnDPfXWW0Q`K7+eH8=bXb}Iq^3ku3hOF)feim|3D z;+qFyRG*$`mmG_M9b4k&@jb*+?g?_>`5zhWp8&vLoIlq8e*pl$J%3~5Oz$^n{@OnZ zDpr(yU)afMI0p zBeGYM;jiMxKayC$sMd5#z%Zii8VqQ)7-8Po&{8skxv4ECPnv<_$Iqgua5X|hq98A? z+*nia?dL8-pSdz!J8A>~%JkW)$VAX&X#H7%Pgdz+zro-iUz zNM|Go%NO#)L`st&th@f3#ULC27y>S3>5>GLE^37qp4M;%V_2`W2>sIilp2t6~#G_x=;pLhAsCjq`uS%~YXC9%ry$v_OhI9^3=}3vyUJ?!U zR!Eoc!0^GyQ&6O~$shYRk*PP+1WkmKK;jW+W~h z-iHmj^Ra%(EIhq&Rv3EO(2wv}`n=*dK{nC*LzEEpzw0;n2SLC;JjH);e&g|f3lJ~A zjze$kf%p~x=>EGqmocV8Yb1JmV#CrLTsnQ8y#7U;A>(HZ;P8QCh>1vnvaB&2twJzr z@O+fqdV<<&puWByb^O%O{c8GJ9e(}cBtm_9z>P@)Ghzd#oe<+S0=?1-Fq9a;h}LUK z1`rcywVDIK=r$|ptitH_>oKTJ9>RUv5EC#YIbe_JQ|IB>kqg+e=@+zV-3i*-20Whg zHRB_}_U@7QmxY>)9Mt7xp+!=UQw?)Ewnhm#pLxBy!iqp)e3C0)1J7D}g+(A? z!YsmX<`{8EXd-qFi9!fDyk3q5*p=kS0bpH}HPW<|FqG^gH_8>gZS~Mx zNg7f5su&yXihU#F@pwr~yd&?=mX&_YPo&#SlrCwGK0s!`XHXm(iBGH#*oSTx0P;+fG(bK~8NI&#;Glm=Km{?~!Y#KY1 z513%{=I;Nn5oJ5K;N2xM{wH_v`1U1?9X%Dd6bd~WG&Z95{yA8l9pE{VMulc*36lOM>lWc!r2QredaPwpSp?* z=dL4fX#q?P?4hUbh+bVrqpai!>TB!B=*i&gs!&7sYwBuHTmKRDsKoQf@6f&NOt_g5 z0GM{C(-mIk-3SE6pl1q!KRL<2+-Httw*n{d2lpq4jD-e zxH$P>S>ZaIICcTMcOJp;VdG$7Zq31-Wp-qEpMo@$Wa+afz}JyefT5fs3=|Y$q@swX zB=J@Z8-TUrM!{ZP4c!B+$sjXD0Ep+yN*hY*8)gFtBlKe%dt&&eBm#tzg&F9?c32SQ z-xzI=Bgs}+9c_mcEd>m)H^8=dNBkUXjhX&tNHx-cm%J3>jMXtV+!h5f*C~OVG zU~AMImikSgqiRbOUyCjZB?28|v~AlS8`f{Zk3a0-o}^Q|t_bk(ftiLDOjT51s;mk# z6;*_~yWkK37@vCkvb52t{|Ny6M>Kk4UjBuccC5Jf`|}_C{q-;ZJayRsT6a$F!urM2 zQE~Sg9^Ji*vnS8v^vNqYbBZy5s~9+72o&Xr-g<=M@WE54uLJ7%sUw50;VgjuA9d_L zs_^F3Ck*JZ0Io#;-Oake-MlM-K^LAK2y+{Tc2Ns3sO4&mB!eH_ZY@T)r9(h4s?ACa zCpOSKV+jJB+Cfv<5i&BWNK4Pco}Gtq^7thjJ$wN(rZ0ely$j^Y=oOgp6Bww<$cWxw z9{Obb4B*XGlwqT)f_N7v0dZ%OKuT3tgL7qhGiSdWJcpbrU;;IGV#mOAU(jI%K4!UJ(OWC$VVB5-E8(`GL|B z5|z$cir6z!D92?%IX(e(LI6&to8WMqIev_DKr2F-K7n?8+6e0m{A&1rF#bDFnE3No z-GB`-0@h*mxhN4Uy9dIYw2hvu41A0YF{)JyoXKB|M`SdD-d~T36T4A){~9V^mZ9X@ zSq$#m6IyDjygHBR@~_P!l44L!(B^D13EQ}u>5i$lw?eyB&UU_$at(@w*_a<5T!kF z9zSo|hSB|pBh(`S6 zsH?A~`*nEq@Fm)}7y}pc)&u}uH~_et)2&%ocoHB4*!D$A(@E%&l8fP_{YSRjfKlZ6 zN0JN}(sCJEHlG4N>yEH7ih!cLF?4i{F=@hdoFV`?bLu)hx2sqP3Rd#%_X%dc)OF+DtCbCV{up!18YvV|Mn`)ySooz9;I38<=!!ah@z?EOdEEAzPx!JCB(6@9D^!a1JI|VR4WZ!d2z4C_4bJ3bVzzWq$zx;zriL zoChGTY+|+0@0O)tUK?McRniD_biktFLvU*STHM{Z79|^3;U>}lo4dB+=E*(Sy?s6A z&7O?(lz3>UD)ZdFv>2F^korEA*I?}Idoo1JH8eD!qM{@O^2qoVWObpZ<^gMiC^(t6 zfU8+Y!JE)UUx2E($KV_=J1bV;0vD4%XH)%JvnoGzlm zWAMWdKjPwrYq)&w7S12Pf~~8!qHl+8@UXIl83BNYg)uhe&qww164bmX76G8-Uj~4N zpxk%GIRC=|p!UUG{!DZ_M^vif}2GqjCK7f?>?~E)2msWH`m4jkPzQcCc5qfzLQy+5AuEeF1o7Y>8>*AXf`MV; zIPX|-w!csw%x7Z~`)1hyHsfMcj63{@1x)rc#+rC1w6`%px`7%tksLS{YlP!5h6Ds= zIG$jJgQ=FdKD0UB%k~X+7vn6`Pc#+3vSq+(YigNOW%F61=8e006-hRQtd~gj_ zRRT5Xd|5J-6b~!hUDOg^m>wadsu&-LlNm-hkU&%;!489+j1Z}#g1y6I@u@IP9N_V1 z)c!9Pe*UHsu(m)*A--9bj&Z3T(221T{s+*eG<1i$a(9NVJL$MN(K0n);BJ zHqnQ=vLb11A>S_5Ff^i3^MCE}ZqW9MN(!XeUEttg4;3X1NK2?gMcx7y`pwCMWRbUT zOBWDb45TC3@gR>9VBZtnQx{`whjkd!W-Ugspc0QRv$WC*3~#+0gR}F|CUz_wO@pDK zq=&TB4D8;0ko4^>oFgxC?!;xB*mn#$vlk#SGz`{yTIk(2120Q1H5T^#uK<9?bB}NT zxqJB{0l;aTJAW0$#SchAZHB&{G4yoE+pYeYygsA-4F+E?o;rTmf3J!Dj~`hCSBs8t zHR%Rd({@DvJ8~w_2m%(}`SG&o0UxWL@TKEt)dRj3UExCj-^^hkqC7{y+qwf?1F4(= zswo>_=-{z9cjgApoW9Nv1Azr|7sJKD5gMfbS>uu(d4FS)cNS`DuvXK6t-2;pZMth| z!$Zdie%hKC(cBtm$HwC6l61VxO~>2ZRD8%!!^iwoe924Zvor-eiWXah832Ux`5W&^ z25cJ=hd>==q?>ABNt_3|I~gNdQyzJdw%C_w!bg)IjWfovczqmB(L>2_AF?N5ap>EE zEQ}$0Qj}(eX>x~j)K#syzSbX?*E5H9P4$v?m_;pcB^pA3Z5$UP4V1ERf z8A3-!0t(VB`pR~mOYveJmfDkUIFb^L0Y3@R_$8&KzSih7eJ??DP1_JDDJh7LkA=3D z0qHpd=%{#+rcWaDZ%4F|E*$d?;;rCCyv({I*l`egXBJ^p2cnN{gk18->{Y^PMcT48 zfdK)-u(qqwE^a(5^}V5~YKXplhT!Jmzr^pP!YI1h|IMl2TMuyup{s8Z2MFf|!{)4E5}= zX5}^l0A}!?2mro_hEJrwmL1^JhiYtCyB$d}HNr3+6wKKh0TASMuNqJnPD+L#hykGvLM zSl!MGKXviPo*w=<*3T1I%g{A*@$`&KEM``-@v(U~?OID9K@PkP8O- z+Mv0zET#mQV^@*|FOfeQZ$`%m2U84CJj_Q3GUi6}QR747oVb&el9pR$VQQ+ZtF0rU zX<*2mzln*7goLt^n!coz^e=K`NPQw)QIX$*ynj4iOlgi&8OAtDG-FSaH5P>0qq&*_ zrYC#wZM}7kgFd4F^_AbhsRXPSbD+=jTcJ}^2e^>-|8e0AY@9R_ksc1Dy@bj-DQRKC ztI)SC#c93J#q~V^u#!$*)+r?nf;2a^L!aJ5(6M7z=#s`$k~4&{b`!V|?Q`cBPB%I| zMA|QS4}JmB*Zi#dp-t363~jp>!`l$`Z?%R%fH48mlC3xL+Yxj-vdty}ft85$?*VNk zJ6M=GWB&Z5IDY&LQC!lwCobaj@$AKq4B^Sb>Q zIcx^@>^O$w`>&&G%P9l`*}NEte|B`dth$qD?@6aOoxbq2VyBk?0_^*A+dZ=o3&$Tv zcI0dV1Ok2QwlL8l8KPu?A%n)^)agqE0N3a6Vy13axe!p)o==KUFE>?zcm6HN?r8;Wkp zc^JaVN_lzdI-%5*&PefgbX&ULZavyYPlKId1nDkAL`KBpm+iZ8l8pc4i7RvgUBtQ5 z=ke%XF`kv)!sVm8@cHTg9ssES1^}wqIJV07+_RHYCjh9yv}sczFQ)+`T_40p_QC$$ z7f|`RRtNx+*RT3gi+gvUV#(Z}kQzG#o7U_@9brV($6Cx9zY5+~ZRvD}4;ek{8*nx4 z1V6hz@F$w@XEzYOHiO_}GXP$s`#r7t5J2=onAZqogv~&Z<3M)xdtdXDQhFv|HqGWm^$$@ly=KTPXJm}8*{PV?`8$7ih$zyR( zmZV{FqC0%a^SAb}Csv`49(FqTHOYoNzcEh4F%U4p!8jva>*|J@1&R1fQsc~&R0Lb< zve^e8<>i$+Fnjm(bpIXzRF%~12mmgyNa1MuBk%Gu@qTs$&a}71Vfuwf*gMPQTyFfd)p1j)I)mHCb+RW7tc2? zN7>J7ar@v7OdmZ6285zQR9}h@bZDID(lFJfVZIwnbI2*c)2j*h!gJ>>fVH(9Jx&!` zD)vO-;*b$H0<$Mm&O)f~AcQv=f>rqk@a*A79NBvbtrAC)@ppzV8NaVhFFHNo zWZ0H}e!(t75bQhxK~AF(K&L4kKl|bEvmFdy+x`R?eaQRwfsZ+XgGFDsTXi5O;YXjJ z8OBeTf)gjt(PwvsKwJO;mbN^w=P+F_-C(D00DA%eS1m1g5&(D;05sJzL@-H!P<k%2gxY)<;0bOq^* z<5ED;CBB*ITm6`iBPac9FaJl(Vc<-cse z>)YqCWab3uYN>r~(hy5{B_#x!FC!Lfup>vt@87gJik7Xz_U-!+5grR^NmZ`xoNR+I zdiZQyyF?W9Bk4-=AWT=*RM(@d^a+L!nhj68mP8S|au8r(;ASpFn47x}M$3p<=$lo9 z;Vhp_20y0#2D&9V(0(IHfHg=9p2SO1&2>YdAft`M#3URza9E&{r&zk{BCcJygg1}x zqlRtLY1}FJeM|7amG>77xmFw`^0x*6*FY%0|4mS^wxZ-e1pxl2Tvz~r`>6Z!meYF{ z+pn#w!}RGhp(w8lQ+)ye%WSw>w1t;VJNP?wLV!zm1iAJ@bNA5*_nL@E-*IRWHwB$q zOhasNU;a36o9<-Py#)ZU?Ex3lHn7%7Cxh>cP|q<4ai4+^x2XuF)7)henmLcBGX{YU zBhbWV2+6&{@V4wvY_KbAjH2oDV{xNQ96Wp&Cr@1v0N}(8p>Bab&tHE01zE9dtFj(E zNcVda{rA%|K%kxpf(_Z6J_AG$2n-K&!OdCe_)r*&O18o)R~YWuz@B-}OY@iqe?nV~B%^Rye}i<%tO#iq$83HNc}$L3o*y zjbVu%P-n$U(sIA5Yv>xWr9b-m`oCEOs-)^5D=Bwbi412n)AvNf@APovo}bZ+eaY6C z9$*S@C27oW<%jn~ErcbW35{*M|C9y%;n%s|iQof8w)BsHi;5fuq$c9tmeqJl65!G9 zE!exh2tLksJP;`T&3GrFtd|U5N)|Fw0szR$%EHgDDORuAh;!$zVdTh(ysn(-ep~aV zm^^L)o;`WV14;bQ|F9~vIx*FB>ey982lwD|z}<{)2>@IzJBaz^Zak74={W)|LT3^q z$i;xnGD?OgqBKEEQ^s<}fp}z{X9RaIdr+*RS2d#dBA1@yt0q zF1dy3*AICyP{Tmb|8Qf@Z;blS4F3Nl1Nhf75hdcZp)%Bde#J|AS*)Lx_%2+y7|M$J z!~mMYjp)25i`LuphQED3^58=e<}ndb-m?(nHyd$Hr=hvWAXw=n6Hv5*w=H>p+g_aV z`_ZkBElaz$hlzR|Nxbfe4Va4PCUX$&GZ!&FbW6X7ohYyA2=|ywKrjvgb|c|uGnmT& z7t0K&E84=+(g`b8t|S0IM*w~eXHMQC0Jui?ukaG)Rk=mP4t)p!^x>;(fF@+{LHcH- z{SDB}#1P?zCdf3`#ip)J@RIa=l{j6iI$zlMQ?0O%yT1qS%+5lRhZz(lB&ro;WXD)q z+Q@2aXh|d_B>YAKD5}`XNXnj9B*Pk&Y%uv3u1s8(5c@Y8xc`*|HMtuI9D*ifj+l5rE7 zvDHvg(h@>zuUN{Gn|5(J)@v?0V11)#DptUe_)*Mt-*W&Z%O1ymWik`!LyeKCf{7Re~_+JbF z4d1I5hn6)PMlM&=es?#t1VuMg*N$ zV;u|)G{v=P!q|LP=u^#Cez87&VKR2@H(i1Cyj)l)4`ziyd?Y#R$tg-l@uEzOinW1@ zk`w||WHB?)68qvUa6H-=M`Nr=1{mO6M|*7R6AnjhB`8Qry;PEwP3FsgeS9Q5Jw3k% z05xS5Gigbw!)#m1kZ2EF8xleE-w4ME08Yf4)-VJ$C%Ngdka;r2}&1ZH<{2McvYFo`11v^|>{&j&^}l=6xlc#=}E zvb4jT*^79vjh)^-2SH6mAG#WLNRI1*^Yq~{%3sgCK0ozhg9!tGDh>uOpMFB`_Osy1 z3QNe?nb-H=gD6kwoH0YJR3{9Q8!pbNFO7aHi z(6KAtzJJR?*uuqLTTR~Y4XR$3^Y++=n)&Y}z<&Y&G}8V5#5#a~3IKd+@w=z=UVLfH z>$3tX9W65$>o$e2BT;_mQAF1#lE%=zBF4* ziE)FjoD5_or7tPVD>y64$VqJ4y5;x#08|teb!8-_ewHUg>KWvMV_k!IVBt6!|M3JX ztctQln6@GYggT;vHUBWw%1;mx>punnbp>J!fPTl@yi`m}_kcM)=~+FxqI}0z?4CCb z5w;f4mXUy}tTcIYX}&aqO^lV57Fup4#ae$uqFUp}O~cW{XL0JpMXXu98P1MA+yHIO zgRpS+T2y^zoWGva&U#dfm1WHP)QSL5k9)VCp+)>ik^t?v@pAyM{AThD2ZZk85x(zc z#tMfzBbeAg$HW|BEc5AN?8-9@T51mP_723=YgfQ3*m+GmD`TzxKmhQN8-lQ*==)g! z|0zMf|Arvo@8$*Yc!$7nm;|b?{wRd_>Az#gj={>p2}U|i(A2dr!aOGvWuHx6dJ$5B za*-Nbh|VcnFnh#hZp`ER?}4*Pdkz4;1OUDw{r9u&$r*qz$pQz%Okx6^Fn|0>te97Z z<+JZ&<(vmtHTNM3r z60v|}an?o{Kdha;hDZK|ia<0h;1#zA@qL+mtymnYmPO&y@;H<&PQ}s|K4@#Ijs*dh z#MIgNFcTb#Gv|#$o!w2@3;;F|b%U;so~D|LI$!_yyZ_Xcl+FgjY_$bL@Zl({fV5$*^9DW@F67w$uK+QKl#|MZIn7kom9Gm3IH~P%) z-v>8;Scd`0v3&9mLoyAzRiW=G$+G1UY$lr|X9SG?t7~YXZ=b<9bl@~j9J_?0N6w&o zxBgI&(}lLWBmCS`v3vV*UOLKi%IxsE@@lcbq*kacr|a_*QzhCHe(fq?pAI1#jLB#;s6`S{A;<^9axn?uYuAedfWJ*oy6w(E*vTjxP3*g4@#M2uHNT9E z|2`RiIo-1J5K{-7L1t(n{f!)?1?MB)Zy}EjgnLe*>tGPf^+TYqYs1+I3ldd*sm7!7 zXSi_g27$n30)eYM9&qm1IZWz56oF<|2Z(c$9w+S%z%fBGO)S552pI)V`G#h z_9vL)5Xts!iO$HjGk~I$RK2u}++1B<19@d-wLe}8YN)MEHYL44PEw*i!cYtA!^wO$V6CWCgry#F|5h6C>pTEqMADnQObm}B^jB2C zqQL{PW*i}~u^tRq$)zGIy_AExyc|>s3{?3R87U~q$dblZL3(;i?Amn%Ckd5V%5CeG z?eO*rAo`;R6QYyhft_&v)E$w6e>Hjm0K^V6VxbT2JxA}3li+TdLB=hV1@k#{mRjal?*U*<_w-#mhxWIMXi>yRsa}UJZHQ&W>a!w4}eIASGF=sH8M1JTyXzSN;9be_{Uq(sHsg z!(=5Ts+wphV|k<#_9UBQf1)XNB-x>hBcYjr8jg>SKowCJwz)@`{u?O-1M}nmLjX{n zFYFLxbIb-s+rvy%1+)9~M(5B#m?+7^Kv@m?N{Y}`WHaOCpuyOHoDAREqAII^K>uKD z-1sw((4IVTl^5MioG=xtDtb^=u!gNk3_|=n;p~ZPU$C^c_`@q+_AKf27TC+dAZ2Q2=W`OW- z%U<+zJ$WgrtqJK!d3_8VI1rVURlMr0mdTL1O4NRQ&Lw~V0C)daFaSQ;h_6w<%h^xm znHL_)w^^7^@G6}5tlKNsmC`54#d2zl~ytX^1#wM*`z zTZ!!flwi5wBH#Ff4pf{RW_k_QF57_HxAthuY{mgBwo=@I?UO5MW6(j=|jJb-5 zeNSNCsH-Fg?qk`^ax5Nq3j)a8_3TEijyC1%M{v^bYSj?!V78Orl z6A0YlAixTQ&Ky32IitoS(%J^0jQ$e>`2g&l&m1>t-yCtE$fv8@}%H8;ckdE}6D6HvW42^G^K zak+;*j;9&(&cN2TdQg&*s8LZ<9kP1OYDqUYH;F&}Pf1R;t%9WFdwWGW%xG?lT?7EE z0qDng7o?f%qm7d>?#)f)(|&9CsIN%A98{Qh9{0DUz5+{#71t%ld^ZiHjr>%={&_00yfy>N6NxDlSM( z$;7Ky&&8PpwL;6bn6GWL2{U@W3Gc`gi z{X4?c3K7Hx5{xu4*i#R?do;zf`6>94pCZ^3F=B3KtQ&BG58n#Jc5~DeMu_9~lW}Bd z2r{kIv9WtIe9TQj&C+O8Es4g5xy?{A#0>}9I-{MV4pgP3YUSir$6DF@NNZ}D{5b%q z%FBl-NlHC7BCk5g#|*oY%yB5r1Z$$45w53>K|!{t$W0Sg9_J>Bn|~v?1ZbG*`={QY zMK=kh*m!ZF^)cqVA)@tS?Q=y@Tl5ZhggJTrru62qS@9O?8hmw>37Z+Oq6$MLCFqkR zV6B+i1PUG7cEpjxC;1>r1^{fR#GXC-(5zV~pac|#?1A@Il8k|kqBFt+7 zotboIBg|(OLcQs}_jH7M&P0gY6a*6(G;tgX51S59SF%BKdQJ}>JmA4D9`$C^ZA1VN z=Jgf-mzM(nQAXfDH%su%b|UfhRIyk9F(K4d^Z3ZVeS2YMVh=0BFeEmgi9sFrAb)xZ zCiFjwR?#caH+wsI>rw)Mhgdnk6a}-cqjl0km};fa=iHXF0Drq4{IjywP9xCngsDUK zVb#J?ET4Oy8~Jkb{zbDMB7fQg%pP$SeOqtC;EunNw75qy;Sts@ctYR1jh-zxAU1Fu zjI^2}z&{iv_wMpQF3%j*3AfeNwYXpQh#2TaEE98imI$YRM-dxHGSNdBCHbY;3D}MhA*;O^ki0Yg$e9no+-AU2t?QaG(HpNI*b+M(ji=vj6u|#zA zA8Z9yS5TBN(9p70l#)KDDJ6kU&U)CHV8vH)Eox>3KUFzQPH-oRo+_;R&P@~nKv;A1 zPp1Af?iOsEd=epUNfj0y*5yP901)N~Qw3Q#XlcPtLkqSV8UzH&1O#d@Ra1kpiZTq9 zm7q&(pt*k#Hm=)3^y?zguPbyI2wX*8UJ;B8Euo?0z`ehlS$nvcc7U%#XAJ4J2)}GS zj~$cP?A0MJWFD_QsDbe|0daW!iN zYn==-{IQ5_x&X2MOXzk9Vwx^OwEuiW`OZhA_Z)A9a6!E(AigcWm% z#uJ@qMmuNBX+$&`10%I$k^`Ca`DBvQNP>k%2D)c1CvRWODLbjCP*+bL?%gs z9`-7j6Ka6-BbxFRVKt=n?+x?9`zpF zFoIl$k|!U+nSc*T20wfHzHMKjtkk*>$$)_};@; z_fhld6=wleRbMb>%owOB86hQRC~{_8B7=NDw7(pAQ?HRhugB2NyNMN$(aw5Ahm4xQ zVa}LSXc0RPO`Lo3s=x@p5$MxqEtXEYN`_yC6=e7;WKrs6O=8nEZp8prR zy@2&g9+OOXfK`ib;iuI%ap>1F?D_dFW{lqiPv=OO7+Yb_?!DsYSO48%G2PFfs&M<( zJ#s>qaCFZhbd60#BpE!T|1oAZNHDfXlCcR|+H0bpmjQjQ3ndCBCM!#PW+ zT*h_=1609hL*B!p3vUB$V%uM+GIJe=2+v7~ z^db80F%Gu+>2NSgC7s`ssDD46R$>7^AG+^j+np}1_Au9qL+ivj$eVc`vqxXSh#tq# zB4#y#Kn@ZD2nhUh5bL)9k-qZ~<~t7NhRtDaW``q3j(!Dz+ApYi|C|HBS6%$y+zt4j z9wcm79`beXU@cDvmZJLY6QKGt53=ppv4h?(dxQkGLBYK9SVdlW&3qQMFUG79r!aH) zdCVGqnP_@BipZE5wO=v+E|$%^hMZX^ku&oo-M@_G^KKD96cYfHVi`*v&V0bZpkT&r z`rJ;Ee7J^{3+VfE?((4Gig|aib8{)4KKO{rkF|72PLd0Ms>R zkv_ctjO*8K+RpUDm{PESXyjXwH>+TwX`I>4B9 z5q-ZXk}o59xU?mDh1fz>MygI$R^bOl6-_=2#MQ%x+kHe-lmGxBA;M}f1x5KJVhGRK zx~=hqPCur&qnooa0yGt{duRy#VsXxYo?z50Q1MRyKxlLjR)&4fO~BjSG(29Mg;Qgq zu&#qUCP!MJm7Ojk4YU!euZQM(dI-?hho8O5nj8Q;?Ho|Jw1Bkx zRj&C@i5*%D0QT-ahUlmyD9M??&NN9VK59#-c zKMx5+=c9aP!d9;(oJ?Bsx^iC|kpbAS+VWmpU;5a0fR%0nTEsi5fCgvGy{PqlVN2X0W(v396Edi!WBdSQ1h9u68{PSCI34B;AQc5 z%S-r}u$TAv%JI67uTWo8g_kcY(6$X}cym8Y8S?|yEGgrW|5Yq_H@6H6CS1pqL1zd6 zN_k3f#k>-1$i0VMo1Wt6j#oIb`z?>g@Lf-!lie}%yrh;ND zoqim%#~#4?+*0oOSIoOdwEsFzAAE+IDj`VA>l0Y+zw!$7z$5Fsl`>1Bf;=A^n z3#0I%C=J_(M8VHkjco@0BrBt^TvNfxo{WlFjLYnXOqDY(^;EuH| z>@YRV9PMmW5UVGT2puJa>u8ekn<8A_1fhC{2sAK&zo9044bT<3YBG|@e2UZgBP3l*mZ`DUK-LP zCS%2d8|0lz$!z?RhgSZt*tPo`T5o%sP6fy#2WLq}>2rCO$WM>S+iY3fbv*`T;GSd6Ryh@Qj zvjofL-ou*3_wehwXE?q06Ku#*xP7)BH&55&`iXj6J6?~AhiY;7=l9rJL`-7I zRa`sy7G+mIW9#xe{O4JWfyDp{X5GO4?dACRP8>oa){HRs{_6EJJSe}%YXBMmfEoYS zBlI({T7e5Ej-y>zB$oiuX6A^q)W!H{2i%&GiZ48s&Qjq&H+)%00FV=gs+@3gq!FmdNyB)O4l&l6I6Wl|l|=h1$woN{uvh>| z>*qOH$Z78nS50}SN=R18$;cm6QdMbVpr>QhuU9w8Mj2qFZy@1hXDhEDt1wMoLaL^j zp(bfYeYhlGm1e$nJz=erh4jejybxvO z{CjlDF?r}2Bm^%&bd&iQ+3g4xPrQr1ZFeHR*-Q=qcD5ckby^4_@rZZjdsM!D#EW5S z|74{0|9b$?kOpK8P^IEBle^+6<&9C*AD$36R^rvmSLoWUC!8IdVd~hQv0@<^{G2ir z&Mqh8zmGyP{ap0%dxOSqJ48N9)z8beq*Wl)<8UllA{%>}B{ZuU(eH|_wuEybC zp5e@aCpfX^AvP7>=AWMsOL#~iP>fYeiB%qcgo(iuO{@`TrGqU4Bk+!lw6XV}A7PbV4d1~bRLEA-_uu9w zV{vP51gew9546Vd_D=XPHym|KN#`>FARBy3Qei<0Z}{uUVMVtPyeeoXFbnejU&!fx zVJrP|;`m;IypBN#G}45c3~#2ZP?VMVNl{L&qmq)6kA#zxlSFuEsL&Xss1`3TDf5Jl z z#XNhBuXj>V(1d}O7hKqQC~MOC_I(LB$m@GeCQ3e!D0&VO0}GKHRD|TxS3i&f; zBGl3v(Z*Kr(=(xKSOq&qM3Ld9@D+H>j+n=1hZWXU6~^(VpIZ7}H5u&-^4!A`TwyLN zg?_F^IMB)-Z)Y_}Z4NPq93c?+xgZUN9erV~EP*H+9ptqM!o?{mcwd;r0iv2Y>!nes z%#FjdoJ{;ma-dtV6&!UGp(!m{uP80~T25N>95?)g$VfI9fS7=>uA-Fm1`TOx_*t02 zOpQwA9u!k=z^4GaH=?g~%COr1Id zit<`8)eVA&6&nZDn>_zegnN_m`_DyuU@nq^mLa*>a%4rV#=wqyF>m}W6q1HtIrlzR zFT96N=}Td(%IXKQ$lyEiSbz^p1KRc`<0naB-5#duN$AmX8P+Vg$8~8DQPQEE_Mu(; zYD^w@l)Op_r>sSDi!rM2Mpzj6AtF2uj~+eZ)RLD0f2t7Lf%z(};{X02;LCge+Ry*# zO0oaQOaXSklurw){rC!%pWkA^f<K0c)29!`8WLeALA?k8LSa`u|Ht$4hv?t31wzeC z(9zEy5l*gfBBR?qk{M$Pr{_!pu(UXfk1?uVSr{+w;Rxk<{=4&ABGuCr>Jk!YVX1{* zNjATn7KEA{vL7ZDa-(?R(26ciV4);MARq@nbvbnQHpAXAWbpYJ^fyv?%RXZjU+C|= z&QHay#ci-|Xc~GY1i;zci1pQc;sC&=gt)r83Nr!~6w{QXrJt~>J3~1c^bfN`MSe>@ zynvM`asbE|))}#?rP@F1^Z(}SHQ#0h#0@FzXIa4G69K@9QDNxps7(Ms48YU~A;bW} zj7`v-BtcWU^)oR=$w)X^i`|CpK<;lwg2AB9U$M?;2f?z4Sq~JoNG+T+5(d#j;-)ZE{C_@2VP|N0&lP6~YP>L~qx5G*|85Y{H z@V4s!KYP|&-ji;-!PmYM>^~U*g!`;V;2sCZ znvc)$>`@5@^&1LL=Qs@SyM~Pa7M9O1#nxpHaphPIQU5xk`IY4Lt8nvVE#0pbjb1Q( z{yhc`XR2}g3^RTL1!4%7kJV!P>SqK1r33(vv69Z}IS;Xh*xc&*4++4D87#Vv-d*NF zOT!W)M~+5il=-qA667pSHoG$&aIn}=1|x=#CI+AfM~f5$lIQ2f?>h_eO&1aE&m+pe45@TJVr4-+f+C!UB0K=)%gGehG2GqoJ+X9QMZP@UibqK+uH`4s|te0YjB& zB!^B$!OZLA{mZas{sWQ^B^cBD5c+r8gzcNI;?=Xy`1HOSZ(h<4H|{d_Ub7{{Rsg0spgNn$ru6qI)TEqlQlT< z%R2)7VqP7%f*62sm=qu|SVVGT-bsW8w}zd)JN6ye*VxTj*9ZU&X+Xy6D)I5nbL7sN zfnX6Iks_GSB5#7KD>6gbOXdtgeQ&0gDYlugS2drg+0n zNfy3_>IhVk$HE9}JQ^PgEG7C+GT}1;#{2v%EgB{Q##2MX!?2+VR2xlE-=*Y1~I;qc!veG9S1A)`0PnXEf&K70_D5?0% zN=n~UmXySZI9I%30Kg46LIePD&lw+B@b~lnSOE~zIzk(9!_2b?4h+w8Q!zWy2}#C! z1OVjuO-;%B)2$H!fUz+FfGILVBd}-dFSy7`e)%}2%Y2R0-P@)3_~8q0<*lr&wP?7nEb!ya%K) z@1bz+9ZVRq3m&!|psf%JQ>{c;>8HX{Cjt6O;c&NXkBI|!6HP4T)n%-+Y~i>o7}EI{ z6wJMV^5Rc?)>wUgC4XM*f_Yx?95?UYLQQQIzffvFy(BO9fUgD@0HF9E0l@#O4B&VB z1z)m;At69mOTV+~^#k0$a|6>R%s^&Rcl7T%9rGq{!rq^5;@r_^xOB1tSI@k`^|K#w z_1tG%Ir|xxPk+J{0)}g6#25h6|11u`jQ`YUk^|K^cif8DOfa9@Q%k5H@l;)Fy-|_;5XudA| zTL2JBe53!i448pHmDo8b>NB#@_Pb=mDB!`JEi#i@iS^`*;wf+?p^!!8w52a3;4NoMXdiEVS-FxJ{f;L zvcgxPWyCrj{L7A9g}I|IW9?!Q0GJn_OPYO7836%(_zUh}biW_b)U^i;)ncKe5Dp{q z{7qfDV^p85#1wAv;NObb5BPA2qM0RFG~ohPExv}E7hj{MvQ}(3s^MC(4wa}8n~#Xs zqUP-r)HE7@iMU4lf8JQ|#uncHLI9|1+%7BtBTE&seu;Naicx&+Jbv7;9;1hhLUw8! zWW={d$5wrarjNjozEd%F*dolFyat6!cj2c^=W%dXF-{$Nf^)|zaPh<&0*ZG81fNL; zkQ_K)hqDJhVpCxWdHZ{OzChvZQo06illDJDAhQ@Y76FKfNye3{m*^U+;LN#B0r07he_;THP zUMLrfx8!KI_6tV<>G^gJ#+VrCiV$^K3@7_2B>-S0^Hpq8aBeK0%{eXA4US4O7}wMS z`x7j&Bhe21Jd9y1D+xI%UI4U5UQTtcf}F}CMHz)@in8(pWMt*D6;#wb%`B`Ge^(Y9 z8X78Lq;DkQXz!>mCoQ*Ag#ch|k~`kzXNblgB@QZR0D#E91pr|ka5{r5FonPM{EKYmX(<=HNW9v%W&ce-(Fb-oxh)pM|y2 z^kFaq`$GC`05Je{6>9`~_CaFMBBTW8lgD3%wB|(^*7*=7^f`w%F&hXBmSO7P)4aH3 z`5YF-FB1%U?tPwXX1V@eDrBohy{}lVa?L(SiP74fCY=#&#<4T z+qDZz2~6%_+nQorIr9wVrLXa*{1r}}I)^joPN0%cz9IVm`3+srV%Hj1JJAUXbkN;15-yAB7gob*tFsVHWZyg!Mvk%&LVgASuC7- z9216aM$6>Eur~8URAe#^95~3^f62v@vmu)Qjy~7>CAfR@DqcT-gvxg>QT6s2s!7t- zym~-Vh5(@W9LjfX#nFPLXku-FbT>2HUy#cC`B||KW7D-_fgJCO6{l>K=eEU=2p9N} zmY*76h1?hy#AwQ6kcR>8jb{PENbWf6m&W2%P8x>CIKfR-4zoiYaWKIK2NLLKVx9S9 zZ3Rh*Iz@TeTzf}*ZA&XF1!q@Bx!wc1N(q)ODWRjMFVUuDi|+v-BqW5_1UT5*8^}q^ z>|_#POrk5^kuIstYlvJnbODNU&BX1#e+~dK9O0PvuPzWkgXw=3#k@a112bZ+kZ7)p zI1?Mh5dg%PoA4g$Py&Hq6BDF24Z^l{Tlv5R7Wuz=`7YkQ`N*2;#o2yrs_Yk3Re!;# zQRAViWQhQeK1d2)OnScn86hjsuhlN(OumPr8ILi0_;pO|cbaHByFG@{Jr0luFD5W3 z6^wu0L*6dR41D#XTUfjFHhx%Mik%xC;lNMN`PuiwbL?3644d-k@$(rF+`(#M4y)#} zpTEaJV*Rr7m^Wh!{C%S_W9D>JSAQa>@tM5;bJV;b?f<;tA}{_&djbBt0pQz_UjL~Q zfX@oJBg6vuoWxS`l!!$`#e9HiJu9FhV`IGppP%2s`^R_jtoS;4_hTdtHe%J%1(-8& z97YWofFA8SA~iM;XktO(-d- zAtE9IW53=;Ue&ZCNQONRy0L zQ1WB8ZEa;K;p^ie;o#^fVPIk+v1jw<-**SHk-_@9IuiD_HhMDB(%Y4!Brr0;8E^B5 z0pti&zJ}A6IB_d*gh=sYzX1i25j2zy=LwO2zDIz6FN|ydHwBs4+N}xN*s37T%n-3; z{IP6!f*EU}v_v@lJJ`$|{aUob(cOn}`uIh@XYbL&=Y+)dU#I#oEruG>*VULdbtZjq zCL{s65CAM8&0mPNacePu%vG#e@Pr3=md|>G!s!n%s{0|dj#+`p{ZH{!-s*)!1sC4s zwE!#)v?;F?dp5nm>D`}k^;jLQp9HR-5-QAY(YblD9vAjkVgHs_*pS1z&F*03ta7ZJ z{g}rGmM^%Bc9|34-)yQ72aAf^LP>E&iI~p3$LlMq=rLcO-NVPnB}BHa;^vtnIJ9#M zHmqEV+*4M5I}ad>n8I_h8F7t)vyt`GohL;lF&FjG~=&?slT%FX1f zzUuOYdH)U5u$fJ;WxQZLu~u5^sPjtu1h(g%uV?*}+p=2_)ewRpe9#XC!4vTbo!(SX*05 z{N;b0I(3rJ($J8wwzSlgmXhAgCIt_Ub0h%BBK;A|+Yu{y8*l>e?XMGA0WoUkAkU`dDdQd=R+$`%v* z^uy^xXE*@dxON+#KYtN}FSU&|*gPOZY+%WfrF^l3yImGZfCU5qMHtd)C#Upl77Fd7 z%V$62jU;ViSCcH*kHr&iW5)1P_;E!kc5k7_Exm?c);`ARy&rM?L@m!N-#lH%%S>;w zQqz;Q{M;rmxJ4jv?L-|8Z-0$-ON)7HDg&7nbIY)3${{rMO68M@-@ktiRK7>eYtsHa z_$Q?P@cgbI177@_i$MMljp_Qc^I!dZ{s>gQ9VN!U_6=a-Pgknl`^_n($I>CjQ1`l& zymcu7OBrgPm7(hCJ$!juj89LANjxpZr)MR2|Kv8_Ke~;#_iqvS@5QuH1JTUK6|Pnm z$eT3-A0860&|_cTyN2VxZo>N9Stz}Dm>5YZ?`x=ia1G`Awqa859xzps$IKKryw6ML zj*rj3U^6i|6Nu(1z4!U4_+?NOf^=07p|6e=u`W1~Y=I52c8DVnKfuQT4`+s>c4-)$ zXndmIwXRnP+*Rbz*WLiT;%sm@)fi951)ySbC=T}yKx+_+StcMF?}++e7#D}}K~~5xHAK9rg$Mwayls*hf4rF$pQI9LV~aV% zM&ZoSb2xYA3Z6f!;6a$WM&oZ7>n$vi{PD-FycF2MA`Xecv(X}AC1#C0hc%1u^FHrY z^9cmzKE~8R=ehAOnRpk&yY9n??%Q$U@Jrl0_Yw!TJ;Ie^WcbYJSzeipoaL5zX(^ps zC+V>JbbI||4X)8+t{kt$p3N_K8nI~h1LV)ThE6ReA;>QrhYuV;UF9d#GU{JZ&VxjC zVuTytZUL$&`5$0#|1(ZQO7M@s=u^H(u0W;mQze^w?d`9;P zZ0>Wm_VLLboICt0#t-R(!QI-U_}o$Y`UCzL_Sh==J(bVy(B~^Y=kjYPBMC61O*Sl* z(-THd5469y9#gGiReB~lM-2Gr_ z6pX}XQ_#ERPh^nyh}z%hDJ&Gk$&*Ad%7bv^&0oTv4 z6cMW?tL6Z3{e%DnH&3!clS<*7s^U55>jVNUpL~@7;_QJh*idjEh4XJ?ME~^&@JT?y z;>D#`<@$G3LK&Cjb9s=Z~1ke-r@loa7gIemoaPu~&!^g2@h6 ziO83wke07tMOtNCe$=o{M)bQsKD~`{(t_{q-w=$SeVU#d?^qnYS-~aA42e)4g7pf@_KRG05~bjBAMv?nmBtLNHoWxL{lsXvP3iT z@>8R1@n#{h?j_AoMb7r}%rs;<8zDwV6>Fj#a5&ZgH+nduDkp(Uj4EQ-H|DiOikk`B z3|ucOr?AAx)JjD|TTdb=DCn;NKwC>o!qn7MPEJ;KmMr~nD?+BnOIwKh03-OU@@gK* zkHxE*p|~^5lLJ9rUMT80JBa6}E?+=9=K0^#3q3zB1Uc!h=;~~MR1;k!n;H`U7$eET z2r1@0n0Nrl>1;?Hr` zAhs_v+3`{^eBpc(PZ5Kw`dG4~wN_OwO^Uj=^ce2Zejg%BL!kFAju;!Dr)Zc4# z)|CW_TBs6J`L_WeI4DTy+PRZdr_~OU3&NwU2oD^e(umW4&H(sU1X2FH3>+U?1N*wM zH{@#jhrhXtgRESXm6v`&0Py*J zPW}1XQ_6D!2lg=9|Bx4w9Q=~im*2zwy|=J=#UYFwFctMOTA_c>f%tsuR@^vy3=i3b zWAgZA6!(`FKV$r_)_*kDA&QXD_fm3%4E%@)6qm@`6WxDwjbi&76#w7&5jSuAfIBx2 z;6ceD4i20-Fz_(3wkHE!DKh}*gkPXCB?ALXMOIk{Gqyl`e(GhXBWi1+-baSecb z6!#yQP#@i5eG#aXp?8!sHs<(XXOE1|J-MD+~8$rgH#bg$=v| zu!!9jE1m9ATypjuBfcA%fjxr~uzNr(z8zi{N8iZ7`S+XSd{HZG9Gr&NV%$*AgV=ya zAkw^oknHD+kzLy2(&-bXEx$)%{Ld}lZ2znf=%J{!Pj_tCuo?c|6dwiV;EjPBiT0l{ z2|5;Vn3JD}i${ONgJL%MmuUYLk@E9p+VbNU zL;$#R;s;EBeFy?9v>28Wfvbg0#oe&HG@|fnTnX#c^wt=iT^Ye@8FGB=FfTh8dve^c z*WijBS)N#)6O8r&j!3jsW9vXN=prL-73N~ipbW%2TA;DJ9TsOsVHdH5BVB@Uf2x6= zJImB*m`Grej#E>cp?Qoig#psXO1XBXn~R5~osEOgym|8%13--cnm}d08a4P?rBJq( z3zEB)oh-3y1if)4Ho!n2UsUnEKRpwBJ4a(kh&9&ts)JuBgFiM0hkD$3%E6$IU*|Cbas!1m!u7*xvx4ZK`o@bpKTuP>U`sg8YHH}W2O z-f+RZy{Q*V?8Fu;0n7NLPf)0E1wZjmk)y+X-jYRb7 z*ah=u&A^ERU*Z9Ik}^|`|Ks8cv-rw0{U^Bbmp(&jUm50qDugg47)wMJ#sGlLX1G%F zHCB8*1#hqIg3)W+(rSbEHulEuBlB_l#!uoDXP(13_RM_P|2wsV!fIUVR>@m1wr_l$CjG<`gZjHtP zwm9&176CvOE>3TN!KopLq_ALQRWGc|^2gp>CtMg_g^XXUCS(bDrW$lVKMB|Ab;sAQ z%-Vk;m&o^9X!VsOf>dbnTL2Iq9?s8lnXEoBfE$5&C04x_PsVHD!wMdm)JyJ8OUIs$ z(dgrEg`%dFaD8Sj|05q4n8tt2OyYEhGXi=;5BVGcwiRfk#}idyXjN#^7+`}Dh(X-= zAQyYaq+&pAe`NdkAk!}psR02P+N}#NpFJ&>U-BAkaj@4T69AN%TQp5O0w2tt4F?-v zWW@Ktj5qg@@w0}HQ(~pxd*+1TX`UBgN-&Fx&g0wd4>_G@Eu&(o=sg_%iiHGsuy4y5 z#UAiJT~ z>UpzIY;v;Dph1J@1AwouuOO317y#6fOC)FARdRgPzBc_4Ovj9&OB(m|cc-P{iw;p3 z9O8hX@jkei*MMg>ADQIBSpyIsA(odZZV?uk`vx-jpo8*x0tI4+V5tcpZp>_m8O^Jr zwv#;)2n15Sy^$6agrdn4aOd_-9`mylz$0FqX%b=*NB+W`7yS9$Su|?c9QM}1=+NwK z%$)eWSn0>sl+#>fk^#Yov}R8}i3Rzm@x{h_Jh8`U|Bs*F#O{qJ@WG6g7})C#7?K+z z#4iNNb>c9jPcJN)I}7LNOMbxUOtI1b#kAnx?GAj| zq9>>MGM5`OfVg@6C#?Op5Mx%g#h?vM(0^k?4BAv5LpL?U`1L)o?ciMe^2-mnaqVjy zI{8wV{Hud01Zh-iL8I1$E2Od??c`ESY+RMkA$wqwCT1^kcX}GW=@@~xtGc0? z4}}R7-`}5U%OPvE2JKVtoo)7ZK042s@gf!Df>K}JFYg!)G!p;kQl_2_{) zMKf^l>pl485}Rs!itibhUOir6;_dNYjre})m3*%N;3WXSDQ>K0C1!TMacw`v{L?UU zO)Cu8NZy}7U@)BrZ)u8=8#`d(_Y-mS`Z{ddpO0yq`eF3CHW;^uL3M9IZM~s`*5|?;kDFYTp-$hcSa^IH$E4)Qio>BkK0d`nnWLt%9_^!P_?o3SN zxwcZXgDntQHM3DBI(jj`8JF8vNu-b|G#l*g?A^2$dZF*YfiDgNlnRxgRH`ekUTU|4T>%9vrayWXMX4$ijPyhQQt+2DNU;3+N>sG@~<#~Z*x2u3i6^} z0dYEzSW=wC)lu%wGUA&zvJmfSgZS`pEFL!+h21-&TY4fo)-zz$C-ZRk#&vO&RJk;h zNtDr4705cb?mzqmYuB$ua&i_tT%(biK=iK9eB@2|7BeRu#jMFkP&Da#yftx5vz>lkmeATX5sTVLZ5cn&=+IizQ^>_sU zFH!c)l!Gy4^Y51Yh^_l)VbtnY7`U+shHPk#A)A_D&=y*oT42f-{V{j{>v&^bdkmwW z4cOcigE&iQOd&%ne7I*EE?wS(vg;>s=h8_G>CqW}ME?ug*FBV)bAtFBVFmb0QZyU=m^HXVgcVSYP;9TVuhPp*XX20luA6 zgx&LJJ?^G0BKP|C*>% zqd6L84MgLt0mz7NgUZ1P@O2JF^{7~MY11Cl-hB&SY+H}(KeH?`(Z%Zw)sK}Y?f5nx zH~zy<_L2E|1pxmtz0CTpU=U|G@0GF67+Yuc5c|E3W8GvDHTVvS978tU*83t}? zi~$4$!?(1;h)pdiBxp*kfPq6>jM&f;LpC|=tl!Fx#=>wG@LASyLwC zyIq@c?aV>mK*GvFc_yFLkpIe1j#V)FWB=+b{+|VaUrh!2Z%q3AuJX zHxOr=@kXFzaqEzm24_4M03H@Hb(BP|5%AD75B<(e1D5oUM>wHio5VUe^vMS}M<8%x z_a@-N5iStHG~<}8*27yDagV^@`>%K7jWNT}p;a^V@6{8#w{OQcUwws9BS#@UIR%Ne zYoT+S78o(0C*FU16xJ;I2*(MnZ=Pdio@Y!IV8=vSf0fO!XU2bu*NZ=;V*7%sv;PGE zC@VhxuVDcHY|FD)y34-vhu4nbmz(=>?&dCheqj;vKOaH{PBfq5|3T#Ohmhej02sK5 zjDK?r3?&d4w4)J*Z*7W^n_A)Zbse$f%LypCvKP0`{)GPBI-|0$3*PQ%M8`Uz2(i+k zQH@xvd22k*FPV=^OXuR}6?1TP#|D&q{Uw@b8K99$&?3|gOL8JHH6Z{st<tgOd!1m4(_<%zxZ-EeVaO#%ResLEw3&U;iKZqt{t0z5WFh;Iq9*5RgT zNi3c<_bKFx1y)uzHWcCul`2(w8USil5fy?g)yf2oL^$ncNpVMXFoLxTyw#)z#rVzX z#SC1)@Nr&U9Pb;=OBPoe1JE%9m>ufKgOId#$xi8-Wb2BJ%$qf?@wzm zQT*W>=u9l2-_|C?2%3`DZ;Ih`9=o{%CT{ABPxnp2vGW^o@5Vv=uxAr$Rf$HlzXy^6 zy-?l72?Lro!RN#ZE-s&s^K=bgSicz8zutsLrw`%&+2iQcx-ryJ0ge4#upqY@pT^}z zfZ(o>qB_~rNOG#HjG@?;?S-92SM17i$Mzf-9PJZ@(ix`0pK<_5<;KtD#o3UN0#QY{ zIKL4FW=6n*UPmEQd~I!I8>CXG1#fTfr`3SGTwR5z@Nk7*sTgG;2oF-C0x;vX7O3T7 zjV`r(@pE2tJSxf-0U$pfXNFYA)?8m~H2Pplst;oA)o2;wgadD8;Z9LDH+&HQGJci# z6SoGLGEfq)GGqXNyd+-CaIYu>my7CQOimQMG)he9(hg_o!@Ia)IUbz)p2y^6rZUWf z`!=9mqil3+QXfm_6yp4m@5pf<#Gsy?5$x@X$#0IrRo1J0T`bUfbj3UX(_G1Q#I$v1 zs`4_4QHY|Hw8%r0UZelBJb)<|z?bR&|K_sZzXbsQM=i?#JOems0=UDtba5A!lJ_3J zp%ahsN07mf+}Iq$$lDLxM6o{I9?s}Lg#vxIHY8AJgx5E>!K}T*vF67jd`)rx<*VP} z;Z1g(oI=r*w_sJ?xQ22Uz-2xQvS&zF%zDDWAL-hHc#yxUI zJ-W1lQYIkN%Mp{4DIJjZHYwW6x*|8xO(aU z_w-NZ(H(zAfu}-C?z1)I6^#9548(Yu7x(kK-Tr2)?6ZwD<)76bdw%gVtMLBJLLTLL zg?~Wk^iKdld47Z5*Ms7pu;;)Wj9=4{sD48X-qL_Py9fZoS-iiAdHyGB;3hF77`v@A z)*UUvg8J^f_m* z?6Y{p91OdDFCN2yo*f~V%Mjz{fUeRxIf`@Vw}bm+Plf7?6&eGwWeN_d4*?Dj}DsZfP|7)|~Oz zswDzIU|40r)yrFmsU0gt{1Yx1~I{K zdtxHKY00$6+^{^?8|{Pb;mVSRo;FxN(nv4dfNxQVoq_3MnQ%b{r~C{+7y}UhfHVSt zMEV0#3AqfMzA*KaqwhCGv&!D6;_Qr76W+w>Rf}-#>#e}0gS@fk(Up_9O&{w0%VdOX z$VQ3SpM8To{(;@=aP`FZ^kIwbnoj}1m48Q;{$&Dyn5*~)0N~1>0D$7-zxte?dJgmF ze&rubp8gg96pO6xAsOJUlJBwd%V`*~t_=oktxsM)7eh8Q#!$8e$ml;UX7Gbed4QoC zn{psv+kTTb^~V>-KE*FL_TwREji*t3nGEmvk0&+ohPpj4UnL2Hl zv})C8L5Ply7V0$?<-j$T*3lA~;tOl?{)19N_{5%Dvm0S~zjRcgH`pgB1n2V_;vpMv zM8^7HS}G2A4kCnd!iro!bPaVvfI%^A>6$7EmnRt9V6GY5KVofL6m1)U;}V>nje*?ww;9~9FWm)uGTcSIKZ6T)8Jy|17q(^i zU{|&q&JV3dUS1q2#)pjNiB-BB01CvRqs+c;&&l--Gnv1`!@e^tQvk*R%1EAq|r# z&P&1Revt$K4p@=xgI-}ysBWc4yq7DYtTmY0x(==uG~p6rW%*e=Ygx(#3z9hqm}{5W znagd;de%x5Pm5?gQ7g#^_D$iRz0* zKSMb6ClFu>Kw<@K7l1K=F>5+u=b^c{dwoBzBY0@?>a1h1{O_uGt|+(0KQAUnb{c)V zcjDWCtmaJWoEub?PYW^w z0AH+Dry$8xUy~|s1AdD>RXqr5V(2QlY>vI1m0qt?3xbtSD+C1lYgB6WYh?Vz z-WGCRxKNzm0Qd4(3u7XR3+iKJPBa2FaukwL-_EZmA`QL3g;6!IJ=Y1VC@vWk?u>XV z9bSu%LW;8Wz73sI)O{{eZAYM6=sj>kGp4o{Eq=Z z>9b74^ttgK00M#H;xDmz|A&~lWfZ2Y?TZQP`=D^&2rN510W)^e|F`$R_}$&me`9kD z+|rx?fEd8$COjE9a&3ET_`VQ#u6@rF!H-PhA!Yz5eYWMh*z`T|g!2*p+1$Nw0$tj* zghmjM?QV^gSs~a>M!qY<8GFe1_oh2z2O0l1mIp9+ViS4)f*424sYCbWc;U=|7yG0jb>hxi&|>*pAO^>RwSQE*3UJucUDPT1uI8or8moqXh#1XKQOmtxC~HEti~i zQb^DzF#sptuaCQIfG69_pOJ!J$SbcHkc{d?Q@Yj&#Oe2&^3FhpF1IG7;PV#VSVzb; zzOpM4bt;Uh8;Wsp{-~u>ph=K3c8<(K$?S%>J1dog0BaB`n~`NoS|o|tOHNy!P?yK1 zz06;VvazCH3L!F>Vzxfc+nn6Z)~dr$BT@zXrF*#m6fQfniF}hxuoF?etG*T+hIbvaydN zRvE*vi|GE&3^(j1`oAl~727!gc#-$_#QLlNj0bPS16nwl359Qi3AF%^lL|ZTVJhOB?vl&vPdlvik+2GbgAis@7~PC zotZ{a{)?&o=BMKxt8Ikx-^GT*?tj9Cjc!86|Nhz{?x?n@{HG_4Bkw z=n)qH4~-Jzy0*iy)t}<-kss({`?*3RPQ{q`nIu&4{{{e5jH#a|cFCw^>E)y40u?U; zfEQ8zXX$_W|CypLD~!4##(dnja?(&Zs29$Eu~pRJ z`#%JLhXeo*O-9MQ_Wf(e#I0TegFC1U&xFr2mtHU7Hn%Vc{+q5MYf}IX*ZsusTX-q>J@5Z0IN( z)MK(Cb7dea2fj}~D=uh&G4*0#DU(7bl^$_&bPU&O33@E0()<24YVl%+}Dz2srei#_;PkNpWR_P@gbp4tUq7d#n%$>A??^TfALZ?#nb zfEUI5C|_bx)WeHM@XP7_xNu+(PJFo;NA|44fsIS?^@{oUV#&wY_t{7M{N=K__=?sy z%h~;NuzSHQtjn8(!U4T7vT+W^w`qbE@4t;J-|xobOUIr_XZ;7Z0n2L{o*9GAXAfQ$ zSqj@FdQ@@>_ir4{8k08l z!QR93aGTSAX8fnQ%J5^8+}N`K@GJ2bwo1-m$J)gR@N|JS0YLW%4{RU<-@`%xX7B_8 zJBb18$YA*Z7wk2-V7)N_Ln7VK)!Pb72>^}{uEDqZxBv)mrW5T-ECUvsf$sCELrJ(w zVLL14vysaf0Gzh5v979S3}9$R80L11!wE9zUkY=$VhNKIdQc$hm)&LRrA+=i&w#^| z8=_SePgHl(W6c11;ruLMdOYq-Ou#p-gRvqj4DJ0K(ZtgRi!(y8$>@s@G6T@a#}59) zFxpi0!dI_n({tvC{RD+#?7~fZ+7hrs$a%3Qr0v#&l|m zGh0^S!I@)ta8*=vDSK*5?CE)X|364>GvmE^nh$c?5IOU+vKS5KH^ ztDnTe=Iuk4tuDK86nBsPfYW<6VjmgujzzQa!{%i;uze*yU-JoeF8v6bKAwS9`4h2d z(pY>rb}(iP>WPWHI^c~?Eik@AGrURXi9On(Xy|MBY~omK`#29Lcdf&tpAYiy<5$VC z{{(10r#MOs6HN&_Hd*M}Asj!w4a;`U!0feeV8?+4D8BX;9$Y&}?BED)UHu*hC`8!w z!%S@ap$Plw_Kj=b;SsS0t|V+?05DH4GEaQEQemFKGa@DTub#$-c~fAkQNu+hU|2Oz zY%%)rtv|Naw<|;R{EPwY%J9OTOjlyro&*3cXh!z6Fed<~hF0Y}au0YuE{SiQ^STa3DhTIQ3S}J$+xjccZ$jQZha3Du^1iHo_&!tU;oEa; z5-BSuWX(G9xL%Njf{ry1pjV(2LE_|ZukIo&zwI9F0e)2?X3*W=sal19(K>@Q7`1(JH+_pY{18Jga|~0ERvG;#b>oYUg?!-?|zncdW&^&o|@7!7u4| z4$^BJ`U>Hdp!5G!n*vIO4k;{Evi%Y|6AeFho#YBKI0aG#8u?KGK1MDLdz#O)hD z;P&OH!;grvVkJ+<|G4t&-I3>k&>edHv7?4UBaA5v_vPWhW?~0}!W~iH%^C}GgK=(Db+MMF(40d{=T)_jOf@zS_y|5R5AX{y`x&ik z!kGX-CP*%76e_VD@bctL+|4&|1bLJfPe#Ykgn>Z{H+)7V?iDg4Ou@DM23S9~5vqIJ zBgjgH1w9jSV`e>0Gj6_F7kip|V`NoFL}?Wm6yt^s+1}WZ<&MpSY#*mlI1uQJKw<*P zzP6atJqhOv8}luSvivkt%%8&P3`=4#nQ<|eN#Y21uP6(b==a}lRt4TVB|2ppaeUKK zl%4<9G)wOJBei*3Y03W&vfC#(ZU1rI63pn^6+;?k;jON%G5_sR*gAVUzFRyG2Uah} z4=Wa8@4^qTan@9P`tIwP+OHeNljoi}vOl)W$;VlW(;u8WWGan*!C;N@tw$4sr~oYH zP~r*mGHD?GOTGtY)Q>8(ceBN|lW$!78rQFUg?rb3#KRJ?6#Pl?(g|Mi`KaUwX9T7E z=i(~Y1^g4O;EWyIICmIr+qHmPBA~XTB|ao3zAej(?*i=1bmJkw69Dkx0I=TRg+3vU zsOM^hMGZr7m28gB7vS>-#41~H{9cl&9w3F=CbQ>X3hE=jO>MX+<*Y045&=Lm2Y@jt zzSuF!fXjtVC_c~TQ%ad$Inxtk|7S|17Ydu=+qWB{uw@;zjPXTf7aMqMR1^-lKlQ=6nE6Mrk@4aV2RP6*D&wSa^d@y3k`ng9_froGT`cV7I-r^8iyy* zK?pDVDZDG`e+>Og%E37%oy4qH;(S%4Ln@nvG~b) zQqC)0c#kWr|HUafk9c9!ah`d<_QO8xTlgV9A>*Ieqdi77tA`N{jTqh}2P0e5$LQ9L z@ZM{kG5^gGSU>Z9{J4GzZXNj+r58nwyvL@Rin3?61E1Pgzw#vOZ!Z6RYN`)=e

zlvDpZ7yzFF!~|U`^mUkL60-ULaZbR=C$irawp4NTzzQo{R>JMc>0DY* zoHm?n>Hxu6a7T#7C1M%Q3x0EK2ek5eQ`9(z|uA zws|00ds-pQUW?foLD)%Qz@98;>@YfEYql$v6DYhM>x(3N3q-4B=u*uas|TjzVt!Mu z2*eW@`N@1{8cz(GiyBycb5SyG%*w^ezNx6`phxYHFsxlT8xO7=Ef?!B_v-VxaxVaY z|3x4$|E-VB@hy`sJ97}l-|xYZO)K#I@&)*2>0ErjdLfQ%S&1t@?7{srKN3wpYVz2> zC8F_Ucjc+PO7Rl|DmAf?=X!bbmf^o5RASf1lZLPI-i3`9<%;+Ex!sxqzPG9^~D_c+9`k1OU5PB9Q%`mHsjiFnD9JF%&I`eK#ii z+S;Wm9!xWsXnzF&$QQe6`AiP33z)$Zg}3q=Ag^^TIB^CbJ+D@(;-rF95ad$T7b=PL zo|Q}je@hkW`Zypb%oAz;PN?Rjg_l+i3#p7fh|`s*wtbJgJcolZCiyo`Q4Y>82;MTJa0}4W7xbyx;|8UAzw)~`dgyA-Mo?A--Qvbg}Q3~$%; z!4^Xx-~MAIzdO0{iy*L*LI8GVp}-tN1Po3VXcJ(KFZ~| z+}28^H{=T0QHfmnl|-t%Z6T9lSXL!mEo_F8nGG2guu^2iM}0L;QYa_MU4P{5gs#L zh-R^l|35}=mD6;y;Kh|^%5s0Je_PX)Sq=E{3qFgot4~_O%`EcQ zf6vKZ)t&qq0Ql+4t*9Lv1Faw+-PHo~h$(L)56=^SZ2zA({4fA;<2wKh47QQoOic(z zZM_oRB5iSSL~T4aN%V_}KQZ(FkgEcvl@|fA-88n1f1MobgeEbtQ?NP!`4I;P$1shW zPYjj|0ihvsy`7`KLaEDEDKxDul)83WrM9&~p>Cws>#FH=+HUlq>rCiyVfT34Dr~?d zhmTFprBJ{RAvy&n)vJu+8BNGob8zVO1oW)rfOK08W+kz1L3iHA%nY9;I@t7+-9~pT zYY>S>0d^b!LVbNuHOLhA%{zi$H4)9>rpbJkIl*sICV*&aV17J8~YsmYXuD}q3osl@k1{TNRr zA?g3KXH0~Zl?CkRy6+b1jFmZoWEbMz|8}1J_u{qx3;=d>0C2_DEMJVO?2f9$m`5l2 z;NrwofKh#(FJt$`Y2(w3I+9fa`JAWfh!Mf8=ZsfC^rPV*y6o6Mxx|r z`k=3!HGTL;#C7$VFXCm^20Sx-QoKN3aR1f6!A5>>jQI0v_$vUwuj%SDTc#C~dQbNf z{JG+((%)GBJ^*+=2$ZwOD=g+e#S49&GI&#>^u-NXM@%KZC%8j;SYlS5I)y7I51?(U zCXh=d2-PYvsg@r$8GShQ-<|HkYyTMoU?9NGyE9l_fGbvK`J-#F6EOfeK50`KcZvS9 za(OYuiA!(t@yoy#Fo(8-3)0UBm{qFe$Nm;pi0N$V(w> zMMh%poI;#kJ_pK)^v9LKd zjB1P~mAnz-<%AjUj77=mpG2=uUqtDZqc2CL_jiOQO0WD;N$S5?jad8#UgI}9`~Fxz z|FNeBY5xB>I_&D7Zkp z0^RF|Lq)7ZDw8d?wYRfTvr^y}9Wj-~5n(!=rh!~4yXI%9!Q$7FaEHAAgS<@Kk)1t8xR- zyOIm1LVgbRSoY5AxU_OU&aYa8lPf;M_w#0B!KlF)(yRfpt3)Ey)(ZZ5HLAPn(W}ccQA#n4uHiMtU~crg(kb>&cNY_ZnO?jph=%~2 z{(Iq_+Wx4lQJ`N94_uhQl7H!33qaHZNO@|B^8wO$Jq`Q4dlaspoZ13SD*Hnz2#*N> zW>{L=YE(XYM^E&7o0yR4(lxC#Vg02(gFBS0>HCvsr+~UMxFbMUhfqEynFz7 zp}Nrv0YJp4Q8{0{D!5tuzC?LFQ1RgD;DC)@&&lEY(6g0Ir%L0Hu@|u5iLQc7i*DC<{ z6#)E}@xO5KyulfMn{6ZVI)ai@_+jT3B*)c;R3d?&PK7sW_+T@0h8a%W`1hoXq8~d= z`~TYw?mRTuNkH&%vOf|XtdQ+(iSI@wbIJYZ8U7OnAPRwq`2dzMZ-AA9Gl@ZKpp;5( zYc%Q(JiixP>-okX7Z=CR3Z=5HOpyHSs+1wWZ7koGW0iVhwVt@o$fo~1pcgqYs2bMi zxMP7K2(MN0KsCJr4Z}S!t8WYRPOS!mqZawe0oa<&w9Q$40NpnF;}b(5;@zyEB2;Xa zlZ`7seT_RuzQK*Jw&2RPl{mL6c;FVy0m02E-#tG*Oir@;L_^F_?ei(xuuJ7 z=Cj2(x@0j9tz3emn^*HfmxoM3{PGb#v;Cg}fImb3pZeW@Z0yzF0E1V?|BMLgi9cra z&uBBUVxh9)qr85A#s3#i{DfXzIzgvY!d50l(?BOIHiYti|9u&b*h|!ZSGpH({Mk(m zU~h&CwvzF)X1aB>21Pm|N-f3sj6htUmMu;Idam)Onp*6|rXa4ZLE-w%!bZsJ5C<2v zoP{67TD3Zx&jAgIem($1MMd$mN~LZq7liwf_ByN|n!-nfGvPrdHYiRZO%a&@0l?W| zHLy9G=!(G$i?Tv6JjMfw6#pCjoYCJHk7REbbO?69vYZg?$ng@l1Pwk|M)al`-LEGA z2=({JzD=w6gw@9v58~08{kU`Z8{GP4C$8_^jH}z%;S&A)!un;nux<&h#kjbB5w2`l zf)euf*FN8X>p$+m-80|u)>yGW{aCps+dq*5_$vVLmo@kQQ2_XN)Jl~83lfokML6Z@ z;wx4^aG0w}vsr<8QzpUL(H@p`jVIXaF(ZlT{`qkLV6tC(Gu(K&FDv`y0Fds&x69WM zqb^ACN4lLFsm?m=7?gn)u0NjDAZQSq=&66 zEGsKza>2{p_4xqc?CdO5t5IF1R;x!U1)(f6!~+K=H^hUPtjZ{Z(+ZYc5T%F`c|3A; zY&>?>cf%I?qc#%=EHegSLYzMux!WMl*$$N)94RzVVth?ctj}e|K+c46?pR4IU|>}b zxXY!mRI89TdAz9f!!oISPxds*n4;=MqEhD$|BjhYNvtVmXD( z-NKv^LEe9CdN7KoH6Wuln6&?rO>%m_1^}@*D23O|luggX_Y+obdC$c`GTf+P-LDxmS%dvWG>>t^a5^9O2wY0 zL|am9>CL+leeos)3&O`4q39Xmjw%*vc++!bQ7AAo(F+?{mtdv~Q5hHF0`K-o2ugmcL$Q@ig`Q$4nM@7gQ|%6@*@G|TTvsU}POvAx$vFVM$(dEU1| zkNqDuw!gX)@bbO>)9>fRpR0K&{sZ+1|3eADiiK8BN~4c)x-Wh{r%a7RM{%E6zz$-n z$+c?IbuEF1T87@0J+PWlf0hHw6q|hZrn^$y&zzw*0fIO7vh6^!pAA{Qm{iLHv6c!L z101k#q=5`NhqukMF4AlKJ4;P8Z8q=mV#Hejv@?Ca*JcC@p8 zaRN}M(=iZmmq?{sG*SV>vchnk;)n;_^Jnm8gHluB0edqK=>NaW$i&y3gR!cvC83!s z#rJO5#)^NkeX%+>6mP}_puVRKYBO~p4=c<|4!{Ot3XBCT%??8&Kd~O5a%2d;*|D03 z0el3QsiW)Za$kADX|{&fRI|K#8BM^}XqrAb0J1MpUaQPJ1U-)YKTBN5WLM4;Ig{XI%5NCYC_WUz_IL+UgA@2FJFkq*_ zmBL|9zIM^=O+^0-ll_tHWPw;KB|hp<8`tug_C_Wb%z4Zu^u;MaVzZoS<)=I;17>wP ztP-%mh?RZoB9Ovyi6Hw$rP6c+Ac={o^nByDw6qlDa=D;XDkCHk$&Y%X=I=JHf?Eak zcug@I-Bo5n9Wl!&s*$kDz$1gIV?~-HHn9w5mJ4>I|vw3s0pK)><{DPZ%fC_ZKA0eh&c3SA`0~PLriKK16w8qCQ+Qys5Et$oi}Wl@O%KUva%A0`U^6-yrxtlIc-O8qM%JI?iA#R z4LK~ake~dd;DBWt^Xu~Sxi^xrB-@i{jwhdFve)Q>-5dlM7`SqUpj8wW%*zbM2l4J$ zm*&Y6AltIMF*`F1bsY833KBG_mxD`(zvW$PWq+jIviP@W;r$ygynh1#{9UhK`fn6m z{RbGpuR9{eMj^5J>=f@4c+A7iW7z-YM$~JN1EoRX7$0mMiy^Rsetpd^dop_ax%#6ayC2^TxasqBH~qdw9*i3$OayZt&tg&D*j(usMsx zJTCkL*vl%Q=tEhN8;lNN&aj~{z}3+a%jV8}q38ZfW3OKUz(2o>@IL|o&nikkSAp3i zEK|+rjIbi4|nnVRxp3N%_=+ ziy&;s@y3J%KZNU5JZaLoZF7{I{ZW*Xs-QIUmjK{jc;Wq@#Lj;sLHLJ5g}=D%_iq7! z-~CM3)Tygy!~vgI5Ag)xLyG&q+O`QQ`=gabKosZ@4Bt5#+*+fJ=rTWHg!&GP{uG&I!A02&hj{1R-Z z#rh#EsbJuGfe#9w?mQ~j@uN3)ccuZ``&GyANGr@uC)$(k&jDaN7dc>q4#dUKj#UJ1 zXN-V_3-oi=GQ2n^6wUnYIScUibj6C#=Hk({(|;KN{>`4}6#)FV0>CLex`4*-GVPm#0wiDak&V+Dyi?1hd?Ae{+%1eEB(ES_92ByXN z!a$D+(n!%JhU{W=Hf|Qy<5Zs|-kCafX<Kc}l`)Y1NCE&(dz|^1>74CM_ae{l#nn%@5&hZDJ`B3eiX+~k z4fx8d2E2>UoNK$!g+G%`s@Y;0zFGNKtjHyUpOMCg^pqB4a_av}VSTJ0 znvN(JOP2k=t5&J{)=!C(dAqv`j*gBm27rVFF*_)e$(k|%2)5G^14!lSq7TZ44^)Uf zOe4NoN$|s&Ob;LjpLVK+PCgX>#Cc*P{jpm!J^19Htt<(W?utEW3j73m@4;S`yr?|acuoLEGgwXZuCpNqp zP?H%+002huUnSRsv7NJg5;Kur5eln)+O(rfqR9CT$8Vi&mm70 z$A_6}jyWAEio>1R8Te^(V+^X{g)Y7pgob|DNU{HB-s$8*#_vqV@64G2i-}nG6K4lR zhqluPv@XXVZ^ro{La*dXLAmLPIPv{%l$D&UAX|0H)O5iN|AN-@)&JcWZ_ z??k^|o#EnO3mt{4;W{PyM7iU$tYB<2dSf>kKO5!6)UbCO9JwCm2BQ}i8v^lOEpN09 za6m1+3bn0N=pP?~ZNsy0!!&BId{(AO?4zuFt1nS(?`2y5MdG|aHl_RGd-X88VHiH^ z5RPAFX7N6Odj<8eZ+s&p1UNDs&oYHvG2Pk5*3wpQCG=|7?zaKJ-{0Sy129Sip~PJ) z$AWIP=tbGEP*Hd62>_&+wj@L?^0LCZ{0-ik*#K+%#1S3Rpnr%pmSu>YPpsT=8_#~a zU>8dQGVh-$7C>wvwA_~Mip|;dLFR;@bEpekiB{R_wRm$xKis}>SWF03oV|AC_)}xP z|IYfyFT6h_>GgjM0xx`@|3x71hdloA=kD*93(6Kh8Dt_dHBnsl-le11vT{D^8}FHaOvX=R{!k80f3D?-fHkAYUF{f*{lh|8CwZ0KTZoox~nBDhy?_ByJN}RLfkV= zX<=TUS77nD^yKdfR=xs&KlwMj5Cr}^0pO{+g)2|@P&}?6-oe93lY?P%10G&G${Tu0 zFCW9jLto?lcgG+qJOEmv{PuKRCb?N*LSiu1<^)l^&onMPd2GLy)@Paim{!LJeZ$;g zbg@Gftpc^}HRw(r|HE!cI5@chZWm?o?Q>>J_sL%Fvk6%y`{A*@sj-KJ+H6LKIRw8~ zl!;3-vhhKy7&P+IV{Xf6+|A48zkh31Hokl_7lt4wR$Bj9CRfdKwD)kZ(%TDaK_>j} zVKf>Al}aUOG@1yhMDnAJOp3Rg#Nb*{{qn(s=Bd7n0X#Hq3$iyYDw48x;dq|7*gvTO zdJ&yB*sC$Qh95T2hq9aE{=F0ei27%4JUJrHKWEj0Zafs&ZE(S+Y;U|xkBy=bgpE(G z6B~n_TUO!TRk77l1OZe1^(z4QXN>2+vYq$WGHJg8fXgSvb>)d}qPedBvB~4J3BnJr zoxnYEH2XF#L&p}4U~gl^6m{UMlA~pS9cHDFv1j{ZMY=C$#d%{wO;3!d?2KN44ru6N zg;+}^s#+`1D9j#j<%D6=fE1jY+JX<@y`Rs@bdz{U&8U3o^h{c*D4S8&6#FNej6Y4Z zv!Z0v1mHAWnx2EAR#lPVti_NzUN|~YoaOm=dOFws8`n4nPUQXNQt1Vi#-bjx>!5%b zAv`=>_(O+ zz%LZn>>Oi6+c0O;v(;i!b#JW8^bw_x(>;j+c=Eackr9Y;$l@$?7ot!uSVqX&C&~k! z#6m3OGUR5ZB~7~8o+qG0*e3N0>JOeNBzcRpT}4KB_Q>C0PtU+De(I;UZQA+ zxPCnd$g+_gfZDwu&`?!4!%V(ex5jWiokL+s!Iv53usGEvpT zngLa*Tp^#Sx3RNTYb}L8b=cY22|hl)TBSl!z=S%Q#s=atG$o3`+ z5=|jND(*3X5T;l{bcR(9-YGC*%fJ*g^Rq{~wHj|$_rh{R0Ja!>Osc2OJRD#p!At^q zI|0Bp#s-WOOJ#ZE)65Vw^LK=sOn|jYftF1h;?TEy%3FQQ{-}XJBM9=J27v#DJVoi1 zlVTU;i?%5L2LRyll?vJY3PP(~wU;>-dXDQSzroTE3z40k2`jxVRDu-VWZX@B?724o zY9ocS2G+Lgz{htl$qYuHNDl<5_XseG7j z>5Md9*DtPger6Sa_qm=w*;ZbnX#6uMw$C@>LVh!>ADN3TaRKnxC{Wcwj}@;a;`U4< z*<1#N69ycgY($ruzR*cz6#L5$X>{7U%%1(i{DjJtEB|o-@bdB!CQqI!QK?mZWD?;~ zb#Djk8efkKfU@}iegyz1<8#Q&Gs(=MK&pszc`5woHw$yIWo|0@9aBk$wS0KlIX&iKu`elq|(0fAq!f#2&5cnP25-w^}hk10O>++O~Q^4*WG zJem6^cK4m6bq2qjJBl5v7okVnX7F})sPnYU_d1|?u@o%`EaMj>iYTk?hFrX zrt5g2A()tf2WpXLj+7envv(4-c2<9m0hkkkN|n+el?Yed=}ABCT8I7s)|ilr`$fs7 zR^ViwrDS!=Tz-g*9eLtj<%2>|GmwoNze~4E3bL?eXd2pAa!0(S9KD0BF)!HJ8@tTE2mtr(ddc7)Bs*yX-!{IL9UVYQ`P_a9- z902Irxq1FDcC7mhgL`*Icu*kJay3_|3em~YJkTER#rtDrW)Sb!-<|G44omFhdx z)6d8gf;S7YagFY~FryI;OsJ1VJrgh@GXm+p_6S$Y5btOOW1tU`T`loh&sf}0t$hMEY1J)2yG16J9SLHC@Q3Oy`O~aP%nvFb{(Kr`Mk~{_+}z;^&V&|7QT;2@w2kZGc~i zi@cm&geO~e$HfeNMJ+$}U0z}eQm0M5`-jDP{R)$Q*zZ5OcAVFh+&*^%yVfnmpx3$} zJlGE!iu0LjjyHMPCSLYjkmIu~Kdd!+V=L?3PxIzYJliu|H~?@F&kV7sZ5LPZVQjz~ zOEUxTejQ(Q4RS)VgC(Lgazv98O?A_wQxz`^O%B1ExsiCkNeuE^R>Q2eHBi*HI;OR# ziZ^m1(I+7U*+I^zYN9y?j(Eh58+}ASulXb;b`98W1%x z@a+iB09apgp4s@HzaH|OKq_YrjIR7rl#3&ib1|byG;#^a3=|R!4tK=IsexFZQUjCUpNIf&iu$Lv>sNduzPII z?3z75@v-vh!B0To<;CcKHw^i!Y~T-1-uWy2{V%P&`Q-zESetP4$u7!eb0{K$zX$}U zL`T3T^ss^+`dxOdY+Jn;17GWg@DP7!OGkv0I=jWAL7ZtfrYS4(wlyVJ!Ug8+BKPe_=K42#2TJ>E!2@f#TIpKHK;^@ z5Js;Ru9or@rlH$xk(ZC4Roy}Xqmu%WkaPzUgD#b$QKwG+2lwHC{) z6Z+%w5iSUdUm5>D4*aply0Y*;iG{X4ctn4d3HN;%i+b5lu?No;gv6^iB4D9+FH<*Gfq zGn`Cb-c>YyGV0B&yEW5|3wtnwXA<7KP1AgLnPk0LSg3# zQiAbr?Es9Z;)(u|E_f~63B5uc= z(WRO2Z;FM+lj;tXVVRFCW?2*@9fI z60B_<{M0h}c7~AcYX#xtl%}FEh^Z6sQF&|d^H+HZqp7f=jEui*22(f9#I?f4_;FGb z6t$^^7Qv3Ft&<_!Sx+B~D~b|*vBDsh5pT=z7V8C!w%D3&jV(EDSY`~xuxfr>OOUCp zTBwysN{qutv!~(m$!|@xkHvy#O|=4l4gmfNWv}?yzx#a8KK7pgfR`l>@}C5NvS$FG z{QERZz!3oOReXpm^gN{d&ma04pUj?t_RSk3z{3+3QU&+;UP>8~-K;U7nkPQa2qLfT z&&P8ynXVm~tajgpYxS|lwXI~lAH=$0VS*^x%?sKxJ<2Ee>?&XGe9#A*^t+R@Sg{F? zhYWn|mlyAX-Ap0x`Yd0pA=bOv=#MouB1vso>%2EQExI zDRfH3NVPD&!H4FKz{1}G0COF4vT0;dhH0g70AV?T8-=;p|3(J# znns~zxHD>6DUf8XLI*!Pyiwf)^N2>RG6rBnju$r8bHS!uS3*WVEUOoVw-SO-m%c<> znS^I;-JBiJxqS<4S-l9i&QrX1^&EMC)22D_=DqjhPmK?M5di#M2Jk!pcxepdp8$YA zov{0z@nz2efIs_pRqO(N766_q+cgQnGMdj+rEgz4f}g(Fgn~(9(J(6oF1BoMT*8aW zytN8sdD>!Rj1T6ghLGU}a__#=q|&oB(-T{=m^PmqkLg*@{whN#`uNylViji|lJ0nl z0aOG4*6!}epKAvb|KwZSzI5A%8~;xJ`(o(8at3Uxo9O|tkYOtiAzXPm@eZRW*VJ5Q z3`UPI57g8t@m_8W&dlhDZKLa>b8SC(P#8>q(_M*F^|98%mM8rpA}R~vVd26{Il==2 zMVT<2wx&udJ7`68sUuO8gX4_k!Lv<$$tk=i*}R?~0wnVh#;l5oPZQ#rW#X3M16p_K z|91+B5ftR&;5$al?NA;4;sPlivqc>Og9gqzbPsl-*vt!s2>}#W1@W;;A7zH%y~F?v zuM>o3p>BxKt3Z(t)KZG4f`c()KyQ4veGTqiJVq4o46V~owhpfz|6K;~GD3iVEUEY} z0D#|}U-%CIz_b77hR@@B_T8SvwKIpXbW=YuA8YuZ5bf342;$7HZVCuCIh}Z!-eMyw$c5JQLqqq8~=S*{+z5jb{Vmb zfy9Pu=@jTyGZ1^@1VWofAr!AqZ&(Qz-pSxvWu;t<;KcyIYXVpSL{Ta)dt^)idE)lr zBh%g|D}Z2n19u4o?i6L=1_8nO=}oX}WH$0!#-d+*2!Vk$YU>rquvDRuy%r5w0+Hgj zcq=9Pg*o8^V-((~?Tdz9HtCOi-`61 zrm(n*g%S;_1Y&B>T=dJWfp7|uHBwgQtGK9AsNU4+^nAddtE;!5QriiQ8Z{DL+Q9~d z_?VJ`S__MsGMVC_p1ffl1H&9)ZjK0wXbldN`lo1batkVnMrDypt1({>lDmTPp}P-R%*m zkYQLQcdX0`!g`|*7Ev5Gl$b(oS1ULurC_q=dbI+zt5?Rjkpr=N^GaO%`4EK$rzi|K z`IK(plMC_5c41lh&yGFO7kqAa*&hdh7jEzU(^Y~05CA;omH!j~9Q#!+=7q0QE(&vu z-)4Y!>*t^F{mylm_72f}V>(>zZOJPV@YC{F$+&~)U&$3SQv|xcd zO!~`2^>^|FwnzD5U4ncDV1|h`m^3k%Ucd&UFJ`6(p^3jee3UX&@^D3y_!z|cyTMW+ zhe|5HuaYbGDpVG292{J&MIFyTL8r5RxyElkHs!7n9Gsl!Mf}wYrRH^oRCX^=E5qnI zZrI%^3dQ5&L=SF~2!EU>770BldZHS{6ogpGFb9FcC*c4)^ZtSvY30R4Y@oqiUNZTl zxQNw4-zloccOb46R(*XASt+c=W zeoCL&lB@_;X3Ya!K4DsPn`P_S=gK~>E2jW9$(il#mzcWnN{;hwJJy)Pc6OMze|YI6 z&iwQx7R}8=zaH&TB`Opem6A=pZFEgBD!wX*ym6=la;>$Pn-I)ndA2LS!-h=r5`{e(wf{WL z8K0-KrZ-nk{YCjZv9gut0!$#lB*EDvAT|tyHNvf}=a0fvKjiq@z>b)Ug+c*GOFe9e z$*QH&2MU?$uv)2m$JWxiQb=eenO0*Z7*f*&A5TBlzAyYEM^I3Z5K&8!5dZGv!RTy5OX%QW>0B=gt>8m(6AKOGaw+T_Y>=6ehg!RMQn;?|jiVm!&b3okG^YSJDs zdnU61i@o7ygAm)2k3L}r{6eqj*^ZSbs-7~&tBeUeS$_opo)SGN{__CvY_ZYb1%R?= zjNh!%R546>boF?-+K%}13VZpSk)0G7R|)g<1O(TP6U9G);*&pO>#{|7clb~k(-Pt2 zV9o15*!Ri$-fKJQ&^^Qf@5lRKu`v)Evb{wuO4eA$y}k#h*qm->xR|^)QT=qY7x&=F zzMXsrz?GPSIINQa!@SHuG;p;*drxaD&j=>N^%R9{o&pYcQyqXiJ=TLeA})Bt)vlN@ zh_kpjLvZ5&usw^3hB#v%u?@DpJuf{NExoOI--}8jfs!q$^sZ7S|3WEOjs7Ab@sA#Qt`y`VRIgiCs?_M(X{7Qio@7Mr!`v}1D-gSy`{C!2akw|b zV46yrz&-aPla$fJawSu<(ut^W3fAzDZ>kaCTZHVsy1dYjz_e-%X22gYV|y0^8Ph<>l|}J+8LvgL85EB!_?Xp&_TEc(is5@qikYvafWD1>NIV>44?U)v9zgPl>^K@V4jjE4tuLS0)cJ>p`kp?}Y=_%MG8zTdY6 z*UlfrUEU%}7iP&xQ(i!12c?{zo)mW$t~}AsD{o5qjrjUy)CoKd0LT6ofIMF%=OuSE z@~TX6dJY?}#TXYZGwCf+)8Qe712-@Jgm3n&MgDtlqI2sOhz<>bm0HcE;u&cBYUN0F zw?vOfH%zM&NZ)4!)*6Gcj*OoU1?7i2ZC|{ zpy$aFLxyFU{%GstfD{`w@)LadOh3`nKQVgqV$6W)Td$Ha~E#@a#P63 z$r1kJM`Y#7LUiStA|tT0j!?*CTNzTt*l6)4eG#j(e6cgz7T>q?#O0B-@MwxboKaRF zCifoYCsz;=P2uIioE@+&!8}n1jj@1x`RP0f$Z`S9@b3{TxK@yjZ^mX~WNIj4t<=bN zut0vi2bW%A6l;5yBbOLs37t(01kydRKEs!Pc`GOm+>qhT_gKw)wOa_mR~UjYGdYx4 zfe#w`IwG8&mw6RNDb+FwY^*F0Q#k_d+cw3!jO%kGgS03#r|xuN6U;+AZtR@ayYlMM zjr85J#lGXMrqbM9e3>b}CokUF#jEw)pBR6-=tLM3V8*$P)dA4;HL->_>XKo04YcQl zX)JNaWxd%V7O|TzH!^xRzH3mvSV+J^jx09$%AG4;@z+oH!X~;Nmt_Xwy|^H>3-Lg- zjSe<+?J1?gZG}R%#zJq=z{y^m-s$Y*DAb6p#l5~pqY?g(4=cgm$xT?kZM8(LQ5h6+ z*4;_K8v*AA`l&e&o2Y)%1w`eG}tpZ*CCE}tmZjbjN#7R#Eo z8rl#6gMQB78%S9bYFJh<=^PJF!! z`IFv3t}z1+j&@K;WqjY>i9Y|T)@n5OwZZ7B9{4aNh(fs#p5tMjmL=&n8`;nqqSaYW zyjGt9z}#eSOswjRIVrxp!j5hC?c~EaTus$^rt82A0F3&li$K9Zgwc6+t!yL;KQF@{ zEnKaTYN5c?+G1SKAH(|sn5q?>(`co;m_Wu{7a%4Cck$$)2d^gFWDLOC>>w=43?vZq zMsq)VMCsMAk?;bxhYE@G8;wfU-`ZO5`^A=}60KS(L`6mjA;AIv2E!K*^EQKnqrDIi z;HT1PHJymAoVO+Vob73aS?QtJm>Y_%**?5ncXv|{?C%wYb8n{L#xx`DV6VFrt5N zx`^Mv#^noe^1v6kb^a*s5w(55`Vy|5DsNY=sIUKg0QhTV!sU@ZyDi^uGfD z?0HK~VNtp9o8;PYiOv^}po~73`)7W_FNZ$IweNRe>%!S+&bIGt^su38$W0+ZWjh_} z)8{^bzRRM-K&&E9xjEC9ytx;}+|0n7xU%%Fbk=m^%J=ejWQygvOLGFyHQWi&YAMpm zDfNwZLt$DFR%Hj!cg$jScmDlvW&WcODvS2{?x*=RSILE^ZffhAa^m~n5G1f+{kFM@lmB;y!5#n&3*w|R%Uv~tWssnXeOTo#}Rj*WOM-T{H zx0Oqg<7JCkDWTYuOJACi(J>Fqs_l&N)t!-V48h7SvG`$JCeBT3h?0WFxSrpLynJI6 z7dFAqGaBQ&H?pz3S6#f56OCpeu4KdtM3UF9V{d^P)*AX^6{w++Bi=$rF_{XPb{5E@ zl}2GmqDF~Wr3|(7O60oPqj6L?!W`^ShkpKHaxfoF#&ZRHIEgdgG1$RrpE-GBx{%1T zY|8S%8eKDnkAE+OVY0&RZixV}B=%spg5IByTLr3M6mO+QhxdtBWPF%=lRo&K83y zd(U=!431=Mjy&tWB0CJDYIq?;uYiV^{L0`Ym!Xc6CAx$&&k@ODNeypbM`Vf10KE~gw{JFb-;tCo>rYtSRu z5pUM=!3P;3C`byx+ciAM%e!Jggfn{5&$#K3pVcoyYkE|8)&kH&2G!|##;KK zmt^`8AoyZ@tUG!~xu7{yIC8h-GS4hdwPoP~Emm)?qxjWYqlTNKJ*q@Upm9znUhC2h zjos;G^~_th_(4Zfn#rOA-X0p{06ZvLI8hLu$C{`|QrfRvc&Cu1jCkBdiW z9meBx2T^)vKOP+W8g~xt#jUTm;o9!?xUzW#F0NgS3o94m(vnYb^|J*yJbyM`tDg&7 zr3O9(=slyIusA!A49r_Bp-p$fR-)Bg381$SIP=a>)7r*7FsJEEJ=&g!0ILmjZPX4X zgVjMRk(4Rq%AInhZmvwKII9*Uk63*G)9Op}bU?RoSG--*3m*_${frECr6HKUTcYQ@ z@81{8bAmBB!5?w9O0EVJ>1>NmF}@gH%?)qV^2R%L{PA9$08FV9h{<$*yQUw;RQJKa zXm@tSwSnR+Mi)ObhN5+dBOlVp3^JQ&*QfMF^V#ey z3(QMAiT1Oh4J?6^=2^a%yDdZ1A~08Z?Jy^YOmuInN%Vnraj}lX1%?RBs2fP{*#kYv z(=?^nE|EfzNQ&>=6*69x##n-k)gQ0~BY7n&od(Vhwg?UMLqc3FG;3TR-8;3#u>L*q z)~G?4_U;&bT=X8+uULTZ_HMw>2S3NnpAX{hMN!ZamyYw8THK8|Dz-ddHXHi$ry`q; zyYyGneky=LMR_$dII!nt6rR}2;|qsTMuz_A^Z`6L`i*Gl-|fWBFSnv(4;lNmmAJU! zGhA5nDK4%21ecc0!xdUrm(Rylc3!ps7nXm5f`L8YVPiwFuL22Pjwm#S(RW9NolXFq zM&CoaCpYvhe2HF~)AbB7NhhlDIPseM%}j+SE&z3jB{D$_@~q!mT397JIl0+twAvK4 zT0LGRlkHVYq&IYew9J-(*+(r$bw^9&_&B0XkQ2H^xDp7tp+~d}I#%*Pl7}5^WxU+> zu}Us~Y)9Xjr$Wm00{jTDf;9?+&|(^Z{wg^)bVp*qR?OJx`zEV?q!1)`lv3G6g-o_j zDOb#(IKH#aLRZbh)6<&G=939>!NuN1NQ_SuB7!2yxAy+4j=FJiLWAsFe%sy7$=*Vt z8l;p;&$5X=EN<@=<%u~N3HYJoxD=JUH?V9-RCRkA6OY(sMsioP7{w z7Y^a^MNtpoF|9JT*aW33PyYY-(qZv_cD{H>d>os`d}==)p7@TQ_iNn!>2uuP&y0K* zu79-+*Y|D4wH@ootFOYP^-FPa&0<_!^(ii_Scr>c*q6z`iobFSBMytFStz!J4cVaYnrHLb=by&D zA2w^xL9FVd&wW;W2r`|lV8gy^ne2#0t7}pzGE!k}ZO5<6=;+ES8%wJool@0WBb67D zQU4&5%C3`vJ|NHhm^HYu$1;OwVJsV^rISg@<`POVP2x3=^5boJETMyZ6o zA8Q+1AtWSB7(aeAm*D@eKdMGo6`D0{A<%~+gaia@bru$_WODfr8i};bgFIj(AA3ws zVExWv*hR*=H;X)Q4t;q!^kouy@1S^ZJFx_opc8APUHNt%Gjf(tVHH{1xN({iH5>q3 zc+n5r0uno^e0dG_UV}X`f*^DXafbtap;C$Dl3XIa>`VsNC)$gsUJx0s6E7Lwp5e*c zKE>)V*WUpEcP?YTJB{t}y3%qsi9I;+!VB_LzT|Z}1-qb`pCgR!HmL2WLj-|lphgMyxjD%su%nBU37)Xtb+!lYV4*@v z^=Ql*Hxeh-EXS4gt8jV!GF;fY43~DUM9I!IxK6bD=Dtn1_2m}a`f4ku;nMCa*kLC6`TC%A^Zq63GUc zAl)VtWNYP8`8=gU@vcUtZ)9iZ?d0PTYUyh4;%#qj8>3Qc)0IkHeL6Q)%2mx&GF3wh zl`g|hZx?H4=@4pb<>=vT=WgZc=A$%E;I+_b1sfY{uFA`j=nWe-6#nOqI@N0l>zA(+ zEYvz7Ff2@Ap|y-x$&^b86mBzGRLxF@UX{J@QDzu6<^*7;(F41(#DSo!U}LM1jD}AI za^dnz+}KSO4TUzEyTRVX0H;V;jchw5z>l&Fn zPcM;_W{{Dwk>%Sm#S2Sp{qz)Lem=SQ2@BX!-sDjM0Mb0fJ>zsSiMWj}#4TBl*hDcX zQ;A{TX)pc$Ue@ku+IC`(V}P=i0S*BN`@#-bGv}yq^uOee3 zFU;0e^2o)suB`)#) za+a4NUKeb)W+SVbBVc{2wjXLbSwJhN@JcQ_uF+Vui;SqGve1hf_)$?&Lbq<+#2S{U z%0jTezhG@)DJT`6`?JL>g%7i&vv2V1?ZrK_`xii_fEYy~?T zSHV)Nr==6*GUczXeYI36=v5Yig-S1IX<2A&1Y0Y6Av!Efs76NbZ0jgA@Ms{k^=~Wu zPakIaA!ip?jTOXB5JxVT<9#cz&GFGphS#M=& z8DMRpGiv3s3w~-D22-5A+8AIe9&zXDFWiGa3jmqV#Qz)s+{KbnQv-;|P{gb>dmpUy zm_2S?CSw|`>&%n?-`w-f-Y*k4HIGT(#x80GWIU8{FISD>OoBlH9D8jx@ul4J~6Ed_n8*^ zy^>|(x<*FM*Rm()>r3aAuM)OcEKldFOH9jbHkaTm4^UV>3tQiT7>ty zcO;4}hoiGIs#U23Yb!kjg_4X|{@6+?E%l@jD8^QY#uPU7t?Z78@xJ&dBY@ZUvx?KX zhA<4T;g1>=^XurdkjN!Rl@{vOfx*FQjo#v^5%j*_{iiEUkY5cLt$;pDRudmR)eS4u*t5!ne#*MjpHoGr6 zI$C&j&{4g5tl(g2FGPlmEyO-vUK$&{E?K9P&sE8!#Vp2kQ^`@+$r7)Tcgs)o#tODA z!xdR56v*_WFo1PSc@jHt<$csU_*Rb--}2eP28!}5zBrL(2hR`qU}a_~M%VB|3^5Ho zQA?#%`IAm(mBxU`jYyeVE}N^PcrVUbkG$j{Y+&0!2C;Nglz(!0ApktZ1WZjM<)xo& zx>34V9KwwFGYT; zb*3HL8Xww#mxx~ zjcKS`s~Qw46|n%tJ*`x+N-tG>rISeRSW1M)EOZOfDJVR$K(?11n)o{MEwv!Mnq~VR zE2Z-9wQ6l+|KI?XR;A;PC%)F7+d`{Zv!;-mnkrPST2-i*Q%`s&_nlXU{I?Q=^e-$r zTu4b|DC;aH2p#Pmbs9|zwNkZ0A(IsozbbPe^sZv1rWn`>Ln?b?Mshe7Wk*m9NPv*T z3W>b2G24rmdy3mYZl>kVCB)a0Cub8{780%RALWTKijP?b6`}bNom$m0$j@JCXNm?eq*E*?5$nx2)9s~M zf#?_Qjwr1H@#MiK6B}WP!CldqGPBt z{D}IKaond^c7sl3F-0NQoRLc8uvRHCwo_}IU%}}AJQIa~zHzfvT){A}nilu|CKhmw z;`!^euCe=;iuZBjE>Gsod$Ra_W(M#HUlqZEu>uAX%=lRta+$pUVS3I1ty@5=kU&R_ zEGMN7`gdywXGa^zq|ygkmA03IrDdd6sqC&&$Ujg>rC+He((@Kl*>x+a?3T4e@{6TJ zc1talT~NqnOY}O6dIfEcnb4tmd#Q_! zy_;U6YpPHxKa$GiC*>0Pee!B>mCF!qtwFZ84LXN9U`Qn=ycz4slR||Vk@z?>9CL{J zeQbz8VQMJejSIk#7_rDA*WC)fDj92?d?-~a_h~G&jlz80)fDWB1xq@;;Nj+}mnpRq z8Q{jo3o-^9NmuLT3y@1#wzJ-r|cEbkx zeO4dPC)y2xTBbW70c)4ecC+FINL#b~Idj-y${mO~2Xnr|S!`M5GEq*P)ojWV8$>46 z@dZ!et%7%F@%s&)z@r$Q4Eq{4aFNQJ>3s3Bxn$^b=>ECf_*odhGu|4GDiHyYlE+snRd2`EsVna> zVu-}q*;Q|CWfNd&VVR^;Yn$mb+IAY1x}#cc*;c33r`y_Cxn|c(mbf}QmwWP8$G`BX zRjZmXVb(YyJRn4{P-!_$b8>dF(OFp3RjJg&R0`Ei?64_lw(^>I_t4zWtu!dP^ zP|aSCI9F>VxZ5J$&6eUr3j~l6GtjW3kU}q&JXA`hCzVR&R7-2?DwV2KmuWSk@wcp5 zOUN|T6M?|eF+{G=t<_0nk7KM<7+cGWKp==qj_>59p{}MK0I_LghbiH>Gu@;KpY9@- zd$JMd8RA~_1_F?UnZZ1xAE;HaJ>W-jnQSk4t^>~WH#G6F!-uKF1PtD$c;AKZ5-`JO z3N%~GyVrUV1FKtW;{>)LYwTl7Z~gDpm0H@`0rb&$sD5mbH#oAW%Ep)UcA4Az@db|fdK~FDPT#)v-G$xW=+G?-rdk7Ar9X5_OK=< zQz_UNU0c>e{+oj^cgkpdFnJ6z5@Lw~5PMUoK67>Vuyt~F5$x^kpSq6Nbz@%akV`4V z^zjs|t< zWbkuL)o39gBuE(lPJu+FwWuqXE56c7MaD5A)(^|GLa{Z&PZWY=wF)dGFg1wmPIJYc zR9A}0#Tb5bmMb@0mWNp}<1XgD+<^ zd=3G?obp&*Tu-+2%D48GE;P~kh5Yu_rJt7Hca>G~nOHa8rR$W^LbnNrn3Dpz%v$uzIYrJBA9siwC^ zuI-|g>ze5;tZUj@JGwVOZe?1F}zCut~py1){D@0TZlc{z3 zY?(s$y;`C|xP=O@MY>=?nm^x~+sv!ZTur?K&V1``Te{dp!n+9hKC~AvE1sR?$7jU5 zQura2$x7ua#TH8oP2A)W10{ihVJfwSMMpB&Q})CJYCBq@CjrG9b^P&uVhE9iQx4#2`7+1z zbIYmy6#{`P%RVu>>f)_b=c@K4R=oV6tK!-$wj-`6oE?*?jDom4^?84#1FR4Uto= z3Vf`sV5L&Pljwe8o$Bb^x&bDRdJP}EI}~#!48hz9L-Fy%;rMX!Xaa!o=-aJ5?5*`6 zr+!N&laAGBwUrzkZ25kkr@J`Xs%q7kSH|gm1K zNsE3I>Ng%G$xdx18QNy7#7qJCA(IO}e$2SNgd}>N7b&FJ*oa9Y3o9F;a+Rubjm9oR zA=B-)kjP3s6%sV?(qm#A(fKS^e(cZ7P{iBlriQ(I^G2TIoQBv5F}e)vX$Z{P9CE zd%`HZIjlEYH#Q(PItZ>dTG(l1@Uqn-AtD5wveGfSO-mFF=z}%yzJ+f;oQV@l=Hl#% z1vp3R;wrHgpm?RYJ;=ibUO(^&Pbgkn_7SerDp@{P%q^^B6@LqGiYWg#AI-qRvBU62 zr*`x@nWz;K3|E~FmK2sb+gPGnbSN6vOT&nPT~Ro36y^}k|Afw;km1jLcMv{)cL+Y1 zFdPN%jKs9JM`3us?g;Vq;MI^asq~gYuJ}=-QswJ3nv|eGe+`9q{QJ}E^{LSthSskMzw zCAnJpxu8i*Iz*S~B+6xH`KF z-d>)9RFDYP78c=T7>8u6y*aTaX1+Zfb0>|$-1mt_PaTVCZw*4fuFcUPD+#r#gu~Cp z0rpxoY{(!TRZ0XpI3OW10d8n?otKABj2d4Wsut1oO$;&nE!*c;aX>`Vn|zXfJeW)eyO938+ng#*E+A zLJcj`_+ugYttAo!l5rlh zCoi?kQfHa#<>W??ilZ}8G+{Kw(63|e zl(9UH{%Fc*T4OMK(ir5uH5_9H_CWVGP0-Moiln&ehztvW8yTsMPQzyuuF)*jwu1<3_(a0p&o?gZj zTYgc3Hw#IgXlZ~z5@8u!4)mv24C{<$#Xtv7aipx@&{4r5tB*(>~NBgE2 z*|!TOjT(sjcSd6-c~)j*A5R>Hk0%W$a2QFzK=%<#D13JW-WxlRe!nw%b!d*ZjdRf0 zm_o)|3n{g#qE1XCs)h$5(%%bVex3*m@PWIt1MgZ?%A|Y@dvsVZa?_L1ra>0^bZmxk zLwaKBTfQiegTYoY9 zlL&4uZi2VBuTZ~!Jzl``>Ueehsl(RRR`Bu)5aQ$F1uIJ@eqUHnxRPSbAdQ8#fyzSD zSE`atk}0HlDw%wug<98NrvZ!&bMm%mX`{1_b+LD~Iq>5Z@iAdh!iW(=epk8WhaY|r z+O%mSRIgfH@NoAKJUu*w*c#P^di8S4-%ki*5p+?fB_&JsIt!yhCjXMcg@GcMiqd^VlNe zoHKbeKBRd317ZrZ=v*{uH1ggVhWFnXNFKT`-WfRnqx$tiwa747s_8YH9bvE6A+BmP z-WuMIo|7p3#6g%twEiQy&Ej}w_#YAg%$~^B5CWYcnDN$7^y|_NDYa|C!`>cN1STHV z8q^KbWcT5_e9L3HM>V$6!nP1w4SLLO1~56Yz;Z9?35|) z>J-XVdaXrU8*973K;K}V6tJA@~`NUIWv+*+ZiSBq$OjSwVP4n)mRFNC_=!OPbkauLz@PR;7WQKyBsqdl>M zN(k|GgM)<<^)uoKECvyH4CO%ZAyNE~_!5C&Hi1Clgkc!br#&+2Rwu)^;{z>1T_kQnI$4S|OZg(^10 z2Glb74W(SUN2O4X)oFE!u5NC23l^-Dh)l~}2nY=q<`(7iJkqP<)$xZ9mStv5BFRa~ zf?trIVC`flXe})SODnx#snZG0cJ_jww;y@!SRuY{{7chgXJJD9`t^l?0Dr;J&Oyv{ zD>SVX6z{WyUl7H{eVe9Wa_?Ns=$noF-sClVWnpShBN>wsqhCv2DVUV~83}#cz|K7;HXktu- zmzxtAz6_D>c4!tCfzhp!@qTv$^2zWExWOm?Y87@%;){U+1A=_IZ(8>ZG^!m6d#w^x zgMAQ3fahVY<4i~?5gsU{l9L3KpJ+7N4%Rj{fuUjHJda{$>nQjTD@adI<)80UyGE~$ zSI58MU~y~1Mh%3Jh$z9q!G+)F>ldiBwXm+LlPZ^LWb!hdLIOi<5XHtB$nTYenLX z?*ASDB$z-T4INUV;X?1fZn!tP8fu_%ohZ}}^@YEqH8CC;@BNTVB{$`A`5u{4HNwK$ zuDXx6KcAeV(pURitXc~zli>$jSXx@uQpn|F z)iT+47Bcw*HWQA~?hcui$+(j-t9u=ajpNCH63OV9;U}Yjt)3}#P9bAS;l?$SQEW1t zJo2{hQG7kFeG0l8t0TK=AR?VC;XMXSK){qj#$T7-dtCyoWNy#}J<{nmz0V#gF8~1gKRb6#3spO9c4+l8tRN^oVsqDU7t~jVtYNqORmKiQ?UiQrUtJQKL zAwG^Xfof6JUR{K*j{p3@R9@>QCJNQa^XqkD&$_dlr?u9?I$fpGPL)aJhZIubft^l? zNPkbbJJ`aV=x)D8aomt*cTXULNFgvtq4+q3d;GkfsYI)3^$=Gf8P6>8o-@0%L|OuY z1I5$5>GyhP;{6_37}+ur&Eg{w?O{(|Rf#ZPPqb`kz=YTPQT#p}{o3Zj#o7Xa-Y$r% zPT@q2U|K<@a|CL|M8JcXL6tB+OdK@;6G!%kF+LVPc6!tdc14f+v6w;tG?N%f5o0D~ z^yP~iIWzj_Gk_-`K%mx*euuM#bo9)r1!ww9s`)sgYf1!qrbVJhS`>O@R7Tf~7_?2P zf}GeeR1frkCovQYIUA`ZE0xKVXXSGBe67~9iL0vz?-sJuYI!+vY*G>j;8(}1;~zi5 z!omcDA(QVCs8pg7jf<1BtyZheP{@@t^Pt_CCr#opase@cr&j_20yBKNO~6pVVs7%d`MuIGqZa|kYpIyhD-)yI zrlLtgG@`tm;AX8sa%?ol_3w#hnJKWhwt=^oC%ipeDdx9@2hn+VdtwfDwCrtQM*!hq zXNAm^M8s9DgfJ%?G_Mnmw>uKh^hy<9o8D_av5y%&446i11{pFlYz9uV2*`d5015~s z3c97013-`5+FS;$imwy8q(-1udIWl>Mc_38g5K#-=$R3X?wM85nOH%y_)18iP$7Wc zH>-6}NFO2o=GV8P zx@Ti_n=~|u3rDo4Jz~RxP$@7FmTC>Gm>?;I3htI#gpz?*^>ssyAWua5IKh|Rs}mWd zm0AgZS37jhu0_vfz|7tmJQp#Y)}$`U7}LBK24+`5-^@zrn;woK#&Aq%6U*Q4Z!>@- z&K72}JOHtpZrQQ0B?eH%-^By~;bi!cc&)->K+ubTpl4Kxt*Cto% zM{@w^LKkfpUjDdNsSkNV^>1L35Wp?yjuru8zQ;57pW9Jca^ z&gU^ULEe2#*=03h$5 z5kW>Dj$!qy;H_45@P6l1%<7Ykj|Vr#$HSWA!(lBkwNGPo&8~?UKUdCN6f)T(xmXELu}i7seHXX#8HtSepM)rs=jC}e1v7=dX$(>aYVG{^cqlew2? z08qfU0aJLMppfGKqF#wS1Q?tXiFy$>Fa%q}7;2B~FneT$SR*q?kIWD&g*NT=sd z46uYT$`Q?Lc%y9{KXi=q=c|2O0KHBiIwu5RXigMnbV+*d4nW2Kb1EZL8)NP;TEm-S?#Pyy zJ+eKoK@X=XF+AR;h7uadOt0{{Q3g{{TwG@>gCRbF=^e002ov JPDHLkV1o7By!8M8 literal 0 HcmV?d00001 diff --git a/dashboard/scripts/a_memorix_electron_validate.cjs b/dashboard/scripts/a_memorix_electron_validate.cjs new file mode 100644 index 00000000..1d5de90e --- /dev/null +++ b/dashboard/scripts/a_memorix_electron_validate.cjs @@ -0,0 +1,406 @@ +const { app, BrowserWindow } = require('electron') +const fs = require('fs') +const path = require('path') + +const DASHBOARD_URL = process.env.MAIBOT_DASHBOARD_URL || 'http://127.0.0.1:7999' +const OUTPUT_DIR = process.env.MAIBOT_UI_SNAPSHOT_DIR + || path.resolve(__dirname, '..', '..', 'tmp', 'ui-snapshots', 'a_memorix-electron') +const TOKEN_PATH = process.env.MAIBOT_WEBUI_TOKEN_PATH + || path.resolve(__dirname, '..', '..', 'data', 'webui.json') +const sampleStamp = String(Date.now()) +const sampleSource = process.env.MAIBOT_UI_SAMPLE_SOURCE || `webui-demo:a_memorix-json-${sampleStamp}` +const sampleName = process.env.MAIBOT_UI_SAMPLE_NAME || `webui-json-validation-${sampleStamp}.json` + +const DEFAULT_SAMPLE = { + paragraphs: [ + { + content: 'Alice 在杭州西湖与 Bob 讨论 A_Memorix 的前端接入与 embedding 调优方案。', + source: sampleSource, + entities: ['Alice', 'Bob', '杭州西湖', 'A_Memorix'], + relations: [ + { subject: 'Alice', predicate: '在', object: '杭州西湖' }, + { subject: 'Alice', predicate: '讨论', object: 'A_Memorix' }, + { subject: 'Bob', predicate: '讨论', object: 'A_Memorix' }, + { subject: 'Bob', predicate: '负责', object: 'embedding 调优' }, + ], + knowledge_type: 'factual', + }, + ], + entities: ['Alice', 'Bob', '杭州西湖', 'A_Memorix', 'embedding 调优'], + relations: [{ subject: 'Alice', predicate: '认识', object: 'Bob' }], +} + +function loadSampleJson() { + const customPath = String(process.env.MAIBOT_UI_IMPORT_JSON_PATH || '').trim() + if (!customPath) { + return JSON.stringify(DEFAULT_SAMPLE, null, 2) + } + return fs.readFileSync(customPath, 'utf8') +} + +const sampleJson = loadSampleJson() + +fs.mkdirSync(OUTPUT_DIR, { recursive: true }) + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function exec(win, code) { + return win.webContents.executeJavaScript(code, true) +} + +async function waitFor(win, predicateCode, label, timeout = 30000, interval = 300) { + const start = Date.now() + while (Date.now() - start < timeout) { + try { + const ok = await exec(win, predicateCode) + if (ok) { + return ok + } + } catch { + // keep polling + } + await wait(interval) + } + throw new Error(`Timeout waiting for ${label}`) +} + +async function sendClick(win, x, y) { + win.webContents.sendInputEvent({ type: 'mouseMove', x, y, movementX: 0, movementY: 0 }) + win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 }) + win.webContents.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 }) +} + +async function capture(win, name) { + const image = await win.webContents.capturePage() + fs.writeFileSync(path.join(OUTPUT_DIR, name), image.toPNG()) + const text = await exec(win, 'document.body ? document.body.innerText : ""') + fs.writeFileSync(path.join(OUTPUT_DIR, name.replace(/\.png$/, '.txt')), text || '') +} + +async function getJson(win, relativePath) { + return exec( + win, + `fetch(${JSON.stringify(relativePath)}, { credentials: 'include' }).then((r) => r.json())`, + ) +} + +async function setSessionCookie(win) { + const raw = fs.readFileSync(TOKEN_PATH, 'utf8') + const config = JSON.parse(raw) + const token = String(config.access_token || '').trim() + if (!token) { + throw new Error(`No access token found in ${TOKEN_PATH}`) + } + const payload = await exec( + win, + `fetch('/api/webui/auth/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ token: ${JSON.stringify(token)} }), + }).then(async (response) => ({ + ok: response.ok, + status: response.status, + body: await response.json(), + }))`, + ) + if (!payload?.ok || !payload?.body?.valid) { + throw new Error(`Failed to authenticate WebUI token via /auth/verify: ${JSON.stringify(payload)}`) + } +} + +async function openImportTab(win) { + await exec(win, `(() => { + const tab = Array.from(document.querySelectorAll('[role="tab"]')).find((el) => (el.textContent || '').trim() === '导入') + if (!tab) return false + tab.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, button: 0, pointerType: 'mouse', isPrimary: true })) + tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })) + tab.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 })) + tab.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })) + return true + })()`) + await waitFor( + win, + `document.body && document.body.innerText.includes('粘贴导入') && document.body.innerText.includes('创建导入任务')`, + 'import panel', + ) +} + +async function setJsonMode(win) { + const trigger = await exec(win, `(() => { + const label = Array.from(document.querySelectorAll('label')).find((node) => (node.textContent || '').includes('输入模式')) + const root = label?.closest('div')?.parentElement || label?.parentElement + const button = root?.querySelector('button') + if (!button) return null + const rect = button.getBoundingClientRect() + return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) } + })()`) + if (!trigger) { + throw new Error('select trigger not found') + } + await sendClick(win, trigger.x, trigger.y) + await waitFor(win, `document.querySelectorAll('[role="option"]').length > 0`, 'select options', 5000, 200) + + const option = await exec(win, `(() => { + const item = Array.from(document.querySelectorAll('[role="option"]')).find((el) => (el.textContent || '').trim() === 'json') + if (!item) return null + const rect = item.getBoundingClientRect() + return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) } + })()`) + if (!option) { + throw new Error('json option not found') + } + await sendClick(win, option.x, option.y) + await waitFor( + win, + `(() => { + const label = Array.from(document.querySelectorAll('label')).find((node) => (node.textContent || '').includes('输入模式')) + const root = label?.closest('div')?.parentElement || label?.parentElement + const button = root?.querySelector('button') + return (button?.textContent || '').trim() === 'json' + })()`, + 'json mode selected', + 8000, + 300, + ) +} + +async function typeIntoLabeled(win, labelText, selector, text) { + const rect = await exec(win, `(() => { + const label = Array.from(document.querySelectorAll('label')).find((node) => (node.textContent || '').includes(${JSON.stringify(labelText)})) + const root = label?.closest('div')?.parentElement || label?.parentElement + const el = root?.querySelector(${JSON.stringify(selector)}) + if (!el) return null + const r = el.getBoundingClientRect() + return { x: Math.round(r.left + 20), y: Math.round(r.top + 20) } + })()`) + if (!rect) { + throw new Error(`field not found: ${labelText}`) + } + await sendClick(win, rect.x, rect.y) + await wait(150) + await win.webContents.insertText(text) + await wait(250) +} + +async function clickButton(win, text) { + const ok = await exec(win, `(() => { + const target = Array.from(document.querySelectorAll('button')).find((el) => (el.textContent || '').includes(${JSON.stringify(text)})) + if (!target) return false + target.scrollIntoView({ block: 'center' }) + target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, button: 0, pointerType: 'mouse', isPrimary: true })) + target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })) + target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 })) + target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })) + return true + })()`) + if (!ok) { + throw new Error(`button not found: ${text}`) + } +} + +async function clickTab(win, text) { + const ok = await exec(win, `(() => { + const target = Array.from(document.querySelectorAll('[role="tab"]')).find((el) => (el.textContent || '').includes(${JSON.stringify(text)})) + if (!target) return false + target.scrollIntoView({ block: 'center' }) + target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, button: 0, pointerType: 'mouse', isPrimary: true })) + target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })) + target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 })) + target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })) + return true + })()`) + if (!ok) { + throw new Error(`tab not found: ${text}`) + } +} + +async function clickGraphElement(win, selector, index = 0) { + const rect = await exec(win, `(() => { + const targets = Array.from(document.querySelectorAll(${JSON.stringify(selector)})) + const target = targets[${index}] + if (!target) return null + target.scrollIntoView({ block: 'center', inline: 'center' }) + const r = target.getBoundingClientRect() + return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } + })()`) + if (!rect) { + throw new Error(`graph element not found: ${selector}[${index}]`) + } + await sendClick(win, rect.x, rect.y) +} + +async function capturePluginFilterState(win) { + await win.loadURL(`${DASHBOARD_URL}/plugin-config`) + await waitFor( + win, + `document.body && document.body.innerText.includes('插件配置') && document.querySelector('input[placeholder="搜索插件..."]')`, + 'plugin config page', + 30000, + 400, + ) + await exec(win, `(() => { + const input = document.querySelector('input[placeholder="搜索插件..."]') + if (!input) return false + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(input, 'memorix') + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + return true + })()`) + await wait(500) + await capture(win, '01-plugin-config-filtered.png') +} + +app.whenReady().then(async () => { + const win = new BrowserWindow({ + width: 1600, + height: 1200, + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + }, + }) + + await win.loadURL(`${DASHBOARD_URL}/auth`) + await waitFor(win, `document.readyState === 'complete'`, 'auth page') + await capture(win, '00-auth-login.png') + await setSessionCookie(win) + + await capturePluginFilterState(win) + + await win.loadURL(`${DASHBOARD_URL}/resource/knowledge-base`) + await waitFor( + win, + `document.body && document.body.innerText.includes('运行时自检') && document.body.innerText.includes('刷新数据')`, + 'memory console ready', + 30000, + 500, + ) + await capture(win, '02-memory-console-before-import.png') + + const beforeGraph = await getJson(win, '/api/webui/memory/graph?limit=120') + const beforeTasks = await getJson(win, '/api/webui/memory/import/tasks?limit=20') + const knownTaskIds = new Set( + Array.isArray(beforeTasks.items) + ? beforeTasks.items.map((item) => String(item.task_id || item.taskId || '')) + : [], + ) + + await openImportTab(win) + await setJsonMode(win) + await typeIntoLabeled(win, '名称', 'input', sampleName) + await typeIntoLabeled(win, '粘贴内容', 'textarea', sampleJson) + await capture(win, '03-memory-import-json-filled.png') + + await clickButton(win, '创建导入任务') + + let taskId = null + let taskStatus = null + const start = Date.now() + while (Date.now() - start < 120000) { + const payload = await getJson(win, '/api/webui/memory/import/tasks?limit=20') + fs.writeFileSync(path.join(OUTPUT_DIR, 'tasks-last.json'), JSON.stringify(payload, null, 2)) + const items = Array.isArray(payload.items) ? payload.items : [] + const task = items.find((item) => !knownTaskIds.has(String(item.task_id || item.taskId || ''))) + if (task) { + taskId = task.task_id || task.taskId || null + taskStatus = task.status || null + if (['completed', 'failed', 'cancelled'].includes(String(taskStatus))) { + break + } + } + await wait(1500) + } + + if (!taskId) { + throw new Error('new json import task not observed') + } + + const detail = await getJson( + win, + `/api/webui/memory/import/tasks/${encodeURIComponent(taskId)}?include_chunks=true`, + ) + fs.writeFileSync(path.join(OUTPUT_DIR, 'task-detail.json'), JSON.stringify(detail, null, 2)) + fs.writeFileSync( + path.join(OUTPUT_DIR, 'task-status.txt'), + `taskId=${taskId}\nstatus=${taskStatus}\nsource=${sampleSource}\n`, + ) + + await clickButton(win, '刷新数据') + await wait(2000) + await capture(win, '04-memory-console-after-import.png') + + await win.loadURL(`${DASHBOARD_URL}/resource/knowledge-graph`) + await waitFor( + win, + `document.body && document.body.innerText.includes('长期记忆图谱') && document.body.innerText.includes('实体关系图') && document.body.innerText.includes('证据视图')`, + 'graph page ready', + 30000, + 400, + ) + await wait(3000) + const afterGraph = await getJson(win, '/api/webui/memory/graph?limit=120') + fs.writeFileSync(path.join(OUTPUT_DIR, 'graph-after.json'), JSON.stringify(afterGraph, null, 2)) + await capture(win, '05-memory-graph-after-import.png') + + if (Array.isArray(afterGraph.nodes) && afterGraph.nodes.length > 0) { + await clickGraphElement(win, '.react-flow__node', 0) + await waitFor(win, `document.body && document.body.innerText.includes('实体详情')`, 'node detail dialog', 10000, 250) + await capture(win, '06-memory-node-detail.png') + try { + await clickButton(win, '切到证据视图') + await waitFor( + win, + `document.body && document.body.innerText.includes('证据视图') && document.querySelectorAll('.react-flow__node').length > 0`, + 'evidence graph after node click', + 10000, + 250, + ) + await capture(win, '07-memory-evidence-view.png') + } catch (error) { + fs.writeFileSync(path.join(OUTPUT_DIR, '07-memory-evidence-view-error.txt'), String(error?.stack || error)) + } + } + + if (Array.isArray(afterGraph.edges) && afterGraph.edges.length > 0) { + try { + await clickTab(win, '实体关系图') + await wait(800) + await clickGraphElement(win, '.react-flow__edge', 0) + await waitFor(win, `document.body && document.body.innerText.includes('关系详情')`, 'edge detail dialog', 10000, 250) + await capture(win, '08-memory-edge-detail.png') + } catch (error) { + fs.writeFileSync(path.join(OUTPUT_DIR, '08-memory-edge-detail-error.txt'), String(error?.stack || error)) + } + } + + const summary = { + before: { + nodes: beforeGraph.total_nodes, + edges: beforeGraph.total_edges, + }, + after: { + nodes: afterGraph.total_nodes, + edges: afterGraph.total_edges, + }, + taskId, + taskStatus, + source: sampleSource, + inputMode: detail?.task?.files?.[0]?.input_mode || null, + strategyType: detail?.task?.files?.[0]?.detected_strategy_type || null, + fileStatus: detail?.task?.files?.[0]?.status || null, + outputDir: OUTPUT_DIR, + } + fs.writeFileSync(path.join(OUTPUT_DIR, 'validation-summary.json'), JSON.stringify(summary, null, 2)) + console.log(JSON.stringify(summary, null, 2)) + + await win.close() + app.quit() +}).catch((error) => { + console.error(error) + app.exit(1) +}) diff --git a/dashboard/src/assets/maimai.ico b/dashboard/src/assets/maimai.ico new file mode 100644 index 0000000000000000000000000000000000000000..578b11cdd0c88dc8f590668a718303d939754f67 GIT binary patch literal 67715 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

9q=wX1$>M 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⁢M5M6l!#t$4Iq9u3gs}Q_@_7Zo*5SE}Y^vaGH+ky#ka*McEbX+p0En4|w zJv}*^)jKx$3xBcXbAs;$lLegB94I^%4(4Ouyaezy35Mg#AvWc?FabI#9RRVY@CO%g zs}v_U?N`&dk`R-gHo65uH*zg+J*qrTrN`5mr53jZbD}tat?24fkzu^;2mptc&n~-_ zu#^AlBbnTt0Lm2rKaN#nbCt5NL(?6ybTSRCZX?ApIte1csD2uNdkRBi8{Syz4cj(0fpLIRiJR}Qvl-hQIH#R|C<~$G)%PvALv*kF zf7x~UCq6U%lf8qnPb3y{a;|xBn)QL14cY^GR&wqDhP%*REGgA+Q>6EJzYHd%hg@d%epovrA(zKoXD#1j!?nP<6J4aKuF1)oJ{(KtipOC= z&Nh_*+uMPoRF9YJ_V3qq7bqAFJKb?Y6R7yGkc0k}uM7Zrkh1`Bf585`+oiFTgos}k zltM1oOD-tSbKOpb^rY`0?t=cECd}qSuykTn}IkC^lS}6s8BGT&9JLS3nQ@6c8 z>tI0mv)k^(>T{VY6FrveuS_ z;`HF3-`|6OXhv8Z4oh2DNG?9nmyg=O)3DCa0k4zIo{m9)d0FzMJL{r$p}pX)J)q(m z97J{IHnj9o(xU)?S3O*k1%yCY0roc?fLFskXiICXpl$Z1hS(su0v+$Nnk2sj8aqc_ zOxAVe$e`MBtD{t<^mHA*VZ4&WU>+f(!f{Ya#x%I)b|< zveT>*`6GmugX9Riw(iTBzi_u%cE%tdHf_k<1_xh;UMQoZYk>67R41XXc&_1$1041G z=4eo;$b|rTe+iZ7G+UGv-^U`Z=2{anE?1Y0;t&+qi3nw~HT|T?DMlxC?xqF2OG>4- zS737imSE+7rYgG+1XNMYuFpB z_yB9c@Cu0uWXZ*VOj4OEM-Hjx2;J-(n^p(4P0O6~{@!M}gq`@_2d6XWngeOxVLfrw zj)!8MQrH;2{mqw&lbe?nc(*PNya4MrBwN3{+3sYW#>H0Qxu91>kjsK!RO2ILSS{8QS@p5�Mr;^wE$g-l5zBebV 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

+ ) +} + +export function CodeEditor(props: CodeEditorProps) { + const { height = '400px', minHeight, maxHeight, className = '' } = props + + return ( + + } + > + + + ) +} + +export default CodeEditor diff --git a/dashboard/src/components/CodeEditorImpl.tsx b/dashboard/src/components/CodeEditorImpl.tsx new file mode 100644 index 00000000..5477a449 --- /dev/null +++ b/dashboard/src/components/CodeEditorImpl.tsx @@ -0,0 +1,105 @@ +import { css } from '@codemirror/lang-css' +import { json, jsonParseLinter } from '@codemirror/lang-json' +import { python } from '@codemirror/lang-python' +import { StreamLanguage } from '@codemirror/language' +import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml' +import { linter } from '@codemirror/lint' +import { oneDark } from '@codemirror/theme-one-dark' +import { EditorView } from '@codemirror/view' +import CodeMirror from '@uiw/react-codemirror' + +import { useTheme } from '@/components/use-theme' + +import type { CodeEditorProps, Language } from './CodeEditor' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const languageExtensions: Record = { + python: [python()], + json: [json(), linter(jsonParseLinter())], + toml: [StreamLanguage.define(tomlMode)], + css: [css()], + text: [], +} + +export default function CodeEditorImpl({ + value, + onChange, + language = 'text', + readOnly = false, + height = '400px', + minHeight, + maxHeight, + placeholder, + theme, + className = '', +}: CodeEditorProps) { + const { resolvedTheme } = useTheme() + + const extensions = [ + ...(languageExtensions[language] || []), + EditorView.lineWrapping, + // 应用 JetBrains Mono 字体 + EditorView.theme({ + '&': { + fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace', + }, + '.cm-content': { + fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace', + }, + '.cm-gutters': { + fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace', + }, + '.cm-scroller': { + fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace', + }, + }), + ] + + if (readOnly) { + extensions.push(EditorView.editable.of(false)) + } + + // 如果外部传了 theme prop 则使用,否则从 context 自动获取 + const effectiveTheme = theme ?? resolvedTheme + + return ( +
+ +
+ ) +} diff --git a/dashboard/src/components/ListFieldEditor.tsx b/dashboard/src/components/ListFieldEditor.tsx new file mode 100644 index 00000000..ac3373f9 --- /dev/null +++ b/dashboard/src/components/ListFieldEditor.tsx @@ -0,0 +1,525 @@ +/** + * ListFieldEditor - 动态数组字段编辑器 + * + * 支持功能: + * - 字符串数组 (string[]) + * - 数字数组 (number[]) + * - 对象数组 (object[]) - 根据 item_fields 定义渲染 + * - 拖拽排序 + * - 动态增删项 + */ + +import { useState, useCallback, useMemo } from 'react' +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Card } from '@/components/ui/card' +import { Switch } from '@/components/ui/switch' +import { Slider } from '@/components/ui/slider' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { GripVertical, Plus, Trash2, AlertCircle } from 'lucide-react' +import { cn } from '@/lib/utils' + +// ============ 类型定义 ============ + +export interface ItemFieldDefinition { + /** 字段类型: "string" | "number" | "boolean" | "select" */ + type: string + label?: string + placeholder?: string + default?: unknown + /** select 类型的选项 */ + choices?: unknown[] + /** slider 类型的最小值 */ + min?: number + /** slider 类型的最大值 */ + max?: number + /** slider 类型的步进 */ + step?: number +} + +export interface ListFieldEditorProps { + /** 当前值 */ + value: unknown[] | unknown + /** 值变化回调 */ + onChange: (value: unknown[]) => void + /** 数组元素类型: "string" | "number" | "object" */ + itemType?: string + /** 当 itemType="object" 时的字段定义 */ + itemFields?: Record + /** 最小元素数量 */ + minItems?: number + /** 最大元素数量 */ + maxItems?: number + /** 是否禁用 */ + disabled?: boolean + /** 新项的占位符文字 */ + placeholder?: string +} + +// ============ 可排序项组件 ============ + +interface SortableItemProps { + id: string + index: number + itemType: string + itemFields?: Record + value: unknown + onChange: (value: unknown) => void + onRemove: () => void + disabled?: boolean + canRemove: boolean + placeholder?: string +} + +function SortableItem({ + id, + index, + itemType, + itemFields, + value, + onChange, + onRemove, + disabled, + canRemove, + placeholder, +}: SortableItemProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id, disabled }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + return ( +
+ {/* 拖拽手柄 */} + + + {/* 内容区域 */} +
+ {itemType === 'object' && itemFields ? ( + } + onChange={onChange} + fields={itemFields} + disabled={disabled} + /> + ) : itemType === 'number' ? ( + onChange(parseFloat(e.target.value) || 0)} + placeholder={placeholder ?? `第 ${index + 1} 项`} + disabled={disabled} + className="font-mono" + /> + ) : ( + onChange(e.target.value)} + placeholder={placeholder ?? `第 ${index + 1} 项`} + disabled={disabled} + /> + )} +
+ + {/* 删除按钮 */} + +
+ ) +} + +// ============ 对象项编辑器 ============ + +interface ObjectItemEditorProps { + value: Record + onChange: (value: Record) => void + fields: Record + disabled?: boolean +} + +function ObjectItemEditor({ + value, + onChange, + fields, + disabled, +}: ObjectItemEditorProps) { + const handleFieldChange = useCallback( + (fieldName: string, fieldValue: unknown) => { + onChange({ + ...value, + [fieldName]: fieldValue, + }) + }, + [value, onChange] + ) + + const renderField = (fieldName: string, fieldDef: ItemFieldDefinition) => { + const fieldValue = value?.[fieldName] + + // boolean / switch + if (fieldDef.type === 'boolean' || fieldDef.type === 'switch') { + return ( +
+ + handleFieldChange(fieldName, checked)} + disabled={disabled} + /> +
+ ) + } + + // slider (number with min/max) + if (fieldDef.type === 'slider' || (fieldDef.type === 'number' && fieldDef.min != null && fieldDef.max != null)) { + const numValue = (fieldValue as number) ?? (fieldDef.default as number) ?? fieldDef.min ?? 0 + return ( +
+
+ + {numValue} +
+ handleFieldChange(fieldName, v[0])} + min={fieldDef.min ?? 0} + max={fieldDef.max ?? 100} + step={fieldDef.step ?? 1} + disabled={disabled} + className="py-1" + /> +
+ ) + } + + // select + if (fieldDef.type === 'select' && fieldDef.choices) { + return ( +
+ + +
+ ) + } + + // number + if (fieldDef.type === 'number') { + return ( +
+ + + handleFieldChange(fieldName, parseFloat(e.target.value) || 0) + } + placeholder={fieldDef.placeholder} + disabled={disabled} + className="h-8 text-sm" + /> +
+ ) + } + + // string (default) + return ( +
+ + handleFieldChange(fieldName, e.target.value)} + placeholder={fieldDef.placeholder} + disabled={disabled} + className="h-8 text-sm" + /> +
+ ) + } + + return ( + + {Object.entries(fields).map(([fieldName, fieldDef]) => ( +
+ {renderField(fieldName, fieldDef)} +
+ ))} +
+ ) +} + +// ============ 主组件 ============ + +export function ListFieldEditor({ + value, + onChange, + itemType = 'string', + itemFields, + minItems, + maxItems, + disabled, + placeholder, +}: ListFieldEditorProps) { + // 确保 value 是数组 + const items: unknown[] = useMemo(() => { + if (Array.isArray(value)) return value + if (typeof value === 'string' && value.trim()) { + // 尝试解析逗号分隔的字符串 + return value.split(',').map((s: string) => s.trim()) + } + return [] + }, [value]) + + // 为每个项生成稳定的 ID + const [itemIds] = useState(() => new Map()) + const getItemId = useCallback( + (index: number) => { + if (!itemIds.has(index)) { + itemIds.set(index, `item-${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`) + } + return itemIds.get(index)! + }, + [itemIds] + ) + + // 同步 itemIds + const sortableIds = useMemo(() => { + // 清理多余的 ID + const newIds: string[] = [] + for (let i = 0; i < items.length; i++) { + newIds.push(getItemId(i)) + } + return newIds + }, [items.length, getItemId]) + + // DnD 传感器配置 + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + // 拖拽结束处理 + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event + if (over && active.id !== over.id) { + const oldIndex = sortableIds.indexOf(active.id as string) + const newIndex = sortableIds.indexOf(over.id as string) + const newItems = arrayMove(items, oldIndex, newIndex) + onChange(newItems) + } + }, + [items, sortableIds, onChange] + ) + + // 添加新项 + const handleAddItem = useCallback(() => { + if (maxItems != null && items.length >= maxItems) return + + let newItem: unknown + if (itemType === 'object' && itemFields) { + // 创建包含默认值的对象 + newItem = Object.fromEntries( + Object.entries(itemFields).map(([k, v]) => [k, v.default ?? '']) + ) + } else if (itemType === 'number') { + newItem = 0 + } else { + newItem = '' + } + + onChange([...items, newItem]) + }, [items, maxItems, itemType, itemFields, onChange]) + + // 修改项 + const handleItemChange = useCallback( + (index: number, newValue: unknown) => { + const newItems = [...items] + newItems[index] = newValue + onChange(newItems) + }, + [items, onChange] + ) + + // 删除项 + const handleRemoveItem = useCallback( + (index: number) => { + if (minItems != null && items.length <= minItems) return + const newItems = items.filter((_: unknown, i: number) => i !== index) + // 清理 itemIds 映射 + itemIds.delete(index) + onChange(newItems) + }, + [items, minItems, itemIds, onChange] + ) + + const canAdd = maxItems == null || items.length < maxItems + const canRemove = minItems == null || items.length > minItems + + return ( +
+ {/* 列表项 */} + {items.length === 0 ? ( +
+ + 暂无数据,点击下方按钮添加 +
+ ) : ( + + +
+ {items.map((item: unknown, index: number) => ( + handleItemChange(index, newValue)} + onRemove={() => handleRemoveItem(index)} + disabled={disabled} + canRemove={canRemove} + placeholder={placeholder} + /> + ))} +
+
+
+ )} + + {/* 添加按钮 */} + + + {/* 限制提示 */} + {(minItems != null || maxItems != null) && (minItems !== null || maxItems !== null) && ( +

+ {minItems != null && maxItems != null + ? `允许 ${minItems} - ${maxItems} 项` + : minItems != null + ? `至少 ${minItems} 项` + : `最多 ${maxItems} 项`} +

+ )} +
+ ) +} + +export default ListFieldEditor diff --git a/dashboard/src/components/RestartingOverlay.legacy.tsx b/dashboard/src/components/RestartingOverlay.legacy.tsx new file mode 100644 index 00000000..aa368f1b --- /dev/null +++ b/dashboard/src/components/RestartingOverlay.legacy.tsx @@ -0,0 +1,189 @@ +import { useEffect, useState } from 'react' +import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react' +import { Progress } from '@/components/ui/progress' + +/** + * @deprecated 请使用新的 RestartOverlay 组件 + * import { RestartOverlay } from '@/components/restart-overlay' + */ +interface RestartingOverlayProps { + onRestartComplete?: () => void + onRestartFailed?: () => void +} + +/** + * @deprecated 请使用新的 RestartOverlay 组件 + * import { RestartOverlay } from '@/components/restart-overlay' + */ +export function RestartingOverlay({ onRestartComplete, onRestartFailed }: RestartingOverlayProps) { + const [progress, setProgress] = useState(0) + const [status, setStatus] = useState<'restarting' | 'checking' | 'success' | 'failed'>('restarting') + const [elapsedTime, setElapsedTime] = useState(0) + const [checkAttempts, setCheckAttempts] = useState(0) + + useEffect(() => { + // 进度条动画 + const progressInterval = setInterval(() => { + setProgress((prev) => { + if (prev >= 90) return prev + return prev + 1 + }) + }, 200) + + // 计时器 + const timerInterval = setInterval(() => { + setElapsedTime((prev) => prev + 1) + }, 1000) + + // 等待3秒后开始检查状态(给后端重启时间) + const initialDelay = setTimeout(() => { + setStatus('checking') + startHealthCheck() + }, 3000) + + return () => { + clearInterval(progressInterval) + clearInterval(timerInterval) + clearTimeout(initialDelay) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const startHealthCheck = () => { + const maxAttempts = 60 // 最多尝试60次(约2分钟) + + const checkHealth = async () => { + try { + setCheckAttempts((prev) => prev + 1) + + const response = await fetch('/api/webui/system/status', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(3000), // 3秒超时 + }) + + if (response.ok) { + // 重启成功 + setProgress(100) + setStatus('success') + setTimeout(() => { + onRestartComplete?.() + }, 1500) + } else { + throw new Error('Status check failed') + } + } catch { + // 继续尝试 + if (checkAttempts < maxAttempts) { + setTimeout(checkHealth, 2000) // 2秒后重试 + } else { + // 超过最大尝试次数 + setStatus('failed') + onRestartFailed?.() + } + } + } + + checkHealth() + } + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + return ( +
+
+ {/* 图标和状态 */} +
+ {status === 'restarting' && ( + <> + +

正在重启麦麦

+

+ 请稍候,麦麦正在重启中... +

+ + )} + + {status === 'checking' && ( + <> + +

检查服务状态

+

+ 等待服务恢复... (尝试 {checkAttempts}/60) +

+ + )} + + {status === 'success' && ( + <> + +

重启成功

+

+ 正在跳转到登录页面... +

+ + )} + + {status === 'failed' && ( + <> + +

重启超时

+

+ 服务未能在预期时间内恢复,请手动检查或刷新页面 +

+ + )} +
+ + {/* 进度条 */} + {status !== 'failed' && ( +
+ +
+ {progress}% + 已用时: {formatTime(elapsedTime)} +
+
+ )} + + {/* 提示信息 */} +
+

+ {status === 'restarting' && '🔄 配置已保存,正在重启主程序...'} + {status === 'checking' && '⏳ 正在等待服务恢复,请勿关闭页面...'} + {status === 'success' && '✅ 配置已生效,服务运行正常'} + {status === 'failed' && '⚠️ 如果长时间无响应,请尝试手动重启'} +

+
+ + {/* 失败时的操作按钮 */} + {status === 'failed' && ( +
+ + +
+ )} +
+
+ ) +} diff --git a/dashboard/src/components/animation-provider.tsx b/dashboard/src/components/animation-provider.tsx new file mode 100644 index 00000000..b9684a11 --- /dev/null +++ b/dashboard/src/components/animation-provider.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { AnimationContext } from '@/lib/animation-context' + +type AnimationProviderProps = { + children: ReactNode + defaultEnabled?: boolean + defaultWavesEnabled?: boolean + storageKey?: string + wavesStorageKey?: string +} + +export function AnimationProvider({ + children, + defaultEnabled = true, + defaultWavesEnabled = true, + storageKey = 'enable-animations', + wavesStorageKey = 'enable-waves-background', +}: AnimationProviderProps) { + const [enableAnimations, setEnableAnimations] = useState(() => { + const stored = localStorage.getItem(storageKey) + return stored !== null ? stored === 'true' : defaultEnabled + }) + + const [enableWavesBackground, setEnableWavesBackground] = useState(() => { + const stored = localStorage.getItem(wavesStorageKey) + return stored !== null ? stored === 'true' : defaultWavesEnabled + }) + + useEffect(() => { + const root = document.documentElement + + if (enableAnimations) { + root.classList.remove('no-animations') + } else { + root.classList.add('no-animations') + } + + localStorage.setItem(storageKey, String(enableAnimations)) + }, [enableAnimations, storageKey]) + + useEffect(() => { + localStorage.setItem(wavesStorageKey, String(enableWavesBackground)) + }, [enableWavesBackground, wavesStorageKey]) + + const value = { + enableAnimations, + setEnableAnimations, + enableWavesBackground, + setEnableWavesBackground, + } + + return {children} +} diff --git a/dashboard/src/components/asset-provider.tsx b/dashboard/src/components/asset-provider.tsx new file mode 100644 index 00000000..028e27e1 --- /dev/null +++ b/dashboard/src/components/asset-provider.tsx @@ -0,0 +1,64 @@ +import { createContext, useContext, useEffect, useMemo, useRef } from 'react' +import type { ReactNode } from 'react' + +import { getAsset } from '@/lib/asset-store' + +type AssetStoreContextType = { + getAssetUrl: (assetId: string) => Promise +} + +const AssetStoreContext = createContext(null) + +type AssetStoreProviderProps = { + children: ReactNode +} + +export function AssetStoreProvider({ children }: AssetStoreProviderProps) { + const urlCache = useRef>(new Map()) + + const getAssetUrl = async (assetId: string): Promise => { + // Check cache first + const cached = urlCache.current.get(assetId) + if (cached) { + return cached + } + + // Fetch from IndexedDB + const record = await getAsset(assetId) + if (!record) { + return undefined + } + + // Create blob URL and cache it + const url = URL.createObjectURL(record.blob) + urlCache.current.set(assetId, url) + return url + } + + const value = useMemo( + () => ({ + getAssetUrl, + }), + [], + ) + + // Cleanup: revoke all blob URLs on unmount + useEffect(() => { + return () => { + urlCache.current.forEach((url) => { + URL.revokeObjectURL(url) + }) + urlCache.current.clear() + } + }, []) + + return {children} +} + +export function useAssetStore() { + const context = useContext(AssetStoreContext) + if (!context) { + throw new Error('useAssetStore must be used within AssetStoreProvider') + } + return context +} diff --git a/dashboard/src/components/back-to-top.tsx b/dashboard/src/components/back-to-top.tsx new file mode 100644 index 00000000..3473566e --- /dev/null +++ b/dashboard/src/components/back-to-top.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState, useRef } from 'react' +import { ArrowUp } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' + +export function BackToTop() { + const [progress, setProgress] = useState(0) + const [visible, setVisible] = useState(false) + const scrollerRef = useRef(null) + + useEffect(() => { + const handleScroll = (e: Event) => { + const target = e.target as HTMLElement + + // 简单的启发式:如果是主要滚动容器(通常高度较大) + // 我们假设页面中主要的滚动区域是高度最大的那个,或者就是当前触发滚动的这个 + // 只要它有足够的滚动空间 + if (target.scrollHeight > target.clientHeight + 100) { + scrollerRef.current = target + + const scrollTop = target.scrollTop + const height = target.scrollHeight - target.clientHeight + const scrolled = height > 0 ? (scrollTop / height) * 100 : 0 + + setProgress(scrolled) + setVisible(scrollTop > 300) + } + } + + // 使用捕获阶段监听所有滚动事件,因为 scroll 事件不冒泡 + window.addEventListener('scroll', handleScroll, { capture: true, passive: true }) + return () => window.removeEventListener('scroll', handleScroll, { capture: true }) + }, []) + + const scrollToTop = () => { + scrollerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }) + } + + // SVG 环形进度条参数 + const radius = 18 + const circumference = 2 * Math.PI * radius + const strokeDashoffset = circumference - (progress / 100) * circumference + + return ( +
+ +
+ ) +} diff --git a/dashboard/src/components/background-effects-controls.tsx b/dashboard/src/components/background-effects-controls.tsx new file mode 100644 index 00000000..e466dd30 --- /dev/null +++ b/dashboard/src/components/background-effects-controls.tsx @@ -0,0 +1,267 @@ +import { RotateCcw } from 'lucide-react' +import * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Slider } from '@/components/ui/slider' +import { hexToHSL } from '@/lib/theme/palette' +import { + type BackgroundEffects, + defaultBackgroundEffects, +} from '@/lib/theme/tokens' + +function hslToHex(hsl: string): string { + if (!hsl) return '#000000' + + const parts = hsl.split(' ').filter(Boolean) + if (parts.length < 3) return '#000000' + + const h = parseFloat(parts[0]) + const s = parseFloat(parts[1].replace('%', '')) + const l = parseFloat(parts[2].replace('%', '')) + + const sDecimal = s / 100 + const lDecimal = l / 100 + const c = (1 - Math.abs(2 * lDecimal - 1)) * sDecimal + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = lDecimal - c / 2 + + let r = 0 + let g = 0 + let b = 0 + + if (h >= 0 && h < 60) { + r = c + g = x + } else if (h >= 60 && h < 120) { + r = x + g = c + } else if (h >= 120 && h < 180) { + g = c + b = x + } else if (h >= 180 && h < 240) { + g = x + b = c + } else if (h >= 240 && h < 300) { + r = x + b = c + } else if (h >= 300 && h < 360) { + r = c + b = x + } + + const toHex = (value: number) => { + const hex = Math.round((value + m) * 255).toString(16) + return hex.length === 1 ? `0${hex}` : hex + } + + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +type BackgroundEffectsControlsProps = { + effects: BackgroundEffects + onChange: (effects: BackgroundEffects) => void + disabled?: boolean +} + +export function BackgroundEffectsControls({ + effects, + onChange, + disabled = false, +}: BackgroundEffectsControlsProps) { + const handleValueChange = (key: keyof BackgroundEffects, value: number) => { + if (disabled) return + + onChange({ + ...effects, + [key]: value, + }) + } + + const handleColorChange = (e: React.ChangeEvent) => { + if (disabled) return + + const hex = e.target.value + const hsl = hexToHSL(hex) + onChange({ + ...effects, + overlayColor: hsl, + }) + } + + const handlePositionChange = (value: string) => { + if (disabled) return + + onChange({ + ...effects, + position: value as BackgroundEffects['position'], + }) + } + + const handleGradientChange = (e: React.ChangeEvent) => { + if (disabled) return + + onChange({ + ...effects, + gradientOverlay: e.target.value, + }) + } + + const handleReset = () => { + if (disabled) return + onChange(defaultBackgroundEffects) + } + + return ( +
+
+

背景效果调节

+ +
+ +
+
+
+ + {effects.blur}px +
+ handleValueChange('blur', vals[0])} + /> +
+ +
+ +
+
+ +
+ +
+
+ +
+
+ + + {Math.round(effects.overlayOpacity * 100)}% + +
+ handleValueChange('overlayOpacity', vals[0] / 100)} + /> +
+ +
+ + +
+ +
+
+ + {effects.brightness}% +
+ handleValueChange('brightness', vals[0])} + /> +
+ +
+
+ + {effects.contrast}% +
+ handleValueChange('contrast', vals[0])} + /> +
+ +
+
+ + {effects.saturate}% +
+ handleValueChange('saturate', vals[0])} + /> +
+ +
+ + +

可选:输入有效的 CSS gradient 字符串

+
+
+
+ ) +} diff --git a/dashboard/src/components/background-layer.tsx b/dashboard/src/components/background-layer.tsx new file mode 100644 index 00000000..15dc7551 --- /dev/null +++ b/dashboard/src/components/background-layer.tsx @@ -0,0 +1,196 @@ +import { useEffect, useRef, useState } from 'react' + +import { useAssetStore } from '@/components/asset-provider' +import type { BackgroundConfig } from '@/lib/theme/tokens' + +type BackgroundLayerProps = { + config: BackgroundConfig + layerId: string +} + +function getAutoOverlayOpacity(layerId: string): number { + switch (layerId) { + case 'page': + return 0.62 + case 'header': + return 0.72 + case 'sidebar': + return 0.78 + case 'card': + return 0.82 + case 'dialog': + return 0.88 + default: + return 0.68 + } +} + +function getAutoGradientOverlay(layerId: string): string | undefined { + if (layerId !== 'page') { + return undefined + } + + return 'linear-gradient(to bottom, hsl(var(--background) / 0.82), hsl(var(--background) / 0.52) 28%, hsl(var(--background) / 0.7) 100%)' +} + +function buildFilterString(effects: BackgroundConfig['effects']): string { + const parts: string[] = [] + if (effects.blur > 0) parts.push(`blur(${effects.blur}px)`) + if (effects.brightness !== 100) parts.push(`brightness(${effects.brightness}%)`) + if (effects.contrast !== 100) parts.push(`contrast(${effects.contrast}%)`) + if (effects.saturate !== 100) parts.push(`saturate(${effects.saturate}%)`) + return parts.join(' ') +} + +function getBackgroundSize(position: BackgroundConfig['effects']['position']): string { + switch (position) { + case 'cover': + return 'cover' + case 'contain': + return 'contain' + case 'center': + return 'auto' + case 'stretch': + return '100% 100%' + default: + return 'cover' + } +} + +function getObjectFit(position: BackgroundConfig['effects']['position']): React.CSSProperties['objectFit'] { + switch (position) { + case 'cover': + return 'cover' + case 'contain': + return 'contain' + case 'center': + return 'none' + case 'stretch': + return 'fill' + default: + return 'cover' + } +} + +export function BackgroundLayer({ config, layerId }: BackgroundLayerProps) { + const { getAssetUrl } = useAssetStore() + const [blobUrl, setBlobUrl] = useState() + const videoRef = useRef(null) + + useEffect(() => { + if (!config.assetId) { + setBlobUrl(undefined) + return + } + getAssetUrl(config.assetId).then(setBlobUrl) + }, [config.assetId, getAssetUrl]) + + useEffect(() => { + if (config.type !== 'video' || !videoRef.current) return + + const mq = window.matchMedia('(prefers-reduced-motion: reduce)') + const apply = () => { + if (videoRef.current) { + if (mq.matches) { + videoRef.current.pause() + } else { + videoRef.current.play().catch(() => {}) + } + } + } + apply() + mq.addEventListener('change', apply) + return () => mq.removeEventListener('change', apply) + }, [config.type]) + + if (config.type === 'none') { + return null + } + + const filterString = buildFilterString(config.effects) + const { overlayColor, overlayOpacity, gradientOverlay } = config.effects + const hasExplicitOverlay = overlayOpacity > 0 + const effectiveOverlayOpacity = hasExplicitOverlay ? overlayOpacity : getAutoOverlayOpacity(layerId) + const effectiveOverlayColor = hasExplicitOverlay + ? `hsl(${overlayColor} / ${effectiveOverlayOpacity})` + : `hsl(var(--background) / ${effectiveOverlayOpacity})` + const effectiveGradientOverlay = gradientOverlay || getAutoGradientOverlay(layerId) + + return ( +
+ {config.type === 'image' && ( +
+ )} + + {config.type === 'video' && blobUrl && ( + " + ) + + @staticmethod + def _build_preview_access_body( + *, + viewer_label: str, + viewer_path: Path, + viewer_link_text: str, + dump_label: str, + dump_path: Path, + dump_link_text: str, + ) -> RenderableType: + viewer_uri = build_file_uri(viewer_path) + dump_uri = build_file_uri(dump_path) + viewer_display_path = build_display_path(viewer_path) + dump_display_path = build_display_path(dump_path) + + return Group( + Text.from_markup( + f"[bold green]{viewer_label}:{viewer_display_path}[/bold green] " + f"[link={viewer_uri}]{viewer_link_text}[/link]" + ), + Text.from_markup( + f"[magenta]{dump_label}:{dump_display_path}[/magenta] " + f"[cyan][link={dump_uri}]{dump_link_text}[/link][/cyan]" + ), + ) + + @classmethod + def build_prompt_preview_access( + cls, + messages: list[Any], + *, + category: str, + chat_id: str, + request_kind: str, + selection_reason: str, + tool_definitions: list[dict[str, Any]] | None = None, + ) -> PromptPreviewAccess: + """保存 Prompt 预览文件,并返回 CLI 展示入口与浏览器可打开的 URI。""" + + viewer_messages: list[dict[str, Any]] = [] + for message in messages: + if isinstance(message, dict): + viewer_messages.append(dict(message)) + continue + + normalized_message = { + "content": getattr(message, "content", None), + "role": getattr(getattr(message, "role", "unknown"), "value", getattr(message, "role", "unknown")), + } + tool_call_id = getattr(message, "tool_call_id", None) + if tool_call_id: + normalized_message["tool_call_id"] = tool_call_id + + tool_calls = getattr(message, "tool_calls", None) + if tool_calls: + normalized_message["tool_calls"] = [ + cls.format_tool_call_for_display(tool_call) for tool_call in tool_calls + ] + viewer_messages.append(normalized_message) + + prompt_dump_text = cls._build_prompt_dump_text(messages) + tool_definition_dump_text = cls._build_tool_definition_dump_text(tool_definitions) + if tool_definition_dump_text: + prompt_dump_text = f"{prompt_dump_text}\n\n{'=' * 80}\n\n{tool_definition_dump_text}" + viewer_html_text = cls._build_prompt_viewer_html( + viewer_messages, + request_kind=request_kind, + selection_reason=selection_reason, + tool_definitions=tool_definitions, + ) + saved_paths = PromptPreviewLogger.save_preview_files( + chat_id, + category, + { + ".html": viewer_html_text, + ".txt": prompt_dump_text, + }, + ) + viewer_html_path = saved_paths[".html"] + prompt_dump_path = saved_paths[".txt"] + body = cls._build_preview_access_body( + viewer_label="html预览", + viewer_path=viewer_html_path, + viewer_link_text="在浏览器打开 Prompt", + dump_label="原始文本", + dump_path=prompt_dump_path, + dump_link_text="点击打开 Prompt 文本", + ) + return PromptPreviewAccess( + body=body, + viewer_path=viewer_html_path, + viewer_uri=build_file_uri(viewer_html_path), + viewer_web_uri=_build_prompt_preview_web_uri(viewer_html_path), + dump_path=prompt_dump_path, + dump_uri=build_file_uri(prompt_dump_path), + ) + + @classmethod + def _build_html_role_class(cls, role: str) -> str: + return { + "system": "system", + "user": "user", + "assistant": "assistant", + "tool": "tool", + }.get(role, "unknown") + + @classmethod + def _build_prompt_viewer_html( + cls, + messages: list[dict[str, Any]], + *, + request_kind: str, + selection_reason: str, + tool_definitions: list[dict[str, Any]] | None = None, + ) -> str: + panel_title, _ = cls.get_request_panel_style(request_kind) + message_cards: List[str] = [] + for index, message in enumerate(messages, start=1): + raw_role = message.get("role", "unknown") + role = raw_role.value if hasattr(raw_role, "value") else str(raw_role) + role_label = cls._get_role_badge_label(role) + role_class = cls._build_html_role_class(role) + content_html = cls._render_message_content_html(message.get("content")) + tool_call_id = message.get("tool_call_id") + tool_call_html = "" + if tool_call_id: + tool_call_html = ( + "
" + "工具调用 ID" + f"{html.escape(str(tool_call_id))}" + "
" + ) + + tool_panels = "" + raw_tool_calls = message.get("tool_calls") or [] + if isinstance(raw_tool_calls, list) and raw_tool_calls: + tool_panels = ( + "
" + "
工具调用
" + f"{''.join(cls._build_tool_call_html(tool_call) for tool_call in raw_tool_calls)}" + "
" + ) + + message_cards.append( + "
" + "
" + f"{html.escape(role_label)}" + f"#{index}" + "
" + f"
{content_html}
" + f"{tool_call_html}" + f"{tool_panels}" + "
" + ) + + subtitle_html = "" + if selection_reason.strip(): + subtitle_html = f"
{html.escape(selection_reason)}
" + + tool_definition_section_html = "" + if tool_definitions: + tool_definition_section_html = ( + "
" + "
" + "全部工具" + f"{len(tool_definitions)} 个" + "
" + "
" + "
本次送入模型的工具定义
" + f"{''.join(cls._build_tool_definition_html(tool_definition) for tool_definition in tool_definitions)}" + "
" + "
" + ) + + return f""" + + + + + {html.escape(panel_title)} + + + +
+
+
{html.escape(panel_title)}
+ {subtitle_html} +
+ {''.join(message_cards)} + {tool_definition_section_html} +
+ +""" + + @classmethod + def build_prompt_access_panel( + cls, + messages: list[Any], + *, + category: str, + chat_id: str, + request_kind: str, + selection_reason: str, + image_display_mode: Literal["legacy", "path_link"] = "path_link", + tool_definitions: list[dict[str, Any]] | None = None, + ) -> RenderableType: + """构建用于查看完整 prompt 的折叠入口内容。""" + + return cls.build_prompt_preview_access( + messages, + category=category, + chat_id=chat_id, + request_kind=request_kind, + selection_reason=selection_reason, + tool_definitions=tool_definitions, + ).body + + @classmethod + def build_prompt_section( + cls, + messages: list[Any], + *, + category: str, + chat_id: str, + request_kind: str, + selection_reason: str, + image_display_mode: Literal["legacy", "path_link"] = "path_link", + folded: bool, + tool_definitions: list[dict[str, Any]] | None = None, + ) -> Panel: + """构建用于嵌入结果面板中的 Prompt 区块。""" + + return cls.build_prompt_section_result( + messages, + category=category, + chat_id=chat_id, + request_kind=request_kind, + selection_reason=selection_reason, + image_display_mode=image_display_mode, + folded=folded, + tool_definitions=tool_definitions, + ).panel + + @classmethod + def build_prompt_section_result( + cls, + messages: list[Any], + *, + category: str, + chat_id: str, + request_kind: str, + selection_reason: str, + image_display_mode: Literal["legacy", "path_link"] = "path_link", + folded: bool, + tool_definitions: list[dict[str, Any]] | None = None, + ) -> PromptSectionResult: + """构建 Prompt 面板,并在折叠模式下返回对应的 HTML 预览入口。""" + + panel_title, panel_border_style = cls.get_request_panel_style(request_kind) + preview_access = cls.build_prompt_preview_access( + messages, + category=category, + chat_id=chat_id, + request_kind=request_kind, + selection_reason=selection_reason, + tool_definitions=tool_definitions, + ) + if folded: + prompt_renderable = preview_access.body + else: + ordered_panels = cls.build_prompt_panels(messages) + prompt_renderable = Group(*ordered_panels) + + return PromptSectionResult( + panel=Panel( + prompt_renderable, + title=panel_title, + subtitle=selection_reason, + border_style=panel_border_style, + padding=(0, 1), + ), + preview_access=preview_access, + ) + + @classmethod + def _build_text_preview_html( + cls, + content: str, + *, + request_kind: str, + subtitle: str, + ) -> str: + panel_title, _ = cls.get_request_panel_style(request_kind) + subtitle_html = f"
{html.escape(subtitle)}
" if subtitle.strip() else "" + return f""" + + + + + {html.escape(panel_title)} + + + +
+
+
{html.escape(panel_title)}
+ {subtitle_html} +
+
+
{html.escape(content)}
+
+
+ +""" + + @classmethod + def build_text_access_panel( + cls, + content: str, + *, + category: str, + chat_id: str, + request_kind: str, + subtitle: str, + ) -> RenderableType: + """构建文本型 Prompt 的折叠入口内容。""" + + html_content = cls._build_text_preview_html(content, request_kind=request_kind, subtitle=subtitle) + saved_paths = PromptPreviewLogger.save_preview_files( + chat_id, + category, + { + ".html": html_content, + ".txt": content, + }, + ) + viewer_html_path = saved_paths[".html"] + text_dump_path = saved_paths[".txt"] + body = cls._build_preview_access_body( + viewer_label="富文本预览", + viewer_path=viewer_html_path, + viewer_link_text="点击在浏览器打开富文本 Prompt 视图", + dump_label="原始文本备份", + dump_path=text_dump_path, + dump_link_text="点击直接打开 Prompt 文本", + ) + return body + + @classmethod + def build_text_section( + cls, + content: str, + *, + category: str, + chat_id: str, + request_kind: str, + subtitle: str, + folded: bool, + ) -> Panel: + """构建文本型 Prompt 的嵌入区块。""" + + panel_title, panel_border_style = cls.get_request_panel_style(request_kind) + if folded: + prompt_renderable = cls.build_text_access_panel( + content, + category=category, + chat_id=chat_id, + request_kind=request_kind, + subtitle=subtitle, + ) + else: + prompt_renderable = Text(content) + + return Panel( + prompt_renderable, + title=panel_title, + subtitle=subtitle, + border_style=panel_border_style, + padding=(0, 1), + ) + + @classmethod + def _render_message_panel(cls, message: Any, index: int, settings: PromptImageDisplaySettings) -> _MessageRenderResult: + if isinstance(message, dict): + raw_role = message.get("role", "unknown") + content = message.get("content") + tool_call_id = message.get("tool_call_id") + else: + raw_role = getattr(message, "role", "unknown") + content = getattr(message, "content", None) + tool_call_id = getattr(message, "tool_call_id", None) + + role = raw_role.value if hasattr(raw_role, "value") else str(raw_role) + title = Text.assemble( + Text(f" {cls._get_role_badge_label(role)} ", style=cls._get_role_badge_style(role)), + Text(f" #{index}", style="muted"), + ) + + parts: List[RenderableType] = [] + if content not in (None, "", []): + parts.append(cls._render_message_content(content, settings)) + + if tool_call_id: + parts.append( + Text.assemble( + Text(" 工具调用ID ", style="bold magenta"), + Text(" "), + Text(str(tool_call_id), style="magenta"), + ) + ) + + if not parts: + parts.append(Text("[空]", style="muted")) + + message_panel = Panel( + Group(*parts), + title=title, + border_style="dim", + padding=(0, 1), + ) + + tool_call_panels: List[Panel] = [] + tool_calls = getattr(message, "tool_calls", None) + if tool_calls: + for tool_call_index, tool_call in enumerate(tool_calls, start=1): + tool_call_panels.append(cls._render_tool_call_panel(tool_call, tool_call_index, index)) + + return _MessageRenderResult(message_panel=message_panel, tool_call_panels=tool_call_panels) + + @classmethod + def build_prompt_panels( + cls, + messages: list[Any], + *, + image_display_mode: Literal["legacy", "path_link"] = "path_link", + ) -> List[Panel]: + """构建完整 prompt 可视化面板。""" + settings = PromptImageDisplaySettings( + display_mode=PromptImageDisplayMode(image_display_mode), + ) + + ordered_panels: List[Panel] = [] + for index, message in enumerate(messages, start=1): + message_render_result = cls._render_message_panel(message, index, settings) + ordered_panels.append(message_render_result.message_panel) + ordered_panels.extend(message_render_result.tool_call_panels) + return ordered_panels diff --git a/src/maisaka/display/prompt_preview_logger.py b/src/maisaka/display/prompt_preview_logger.py new file mode 100644 index 00000000..844d8228 --- /dev/null +++ b/src/maisaka/display/prompt_preview_logger.py @@ -0,0 +1,88 @@ +"""Maisaka Prompt 预览落盘器。""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Dict + +from .preview_path_utils import build_preview_chat_dir_name, normalize_preview_name + + +class PromptPreviewLogger: + """负责保存 Maisaka Prompt 预览文件并控制目录容量。""" + + _BASE_DIR = Path("logs") / "maisaka_prompt" + _DEFAULT_MAX_PREVIEW_GROUPS_PER_CHAT = 256 + _TRIM_COUNT = 100 + + @classmethod + def _build_file_stem(cls, chat_dir: Path) -> str: + base_stem = str(int(time.time() * 1000)) + candidate_stem = base_stem + suffix_index = 1 + while any((chat_dir / f"{candidate_stem}{suffix}").exists() for suffix in (".html", ".txt")): + candidate_stem = f"{base_stem}_{suffix_index}" + suffix_index += 1 + return candidate_stem + + @classmethod + def save_preview_files( + cls, + chat_id: str, + category: str, + files: Dict[str, str], + ) -> Dict[str, Path]: + """保存同一份 Prompt 预览的多个文件并执行超量清理。""" + + normalized_category = normalize_preview_name(category) + chat_dir = (cls._BASE_DIR / normalized_category / build_preview_chat_dir_name(chat_id)).resolve() + chat_dir.mkdir(parents=True, exist_ok=True) + stem = cls._build_file_stem(chat_dir) + saved_paths: Dict[str, Path] = {} + try: + for suffix, content in files.items(): + normalized_suffix = suffix if suffix.startswith(".") else f".{suffix}" + file_path = chat_dir / f"{stem}{normalized_suffix}" + file_path.write_text(content, encoding="utf-8") + saved_paths[normalized_suffix] = file_path + finally: + cls._trim_overflow(chat_dir) + return saved_paths + + @classmethod + def _trim_overflow(cls, chat_dir: Path) -> None: + """超过阈值时按批次删除最老的若干组预览文件。""" + + max_preview_groups = cls._get_max_preview_groups_per_chat() + grouped_files: dict[str, list[Path]] = {} + for file_path in chat_dir.iterdir(): + if not file_path.is_file(): + continue + grouped_files.setdefault(file_path.stem, []).append(file_path) + + if len(grouped_files) <= max_preview_groups: + return + + sorted_groups = sorted( + grouped_files.items(), + key=lambda item: min(path.stat().st_mtime for path in item[1]), + ) + overflow_count = len(grouped_files) - max_preview_groups + trim_count = min(len(sorted_groups), max(cls._TRIM_COUNT, overflow_count)) + for _, file_group in sorted_groups[:trim_count]: + for old_file in file_group: + try: + old_file.unlink() + except FileNotFoundError: + continue + + @classmethod + def _get_max_preview_groups_per_chat(cls) -> int: + try: + from src.config.config import global_config + + configured_limit = global_config.log.maisaka_prompt_preview_limit + return max(1, int(configured_limit or cls._DEFAULT_MAX_PREVIEW_GROUPS_PER_CHAT)) + except Exception: + return cls._DEFAULT_MAX_PREVIEW_GROUPS_PER_CHAT diff --git a/src/maisaka/display/stage_status_board.py b/src/maisaka/display/stage_status_board.py new file mode 100644 index 00000000..97e47800 --- /dev/null +++ b/src/maisaka/display/stage_status_board.py @@ -0,0 +1,125 @@ +"""Maisaka 阶段状态广播。""" + +from __future__ import annotations + +from typing import Any + +import asyncio +import threading +import time + + +class MaisakaStageStatusBoard: + """维护 Maisaka 阶段状态,并推送给 WebUI 麦麦观察。""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._entries: dict[str, dict[str, Any]] = {} + + def update( + self, + *, + session_id: str, + session_name: str, + stage: str, + detail: str = "", + round_text: str = "", + agent_state: str = "", + ) -> None: + """更新一个会话的阶段状态。""" + + now = time.time() + with self._lock: + current = self._entries.get(session_id, {}) + previous_stage = str(current.get("stage") or "").strip() + stage_started_at = float(current.get("stage_started_at") or now) + if previous_stage != stage: + stage_started_at = now + + payload = { + "session_id": session_id, + "session_name": session_name, + "stage": stage, + "detail": detail, + "round_text": round_text, + "agent_state": agent_state, + "stage_started_at": stage_started_at, + "updated_at": now, + "timestamp": now, + } + self._entries[session_id] = payload + + self._schedule_stage_status_event(payload) + + def remove(self, session_id: str) -> None: + """移除一个会话的阶段状态。""" + + with self._lock: + removed = self._entries.pop(session_id, None) + + self._schedule_stage_removed_event(session_id, removed) + + def snapshot(self) -> list[dict[str, Any]]: + """返回当前所有聊天流的阶段状态快照。""" + + with self._lock: + return [dict(entry) for entry in self._entries.values()] + + @staticmethod + def _schedule_stage_status_event(payload: dict[str, Any]) -> None: + try: + from src.maisaka.monitor_events import emit_stage_status + + asyncio.get_running_loop().create_task(emit_stage_status(**payload)) + except RuntimeError: + return + + @staticmethod + def _schedule_stage_removed_event(session_id: str, removed: dict[str, Any] | None) -> None: + try: + from src.maisaka.monitor_events import emit_stage_removed + + asyncio.get_running_loop().create_task( + emit_stage_removed( + session_id=session_id, + session_name=str((removed or {}).get("session_name") or ""), + ) + ) + except RuntimeError: + return + + +_stage_board = MaisakaStageStatusBoard() + + +def update_stage_status( + *, + session_id: str, + session_name: str, + stage: str, + detail: str = "", + round_text: str = "", + agent_state: str = "", +) -> None: + """更新 WebUI 麦麦观察中的阶段状态。""" + + _stage_board.update( + session_id=session_id, + session_name=session_name, + stage=stage, + detail=detail, + round_text=round_text, + agent_state=agent_state, + ) + + +def remove_stage_status(session_id: str) -> None: + """移除 WebUI 麦麦观察中的阶段状态。""" + + _stage_board.remove(session_id) + + +def get_stage_status_snapshot() -> list[dict[str, Any]]: + """获取当前阶段状态快照。""" + + return _stage_board.snapshot() diff --git a/src/maisaka/history_post_processor.py b/src/maisaka/history_post_processor.py new file mode 100644 index 00000000..aa038f08 --- /dev/null +++ b/src/maisaka/history_post_processor.py @@ -0,0 +1,99 @@ +"""Maisaka 历史消息轮次结束后处理。""" + +from dataclasses import dataclass +from math import ceil + +from .context_messages import LLMContextMessage +from .history_utils import drop_leading_orphan_tool_results, drop_orphan_tool_results, normalize_tool_result_order + +TRIM_TARGET_RATIO = 1.0 +TRIM_THRESHOLD_RATIO = 2.0 + + +@dataclass(slots=True) +class HistoryPostProcessResult: + """历史后处理结果。""" + + history: list[LLMContextMessage] + removed_count: int + changed_count: int + remaining_context_count: int + + +def process_chat_history_after_cycle( + chat_history: list[LLMContextMessage], + *, + max_context_size: int, +) -> HistoryPostProcessResult: + """在每轮结束后统一执行历史裁切与清理。""" + + processed_history = list(chat_history) + processed_history, normalized_removed_count, moved_tool_result_count = _normalize_history_structure( + processed_history + ) + remaining_context_count = sum(1 for message in processed_history if message.count_in_context) + + compact_removed_count = 0 + trim_threshold = ceil(max_context_size * TRIM_THRESHOLD_RATIO) + if remaining_context_count > trim_threshold: + target_context_count = max(1, int(max_context_size * TRIM_TARGET_RATIO)) + removed_early_message_count = _trim_history_to_context_target( + processed_history, + target_context_count=target_context_count, + ) + processed_history, removed_after_trim_count, moved_after_trim_count = _normalize_history_structure( + processed_history + ) + compact_removed_count = removed_early_message_count + removed_after_trim_count + moved_tool_result_count += moved_after_trim_count + + remaining_context_count = sum(1 for message in processed_history if message.count_in_context) + removed_count = normalized_removed_count + compact_removed_count + changed_count = removed_count + moved_tool_result_count + return HistoryPostProcessResult( + history=processed_history, + removed_count=removed_count, + changed_count=changed_count, + remaining_context_count=remaining_context_count, + ) + + +def _normalize_history_structure( + chat_history: list[LLMContextMessage], +) -> tuple[list[LLMContextMessage], int, int]: + """规范化历史消息结构,保证工具调用链符合 LLM 消息协议。""" + + processed_history, orphan_removed_count = drop_orphan_tool_results(chat_history) + processed_history, moved_tool_result_count = normalize_tool_result_order(processed_history) + processed_history, leading_orphan_removed_count = drop_leading_orphan_tool_results(processed_history) + return ( + processed_history, + orphan_removed_count + leading_orphan_removed_count, + moved_tool_result_count, + ) + + +def _trim_history_to_context_target( + chat_history: list[LLMContextMessage], + *, + target_context_count: int, +) -> int: + """移除最早的一段历史,直到普通上下文消息数量降到目标值以内。""" + + remaining_context_count = sum(1 for message in chat_history if message.count_in_context) + if remaining_context_count <= target_context_count: + return 0 + + remove_count = 0 + for message in chat_history: + remove_count += 1 + if message.count_in_context: + remaining_context_count -= 1 + if remaining_context_count <= target_context_count: + break + + if remove_count <= 0: + return 0 + + del chat_history[:remove_count] + return remove_count diff --git a/src/maisaka/history_utils.py b/src/maisaka/history_utils.py new file mode 100644 index 00000000..4c3e1a80 --- /dev/null +++ b/src/maisaka/history_utils.py @@ -0,0 +1,178 @@ +"""Maisaka 历史消息处理辅助工具。""" + +from typing import TYPE_CHECKING + +from src.common.data_models.message_component_data_model import MessageSequence, ReplyComponent, TextComponent + +from .context_messages import AssistantMessage, LLMContextMessage, ToolResultMessage +from .message_adapter import build_visible_text_from_sequence, clone_message_sequence, format_speaker_content + +if TYPE_CHECKING: + from src.chat.message_receive.message import SessionMessage + + +def build_prefixed_message_sequence( + source_sequence: MessageSequence, + planner_prefix: str, +) -> MessageSequence: + """基于原始消息序列构造带规划器前缀的新序列。""" + + planner_components = clone_message_sequence(source_sequence).components + if planner_components and isinstance(planner_components[0], TextComponent): + planner_components[0].text = f"{planner_prefix}{planner_components[0].text}" + else: + planner_components.insert(0, TextComponent(planner_prefix)) + return MessageSequence(planner_components) + + +def build_session_message_visible_text( + message: "SessionMessage", + source_sequence: MessageSequence | None = None, + *, + include_reply_components: bool = True, +) -> str: + """将真实会话消息转换为 Maisaka 可见文本。""" + + normalized_sequence = source_sequence if source_sequence is not None else message.raw_message + user_info = message.message_info.user_info + speaker_name = user_info.user_cardname or user_info.user_nickname or user_info.user_id + visible_message_id = None if message.is_notify else message.message_id + + visible_sequence = MessageSequence([]) + visible_sequence.text( + format_speaker_content( + speaker_name, + "", + message.timestamp, + visible_message_id, + ) + ) + for component in clone_message_sequence(normalized_sequence).components: + if not include_reply_components and isinstance(component, ReplyComponent): + continue + visible_sequence.components.append(component) + return build_visible_text_from_sequence(visible_sequence).strip() + + +def drop_leading_orphan_tool_results( + chat_history: list[LLMContextMessage], +) -> tuple[list[LLMContextMessage], int]: + """移除历史前缀中缺少对应 tool_call 的工具结果消息。""" + + if not chat_history: + return chat_history, 0 + + available_tool_call_ids = { + tool_call.call_id + for message in chat_history + if isinstance(message, AssistantMessage) + for tool_call in message.tool_calls + if tool_call.call_id + } + + first_valid_index = 0 + while first_valid_index < len(chat_history): + message = chat_history[first_valid_index] + if not isinstance(message, ToolResultMessage): + break + if message.tool_call_id in available_tool_call_ids: + break + first_valid_index += 1 + + if first_valid_index == 0: + return chat_history, 0 + return chat_history[first_valid_index:], first_valid_index + + +def drop_orphan_tool_results( + chat_history: list[LLMContextMessage], +) -> tuple[list[LLMContextMessage], int]: + """移除窗口任意位置中缺少对应 tool_call 的工具结果消息。""" + + if not chat_history: + return chat_history, 0 + + available_tool_call_ids = { + tool_call.call_id + for message in chat_history + if isinstance(message, AssistantMessage) + for tool_call in message.tool_calls + if tool_call.call_id + } + + filtered_history: list[LLMContextMessage] = [] + removed_count = 0 + for message in chat_history: + if isinstance(message, ToolResultMessage) and message.tool_call_id not in available_tool_call_ids: + removed_count += 1 + continue + filtered_history.append(message) + + return filtered_history, removed_count + + +def normalize_tool_result_order( + chat_history: list[LLMContextMessage], +) -> tuple[list[LLMContextMessage], int]: + """把被其他消息隔开的 tool 结果移动到对应 assistant tool_calls 后面。""" + + if not chat_history: + return chat_history, 0 + + consumed_indexes: set[int] = set() + normalized_history: list[LLMContextMessage] = [] + moved_count = 0 + + for index, message in enumerate(chat_history): + if index in consumed_indexes: + continue + + normalized_history.append(message) + if not isinstance(message, AssistantMessage) or not message.tool_calls: + continue + + appended_tool_result_count = 0 + for tool_call in message.tool_calls: + tool_call_id = str(tool_call.call_id or "").strip() + if not tool_call_id: + continue + + matching_index = _find_tool_result_index( + chat_history, + tool_call_id=tool_call_id, + start_index=index + 1, + consumed_indexes=consumed_indexes, + ) + if matching_index is None: + continue + + consumed_indexes.add(matching_index) + normalized_history.append(chat_history[matching_index]) + expected_index = index + appended_tool_result_count + 1 + if matching_index != expected_index: + moved_count += 1 + appended_tool_result_count += 1 + + if moved_count <= 0: + return chat_history, 0 + return normalized_history, moved_count + + +def _find_tool_result_index( + chat_history: list[LLMContextMessage], + *, + tool_call_id: str, + start_index: int, + consumed_indexes: set[int], +) -> int | None: + """查找指定 tool_call_id 对应的 tool 结果消息位置。""" + + for index in range(start_index, len(chat_history)): + if index in consumed_indexes: + continue + message = chat_history[index] + if not isinstance(message, ToolResultMessage): + continue + if message.tool_call_id == tool_call_id: + return index + return None diff --git a/src/maisaka/message_adapter.py b/src/maisaka/message_adapter.py new file mode 100644 index 00000000..32a519d6 --- /dev/null +++ b/src/maisaka/message_adapter.py @@ -0,0 +1,111 @@ +"""Maisaka 文本与消息片段适配工具。""" + +from copy import deepcopy +from datetime import datetime +from typing import Optional + +import re + +from src.common.data_models.message_component_data_model import ( + AtComponent, + EmojiComponent, + ImageComponent, + MessageSequence, + ReplyComponent, + TextComponent, +) + +SPEAKER_PREFIX_PATTERN = re.compile( + r"^(?:(?P\d{2}:\d{2}:\d{2}))?(?:\[msg_id:(?P[^\]]+)\])?\[(?P[^\]]+)\](?P.*)$", + re.DOTALL, +) + + +def format_speaker_content( + speaker_name: str, + content: str, + timestamp: Optional[datetime] = None, + message_id: Optional[str] = None, +) -> str: + """将可见文本格式化为带说话人前缀的样式。""" + + time_prefix = timestamp.strftime("%H:%M:%S") if timestamp is not None else "" + message_id_prefix = f"[msg_id:{message_id}]" if message_id else "" + return f"{time_prefix}{message_id_prefix}[{speaker_name}]{content}" + + +def parse_speaker_content(content: str) -> tuple[Optional[str], str]: + """解析形如 `[speaker]message` 的可见文本。""" + + match = SPEAKER_PREFIX_PATTERN.match(content or "") + if not match: + return None, content or "" + return match.group("speaker"), match.group("content") + + +def clone_message_sequence(message_sequence: MessageSequence) -> MessageSequence: + """复制消息片段序列。""" + + return MessageSequence([deepcopy(component) for component in message_sequence.components]) + + +def _render_at_component_text(component: AtComponent) -> str: + """将 AtComponent 渲染为文本。""" + + target_name = component.target_user_cardname or component.target_user_nickname or component.target_user_id + return f"@{target_name}".strip() + + +def build_visible_text_from_sequence(message_sequence: MessageSequence) -> str: + """从消息片段序列提取可见文本。""" + + parts: list[str] = [] + pending_reply_body_prefix = False + + def append_visible_part(text: str) -> None: + nonlocal pending_reply_body_prefix + if not text: + return + if pending_reply_body_prefix: + parts.append(f"\n[发言内容]{text}") + pending_reply_body_prefix = False + return + parts.append(text) + + for component in message_sequence.components: + if isinstance(component, TextComponent): + match = SPEAKER_PREFIX_PATTERN.match(component.text or "") + if not match: + append_visible_part(component.text) + continue + + normalized_parts: list[str] = [] + if match.group("timestamp"): + normalized_parts.append(match.group("timestamp")) + message_id = match.group("message_id") + if message_id: + normalized_parts.append(f"[msg_id:{message_id}]") + normalized_parts.append(f"[{match.group('speaker')}]") + normalized_parts.append(match.group("content")) + append_visible_part("".join(normalized_parts)) + continue + + if isinstance(component, EmojiComponent): + append_visible_part(component.content.strip() or "[表情包]") + continue + + if isinstance(component, ImageComponent): + append_visible_part(component.content.strip() or "[图片,识别中.....]") + continue + + if isinstance(component, AtComponent): + append_visible_part(_render_at_component_text(component)) + continue + + if isinstance(component, ReplyComponent): + target_message_id = component.target_message_id.strip() + if target_message_id: + parts.append(f"[引用消息]{target_message_id}") + pending_reply_body_prefix = True + + return "".join(parts) diff --git a/src/maisaka/monitor_events.py b/src/maisaka/monitor_events.py new file mode 100644 index 00000000..3b652251 --- /dev/null +++ b/src/maisaka/monitor_events.py @@ -0,0 +1,589 @@ +"""MaiSaka 实时监控事件广播模块。 + +通过统一 WebSocket 将 MaiSaka 推理引擎各阶段状态实时推送给前端监控界面。 +""" + +from datetime import datetime +from typing import Any, Dict, List, Optional +import json +import time + +from src.common.logger import get_logger + +logger = get_logger("maisaka_monitor") + +MONITOR_DOMAIN = "maisaka_monitor" +MONITOR_TOPIC = "main" + + +def _normalize_payload_value(value: Any) -> Any: + """将事件载荷中的任意值规范化为可序列化结构。""" + + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, dict): + normalized_dict: Dict[str, Any] = {} + for key, item in value.items(): + normalized_dict[str(key)] = _normalize_payload_value(item) + return normalized_dict + if isinstance(value, (list, tuple, set)): + return [_normalize_payload_value(item) for item in value] + if hasattr(value, "model_dump"): + try: + return _normalize_payload_value(value.model_dump()) + except Exception: + return str(value) + if hasattr(value, "__dict__"): + try: + return _normalize_payload_value(dict(value.__dict__)) + except Exception: + return str(value) + return str(value) + + +def _extract_text_content(content: Any) -> Optional[str]: + """从消息内容中提取纯文本表示。""" + + if content is None: + return None + if isinstance(content, str): + return content + if isinstance(content, list): + text_parts: List[str] = [] + for block in content: + if isinstance(block, dict): + block_type = block.get("type", "") + if block_type == "text": + text_parts.append(str(block.get("text", ""))) + elif block_type == "image_url": + text_parts.append("[图片,识别中.....]") + else: + text_parts.append(f"[{block_type}]") + elif isinstance(block, str): + text_parts.append(block) + return "\n".join(text_parts) if text_parts else None + return str(content) + + +def _normalize_tool_call_arguments(arguments: Any) -> tuple[Any, Optional[str]]: + """标准化工具调用参数,兼容 JSON 字符串和对象。""" + + if isinstance(arguments, str): + raw_arguments = arguments + try: + parsed_arguments = json.loads(arguments) if arguments.strip() else {} + except json.JSONDecodeError: + return {}, raw_arguments + return _normalize_payload_value(parsed_arguments), raw_arguments + return _normalize_payload_value(arguments or {}), None + + +def _serialize_single_tool_call(tool_call: Any) -> Dict[str, Any]: + """将不同来源的 tool_call 标准化为前端可直接展示的结构。""" + + if isinstance(tool_call, dict): + function_info = tool_call.get("function") + if isinstance(function_info, dict): + raw_arguments = function_info.get("arguments", tool_call.get("arguments", tool_call.get("args", {}))) + name = function_info.get("name", tool_call.get("name", tool_call.get("func_name", "unknown"))) + else: + raw_arguments = tool_call.get("arguments", tool_call.get("args", {})) + name = tool_call.get("name", tool_call.get("func_name", "unknown")) + + arguments, arguments_raw = _normalize_tool_call_arguments(raw_arguments) + serialized: Dict[str, Any] = { + "id": str(tool_call.get("id", tool_call.get("call_id", ""))), + "name": str(name or "unknown"), + "arguments": arguments, + } + if arguments_raw is not None: + serialized["arguments_raw"] = arguments_raw + return serialized + + raw_arguments = getattr(tool_call, "args", None) + if raw_arguments is None: + raw_arguments = getattr(tool_call, "arguments", None) + arguments, arguments_raw = _normalize_tool_call_arguments(raw_arguments) + serialized = { + "id": str(getattr(tool_call, "id", None) or getattr(tool_call, "call_id", "")), + "name": str(getattr(tool_call, "func_name", None) or getattr(tool_call, "name", "unknown")), + "arguments": arguments, + } + if arguments_raw is not None: + serialized["arguments_raw"] = arguments_raw + return serialized + + +def _serialize_tool_calls_from_objects(tool_calls: List[Any]) -> List[Dict[str, Any]]: + """将工具调用对象列表序列化为字典列表。""" + + return [_serialize_single_tool_call(tool_call) for tool_call in tool_calls] + + +def _serialize_tool_calls_from_dicts(tool_calls: List[Any]) -> List[Dict[str, Any]]: + """将工具调用字典列表标准化为可传输格式。""" + + return [_serialize_single_tool_call(tool_call) for tool_call in tool_calls] + + +def _serialize_message(message: Any) -> Dict[str, Any]: + """将单条消息序列化为可通过 WebSocket 传输的字典。""" + + if isinstance(message, dict): + serialized: Dict[str, Any] = { + "role": str(message.get("role", "unknown")), + "content": _extract_text_content(message.get("content")), + } + if message.get("tool_call_id"): + serialized["tool_call_id"] = str(message["tool_call_id"]) + if message.get("tool_calls"): + serialized["tool_calls"] = _serialize_tool_calls_from_dicts(message["tool_calls"]) + return serialized + + raw_role = getattr(message, "role", "unknown") + role_str = raw_role.value if hasattr(raw_role, "value") else str(raw_role) + + serialized = { + "role": role_str, + "content": _extract_text_content(getattr(message, "content", None)), + } + tool_call_id = getattr(message, "tool_call_id", None) + if tool_call_id: + serialized["tool_call_id"] = str(tool_call_id) + + tool_calls = getattr(message, "tool_calls", None) + if tool_calls: + serialized["tool_calls"] = _serialize_tool_calls_from_objects(tool_calls) + + return serialized + + +def _serialize_messages(messages: List[Any]) -> List[Dict[str, Any]]: + """批量序列化消息列表。""" + + return [_serialize_message(message) for message in messages] + + +def _enrich_session_identity(data: Dict[str, Any]) -> Dict[str, Any]: + """为监控事件补充会话展示所需的群/用户标识。""" + + session_id = data.get("session_id") + if not session_id: + return data + + try: + from src.chat.message_receive.chat_manager import chat_manager + + chat_stream = chat_manager.get_session_by_session_id(str(session_id)) + except Exception: + return data + + if chat_stream is None: + return data + + session_name = chat_manager.get_session_name(str(session_id)) + if session_name: + data.setdefault("session_name", session_name) + data.setdefault("is_group_chat", chat_stream.is_group_session) + data.setdefault("group_id", chat_stream.group_id) + data.setdefault("user_id", chat_stream.user_id) + data.setdefault("platform", chat_stream.platform) + return data + + +def _serialize_tool_results(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """标准化最终 planner 卡中的工具结果列表。""" + + serialized_tools: List[Dict[str, Any]] = [] + for tool in tools: + serialized_tool = { + "tool_call_id": str(tool.get("tool_call_id", "")), + "tool_name": str(tool.get("tool_name", "")), + "tool_args": _normalize_payload_value(tool.get("tool_args", {})), + "success": bool(tool.get("success", False)), + "duration_ms": float(tool.get("duration_ms", 0.0) or 0.0), + "summary": str(tool.get("summary", "")), + } + detail = tool.get("detail") + if detail is not None: + serialized_tool["detail"] = _normalize_payload_value(detail) + serialized_tools.append(serialized_tool) + return serialized_tools + + +def _serialize_request_block( + messages: Optional[List[Any]], + selected_history_count: Optional[int], + tool_count: Optional[int], +) -> Optional[Dict[str, Any]]: + """标准化请求区块。""" + + if messages is None and selected_history_count is None and tool_count is None: + return None + + return { + "messages": _serialize_messages(list(messages or [])), + "selected_history_count": int(selected_history_count or 0), + "tool_count": int(tool_count or 0), + } + + +def _serialize_planner_block( + content: Optional[str], + tool_calls: Optional[List[Any]], + prompt_tokens: Optional[int], + completion_tokens: Optional[int], + total_tokens: Optional[int], + duration_ms: Optional[float], + prompt_html_uri: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + """标准化 planner 结果区块。""" + + if ( + content is None + and tool_calls is None + and prompt_tokens is None + and completion_tokens is None + and total_tokens is None + and duration_ms is None + and prompt_html_uri is None + ): + return None + + return { + "content": content, + "tool_calls": _serialize_tool_calls_from_objects(list(tool_calls or [])), + "prompt_tokens": int(prompt_tokens or 0), + "completion_tokens": int(completion_tokens or 0), + "total_tokens": int(total_tokens or 0), + "duration_ms": float(duration_ms or 0.0), + "prompt_html_uri": str(prompt_html_uri or ""), + } + + +def _serialize_timing_gate_block( + *, + request_messages: Optional[List[Any]], + selected_history_count: Optional[int], + tool_count: Optional[int], + action: Optional[str], + content: Optional[str], + tool_calls: Optional[List[Any]], + tool_results: Optional[List[str]], + prompt_tokens: Optional[int], + completion_tokens: Optional[int], + total_tokens: Optional[int], + duration_ms: Optional[float], +) -> Optional[Dict[str, Any]]: + """标准化 Timing Gate 结果区块。""" + + if ( + request_messages is None + and selected_history_count is None + and tool_count is None + and action is None + and content is None + and tool_calls is None + and tool_results is None + and prompt_tokens is None + and completion_tokens is None + and total_tokens is None + and duration_ms is None + ): + return None + + return { + "request": _serialize_request_block( + request_messages, + selected_history_count, + tool_count, + ), + "result": { + "action": action, + "content": content, + "tool_calls": _serialize_tool_calls_from_objects(list(tool_calls or [])), + "tool_results": _normalize_payload_value(list(tool_results or [])), + "prompt_tokens": int(prompt_tokens or 0), + "completion_tokens": int(completion_tokens or 0), + "total_tokens": int(total_tokens or 0), + "duration_ms": float(duration_ms or 0.0), + }, + } + + +async def _broadcast(event: str, data: Dict[str, Any]) -> None: + """通过统一 WebSocket 管理器向监控主题广播事件。""" + + try: + from src.webui.routers.websocket.manager import websocket_manager + + data = _enrich_session_identity(data) + subscription_key = f"{MONITOR_DOMAIN}:{MONITOR_TOPIC}" + total_connections = len(websocket_manager.connections) + subscriber_count = sum( + 1 + for connection in websocket_manager.connections.values() + if subscription_key in connection.subscriptions + ) +# The above code is using the Python logging module to log a diagnostic message. It is logging +# information about the `_broadcast` function, including the `manager_id`, `total_connections`, +# `subscriber_count`, and `event` variables. The `logger.info()` function is used to log the message +# at the INFO level. + # logger.info( + # f"[诊断] _broadcast: manager_id={id(websocket_manager)} " + # f"总连接={total_connections} 订阅者={subscriber_count} event={event}" + # ) + await websocket_manager.broadcast_to_topic( + domain=MONITOR_DOMAIN, + topic=MONITOR_TOPIC, + event=event, + data=data, + ) + except Exception as exc: + logger.warning(f"MaiSaka 监控事件广播失败: {exc}", exc_info=True) + + +async def emit_session_start( + session_id: str, + session_name: str, + *, + is_group_chat: bool, + group_id: Optional[str], + user_id: Optional[str], + platform: str, +) -> None: + """广播会话开始事件。""" + + await _broadcast("session.start", { + "session_id": session_id, + "session_name": session_name, + "is_group_chat": is_group_chat, + "group_id": group_id, + "user_id": user_id, + "platform": platform, + "timestamp": time.time(), + }) + + +async def emit_stage_status( + *, + session_id: str, + session_name: str, + stage: str, + detail: str = "", + round_text: str = "", + agent_state: str = "", + stage_started_at: float, + updated_at: float, + timestamp: float, +) -> None: + """广播单个聊天流的当前阶段状态。""" + + await _broadcast("stage.status", { + "session_id": session_id, + "session_name": session_name, + "stage": stage, + "detail": detail, + "round_text": round_text, + "agent_state": agent_state, + "stage_started_at": stage_started_at, + "updated_at": updated_at, + "timestamp": timestamp, + }) + + +async def emit_stage_removed( + *, + session_id: str, + session_name: str = "", +) -> None: + """广播聊天流阶段状态移除事件。""" + + await _broadcast("stage.removed", { + "session_id": session_id, + "session_name": session_name, + "timestamp": time.time(), + }) + + +async def emit_message_ingested( + session_id: str, + speaker_name: str, + content: str, + message_id: str, + timestamp: float, +) -> None: + """广播新消息注入事件。""" + + await _broadcast("message.ingested", { + "session_id": session_id, + "speaker_name": speaker_name, + "content": content, + "message_id": message_id, + "timestamp": timestamp, + }) + + +async def emit_message_sent( + session_id: str, + speaker_name: str, + content: str, + message_id: str, + timestamp: float, + source_kind: str = "", +) -> None: + """广播 MaiSaka 自己发送的消息事件。""" + + await _broadcast("message.sent", { + "session_id": session_id, + "speaker_name": speaker_name, + "content": content, + "message_id": message_id, + "source_kind": source_kind, + "timestamp": timestamp, + }) + + +async def emit_cycle_start( + session_id: str, + cycle_id: int, + round_index: int, + max_rounds: int, + history_count: int, +) -> None: + """广播推理循环开始事件。""" + + await _broadcast("cycle.start", { + "session_id": session_id, + "cycle_id": cycle_id, + "round_index": round_index, + "max_rounds": max_rounds, + "history_count": history_count, + "timestamp": time.time(), + }) + + +async def emit_cycle_end( + session_id: str, + cycle_id: int, + time_records: Dict[str, float], + agent_state: str, + end_reason: str, + end_detail: str, +) -> None: + """广播单个推理循环结束事件。""" + + await _broadcast("cycle.end", { + "session_id": session_id, + "cycle_id": cycle_id, + "time_records": _normalize_payload_value(time_records), + "agent_state": agent_state, + "end_reason": end_reason, + "end_detail": end_detail, + "timestamp": time.time(), + }) + + +async def emit_timing_gate_result( + session_id: str, + cycle_id: int, + action: str, + content: Optional[str], + tool_calls: List[Any], + messages: List[Any], + prompt_tokens: int, + selected_history_count: int, + duration_ms: float, +) -> None: + """广播 Timing Gate 结果事件。""" + + await _broadcast("timing_gate.result", { + "session_id": session_id, + "cycle_id": cycle_id, + "action": action, + "content": content, + "tool_calls": _serialize_tool_calls_from_objects(tool_calls), + "messages": _serialize_messages(messages), + "prompt_tokens": prompt_tokens, + "selected_history_count": selected_history_count, + "duration_ms": duration_ms, + "timestamp": time.time(), + }) + + +async def emit_planner_finalized( + *, + session_id: str, + cycle_id: int, + timing_request_messages: Optional[List[Any]], + timing_selected_history_count: Optional[int], + timing_tool_count: Optional[int], + timing_action: Optional[str], + timing_content: Optional[str], + timing_tool_calls: Optional[List[Any]], + timing_tool_results: Optional[List[str]], + timing_prompt_tokens: Optional[int], + timing_completion_tokens: Optional[int], + timing_total_tokens: Optional[int], + timing_duration_ms: Optional[float], + planner_request_messages: Optional[List[Any]], + planner_selected_history_count: Optional[int], + planner_tool_count: Optional[int], + planner_content: Optional[str], + planner_tool_calls: Optional[List[Any]], + planner_prompt_tokens: Optional[int], + planner_completion_tokens: Optional[int], + planner_total_tokens: Optional[int], + planner_duration_ms: Optional[float], + planner_prompt_html_uri: Optional[str] = None, + tools: Optional[List[Dict[str, Any]]] = None, + time_records: Optional[Dict[str, float]] = None, + agent_state: str = "", + planner_interrupted: bool = False, + end_reason: str = "", + end_detail: str = "", +) -> None: + """广播一轮 planner 结束后的最终聚合事件。""" + + await _broadcast("planner.finalized", { + "session_id": session_id, + "cycle_id": cycle_id, + "timestamp": time.time(), + "timing_gate": _serialize_timing_gate_block( + request_messages=timing_request_messages, + selected_history_count=timing_selected_history_count, + tool_count=timing_tool_count, + action=timing_action, + content=timing_content, + tool_calls=timing_tool_calls, + tool_results=timing_tool_results, + prompt_tokens=timing_prompt_tokens, + completion_tokens=timing_completion_tokens, + total_tokens=timing_total_tokens, + duration_ms=timing_duration_ms, + ), + "request": _serialize_request_block( + planner_request_messages, + planner_selected_history_count, + planner_tool_count, + ), + "planner": _serialize_planner_block( + planner_content, + planner_tool_calls, + planner_prompt_tokens, + planner_completion_tokens, + planner_total_tokens, + planner_duration_ms, + planner_prompt_html_uri, + ), + "tools": _serialize_tool_results(list(tools or [])), + "interrupted": planner_interrupted, + "final_state": { + "time_records": _normalize_payload_value(time_records or {}), + "agent_state": agent_state, + "end_reason": end_reason, + "end_detail": end_detail, + }, + }) diff --git a/src/maisaka/planner_message_utils.py b/src/maisaka/planner_message_utils.py new file mode 100644 index 00000000..bd48d71f --- /dev/null +++ b/src/maisaka/planner_message_utils.py @@ -0,0 +1,114 @@ +"""Maisaka 规划器消息构造工具。""" + +from datetime import datetime +from typing import Optional + +from src.chat.message_receive.message import SessionMessage +from src.common.data_models.message_component_data_model import MessageSequence, TextComponent + +from .context_messages import SessionBackedMessage +from .message_adapter import format_speaker_content + + +def build_planner_prefix( + *, + timestamp: datetime, + user_name: str, + group_card: str = "", + message_id: Optional[str] = None, + include_message_id: bool = True, +) -> str: + """构造 Maisaka 规划器使用的统一消息前缀。 + + Args: + timestamp: 消息时间。 + user_name: 展示给规划器的用户名。 + group_card: 群昵称。 + message_id: 消息 ID。 + include_message_id: 是否输出 `msg_id` 段。 + + Returns: + str: 拼接完成的规划器前缀。 + """ + + prefix_parts = [] + if include_message_id: + prefix_parts.append(f"[msg_id]{message_id or ''}\n") + prefix_parts.extend( + [ + f"[时间]{timestamp.strftime('%H:%M:%S')}\n", + f"[用户名]{user_name}\n", + ] + ) + normalized_group_card = group_card.strip() + if normalized_group_card: + prefix_parts.append(f"[用户群昵称]{normalized_group_card}\n") + prefix_parts.append("[发言内容]") + return "".join(prefix_parts) + + +def build_planner_user_prefix_from_session_message(message: SessionMessage) -> str: + """根据真实会话消息构造规划器前缀。 + + Args: + message: 原始会话消息。 + + Returns: + str: 规划器前缀字符串。 + """ + + user_info = message.message_info.user_info + user_name = user_info.user_nickname or user_info.user_id + return build_planner_prefix( + timestamp=message.timestamp, + user_name=user_name, + group_card=user_info.user_cardname or "", + message_id=message.message_id, + include_message_id=not message.is_notify and bool(message.message_id), + ) + + +def build_session_backed_text_message( + *, + speaker_name: str, + text: str, + timestamp: datetime, + source_kind: str, + group_card: str = "", + message_id: Optional[str] = None, + include_message_id: bool = True, +) -> SessionBackedMessage: + """构造带规划器前缀的纯文本历史消息。 + + Args: + speaker_name: 发言者名称。 + text: 发言内容。 + timestamp: 发言时间。 + source_kind: 上下文来源类型。 + group_card: 群昵称。 + message_id: 消息 ID。 + include_message_id: 是否输出 `msg_id` 段。 + + Returns: + SessionBackedMessage: 可直接写入历史的上下文消息。 + """ + + planner_prefix = build_planner_prefix( + timestamp=timestamp, + user_name=speaker_name, + group_card=group_card, + message_id=message_id, + include_message_id=include_message_id, + ) + return SessionBackedMessage( + raw_message=MessageSequence([TextComponent(f"{planner_prefix}{text}")]), + visible_text=format_speaker_content( + speaker_name, + text, + timestamp, + message_id if include_message_id else None, + ), + timestamp=timestamp, + message_id=message_id, + source_kind=source_kind, + ) diff --git a/src/maisaka/reasoning_engine.py b/src/maisaka/reasoning_engine.py new file mode 100644 index 00000000..afe4b063 --- /dev/null +++ b/src/maisaka/reasoning_engine.py @@ -0,0 +1,1540 @@ +"""Maisaka 推理引擎。""" + +from datetime import datetime +from typing import TYPE_CHECKING, Any, Literal, Optional + +import asyncio +import difflib +import json +import time +import traceback + +from src.chat.heart_flow.heartFC_utils import CycleDetail +from src.chat.message_receive.message import SessionMessage +from src.common.data_models.message_component_data_model import EmojiComponent, ImageComponent, MessageSequence, TextComponent +from src.common.logger import get_logger +from src.common.prompt_i18n import load_prompt +from src.core.tooling import ToolAvailabilityContext, ToolExecutionContext, ToolExecutionResult, ToolInvocation, ToolSpec +from src.llm_models.exceptions import ReqAbortException +from src.llm_models.payload_content.tool_option import ToolCall +from src.services import database_service as database_api +from src.services.memory_service import memory_service + +from .builtin_tool import build_builtin_tool_handlers as build_split_builtin_tool_handlers +from .builtin_tool import get_builtin_tool_visibility, is_builtin_tool_in_action_stage +from .builtin_tool import get_timing_tools +from .chat_loop_service import ChatResponse +from .chat_history_visual_refresher import refresh_chat_history_visual_placeholders +from .builtin_tool.context import BuiltinToolRuntimeContext +from .context_messages import ( + AssistantMessage, + ComplexSessionMessage, + LLMContextMessage, + SessionBackedMessage, + TIMING_GATE_INVALID_TOOL_HINT_SOURCE, + ToolResultMessage, + contains_complex_message, +) +from .history_post_processor import process_chat_history_after_cycle +from .history_utils import build_prefixed_message_sequence, build_session_message_visible_text +from .monitor_events import ( + emit_cycle_end, + emit_cycle_start, + emit_message_ingested, + emit_planner_finalized, + emit_timing_gate_result, +) +from .planner_message_utils import build_planner_user_prefix_from_session_message +from .visual_mode_utils import resolve_enable_visual_planner + +if TYPE_CHECKING: + from .runtime import MaisakaHeartFlowChatting + from .tool_provider import BuiltinToolHandler + +logger = get_logger("maisaka_reasoning_engine") + +TIMING_GATE_CONTEXT_DROP_HEAD_RATIO = 0.7 +TIMING_GATE_MAX_ATTEMPTS = 3 +TIMING_GATE_TOOL_NAMES = {"continue", "no_reply", "wait"} +HISTORY_SILENT_TOOL_NAMES = {"finish"} + + +class MaisakaReasoningEngine: + """负责内部思考、推理与工具执行。""" + + def __init__(self, runtime: "MaisakaHeartFlowChatting") -> None: + self._runtime = runtime + self._last_reasoning_content: str = "" + + @staticmethod + def _get_runtime_manager() -> Any: + """获取插件运行时管理器。 + + Returns: + Any: 插件运行时管理器单例。 + """ + + from src.plugin_runtime.integration import get_plugin_runtime_manager + + return get_plugin_runtime_manager() + + @property + def last_reasoning_content(self) -> str: + """返回最近一轮思考文本。""" + + return self._last_reasoning_content + + def build_builtin_tool_handlers(self) -> dict[str, "BuiltinToolHandler"]: + """构造 Maisaka 内置工具处理器映射。 + + Returns: + dict[str, BuiltinToolHandler]: 工具名到处理器的映射。 + """ + + return build_split_builtin_tool_handlers(BuiltinToolRuntimeContext(self, self._runtime)) + + async def _run_interruptible_planner( + self, + *, + injected_user_messages: Optional[list[str]] = None, + tool_definitions: Optional[list[dict[str, Any]]] = None, + ) -> Any: + """运行一轮可被新消息打断的主 planner 请求。""" + + interrupt_flag = asyncio.Event() + interrupted = False + self._runtime._bind_planner_interrupt_flag(interrupt_flag) + self._runtime._chat_loop_service.set_interrupt_flag(interrupt_flag) + try: + return await self._runtime._chat_loop_service.chat_loop_step( + self._runtime._chat_history, + injected_user_messages=injected_user_messages, + tool_definitions=tool_definitions, + ) + except ReqAbortException: + interrupted = True + raise + finally: + self._runtime._unbind_planner_interrupt_flag( + interrupt_flag, + interrupted=interrupted, + ) + self._runtime._chat_loop_service.set_interrupt_flag(None) + + async def _run_timing_gate_sub_agent( + self, + *, + system_prompt: str, + tool_definitions: list[dict[str, Any]], + ) -> Any: + """运行一轮 Timing Gate 子代理请求。 + + Timing Gate 阶段不再响应新的 planner 打断,只有主 planner 阶段允许被打断。 + """ + + return await self._runtime.run_sub_agent( + context_message_limit=self._runtime._max_context_size, + drop_head_context_count=int( + self._runtime._max_context_size * TIMING_GATE_CONTEXT_DROP_HEAD_RATIO, + ), + system_prompt=system_prompt, + request_kind="timing_gate", + interrupt_flag=None, + tool_definitions=tool_definitions, + ) + + def _build_timing_gate_system_prompt(self) -> str: + """构造 Timing Gate 子代理使用的系统提示词。""" + + return load_prompt( + "maisaka_timing_gate", + **self._runtime._chat_loop_service.build_prompt_template_context(), + ) + + async def _build_action_tool_definitions(self) -> tuple[list[dict[str, Any]], str]: + """构造 Action Loop 阶段可见的工具定义与 deferred tools 提示。""" + + if self._runtime._tool_registry is None: + self._runtime.update_deferred_tool_specs([]) + self._runtime.set_current_action_tool_names([]) + return [], "" + + availability_context = self._build_tool_availability_context() + tool_specs = await self._runtime._tool_registry.list_tools(availability_context) + visible_builtin_tool_specs: list[ToolSpec] = [] + deferred_tool_specs: list[ToolSpec] = [] + for tool_spec in tool_specs: + if tool_spec.provider_name == "maisaka_builtin": + if not is_builtin_tool_in_action_stage(tool_spec): + continue + visibility = get_builtin_tool_visibility(tool_spec) + if visibility == "visible": + visible_builtin_tool_specs.append(tool_spec) + elif visibility == "deferred": + deferred_tool_specs.append(tool_spec) + continue + deferred_tool_specs.append(tool_spec) + + self._runtime.update_deferred_tool_specs(deferred_tool_specs) + selected_history, _ = self._runtime._chat_loop_service.select_llm_context_messages( + self._runtime._chat_history, + request_kind="planner", + ) + self._runtime.sync_discovered_deferred_tools_with_context(selected_history) + discovered_deferred_tool_specs = self._runtime.get_discovered_deferred_tool_specs() + visible_tool_specs = [*visible_builtin_tool_specs, *discovered_deferred_tool_specs] + self._runtime.set_current_action_tool_names([tool_spec.name for tool_spec in visible_tool_specs]) + return ( + [tool_spec.to_llm_definition() for tool_spec in visible_tool_specs], + self._runtime.build_deferred_tools_reminder(), + ) + + async def _invoke_tool_call( + self, + tool_call: ToolCall, + latest_thought: str, + anchor_message: SessionMessage, + *, + append_history: bool = True, + store_record: bool = True, + ) -> tuple[ToolInvocation, ToolExecutionResult, Optional[ToolSpec]]: + """执行单个工具调用,并按需写入记录与历史。""" + + invocation = self._build_tool_invocation(tool_call, latest_thought) + if self._runtime._tool_registry is None: + result = ToolExecutionResult( + tool_name=tool_call.func_name, + success=False, + error_message="统一工具注册表尚未初始化。", + ) + if store_record: + await self._store_tool_execution_record(invocation, result, None) + if append_history: + self._append_tool_execution_result(tool_call, result) + return invocation, result, None + + execution_context = self._build_tool_execution_context(latest_thought, anchor_message) + tool_spec = await self._runtime._tool_registry.get_tool_spec(invocation.tool_name) + result = await self._runtime._tool_registry.invoke(invocation, execution_context) + if store_record: + await self._store_tool_execution_record(invocation, result, tool_spec) + if append_history: + self._append_tool_execution_result(tool_call, result) + return invocation, result, tool_spec + + async def _run_timing_gate( + self, + anchor_message: SessionMessage, + ) -> tuple[Literal["continue", "no_reply", "wait"], Any, list[str], list[dict[str, Any]]]: + """运行 Timing Gate 子代理并返回控制决策。""" + + if self._runtime._force_next_timing_continue: + return self._build_forced_continue_timing_result() + + tool_result_summaries: list[str] = [] + tool_monitor_results: list[dict[str, Any]] = [] + response: Any = None + selected_tool_call: Optional[ToolCall] = None + invalid_tool_text = "" + for attempt_index in range(TIMING_GATE_MAX_ATTEMPTS): + timing_tool_definitions = get_timing_tools(self._build_tool_availability_context()) + available_timing_tool_names = { + str(tool_definition.get("name") or "").strip() + for tool_definition in timing_tool_definitions + if str(tool_definition.get("name") or "").strip() + } + response = await self._run_timing_gate_sub_agent( + system_prompt=self._build_timing_gate_system_prompt(), + tool_definitions=timing_tool_definitions, + ) + selected_tool_call = None + for tool_call in response.tool_calls: + if tool_call.func_name in available_timing_tool_names: + selected_tool_call = tool_call + break + + if selected_tool_call is not None: + break + + invalid_tool_names = [ + str(tool_call.func_name).strip() + for tool_call in response.tool_calls + if str(tool_call.func_name).strip() + ] + invalid_tool_text = "、".join(invalid_tool_names) if invalid_tool_names else "无工具" + self._append_timing_gate_invalid_tool_hint(invalid_tool_text) + remaining_attempts = TIMING_GATE_MAX_ATTEMPTS - attempt_index - 1 + if remaining_attempts > 0: + logger.warning( + f"{self._runtime.log_prefix} Timing Gate 未返回有效控制工具:{invalid_tool_text}," + f"将重试 ({attempt_index + 1}/{TIMING_GATE_MAX_ATTEMPTS})" + ) + tool_result_summaries.append( + f"- retry [非法 Timing 工具]: 返回了 {invalid_tool_text},将重试 " + f"({attempt_index + 1}/{TIMING_GATE_MAX_ATTEMPTS})" + ) + continue + + logger.warning( + f"{self._runtime.log_prefix} Timing Gate 连续 {TIMING_GATE_MAX_ATTEMPTS} 次未返回有效控制工具:" + f"{invalid_tool_text},将按 no_reply 处理" + ) + self._runtime._enter_stop_state() + tool_result_summaries.append( + f"- no_reply [非法 Timing 工具]: 返回了 {invalid_tool_text},已停止本轮并等待新消息" + ) + return "no_reply", response, tool_result_summaries, tool_monitor_results + + if selected_tool_call is None: + self._runtime._enter_stop_state() + tool_result_summaries.append( + "- no_reply [非法 Timing 工具]: 已停止本轮并等待新消息" + ) + return "no_reply", response, tool_result_summaries, tool_monitor_results + + if invalid_tool_text: + self._runtime._chat_history = [ + message + for message in self._runtime._chat_history + if message.source != TIMING_GATE_INVALID_TOOL_HINT_SOURCE + ] + + append_history = False + store_record = selected_tool_call.func_name != "continue" + invocation, result, tool_spec = await self._invoke_tool_call( + selected_tool_call, + response.content or "", + anchor_message, + append_history=append_history, + store_record=store_record, + ) + tool_result_summaries.append(self._build_tool_result_summary(selected_tool_call, result)) + tool_monitor_results.append( + self._build_tool_monitor_result( + selected_tool_call, + invocation, + result, + duration_ms=0.0, + tool_spec=tool_spec, + ) + ) + self._append_timing_gate_execution_result(response, selected_tool_call, result) + + timing_action = str(result.metadata.get("timing_action") or selected_tool_call.func_name).strip() + available_timing_action_names = { + str(tool_definition.get("name") or "").strip() + for tool_definition in get_timing_tools(self._build_tool_availability_context()) + if str(tool_definition.get("name") or "").strip() + } + if timing_action not in available_timing_action_names: + logger.warning( + f"{self._runtime.log_prefix} Timing Gate 返回未知动作 {timing_action!r},将按 no_reply 处理" + ) + self._runtime._enter_stop_state() + tool_result_summaries.append( + f"- no_reply [未知 Timing 动作]: 返回了 {timing_action!r},已停止本轮并等待新消息" + ) + return "no_reply", response, tool_result_summaries, tool_monitor_results + return timing_action, response, tool_result_summaries, tool_monitor_results + + def _build_forced_continue_timing_result( + self, + ) -> tuple[Literal["continue"], ChatResponse, list[str], list[dict[str, Any]]]: + """构造跳过 Timing Gate 时使用的伪 continue 结果。""" + + reason = self._runtime._consume_force_next_timing_continue_reason() or "本轮直接跳过 Timing Gate 并视作 continue。" + logger.info(f"{self._runtime.log_prefix} {reason}") + return ( + "continue", + ChatResponse( + content=reason, + tool_calls=[], + request_messages=[], + raw_message=AssistantMessage( + content="", + timestamp=datetime.now(), + source_kind="perception", + ), + selected_history_count=min( + sum(1 for message in self._runtime._chat_history if message.count_in_context), + self._runtime._max_context_size, + ), + tool_count=0, + prompt_tokens=0, + built_message_count=0, + completion_tokens=0, + total_tokens=0, + prompt_section=None, + ), + [f"- continue [强制跳过]: {reason}"], + [], + ) + + def _append_timing_gate_invalid_tool_hint(self, invalid_tool_text: str) -> None: + """写入一条仅 Timing Gate 可见的非法工具提示,并保证最多保留最新一条。""" + + self._runtime._chat_history = [ + message + for message in self._runtime._chat_history + if message.source != TIMING_GATE_INVALID_TOOL_HINT_SOURCE + ] + normalized_tool_text = invalid_tool_text.strip() or "无工具" + hint_content = ( + "Timing Gate 上一轮选择了非法工具:" + f"{normalized_tool_text}。\n" + "Timing Gate 只能调用当前可用的 continue、no_reply 或 wait 中的一个工具。" + ) + self._runtime._chat_history.append( + SessionBackedMessage( + raw_message=MessageSequence([TextComponent(hint_content)]), + visible_text=hint_content, + timestamp=datetime.now(), + source_kind=TIMING_GATE_INVALID_TOOL_HINT_SOURCE, + ) + ) + + @staticmethod + def _mark_timing_gate_completed(timing_action: str) -> bool: + """根据门控动作决定下一轮是否还需要重新执行 timing。""" + + return timing_action != "continue" + + @staticmethod + def _should_retry_planner_after_interrupt( + *, + round_index: int, + max_internal_rounds: int, + has_pending_messages: bool, + ) -> bool: + return has_pending_messages and round_index < max_internal_rounds + + async def run_loop(self) -> None: + """独立消费消息批次,并执行对应的内部思考轮次。""" + try: + while self._runtime._running: + queued_trigger = await self._runtime._internal_turn_queue.get() + message_triggered, timeout_triggered = self._drain_ready_turn_triggers(queued_trigger) + + if self._runtime._agent_state == self._runtime._STATE_WAIT and not timeout_triggered: + self._runtime._message_turn_scheduled = False + logger.debug(f"{self._runtime.log_prefix} 当前仍处于 wait 状态,忽略消息触发并继续等待超时") + continue + + if message_triggered: + await self._runtime._wait_for_message_quiet_period() + self._runtime._message_turn_scheduled = False + + cached_messages = ( + self._runtime._collect_pending_messages() + if self._runtime._has_pending_messages() + else [] + ) + if cached_messages: + self._runtime._agent_state = self._runtime._STATE_RUNNING + self._runtime._update_stage_status( + "消息整理", + f"待处理消息 {len(cached_messages)} 条", + ) + asyncio.create_task(self._runtime._trigger_batch_learning(cached_messages)) + if timeout_triggered: + self._runtime._chat_history.append( + self._build_wait_completed_message(has_new_messages=True) + ) + await self._ingest_messages(cached_messages) + anchor_message = cached_messages[-1] + else: + anchor_message = self._get_timeout_anchor_message() + if anchor_message is None: + logger.warning(f"{self._runtime.log_prefix} wait 超时后没有可复用的锚点消息,跳过本轮") + continue + logger.info(f"{self._runtime.log_prefix} 等待超时后开始新一轮思考") + if self._runtime._pending_wait_tool_call_id: + self._runtime._chat_history.append( + self._build_wait_completed_message(has_new_messages=False) + ) + + try: + timing_gate_required = True + round_index = 0 + while round_index < self._runtime._max_internal_rounds: + cycle_detail = self._start_cycle() + round_text = f"第 {round_index + 1}/{self._runtime._max_internal_rounds} 轮" + self._runtime._log_cycle_started(cycle_detail, round_index) + self._runtime._update_stage_status("启动循环", f"循环 {cycle_detail.cycle_id}", round_text=round_text) + await emit_cycle_start( + session_id=self._runtime.session_id, + cycle_id=cycle_detail.cycle_id, + round_index=round_index, + max_rounds=self._runtime._max_internal_rounds, + history_count=len(self._runtime._chat_history), + ) + planner_started_at = 0.0 + planner_duration_ms = 0.0 + timing_duration_ms = 0.0 + current_stage_started_at = 0.0 + timing_action: Optional[str] = None + timing_response: Optional[ChatResponse] = None + timing_tool_results: Optional[list[str]] = None + timing_tool_monitor_results: Optional[list[dict[str, Any]]] = None + response: Optional[ChatResponse] = None + action_tool_definitions: list[dict[str, Any]] = [] + planner_extra_lines: list[str] = [] + planner_interrupted = False + cycle_end_reason = "continue" + cycle_end_detail = "本轮思考完成,继续后续内部轮次。" + tool_result_summaries: list[str] = [] + tool_monitor_results: list[dict[str, Any]] = [] + try: + visual_refresh_started_at = time.time() + refreshed_message_count = await self._refresh_chat_history_visual_placeholders() + cycle_detail.time_records["visual_refresh"] = time.time() - visual_refresh_started_at + if refreshed_message_count > 0: + logger.info( + f"{self._runtime.log_prefix} 本轮思考前已刷新 {refreshed_message_count} 条视觉占位历史消息" + ) + + if timing_gate_required: + self._runtime._update_stage_status("Timing Gate", "等待门控决策", round_text=round_text) + current_stage_started_at = time.time() + timing_started_at = time.time() + ( + timing_action, + timing_response, + timing_tool_results, + timing_tool_monitor_results, + ) = await self._run_timing_gate(anchor_message) + timing_elapsed_seconds = time.time() - timing_started_at + if timing_action == "no_reply": + await self._runtime._wait_for_timing_gate_non_continue_cooldown( + timing_elapsed_seconds + ) + timing_duration_ms = (time.time() - timing_started_at) * 1000 + cycle_detail.time_records["timing_gate"] = timing_duration_ms / 1000 + await emit_timing_gate_result( + session_id=self._runtime.session_id, + cycle_id=cycle_detail.cycle_id, + action=timing_action, + content=timing_response.content, + tool_calls=timing_response.tool_calls, + messages=[], + prompt_tokens=timing_response.prompt_tokens, + selected_history_count=timing_response.selected_history_count, + duration_ms=timing_duration_ms, + ) + timing_gate_required = self._mark_timing_gate_completed(timing_action) + if timing_action != "continue": + if timing_action == "wait": + cycle_end_reason = "timing_wait" + cycle_end_detail = "Timing Gate 选择 wait,本轮不会进入 Planner,将在等待结束后继续。" + else: + cycle_end_reason = "timing_no_reply" + cycle_end_detail = "Timing Gate 选择 no_reply,本轮不会进入 Planner。" + logger.debug( + f"{self._runtime.log_prefix} Timing Gate 结束当前回合: " + f"回合={round_index + 1} 动作={timing_action}" + ) + break + else: + logger.info( + f"{self._runtime.log_prefix} 跳过 Timing Gate,继续执行 Planner: " + f"回合={round_index + 1}" + ) + + planner_started_at = time.time() + current_stage_started_at = planner_started_at + self._runtime._update_stage_status("Planner", "组织上下文并请求模型", round_text=round_text) + action_tool_definitions, deferred_tools_reminder = await self._build_action_tool_definitions() + logger.info( + f"{self._runtime.log_prefix} 规划器开始执行: " + f"回合={round_index + 1} " + f"历史消息数={len(self._runtime._chat_history)} " + f"开始时间={planner_started_at:.3f}" + ) + response = await self._run_interruptible_planner( + injected_user_messages=[deferred_tools_reminder] if deferred_tools_reminder else None, + tool_definitions=action_tool_definitions, + ) + planner_duration_ms = (time.time() - planner_started_at) * 1000 + cycle_detail.time_records["planner"] = planner_duration_ms / 1000 + # logger.info( + # f"{self._runtime.log_prefix} 规划器执行完成: " + # f"回合={round_index + 1} " + # f"耗时={cycle_detail.time_records['planner']:.3f} 秒" + # ) + reasoning_content = response.content or "" + if self._should_replace_reasoning(reasoning_content): + response.content = "我应该根据我上面思考的内容进行反思,重新思考我下一步的行动,我需要分析当前场景,对话,以及我可以使用的工具,然后直接输出我的想法" + response.raw_message.content = "我应该根据我上面思考的内容进行反思,重新思考我下一步的行动,我需要分析当前场景,对话,以及我可以使用的工具,然后直接输出我的想法" + logger.info(f"{self._runtime.log_prefix} 当前思考与上一轮过于相似,已替换为重新思考提示") + + self._last_reasoning_content = reasoning_content + self._runtime._chat_history.append(response.raw_message) + tool_monitor_results = [] + + if response.tool_calls: + tool_started_at = time.time() + ( + should_pause, + pause_tool_name, + tool_result_summaries, + tool_monitor_results, + ) = await self._handle_tool_calls( + response.tool_calls, + response.content or "", + anchor_message, + ) + cycle_detail.time_records["tool_calls"] = time.time() - tool_started_at + if should_pause: + if pause_tool_name == "finish": + cycle_end_reason = "finish" + cycle_end_detail = "Planner 调用 finish,结束本轮思考并等待新消息。" + elif pause_tool_name: + cycle_end_reason = f"tool_pause:{pause_tool_name}" + cycle_end_detail = f"工具 {pause_tool_name} 要求暂停当前思考循环。" + else: + cycle_end_reason = "tool_pause" + cycle_end_detail = "工具要求暂停当前思考循环。" + break + cycle_end_reason = "tool_continue" + cycle_end_detail = "Planner 工具执行完成,继续下一轮内部思考。" + continue + + if not response.content: + cycle_end_reason = "empty_planner_response" + cycle_end_detail = "Planner 没有返回文本或工具调用,本轮思考结束。" + break + except ReqAbortException as exc: + planner_interrupted = True + cycle_end_reason = "planner_interrupted" + cycle_end_detail = "Planner 被新消息打断,当前轮结束。" + self._runtime._update_stage_status( + "Planner 已打断", + str(exc) or "收到外部中断信号", + round_text=round_text, + ) + interrupted_at = time.time() + interrupted_stage_label = "Planner" + interrupted_text = "Planner 收到新消息,开始重新决策" + interrupted_response = ChatResponse( + content=interrupted_text or None, + tool_calls=[], + request_messages=[], + raw_message=AssistantMessage( + content=interrupted_text, + timestamp=datetime.now(), + tool_calls=[], + source_kind="perception", + ), + selected_history_count=len(self._runtime._chat_history), + tool_count=len(action_tool_definitions), + prompt_tokens=0, + built_message_count=0, + completion_tokens=0, + total_tokens=0, + prompt_section=None, + ) + interrupted_extra_lines = [ + "状态:已被新消息打断", + f"打断位置:{interrupted_stage_label} 请求流式响应阶段", + f"打断耗时:{interrupted_at - current_stage_started_at:.3f} 秒", + ] + response = interrupted_response + planner_extra_lines = interrupted_extra_lines + logger.info( + f"{self._runtime.log_prefix} {interrupted_stage_label} 打断成功: " + f"回合={round_index + 1} " + f"开始时间={current_stage_started_at:.3f} " + f"打断时间={interrupted_at:.3f} " + f"耗时={interrupted_at - current_stage_started_at:.3f} 秒" + ) + if not self._should_retry_planner_after_interrupt( + round_index=round_index, + max_internal_rounds=self._runtime._max_internal_rounds, + has_pending_messages=self._runtime._has_pending_messages(), + ): + break + + await self._runtime._wait_for_message_quiet_period() + self._runtime._message_turn_scheduled = False + interrupted_messages = self._runtime._collect_pending_messages() + if not interrupted_messages: + break + + asyncio.create_task(self._runtime._trigger_batch_learning(interrupted_messages)) + await self._ingest_messages(interrupted_messages) + anchor_message = interrupted_messages[-1] + logger.info( + f"{self._runtime.log_prefix} 淇濇寔娲昏穬鐘舵€侊紝璺宠繃 Timing Gate 鐩存帴閲嶈瘯 Planner: " + f"鍥炲悎={round_index + 2}" + ) + continue + finally: + completed_cycle = self._end_cycle(cycle_detail) + if ( + round_index + 1 >= self._runtime._max_internal_rounds + and cycle_end_reason in {"continue", "tool_continue"} + ): + cycle_end_reason = "max_rounds" + cycle_end_detail = ( + f"已达到内部思考轮次上限 {self._runtime._max_internal_rounds}," + "本轮处理结束。" + ) + self._runtime._render_context_usage_panel( + cycle_id=cycle_detail.cycle_id, + time_records=dict(completed_cycle.time_records), + timing_selected_history_count=( + timing_response.selected_history_count if timing_response is not None else None + ), + timing_prompt_tokens=( + timing_response.prompt_tokens if timing_response is not None else None + ), + timing_action=timing_action or "", + timing_response=timing_response.content or "" if timing_response is not None else "", + timing_tool_calls=timing_response.tool_calls if timing_response is not None else None, + timing_tool_results=timing_tool_results, + timing_tool_detail_results=timing_tool_monitor_results, + timing_prompt_section=( + timing_response.prompt_section if timing_response is not None else None + ), + planner_selected_history_count=( + response.selected_history_count if response is not None else None + ), + planner_prompt_tokens=response.prompt_tokens if response is not None else None, + planner_response=response.content or "" if response is not None else "", + planner_tool_calls=response.tool_calls if response is not None else None, + planner_tool_results=tool_result_summaries, + planner_tool_detail_results=tool_monitor_results, + planner_prompt_section=response.prompt_section if response is not None else None, + planner_extra_lines=planner_extra_lines, + ) + await emit_planner_finalized( + session_id=self._runtime.session_id, + cycle_id=cycle_detail.cycle_id, + timing_request_messages=( + timing_response.request_messages if timing_response is not None else None + ), + timing_selected_history_count=( + timing_response.selected_history_count if timing_response is not None else None + ), + timing_tool_count=timing_response.tool_count if timing_response is not None else None, + timing_action=timing_action, + timing_content=timing_response.content if timing_response is not None else None, + timing_tool_calls=timing_response.tool_calls if timing_response is not None else None, + timing_tool_results=timing_tool_results, + timing_prompt_tokens=timing_response.prompt_tokens if timing_response is not None else None, + timing_completion_tokens=( + timing_response.completion_tokens if timing_response is not None else None + ), + timing_total_tokens=timing_response.total_tokens if timing_response is not None else None, + timing_duration_ms=timing_duration_ms if timing_response is not None else None, + planner_request_messages=response.request_messages if response is not None else None, + planner_selected_history_count=( + response.selected_history_count if response is not None else None + ), + planner_tool_count=response.tool_count if response is not None else None, + planner_content=response.content if response is not None else None, + planner_tool_calls=response.tool_calls if response is not None else None, + planner_prompt_tokens=response.prompt_tokens if response is not None else None, + planner_completion_tokens=( + response.completion_tokens if response is not None else None + ), + planner_total_tokens=response.total_tokens if response is not None else None, + planner_duration_ms=planner_duration_ms if response is not None else None, + planner_prompt_html_uri=response.prompt_html_uri if response is not None else None, + tools=tool_monitor_results, + time_records=dict(completed_cycle.time_records), + agent_state=self._runtime._agent_state, + planner_interrupted=planner_interrupted, + end_reason=cycle_end_reason, + end_detail=cycle_end_detail, + ) + await emit_cycle_end( + session_id=self._runtime.session_id, + cycle_id=cycle_detail.cycle_id, + time_records=dict(completed_cycle.time_records), + agent_state=self._runtime._agent_state, + end_reason=cycle_end_reason, + end_detail=cycle_end_detail, + ) + if not planner_interrupted: + round_index += 1 + finally: + if self._runtime._agent_state == self._runtime._STATE_RUNNING: + self._runtime._agent_state = self._runtime._STATE_STOP + if self._runtime._running: + self._runtime._update_stage_status("等待消息", "本轮处理结束") + except asyncio.CancelledError: + self._runtime._log_internal_loop_cancelled() + raise + except Exception: + logger.exception(f"{self._runtime.log_prefix} Maisaka 内部循环发生异常") + logger.error(traceback.format_exc()) + raise + + def _drain_ready_turn_triggers( + self, + queued_trigger: Literal["message", "timeout"], + ) -> tuple[bool, bool]: + """合并当前已就绪的消息触发信号。""" + + message_triggered = queued_trigger == "message" + timeout_triggered = queued_trigger == "timeout" + + while True: + try: + next_trigger = self._runtime._internal_turn_queue.get_nowait() + except asyncio.QueueEmpty: + break + + if next_trigger == "message": + message_triggered = True + continue + if next_trigger == "timeout": + timeout_triggered = True + continue + + return message_triggered, timeout_triggered + + def _get_timeout_anchor_message(self) -> Optional[SessionMessage]: + """在 wait 超时后复用最近一条真实用户消息作为锚点。""" + if self._runtime.message_cache: + return self._runtime.message_cache[-1] + return None + + def _build_wait_completed_message(self, *, has_new_messages: bool) -> ToolResultMessage: + """构造 wait 完成后的工具结果消息。""" + tool_call_id = self._runtime._pending_wait_tool_call_id or "wait_timeout" + self._runtime._pending_wait_tool_call_id = None + content = ( + "等待已结束,期间收到了新的用户输入。请结合这些新消息继续下一轮思考。" + if has_new_messages + else "等待已超时,期间没有收到新的用户输入。请基于现有上下文继续下一轮思考。" + ) + return ToolResultMessage( + content=content, + timestamp=datetime.now(), + tool_call_id=tool_call_id, + tool_name="wait", + ) + + async def _ingest_messages(self, messages: list[SessionMessage]) -> None: + """处理传入消息列表,将其转换为历史消息并加入聊天历史缓存。""" + for message in messages: + history_message = await self._build_history_message(message) + if history_message is None: + continue + + self._insert_chat_history_message(history_message) + + # 向监控前端广播新消息注入事件 + user_info = message.message_info.user_info + speaker_name = user_info.user_cardname or user_info.user_nickname or user_info.user_id + await emit_message_ingested( + session_id=self._runtime.session_id, + speaker_name=speaker_name, + content=(message.processed_plain_text or "").strip(), + message_id=message.message_id, + timestamp=message.timestamp.timestamp(), + ) + + async def _build_history_message( + self, + message: SessionMessage, + *, + source_kind: str = "user", + ) -> Optional[LLMContextMessage]: + """根据真实消息构造对应的上下文消息。""" + + source_sequence = message.raw_message + visible_text = self._build_legacy_visible_text(message, source_sequence, source_kind=source_kind) + planner_prefix = build_planner_user_prefix_from_session_message(message) + if contains_complex_message(source_sequence): + return ComplexSessionMessage.from_session_message( + message, + planner_prefix=planner_prefix, + visible_text=visible_text, + source_kind=source_kind, + ) + + user_sequence = await self._build_message_sequence(message, planner_prefix=planner_prefix) + if not user_sequence.components: + return None + + return SessionBackedMessage.from_session_message( + message, + raw_message=user_sequence, + visible_text=visible_text, + source_kind=source_kind, + ) + + async def _build_message_sequence( + self, + message: SessionMessage, + *, + planner_prefix: str, + ) -> MessageSequence: + message_sequence = build_prefixed_message_sequence(message.raw_message, planner_prefix) + if resolve_enable_visual_planner(): + await self._hydrate_visual_components(message_sequence.components) + return message_sequence + + async def _hydrate_visual_components(self, planner_components: list[object]) -> None: + """在 Maisaka 真正需要图片或表情时,按需回填二进制数据。""" + load_tasks: list[asyncio.Task[None]] = [] + for component in planner_components: + if isinstance(component, ImageComponent) and not component.binary_data: + load_tasks.append(asyncio.create_task(component.load_image_binary())) + continue + if isinstance(component, EmojiComponent) and not component.binary_data: + load_tasks.append(asyncio.create_task(component.load_emoji_binary())) + + if not load_tasks: + return + + results = await asyncio.gather(*load_tasks, return_exceptions=True) + for result in results: + if isinstance(result, Exception): + logger.warning(f"{self._runtime.log_prefix} 回填图片或表情二进制数据失败,Maisaka 将退化为文本占位: {result}") + + async def _refresh_chat_history_visual_placeholders(self) -> int: + """在进入新一轮规划前,尝试用已完成的识图结果刷新历史占位。""" + + return await refresh_chat_history_visual_placeholders( + chat_history=self._runtime._chat_history, + build_history_message=lambda message, source_kind: self._build_history_message( + message, + source_kind=source_kind, + ), + build_visible_text=lambda message, source_kind: self._build_legacy_visible_text( + message, + message.raw_message, + source_kind=source_kind, + ), + ) + + def _build_legacy_visible_text( + self, + message: SessionMessage, + source_sequence: MessageSequence, + *, + source_kind: str = "user", + ) -> str: + return build_session_message_visible_text( + message, + source_sequence, + include_reply_components=source_kind != "guided_reply", + ) + + def _insert_chat_history_message(self, message: LLMContextMessage) -> int: + """将消息按处理顺序追加到聊天历史末尾。""" + self._runtime._chat_history.append(message) + return len(self._runtime._chat_history) - 1 + + def _start_cycle(self) -> CycleDetail: + """开始一轮 Maisaka 思考循环。""" + self._runtime._cycle_counter += 1 + self._runtime._current_cycle_detail = CycleDetail(cycle_id=self._runtime._cycle_counter) + self._runtime._current_cycle_detail.thinking_id = f"maisaka_tid{round(time.time(), 2)}" + return self._runtime._current_cycle_detail + + def _end_cycle(self, cycle_detail: CycleDetail, only_long_execution: bool = True) -> CycleDetail: + """结束并记录一轮 Maisaka 思考循环。""" + cycle_detail.end_time = time.time() + self._runtime.history_loop.append(cycle_detail) + self._post_process_chat_history_after_cycle() + + timer_strings = [ + f"{name}: {duration:.2f}s" + for name, duration in cycle_detail.time_records.items() + if not only_long_execution or duration >= 0.1 + ] + self._runtime._log_cycle_completed(cycle_detail, timer_strings) + return cycle_detail + + def _post_process_chat_history_after_cycle(self) -> None: + """裁剪聊天历史,保证用户消息数量不超过配置限制。""" + process_result = process_chat_history_after_cycle( + self._runtime._chat_history, + max_context_size=self._runtime._max_context_size, + ) + if process_result.changed_count <= 0: + return + + self._runtime._chat_history = process_result.history + if process_result.removed_count <= 0: + return + self._runtime._log_history_trimmed( + process_result.removed_count, + process_result.remaining_context_count, + ) + + @staticmethod + def _calculate_similarity(text1: str, text2: str) -> float: + """计算两个文本之间的相似度。 + + Args: + text1: 第一个文本 + text2: 第二个文本 + + Returns: + float: 相似度值,范围 0-1,1 表示完全相同 + """ + return difflib.SequenceMatcher(None, text1, text2).ratio() + + def _should_replace_reasoning(self, current_content: str) -> bool: + """判断是否需要替换推理内容。 + + 当当前推理内容与上一次相似度大于90%时,返回True。 + + Args: + current_content: 当前的推理内容 + + Returns: + bool: 是否需要替换 + """ + if not self._last_reasoning_content or not current_content: + logger.info( + f"{self._runtime.log_prefix} 跳过思考相似度判定: " + f"上一轮为空={not bool(self._last_reasoning_content)} " + f"当前为空={not bool(current_content)} 相似度=0.00" + ) + return False + + similarity = self._calculate_similarity(current_content, self._last_reasoning_content) + logger.debug(f"{self._runtime.log_prefix} 思考内容相似度: {similarity:.2f}") + return similarity > 0.9 + + @staticmethod + def _post_process_reply_text(reply_text: str) -> list[str]: + """沿用旧回复链的文本后处理,执行分段与错别字注入。""" + return BuiltinToolRuntimeContext.post_process_reply_text(reply_text) + + def _build_tool_invocation(self, tool_call: ToolCall, latest_thought: str) -> ToolInvocation: + """将模型输出的工具调用转换为统一调用对象。 + + Args: + tool_call: 模型返回的工具调用。 + latest_thought: 当前轮的最新思考文本。 + + Returns: + ToolInvocation: 统一工具调用对象。 + """ + + return ToolInvocation( + tool_name=tool_call.func_name, + arguments=dict(tool_call.args or {}), + call_id=tool_call.call_id, + session_id=self._runtime.session_id, + stream_id=self._runtime.session_id, + reasoning=latest_thought, + ) + + def _build_tool_availability_context(self) -> ToolAvailabilityContext: + """构造当前聊天的工具暴露上下文。""" + + chat_stream = self._runtime.chat_stream + return ToolAvailabilityContext( + session_id=self._runtime.session_id, + stream_id=self._runtime.session_id, + is_group_chat=chat_stream.is_group_session, + group_id=str(getattr(chat_stream, "group_id", "") or "").strip(), + user_id=str(getattr(chat_stream, "user_id", "") or "").strip(), + platform=str(getattr(chat_stream, "platform", "") or "").strip(), + ) + + def _build_tool_execution_context( + self, + latest_thought: str, + anchor_message: SessionMessage, + ) -> ToolExecutionContext: + """构造统一工具执行上下文。 + + Args: + latest_thought: 当前轮的最新思考文本。 + anchor_message: 当前轮的锚点消息。 + + Returns: + ToolExecutionContext: 统一工具执行上下文。 + """ + + return ToolExecutionContext( + session_id=self._runtime.session_id, + stream_id=self._runtime.session_id, + reasoning=latest_thought, + metadata={"anchor_message": anchor_message}, + ) + + @staticmethod + def _normalize_tool_record_value(value: Any) -> Any: + """将工具记录中的任意值规范化为可序列化结构。 + + Args: + value: 原始值。 + + Returns: + Any: 适合写入 JSON 的规范化结果。 + """ + + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, dict): + normalized_dict: dict[str, Any] = {} + for key, item in value.items(): + normalized_dict[str(key)] = MaisakaReasoningEngine._normalize_tool_record_value(item) + return normalized_dict + if isinstance(value, (list, tuple, set)): + return [MaisakaReasoningEngine._normalize_tool_record_value(item) for item in value] + if isinstance(value, bytes): + return f"" + if hasattr(value, "model_dump"): + try: + return MaisakaReasoningEngine._normalize_tool_record_value(value.model_dump()) + except Exception: + return str(value) + if hasattr(value, "__dict__"): + try: + return MaisakaReasoningEngine._normalize_tool_record_value(dict(value.__dict__)) + except Exception: + return str(value) + return str(value) + + @staticmethod + def _truncate_tool_record_text(text: str, max_length: int = 180) -> str: + """截断工具记录中的展示文本。 + + Args: + text: 原始文本。 + max_length: 最长保留字符数。 + + Returns: + str: 截断后的文本。 + """ + + normalized_text = text.strip() + if len(normalized_text) <= max_length: + return normalized_text + return f"{normalized_text[: max_length - 1]}…" + + def _build_tool_record_payload( + self, + invocation: ToolInvocation, + result: ToolExecutionResult, + tool_spec: Optional[ToolSpec], + ) -> dict[str, Any]: + """构造统一工具落库数据。 + + Args: + invocation: 工具调用对象。 + result: 工具执行结果。 + tool_spec: 对应的工具声明。 + + Returns: + dict[str, Any]: 可直接写入数据库的工具记录数据。 + """ + + payload: dict[str, Any] = { + "call_id": invocation.call_id, + "session_id": invocation.session_id, + "stream_id": invocation.stream_id, + "arguments": self._normalize_tool_record_value(invocation.arguments), + "success": result.success, + "content": result.content, + "error_message": result.error_message, + "history_content": result.get_history_content(), + "structured_content": self._normalize_tool_record_value(result.structured_content), + "metadata": self._normalize_tool_record_value(result.metadata), + } + if tool_spec is not None: + payload["provider_name"] = tool_spec.provider_name + payload["provider_type"] = tool_spec.provider_type + payload["brief_description"] = tool_spec.brief_description + payload["detailed_description"] = tool_spec.detailed_description + payload["title"] = tool_spec.title + return payload + + def _build_tool_display_prompt( + self, + invocation: ToolInvocation, + result: ToolExecutionResult, + tool_spec: Optional[ToolSpec], + ) -> str: + """构造展示给历史回放与 UI 的工具摘要。 + + Args: + invocation: 工具调用对象。 + result: 工具执行结果。 + tool_spec: 对应的工具声明。 + + Returns: + str: 用于展示的工具摘要文本。 + """ + + custom_display_prompt = result.metadata.get("record_display_prompt") + if isinstance(custom_display_prompt, str) and custom_display_prompt.strip(): + return custom_display_prompt.strip() + + structured_content = ( + result.structured_content + if isinstance(result.structured_content, dict) + else {} + ) + history_content = self._truncate_tool_record_text(result.get_history_content(), max_length=200) + normalized_args = self._normalize_tool_record_value(invocation.arguments) + + if invocation.tool_name == "reply": + target_user_name = str(structured_content.get("target_user_name") or "对方").strip() or "对方" + reply_text = str(structured_content.get("reply_text") or "").strip() + if result.success and reply_text: + return f"你对{target_user_name}进行了回复:{reply_text}" + target_message_id = str(invocation.arguments.get("msg_id") or "").strip() + error_text = self._truncate_tool_record_text(result.error_message or history_content, max_length=120) + return f"你尝试回复消息 {target_message_id or 'unknown'},但失败了:{error_text}" + + if invocation.tool_name == "send_emoji": + if result.success: + return "你发送了表情包。" + return f"你尝试发送表情包,但失败了:{self._truncate_tool_record_text(result.error_message or history_content, 120)}" + + if invocation.tool_name == "wait": + wait_seconds = invocation.arguments.get("seconds", 30) + return f"你让当前对话先等待 {wait_seconds} 秒。" + + if invocation.tool_name == "no_reply": + return "你暂停了当前对话循环,等待新的外部消息。" + + if invocation.tool_name == "finish": + return "你结束了本轮思考,等待新的外部消息后再继续。" + + if invocation.tool_name == "continue": + return "你允许当前对话继续进入下一轮完整思考与工具执行。" + + if invocation.tool_name == "query_jargon": + words = invocation.arguments.get("words", []) + if isinstance(words, list): + words_text = "、".join(str(item).strip() for item in words if str(item).strip()) + else: + words_text = "" + if words_text: + return f"你查询了这些黑话或词条:{words_text}" + return "你查询了一次黑话或词条信息。" + + if invocation.tool_name == "query_person_info": + person_name = str(invocation.arguments.get("person_name") or "").strip() + if person_name: + return f"你查询了人物信息:{person_name}" + return "你查询了一次人物信息。" + + if invocation.tool_name == "query_memory": + query_text = str(invocation.arguments.get("query") or "").strip() + mode = str(invocation.arguments.get("mode") or "search").strip() or "search" + hit_items = structured_content.get("hits") + hit_count = len(hit_items) if isinstance(hit_items, list) else 0 + if query_text: + return f"你查询了长期记忆:{query_text}(模式:{mode},命中 {hit_count} 条)" + return f"你按时间范围查询了一次长期记忆(模式:{mode},命中 {hit_count} 条)。" + + if invocation.tool_name == "view_complex_message": + target_message_id = str(invocation.arguments.get("msg_id") or "").strip() + if target_message_id: + return f"你查看了复杂消息 {target_message_id} 的完整内容。" + return "你查看了一条复杂消息的完整内容。" + + brief_description = "" + if tool_spec is not None: + brief_description = tool_spec.brief_description.strip() + + if normalized_args: + arguments_text = self._truncate_tool_record_text( + json.dumps(normalized_args, ensure_ascii=False), + max_length=160, + ) + else: + arguments_text = "{}" + + if result.success: + if brief_description: + return f"{brief_description} 参数={arguments_text};结果:{history_content or '执行成功'}" + return f"你调用了工具 {invocation.tool_name},参数={arguments_text};结果:{history_content or '执行成功'}" + + error_text = self._truncate_tool_record_text(result.error_message or history_content, max_length=160) + return f"你调用了工具 {invocation.tool_name},参数={arguments_text};执行失败:{error_text}" + + async def _store_tool_execution_record( + self, + invocation: ToolInvocation, + result: ToolExecutionResult, + tool_spec: Optional[ToolSpec], + ) -> None: + """将工具执行结果落库到统一工具记录表。 + + Args: + invocation: 工具调用对象。 + result: 工具执行结果。 + tool_spec: 对应的工具声明。 + """ + + if self._runtime.chat_stream is None: + logger.debug( + f"{self._runtime.log_prefix} 当前没有 chat_stream,跳过工具记录存储: " + f"工具={invocation.tool_name}" + ) + return + + builtin_prompt = "" + if tool_spec is not None: + builtin_prompt = tool_spec.build_llm_description() + + try: + tool_record_payload = self._build_tool_record_payload(invocation, result, tool_spec) + saved_record = await database_api.store_tool_info( + chat_stream=self._runtime.chat_stream, + builtin_prompt=builtin_prompt, + display_prompt=self._build_tool_display_prompt(invocation, result, tool_spec), + tool_id=invocation.call_id, + tool_data=tool_record_payload, + tool_name=invocation.tool_name, + tool_reasoning=invocation.reasoning, + ) + except Exception: + logger.exception( + f"{self._runtime.log_prefix} 写入工具记录失败: 工具={invocation.tool_name} 调用编号={invocation.call_id}" + ) + return + + if invocation.tool_name == "query_memory" and isinstance(saved_record, dict): + try: + enqueue_payload = await memory_service.enqueue_feedback_task( + query_tool_id=str(saved_record.get("tool_id") or invocation.call_id or "").strip(), + session_id=str(saved_record.get("session_id") or self._runtime.chat_stream.session_id or "").strip(), + query_timestamp=saved_record.get("timestamp"), + structured_content=tool_record_payload.get("structured_content") + if isinstance(tool_record_payload.get("structured_content"), dict) + else {}, + ) + except Exception: + logger.exception( + f"{self._runtime.log_prefix} 反馈纠错任务入队失败: tool_call_id={invocation.call_id}" + ) + else: + if not bool(enqueue_payload.get("success")): + logger.debug( + f"{self._runtime.log_prefix} 反馈纠错任务未入队: " + f"tool_call_id={invocation.call_id} reason={enqueue_payload.get('reason', '')}" + ) + + def _append_tool_execution_result(self, tool_call: ToolCall, result: ToolExecutionResult) -> None: + """将统一工具执行结果写回 Maisaka 历史。 + + Args: + tool_call: 原始工具调用对象。 + result: 统一工具执行结果。 + """ + + if tool_call.func_name in HISTORY_SILENT_TOOL_NAMES: + self._remove_tool_call_from_history(tool_call) + return + + history_content = result.get_history_content() + if not history_content: + history_content = "工具执行成功。" if result.success else f"工具 {tool_call.func_name} 执行失败。" + + self._runtime._chat_history.append( + ToolResultMessage( + content=history_content, + timestamp=datetime.now(), + tool_call_id=tool_call.call_id, + tool_name=tool_call.func_name, + success=result.success, + ) + ) + + def _remove_tool_call_from_history(self, tool_call: ToolCall) -> None: + """从历史里的 assistant 消息中移除控制类工具调用。""" + + tool_call_id = str(tool_call.call_id or "").strip() + if not tool_call_id: + return + + for index in range(len(self._runtime._chat_history) - 1, -1, -1): + message = self._runtime._chat_history[index] + if not isinstance(message, AssistantMessage) or not message.tool_calls: + continue + + remaining_tool_calls = [ + existing_tool_call + for existing_tool_call in message.tool_calls + if str(existing_tool_call.call_id or "").strip() != tool_call_id + ] + if len(remaining_tool_calls) == len(message.tool_calls): + continue + + if remaining_tool_calls: + message.tool_calls = remaining_tool_calls + elif message.content.strip(): + message.tool_calls = [] + else: + del self._runtime._chat_history[index] + return + + def _append_timing_gate_execution_result( + self, + response: ChatResponse, + tool_call: ToolCall, + result: ToolExecutionResult, + ) -> None: + """将 Timing Gate 的决策链写入历史,供后续门控复用。""" + + self._runtime._chat_history.append( + AssistantMessage( + content=response.content or "", + timestamp=response.raw_message.timestamp, + tool_calls=[tool_call], + source_kind="timing_gate", + ) + ) + if tool_call.func_name == "wait": + return + self._append_tool_execution_result(tool_call, result) + + def _build_tool_result_summary(self, tool_call: ToolCall, result: ToolExecutionResult) -> str: + """构建用于终端展示的工具结果摘要。""" + + history_content = result.get_history_content().strip() + if not history_content: + history_content = result.error_message.strip() + if not history_content: + history_content = "执行成功" if result.success else "执行失败" + + summary_prefix = "[成功]" if result.success else "[失败]" + normalized_content = self._truncate_tool_record_text(history_content, max_length=200) + return f"- {tool_call.func_name} {summary_prefix}: {normalized_content}" + + def _build_tool_monitor_result( + self, + tool_call: ToolCall, + invocation: ToolInvocation, + result: ToolExecutionResult, + duration_ms: float, + tool_spec: Optional[ToolSpec] = None, + ) -> dict[str, Any]: + """构建 planner.finalized 中单个工具的监控结果。""" + + monitor_detail = result.metadata.get("monitor_detail") + normalized_detail = None + if monitor_detail is not None: + normalized_detail = self._normalize_tool_record_value(monitor_detail) + + monitor_card = result.metadata.get("monitor_card") + normalized_card = None + if monitor_card is not None: + normalized_card = self._normalize_tool_record_value(monitor_card) + + monitor_sub_cards = result.metadata.get("monitor_sub_cards") + normalized_sub_cards = None + if monitor_sub_cards is not None: + normalized_sub_cards = self._normalize_tool_record_value(monitor_sub_cards) + + return { + "tool_call_id": tool_call.call_id, + "tool_name": tool_call.func_name, + "tool_title": tool_spec.title.strip() if tool_spec is not None and tool_spec.title.strip() else "", + "tool_args": self._normalize_tool_record_value( + invocation.arguments if isinstance(invocation.arguments, dict) else {} + ), + "success": result.success, + "duration_ms": round(duration_ms, 2), + "summary": self._build_tool_result_summary(tool_call, result), + "detail": normalized_detail, + "card": normalized_card, + "sub_cards": normalized_sub_cards, + } + + async def _handle_tool_calls( + self, + tool_calls: list[ToolCall], + latest_thought: str, + anchor_message: SessionMessage, + ) -> tuple[bool, str, list[str], list[dict[str, Any]]]: + """执行一批统一工具调用。 + + Args: + tool_calls: 模型返回的工具调用列表。 + latest_thought: 当前轮的最新思考文本。 + anchor_message: 当前轮的锚点消息。 + + Returns: + tuple[bool, str, list[str], list[dict[str, Any]]]: 是否需要暂停当前思考循环、 + 触发暂停的工具名、工具结果摘要列表,以及最终监控事件使用的工具详情列表。 + """ + + tool_result_summaries: list[str] = [] + tool_monitor_results: list[dict[str, Any]] = [] + + if self._runtime._tool_registry is None: + for tool_call in tool_calls: + invocation = self._build_tool_invocation(tool_call, latest_thought) + result = ToolExecutionResult( + tool_name=tool_call.func_name, + success=False, + error_message="统一工具注册表尚未初始化。", + ) + await self._store_tool_execution_record(invocation, result, None) + self._append_tool_execution_result(tool_call, result) + tool_result_summaries.append(self._build_tool_result_summary(tool_call, result)) + tool_monitor_results.append( + self._build_tool_monitor_result(tool_call, invocation, result, duration_ms=0.0, tool_spec=None) + ) + return False, "", tool_result_summaries, tool_monitor_results + + execution_context = self._build_tool_execution_context(latest_thought, anchor_message) + availability_context = self._build_tool_availability_context() + tool_spec_map = { + tool_spec.name: tool_spec + for tool_spec in await self._runtime._tool_registry.list_tools(availability_context) + } + total_tool_count = len(tool_calls) + for tool_index, tool_call in enumerate(tool_calls, start=1): + invocation = self._build_tool_invocation(tool_call, latest_thought) + self._runtime._update_stage_status( + f"工具执行 · {invocation.tool_name}", + f"第 {tool_index}/{total_tool_count} 个工具", + ) + tool_started_at = time.time() + if not self._runtime.is_action_tool_currently_available(invocation.tool_name): + result = ToolExecutionResult( + tool_name=invocation.tool_name, + success=False, + error_message=( + f"工具 {invocation.tool_name} 当前未直接暴露给 planner。" + "如果它在 deferred tools 提示中,请先调用 tool_search。" + ), + ) + else: + result = await self._runtime._tool_registry.invoke(invocation, execution_context) + tool_duration_ms = (time.time() - tool_started_at) * 1000 + await self._store_tool_execution_record( + invocation, + result, + tool_spec_map.get(invocation.tool_name), + ) + self._append_tool_execution_result(tool_call, result) + tool_result_summaries.append(self._build_tool_result_summary(tool_call, result)) + tool_monitor_results.append( + self._build_tool_monitor_result( + tool_call, + invocation, + result, + tool_duration_ms, + tool_spec=tool_spec_map.get(invocation.tool_name), + ) + ) + + if not result.success and tool_call.func_name == "reply": + logger.warning(f"{self._runtime.log_prefix} 回复工具未生成可见消息,将继续下一轮循环") + + if bool(result.metadata.get("pause_execution", False)): + return True, invocation.tool_name, tool_result_summaries, tool_monitor_results + + return False, "", tool_result_summaries, tool_monitor_results diff --git a/src/maisaka/reply_effect/__init__.py b/src/maisaka/reply_effect/__init__.py new file mode 100644 index 00000000..efccf32e --- /dev/null +++ b/src/maisaka/reply_effect/__init__.py @@ -0,0 +1,5 @@ +"""Maisaka 回复效果观察器。""" + +from .tracker import ReplyEffectTracker + +__all__ = ["ReplyEffectTracker"] diff --git a/src/maisaka/reply_effect/image_utils.py b/src/maisaka/reply_effect/image_utils.py new file mode 100644 index 00000000..98b3e46a --- /dev/null +++ b/src/maisaka/reply_effect/image_utils.py @@ -0,0 +1,100 @@ +"""回复效果记录中的图片/表情附件提取工具。""" + +from base64 import b64encode +from pathlib import Path +from typing import Any + +from src.common.data_models.message_component_data_model import EmojiComponent, ImageComponent, MessageSequence + + +_MAX_INLINE_IMAGE_BYTES = 2 * 1024 * 1024 + + +def extract_visual_attachments_from_sequence(message_sequence: MessageSequence | None) -> list[dict[str, Any]]: + """从消息片段中提取可供评分页面展示的图片/表情信息。""" + + if message_sequence is None: + return [] + + attachments: list[dict[str, Any]] = [] + for index, component in enumerate(message_sequence.components): + if isinstance(component, ImageComponent): + attachments.append(_build_visual_attachment(component, index=index, kind="image")) + elif isinstance(component, EmojiComponent): + attachments.append(_build_visual_attachment(component, index=index, kind="emoji")) + return attachments + + +def _build_visual_attachment(component: ImageComponent | EmojiComponent, *, index: int, kind: str) -> dict[str, Any]: + binary_hash = str(component.binary_hash or "").strip() + attachment: dict[str, Any] = { + "kind": kind, + "index": index, + "hash": binary_hash, + "content": str(component.content or "").strip(), + "path": "", + "data_url": "", + } + + file_path = _resolve_image_path(binary_hash, kind=kind) + if file_path: + attachment["path"] = str(file_path) + attachment["file_name"] = file_path.name + attachment["mime_type"] = _guess_mime_type(file_path.suffix) + return attachment + + binary_data = bytes(component.binary_data or b"") + if binary_data and len(binary_data) <= _MAX_INLINE_IMAGE_BYTES: + mime_type = _guess_mime_type_from_bytes(binary_data) + attachment["mime_type"] = mime_type + attachment["data_url"] = f"data:{mime_type};base64,{b64encode(binary_data).decode('ascii')}" + return attachment + + +def _resolve_image_path(binary_hash: str, *, kind: str) -> Path | None: + if not binary_hash: + return None + + try: + from sqlmodel import select + + from src.common.database.database import get_db_session + from src.common.database.database_model import Images, ImageType + + image_type = ImageType.EMOJI if kind == "emoji" else ImageType.IMAGE + with get_db_session() as db: + statement = select(Images).filter_by(image_hash=binary_hash, image_type=image_type).limit(1) + image_record = db.exec(statement).first() + if image_record is None or getattr(image_record, "no_file_flag", False): + return None + file_path = Path(str(image_record.full_path or "")).expanduser().resolve() + if file_path.is_file(): + return file_path + except Exception: + return None + return None + + +def _guess_mime_type(suffix: str) -> str: + normalized_suffix = suffix.lower().lstrip(".") + if normalized_suffix in {"jpg", "jpeg"}: + return "image/jpeg" + if normalized_suffix == "gif": + return "image/gif" + if normalized_suffix == "webp": + return "image/webp" + if normalized_suffix == "bmp": + return "image/bmp" + return "image/png" + + +def _guess_mime_type_from_bytes(binary_data: bytes) -> str: + if binary_data.startswith(b"\xff\xd8\xff"): + return "image/jpeg" + if binary_data.startswith(b"GIF8"): + return "image/gif" + if binary_data.startswith(b"RIFF") and b"WEBP" in binary_data[:16]: + return "image/webp" + if binary_data.startswith(b"BM"): + return "image/bmp" + return "image/png" diff --git a/src/maisaka/reply_effect/judge.py b/src/maisaka/reply_effect/judge.py new file mode 100644 index 00000000..58b20c74 --- /dev/null +++ b/src/maisaka/reply_effect/judge.py @@ -0,0 +1,116 @@ +"""回复效果 LLM 窄维度评审。""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any, Dict, List, Tuple + +import json + +from .models import FollowupMessageSnapshot, ReplyEffectRecord, RubricScoreItem, RubricScores +from .scoring import normalize_text_for_prompt + +JudgeRunner = Callable[[str], Awaitable[str]] + + +async def judge_reply_effect(record: ReplyEffectRecord, judge_runner: JudgeRunner | None) -> Tuple[RubricScores, str]: + """执行 LLM rubric judge,失败时返回中性分。""" + + if judge_runner is None: + return RubricScores(), "未提供 LLM judge runner" + + prompt = build_judge_prompt(record) + try: + response_text = await judge_runner(prompt) + payload = _loads_json_object(response_text) + return parse_rubric_scores(payload), "" + except Exception as exc: + return RubricScores(), str(exc) + + +def build_judge_prompt(record: ReplyEffectRecord) -> str: + """构建窄维度评分 prompt。""" + + followup_text = _format_followups(record.followup_messages) + return ( + "你是 Maisaka 回复效果的窄维度评审器,只评估这一次 bot 回复的交互感知质量。\n" + "不要评价总体满意度,不要给建议,只输出 JSON。\n\n" + "评分范围:1 到 5,1=很差,3=中性,5=很好。\n" + "uncanny_risk 的 1=完全不怪,5=非常过度拟人/越界/油腻。\n\n" + f"bot 回复:\n{normalize_text_for_prompt(record.reply.reply_text, 1200)}\n\n" + f"后续用户消息:\n{followup_text or '(暂无后续用户消息)'}\n\n" + "请输出严格 JSON 对象,格式如下:\n" + "{\n" + ' "social_presence": {"score": 3, "reason": "...", "evidence_spans": ["..."], "confidence": 0.7},\n' + ' "warmth": {"score": 3, "reason": "...", "evidence_spans": ["..."], "confidence": 0.7},\n' + ' "competence": {"score": 3, "reason": "...", "evidence_spans": ["..."], "confidence": 0.7},\n' + ' "appropriateness": {"score": 3, "reason": "...", "evidence_spans": ["..."], "confidence": 0.7},\n' + ' "uncanny_risk": {"score": 3, "reason": "...", "evidence_spans": ["..."], "confidence": 0.7}\n' + "}" + ) + + +def parse_rubric_scores(payload: Dict[str, Any]) -> RubricScores: + """解析 LLM rubric JSON。""" + + return RubricScores( + social_presence=_parse_item(payload.get("social_presence")), + warmth=_parse_item(payload.get("warmth")), + competence=_parse_item(payload.get("competence")), + appropriateness=_parse_item(payload.get("appropriateness")), + uncanny_risk=_parse_item(payload.get("uncanny_risk")), + available=True, + ) + + +def _parse_item(raw_item: Any) -> RubricScoreItem: + if not isinstance(raw_item, dict): + raw_item = {} + score = _coerce_float(raw_item.get("score"), 3.0) + score = max(1.0, min(5.0, score)) + evidence_spans = raw_item.get("evidence_spans") + if not isinstance(evidence_spans, list): + evidence_spans = [] + return RubricScoreItem( + score=score, + normalized_score=round((score - 1.0) / 4.0, 4), + reason=str(raw_item.get("reason") or "").strip(), + evidence_spans=[str(item).strip() for item in evidence_spans if str(item).strip()], + confidence=max(0.0, min(1.0, _coerce_float(raw_item.get("confidence"), 0.0))), + ) + + +def _loads_json_object(response_text: str) -> Dict[str, Any]: + normalized_response = str(response_text or "").strip() + if normalized_response.startswith("```"): + normalized_response = normalized_response.strip("`") + if normalized_response.lower().startswith("json"): + normalized_response = normalized_response[4:].strip() + try: + parsed = json.loads(normalized_response) + except json.JSONDecodeError: + start = normalized_response.find("{") + end = normalized_response.rfind("}") + if start < 0 or end <= start: + raise + parsed = json.loads(normalized_response[start : end + 1]) + if not isinstance(parsed, dict): + raise ValueError("LLM judge 未返回 JSON 对象") + return parsed + + +def _format_followups(followups: List[FollowupMessageSnapshot]) -> str: + lines: List[str] = [] + for index, followup in enumerate(followups[:5], start=1): + marker = "目标用户" if followup.is_target_user else "其他用户" + lines.append( + f"{index}. [{marker}] {normalize_text_for_prompt(followup.visible_text or followup.plain_text, 500)}" + ) + return "\n".join(lines) + + +def _coerce_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default diff --git a/src/maisaka/reply_effect/models.py b/src/maisaka/reply_effect/models.py new file mode 100644 index 00000000..628c8c44 --- /dev/null +++ b/src/maisaka/reply_effect/models.py @@ -0,0 +1,167 @@ +"""回复效果观察器的数据模型。""" + +from dataclasses import asdict, dataclass, field +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any, Dict, List, Optional + + +SCHEMA_VERSION = 1 + + +class ReplyEffectStatus(str, Enum): + """回复效果记录状态。""" + + PENDING = "pending" + FINALIZED = "finalized" + + +@dataclass(slots=True) +class SessionSnapshot: + """会话快照。""" + + session_id: str + platform_type_id: str + platform: str + chat_type: str + group_id: str + user_id: str + session_name: str + + +@dataclass(slots=True) +class UserSnapshot: + """用户快照。""" + + user_id: str + nickname: str + cardname: str + + +@dataclass(slots=True) +class ReplySnapshot: + """被观察的回复内容。""" + + tool_call_id: str + target_message_id: str + set_quote: bool + reply_text: str + reply_segments: List[str] + planner_reasoning: str + reference_info: str + tool_context: Dict[str, Any] = field(default_factory=dict) + send_results: List[Dict[str, Any]] = field(default_factory=list) + reply_metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class FollowupMessageSnapshot: + """后续用户消息快照。""" + + message_id: str + timestamp: str + user_id: str + nickname: str + cardname: str + visible_text: str + plain_text: str + latency_seconds: float + is_target_user: bool + quote_target_ids: List[str] = field(default_factory=list) + attachments: List[Dict[str, Any]] = field(default_factory=list) + + +@dataclass(slots=True) +class BehaviorSignals: + """行为满意度信号。""" + + continue_2turns: float = 0.0 + next_user_sentiment: float = 0.5 + user_expansion: float = 0.0 + no_correction: float = 1.0 + no_abort: float = 1.0 + evidence_source: str = "no_followup" + + +@dataclass(slots=True) +class RubricScoreItem: + """单个 LLM rubric 项。""" + + score: float = 3.0 + normalized_score: float = 0.5 + reason: str = "" + evidence_spans: List[str] = field(default_factory=list) + confidence: float = 0.0 + + +@dataclass(slots=True) +class RubricScores: + """LLM 感知质量评分。""" + + social_presence: RubricScoreItem = field(default_factory=RubricScoreItem) + warmth: RubricScoreItem = field(default_factory=RubricScoreItem) + competence: RubricScoreItem = field(default_factory=RubricScoreItem) + appropriateness: RubricScoreItem = field(default_factory=RubricScoreItem) + uncanny_risk: RubricScoreItem = field(default_factory=RubricScoreItem) + available: bool = False + + +@dataclass(slots=True) +class FrictionSignals: + """摩擦和反感信号。""" + + explicit_negative: float = 0.0 + repair_loop: float = 0.0 + uncanny_risk: float = 0.5 + evidence_messages: List[str] = field(default_factory=list) + + +@dataclass(slots=True) +class ReplyEffectScores: + """最终效果评分。""" + + asi: float + behavior_score: float + relational_score: float + friction_score: float + behavior_signals: BehaviorSignals + rubric_scores: RubricScores + friction_signals: FrictionSignals + judge_error: str = "" + + +@dataclass(slots=True) +class ReplyEffectRecord: + """一条回复效果观察记录。""" + + effect_id: str + status: ReplyEffectStatus + created_at: str + updated_at: str + session: SessionSnapshot + reply: ReplySnapshot + target_user: UserSnapshot + context_snapshot: List[Dict[str, Any]] = field(default_factory=list) + followup_messages: List[FollowupMessageSnapshot] = field(default_factory=list) + scores: Optional[ReplyEffectScores] = None + finalized_at: str = "" + finalize_reason: str = "" + confidence_note: str = "" + followup_summary: Dict[str, Any] = field(default_factory=dict) + file_path: Optional[Path] = field(default=None, repr=False) + + def to_json_dict(self) -> Dict[str, Any]: + """转换为可直接写入 JSON 的字典。""" + + payload = asdict(self) + payload["schema_version"] = SCHEMA_VERSION + payload["status"] = self.status.value + payload.pop("file_path", None) + return payload + + +def now_iso() -> str: + """返回本地时区 ISO 时间字符串。""" + + return datetime.now().astimezone().isoformat(timespec="seconds") diff --git a/src/maisaka/reply_effect/path_utils.py b/src/maisaka/reply_effect/path_utils.py new file mode 100644 index 00000000..4114bcbf --- /dev/null +++ b/src/maisaka/reply_effect/path_utils.py @@ -0,0 +1,24 @@ +"""回复效果日志路径工具。""" + +from pathlib import Path + +from src.maisaka.display.preview_path_utils import build_preview_chat_dir_name, normalize_preview_name + +BASE_DIR = Path("logs") / "maisaka_reply_effect" + + +def build_reply_effect_chat_dir_name(session_id: str) -> str: + """构建回复效果日志的会话目录名。""" + + chat_dir_name = build_preview_chat_dir_name(session_id) + normalized_chat_dir_name = normalize_preview_name(chat_dir_name) + if normalized_chat_dir_name != "unknown": + return normalized_chat_dir_name + return "unknown_chat" + + +def build_reply_effect_chat_dir(session_id: str, base_dir: Path | None = None) -> Path: + """返回某个会话对应的回复效果日志目录。""" + + root_dir = base_dir or BASE_DIR + return root_dir / build_reply_effect_chat_dir_name(session_id) diff --git a/src/maisaka/reply_effect/quote_utils.py b/src/maisaka/reply_effect/quote_utils.py new file mode 100644 index 00000000..531ac423 --- /dev/null +++ b/src/maisaka/reply_effect/quote_utils.py @@ -0,0 +1,32 @@ +"""回复效果记录中的引用消息辅助工具。""" + +from typing import Any + +from src.common.data_models.message_component_data_model import MessageSequence, ReplyComponent + + +def extract_quote_target_ids(message_sequence: MessageSequence | None) -> list[str]: + """从消息片段中提取引用回复目标消息 ID。""" + + if message_sequence is None: + return [] + + target_ids: list[str] = [] + for component in getattr(message_sequence, "components", []): + if not isinstance(component, ReplyComponent): + continue + target_message_id = str(component.target_message_id or "").strip() + if target_message_id: + target_ids.append(target_message_id) + return target_ids + + +def message_id_from_context_message(message: Any) -> str: + """尽量从 Maisaka 上下文消息中取真实消息 ID。""" + + message_id = str(getattr(message, "message_id", "") or "").strip() + if message_id: + return message_id + + original_message = getattr(message, "original_message", None) + return str(getattr(original_message, "message_id", "") or "").strip() diff --git a/src/maisaka/reply_effect/scoring.py b/src/maisaka/reply_effect/scoring.py new file mode 100644 index 00000000..41a38b5b --- /dev/null +++ b/src/maisaka/reply_effect/scoring.py @@ -0,0 +1,262 @@ +"""回复效果评分规则。""" + +from __future__ import annotations + +from typing import Iterable, List + +import re + +from .models import BehaviorSignals, FollowupMessageSnapshot, FrictionSignals, ReplyEffectScores, RubricScores + +NEGATIVE_PATTERNS = ( + "你没懂", + "没懂", + "不是这个意思", + "不是", + "别这样", + "好烦", + "烦死", + "算了", + "离谱", + "无语", + "你在说什么", + "听不懂", + "看不懂", + "错了", + "不对", +) +REPAIR_PATTERNS = ( + "我是说", + "我说的是", + "重新说", + "再说一遍", + "不是问", + "你理解错", + "你搞错", + "我问的是", + "纠正", +) +POSITIVE_PATTERNS = ( + "谢谢", + "感谢", + "懂了", + "明白了", + "可以", + "有用", + "不错", + "好耶", + "太好了", +) + + +def clamp(value: float, lower: float = 0.0, upper: float = 1.0) -> float: + """限制数值范围。""" + + return max(lower, min(upper, value)) + + +def score_reply_effect( + followups: List[FollowupMessageSnapshot], + rubric_scores: RubricScores, + *, + target_user_id: str = "", + judge_error: str = "", +) -> ReplyEffectScores: + """计算一条回复的 ASI 分数。""" + + behavior_signals = build_behavior_signals(followups, target_user_id=target_user_id) + friction_signals = build_friction_signals(followups, rubric_scores, target_user_id=target_user_id) + behavior_score = calculate_behavior_score(behavior_signals) + relational_score = calculate_relational_score(rubric_scores) + friction_score = calculate_friction_score(friction_signals) + asi = calculate_asi_score(behavior_score, relational_score, friction_score) + return ReplyEffectScores( + asi=asi, + behavior_score=round(behavior_score, 4), + relational_score=round(relational_score, 4), + friction_score=round(friction_score, 4), + behavior_signals=behavior_signals, + rubric_scores=rubric_scores, + friction_signals=friction_signals, + judge_error=judge_error, + ) + + +def build_behavior_signals( + followups: List[FollowupMessageSnapshot], + *, + target_user_id: str = "", +) -> BehaviorSignals: + """从后续消息构造行为满意度信号。""" + + target_followups = [ + followup + for followup in followups + if target_user_id and followup.user_id == target_user_id + ] + evidence_followups = target_followups or followups + evidence_source = ( + "target_user_feedback" + if target_followups + else "indirect_session_feedback" + if followups + else "no_followup" + ) + if not evidence_followups: + return BehaviorSignals( + continue_2turns=0.0, + next_user_sentiment=0.5, + user_expansion=0.0, + no_correction=1.0, + no_abort=0.6, + evidence_source=evidence_source, + ) + + combined_text = "\n".join(followup.plain_text for followup in evidence_followups) + negative_count = count_matches(combined_text, NEGATIVE_PATTERNS) + repair_count = count_matches(combined_text, REPAIR_PATTERNS) + positive_count = count_matches(combined_text, POSITIVE_PATTERNS) + average_length = sum(len(followup.plain_text.strip()) for followup in evidence_followups) / len(evidence_followups) + + return BehaviorSignals( + continue_2turns=1.0 if len(evidence_followups) >= 2 else 0.5, + next_user_sentiment=estimate_sentiment(positive_count, negative_count, repair_count), + user_expansion=clamp((average_length - 8.0) / 42.0), + no_correction=0.0 if repair_count > 0 else 1.0, + no_abort=0.0 if negative_count >= 2 or "算了" in combined_text else 1.0, + evidence_source=evidence_source, + ) + + +def build_friction_signals( + followups: List[FollowupMessageSnapshot], + rubric_scores: RubricScores, + *, + target_user_id: str = "", +) -> FrictionSignals: + """从后续消息和 LLM judge 结果构造摩擦信号。""" + + evidence_messages: List[str] = [] + explicit_negative = 0.0 + repair_loop = 0.0 + for followup in followups: + text = followup.plain_text + source_weight = 1.0 if target_user_id and followup.user_id == target_user_id else 0.65 + if any(pattern in text for pattern in NEGATIVE_PATTERNS): + explicit_negative = max(explicit_negative, source_weight) + evidence_messages.append(followup.message_id) + if any(pattern in text for pattern in REPAIR_PATTERNS): + repair_loop = max(repair_loop, source_weight) + evidence_messages.append(followup.message_id) + + uncanny_risk = rubric_scores.uncanny_risk.normalized_score if rubric_scores.available else 0.5 + return FrictionSignals( + explicit_negative=round(clamp(explicit_negative), 4), + repair_loop=round(clamp(repair_loop), 4), + uncanny_risk=round(clamp(uncanny_risk), 4), + evidence_messages=sorted(set(evidence_messages)), + ) + + +def calculate_behavior_score(signals: BehaviorSignals) -> float: + """计算行为满意度分数。""" + + return clamp( + 0.30 * signals.continue_2turns + + 0.25 * signals.next_user_sentiment + + 0.20 * signals.user_expansion + + 0.15 * signals.no_correction + + 0.10 * signals.no_abort + ) + + +def calculate_relational_score(rubric_scores: RubricScores) -> float: + """计算感知质量分数。""" + + if not rubric_scores.available: + return 0.5 + return clamp( + 0.35 * rubric_scores.social_presence.normalized_score + + 0.25 * rubric_scores.warmth.normalized_score + + 0.25 * rubric_scores.competence.normalized_score + + 0.15 * rubric_scores.appropriateness.normalized_score + ) + + +def calculate_friction_score(signals: FrictionSignals) -> float: + """计算摩擦惩罚分数。""" + + return clamp( + 0.40 * signals.explicit_negative + + 0.30 * signals.repair_loop + + 0.30 * signals.uncanny_risk + ) + + +def calculate_asi_score(behavior_score: float, relational_score: float, friction_score: float) -> float: + """计算 0-100 的 ASI 总分,摩擦分越高扣分越多。""" + + return round( + clamp( + 0.45 * behavior_score + + 0.35 * relational_score + + 0.20 * (1.0 - friction_score) + ) + * 100, + 2, + ) + + +def has_explicit_negative_feedback( + followups: Iterable[FollowupMessageSnapshot], + *, + target_user_id: str = "", + allow_indirect: bool = False, +) -> bool: + """判断是否出现可提前结算的明确负反馈。""" + + for followup in followups: + if target_user_id and followup.user_id != target_user_id and not allow_indirect: + continue + if any(pattern in followup.plain_text for pattern in NEGATIVE_PATTERNS): + return True + return False + + +def has_repair_loop( + followups: Iterable[FollowupMessageSnapshot], + *, + target_user_id: str = "", + allow_indirect: bool = False, +) -> bool: + """判断是否出现修复循环。""" + + repair_count = 0 + for followup in followups: + if target_user_id and followup.user_id != target_user_id and not allow_indirect: + continue + if any(pattern in followup.plain_text for pattern in REPAIR_PATTERNS): + repair_count += 1 + return repair_count >= 1 + + +def count_matches(text: str, patterns: Iterable[str]) -> int: + """统计模式命中次数。""" + + return sum(1 for pattern in patterns if pattern and pattern in text) + + +def estimate_sentiment(positive_count: int, negative_count: int, repair_count: int) -> float: + """用轻量规则估计后续消息情绪。""" + + raw_score = 0.5 + 0.2 * positive_count - 0.25 * negative_count - 0.15 * repair_count + return round(clamp(raw_score), 4) + + +def normalize_text_for_prompt(text: str, limit: int = 800) -> str: + """清理用于评分 prompt 的文本。""" + + normalized_text = re.sub(r"\s+", " ", str(text or "")).strip() + if len(normalized_text) <= limit: + return normalized_text + return normalized_text[: limit - 1] + "…" diff --git a/src/maisaka/reply_effect/storage.py b/src/maisaka/reply_effect/storage.py new file mode 100644 index 00000000..150336e4 --- /dev/null +++ b/src/maisaka/reply_effect/storage.py @@ -0,0 +1,86 @@ +"""回复效果独立 JSON 存储。""" + +from pathlib import Path +from typing import Dict + +import json +import time + +from .models import ReplyEffectRecord +from .path_utils import BASE_DIR, build_reply_effect_chat_dir, normalize_preview_name + + +class ReplyEffectStorage: + """负责回复效果记录的独立 JSON 文件存储。""" + + _DEFAULT_MAX_RECORDS_PER_CHAT = 256 + _TRIM_COUNT = 100 + + def __init__(self, base_dir: Path | None = None) -> None: + self._base_dir = base_dir or BASE_DIR + + def create_record_file(self, record: ReplyEffectRecord) -> Path: + """为新记录创建文件路径并写入初始 JSON。""" + + chat_dir_name = normalize_preview_name(record.session.platform_type_id) + if chat_dir_name == "unknown": + chat_dir = build_reply_effect_chat_dir(record.session.session_id, self._base_dir).resolve() + else: + chat_dir = (self._base_dir / chat_dir_name).resolve() + chat_dir.mkdir(parents=True, exist_ok=True) + timestamp_ms = int(time.time() * 1000) + safe_effect_id = record.effect_id.replace("-", "") + file_path = chat_dir / f"{timestamp_ms}_{safe_effect_id}.json" + record.file_path = file_path + self.save_record(record) + self._trim_overflow(chat_dir) + return file_path + + def save_record(self, record: ReplyEffectRecord) -> None: + """原子写入记录 JSON。""" + + if record.file_path is None: + self.create_record_file(record) + return + + file_path = record.file_path + file_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = file_path.with_name(f".{file_path.name}.tmp") + temp_path.write_text( + json.dumps(record.to_json_dict(), ensure_ascii=False, indent=2, default=str), + encoding="utf-8", + ) + temp_path.replace(file_path) + + @staticmethod + def read_json(file_path: Path) -> Dict[str, object]: + """读取已保存的 JSON 文件。""" + + return json.loads(file_path.read_text(encoding="utf-8")) + + def _trim_overflow(self, chat_dir: Path) -> None: + """超过容量时删除最旧的回复效果记录。""" + + max_records = self._get_max_records_per_chat() + files = [file_path for file_path in chat_dir.glob("*.json") if file_path.is_file()] + if len(files) <= max_records: + return + + sorted_files = sorted(files, key=lambda file_path: file_path.stat().st_mtime) + overflow_count = len(files) - max_records + trim_count = min(len(sorted_files), max(self._TRIM_COUNT, overflow_count)) + for old_file in sorted_files[:trim_count]: + try: + old_file.unlink() + except FileNotFoundError: + continue + + @classmethod + def _get_max_records_per_chat(cls) -> int: + try: + from src.config.config import global_config + + configured_limit = global_config.log.maisaka_reply_effect_limit + return max(1, int(configured_limit or cls._DEFAULT_MAX_RECORDS_PER_CHAT)) + except Exception: + return cls._DEFAULT_MAX_RECORDS_PER_CHAT diff --git a/src/maisaka/reply_effect/tracker.py b/src/maisaka/reply_effect/tracker.py new file mode 100644 index 00000000..425b534a --- /dev/null +++ b/src/maisaka/reply_effect/tracker.py @@ -0,0 +1,273 @@ +"""会话级回复效果观察器。""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List + +import asyncio +import time +import uuid + +from src.chat.message_receive.message import SessionMessage +from src.maisaka.history_utils import build_session_message_visible_text + +from .image_utils import extract_visual_attachments_from_sequence +from .judge import JudgeRunner, judge_reply_effect +from .models import ( + FollowupMessageSnapshot, + ReplyEffectRecord, + ReplyEffectStatus, + ReplySnapshot, + SessionSnapshot, + UserSnapshot, + now_iso, +) +from .quote_utils import extract_quote_target_ids +from .path_utils import build_reply_effect_chat_dir_name +from .scoring import ( + has_explicit_negative_feedback, + has_repair_loop, + score_reply_effect, +) +from .storage import ReplyEffectStorage + +TARGET_USER_FOLLOWUP_LIMIT = 2 +SESSION_FOLLOWUP_LIMIT = 5 +OBSERVATION_WINDOW_SECONDS = 600.0 + + +class ReplyEffectTracker: + """追踪单个 Maisaka 会话内 reply 工具回复后的用户反馈。""" + + def __init__( + self, + *, + session_id: str, + session_name: str, + chat_stream: Any, + judge_runner: JudgeRunner | None = None, + storage: ReplyEffectStorage | None = None, + ) -> None: + self._session_id = session_id + self._session_name = session_name + self._chat_stream = chat_stream + self._judge_runner = judge_runner + self._storage = storage or ReplyEffectStorage() + self._pending_records: Dict[str, ReplyEffectRecord] = {} + self._timeout_tasks: Dict[str, asyncio.Task[None]] = {} + + async def record_reply( + self, + *, + tool_call_id: str, + target_message: SessionMessage, + set_quote: bool, + reply_text: str, + reply_segments: List[str], + planner_reasoning: str, + reference_info: str, + tool_context: Dict[str, Any] | None = None, + send_results: List[Dict[str, Any]] | None = None, + reply_metadata: Dict[str, Any] | None = None, + context_snapshot: List[Dict[str, Any]] | None = None, + ) -> ReplyEffectRecord: + """登记一条已经成功发出的 reply 回复。""" + + effect_id = str(uuid.uuid4()) + target_user_info = target_message.message_info.user_info + record = ReplyEffectRecord( + effect_id=effect_id, + status=ReplyEffectStatus.PENDING, + created_at=now_iso(), + updated_at=now_iso(), + session=self._build_session_snapshot(), + reply=ReplySnapshot( + tool_call_id=tool_call_id, + target_message_id=target_message.message_id, + set_quote=set_quote, + reply_text=reply_text, + reply_segments=list(reply_segments), + planner_reasoning=planner_reasoning, + reference_info=reference_info, + tool_context=dict(tool_context or {}), + send_results=list(send_results or []), + reply_metadata=dict(reply_metadata or {}), + ), + target_user=UserSnapshot( + user_id=str(target_user_info.user_id or "").strip(), + nickname=str(target_user_info.user_nickname or "").strip(), + cardname=str(target_user_info.user_cardname or "").strip(), + ), + context_snapshot=list(context_snapshot or []), + ) + self._storage.create_record_file(record) + self._pending_records[effect_id] = record + self._timeout_tasks[effect_id] = asyncio.create_task(self._finalize_after_timeout(effect_id)) + return record + + async def observe_user_message(self, message: SessionMessage) -> None: + """观察一条后续用户消息,并在满足规则时完成相关 pending 记录。""" + + if not self._pending_records or message.session_id != self._session_id: + return + + for effect_id, record in list(self._pending_records.items()): + if record.status != ReplyEffectStatus.PENDING: + continue + followup = self._build_followup_snapshot(message, record) + record.followup_messages.append(followup) + record.updated_at = now_iso() + self._storage.save_record(record) + + reason = self._resolve_finalize_reason(record) + if reason: + await self.finalize(effect_id, reason) + + async def finalize_all(self, reason: str = "runtime_stop") -> None: + """强制完成当前会话所有 pending 记录。""" + + for effect_id in list(self._pending_records.keys()): + await self.finalize(effect_id, reason) + + async def finalize(self, effect_id: str, reason: str) -> None: + """完成一条 pending 记录并写回 JSON。""" + + record = self._pending_records.pop(effect_id, None) + if record is None or record.status == ReplyEffectStatus.FINALIZED: + return + + timeout_task = self._timeout_tasks.pop(effect_id, None) + current_task = asyncio.current_task() + if timeout_task is not None and timeout_task is not current_task: + timeout_task.cancel() + + rubric_scores, judge_error = await judge_reply_effect(record, self._judge_runner) + record.scores = score_reply_effect( + record.followup_messages, + rubric_scores, + target_user_id=record.target_user.user_id, + judge_error=judge_error, + ) + record.status = ReplyEffectStatus.FINALIZED + record.finalized_at = now_iso() + record.updated_at = record.finalized_at + record.finalize_reason = reason + record.confidence_note = self._build_confidence_note(record) + record.followup_summary = self._build_followup_summary(record) + self._storage.save_record(record) + + def _build_session_snapshot(self) -> SessionSnapshot: + platform = str(getattr(self._chat_stream, "platform", "") or "").strip() + group_id = str(getattr(self._chat_stream, "group_id", "") or "").strip() + user_id = str(getattr(self._chat_stream, "user_id", "") or "").strip() + is_group_session = bool(getattr(self._chat_stream, "is_group_session", False)) + return SessionSnapshot( + session_id=self._session_id, + platform_type_id=build_reply_effect_chat_dir_name(self._session_id), + platform=platform, + chat_type="group" if is_group_session else "private", + group_id=group_id, + user_id=user_id, + session_name=self._session_name, + ) + + def _build_followup_snapshot( + self, + message: SessionMessage, + record: ReplyEffectRecord, + ) -> FollowupMessageSnapshot: + user_info = message.message_info.user_info + plain_text = str(message.processed_plain_text or "").strip() + try: + visible_text = build_session_message_visible_text(message) + except Exception: + visible_text = plain_text + latency_seconds = max(0.0, time.time() - _parse_iso_timestamp(record.created_at)) + user_id = str(user_info.user_id or "").strip() + return FollowupMessageSnapshot( + message_id=str(message.message_id or "").strip(), + timestamp=_message_timestamp_to_iso(message), + user_id=user_id, + nickname=str(user_info.user_nickname or "").strip(), + cardname=str(user_info.user_cardname or "").strip(), + visible_text=visible_text, + plain_text=plain_text, + latency_seconds=round(latency_seconds, 3), + is_target_user=bool(record.target_user.user_id and user_id == record.target_user.user_id), + quote_target_ids=extract_quote_target_ids(message.raw_message), + attachments=extract_visual_attachments_from_sequence(message.raw_message), + ) + + def _resolve_finalize_reason(self, record: ReplyEffectRecord) -> str: + target_user_id = record.target_user.user_id + target_followups = [ + followup + for followup in record.followup_messages + if target_user_id and followup.user_id == target_user_id + ] + has_target_feedback = bool(target_followups) + if has_explicit_negative_feedback(target_followups, target_user_id=target_user_id, allow_indirect=False): + return "explicit_negative" + if has_repair_loop(target_followups, target_user_id=target_user_id, allow_indirect=False): + return "repair_loop" + if len(target_followups) >= TARGET_USER_FOLLOWUP_LIMIT: + return "target_user_followups" + + if not target_user_id or not has_target_feedback: + allow_indirect = not target_user_id + if has_explicit_negative_feedback( + record.followup_messages, + target_user_id=target_user_id, + allow_indirect=allow_indirect, + ): + return "explicit_negative" + if has_repair_loop( + record.followup_messages, + target_user_id=target_user_id, + allow_indirect=allow_indirect, + ): + return "repair_loop" + if len(record.followup_messages) >= SESSION_FOLLOWUP_LIMIT: + return "session_followups_limit" + + return "" + + async def _finalize_after_timeout(self, effect_id: str) -> None: + try: + await asyncio.sleep(OBSERVATION_WINDOW_SECONDS) + await self.finalize(effect_id, "window_timeout") + except asyncio.CancelledError: + return + + @staticmethod + def _build_confidence_note(record: ReplyEffectRecord) -> str: + if not record.followup_messages: + return "没有观察到后续用户消息,行为分使用保守中性信号。" + if any(followup.is_target_user for followup in record.followup_messages): + return "行为反馈包含回复对象本人的后续发言。" + return "行为反馈来自同会话其他用户,不是回复对象本人,置信度较低。" + + @staticmethod + def _build_followup_summary(record: ReplyEffectRecord) -> Dict[str, Any]: + target_count = sum(1 for followup in record.followup_messages if followup.is_target_user) + return { + "total_count": len(record.followup_messages), + "target_user_count": target_count, + "other_user_count": len(record.followup_messages) - target_count, + "target_user_id": record.target_user.user_id, + } + + +def _message_timestamp_to_iso(message: SessionMessage) -> str: + timestamp = getattr(message, "timestamp", None) + if isinstance(timestamp, datetime): + return timestamp.astimezone().isoformat(timespec="seconds") + return now_iso() + + +def _parse_iso_timestamp(value: str) -> float: + try: + return datetime.fromisoformat(value).timestamp() + except ValueError: + return time.time() diff --git a/src/maisaka/runtime.py b/src/maisaka/runtime.py new file mode 100644 index 00000000..3af109ef --- /dev/null +++ b/src/maisaka/runtime.py @@ -0,0 +1,1851 @@ +"""Maisaka 非 CLI 运行时。""" + +from collections import deque +from datetime import datetime +from math import ceil +from typing import Any, Literal, Optional, Sequence + +import asyncio +import json +import time + +from rich.console import Group, RenderableType +from rich.panel import Panel +from rich.pretty import Pretty +from rich.text import Text + +from src.cli.console import console +from src.chat.heart_flow.heartFC_utils import CycleDetail +from src.chat.message_receive.chat_manager import BotChatSession, chat_manager +from src.chat.message_receive.message import SessionMessage +from src.chat.utils.utils import is_mentioned_bot_in_message +from src.common.data_models.mai_message_data_model import GroupInfo, UserInfo +from src.common.logger import get_logger +from src.common.utils.utils_config import ChatConfigUtils, ExpressionConfigUtils +from src.config.config import global_config +from src.core.tooling import ToolRegistry, ToolSpec +from src.learners.expression_learner import ExpressionLearner +from src.learners.jargon_miner import JargonMiner +from src.llm_models.payload_content.resp_format import RespFormat +from src.llm_models.payload_content.tool_option import ToolDefinitionInput +from src.mcp_module import MCPManager +from src.mcp_module.config import build_mcp_server_runtime_configs +from src.mcp_module.host_llm_bridge import MCPHostLLMBridge +from src.mcp_module.provider import MCPToolProvider +from src.plugin_runtime.tool_provider import PluginToolProvider +from src.plugin_runtime.hook_payloads import deserialize_prompt_messages + +from .chat_loop_service import ChatResponse, MaisakaChatLoopService +from .context_messages import ( + AssistantMessage, + LLMContextMessage, + ReferenceMessage, + ReferenceMessageType, + ToolResultMessage, +) +from .display.display_utils import build_tool_call_summary_lines, format_token_count +from .display.prompt_cli_renderer import PromptCLIVisualizer +from .display.stage_status_board import remove_stage_status, update_stage_status +from .history_utils import drop_leading_orphan_tool_results +from .monitor_events import emit_message_sent, emit_session_start +from .reasoning_engine import MaisakaReasoningEngine +from .reply_effect import ReplyEffectTracker +from .reply_effect.image_utils import extract_visual_attachments_from_sequence +from .reply_effect.quote_utils import extract_quote_target_ids, message_id_from_context_message +from .tool_provider import MaisakaBuiltinToolProvider + +logger = get_logger("maisaka_runtime") + +MAX_INTERNAL_ROUNDS = 10 +MAX_RETAINED_MESSAGE_CACHE_SIZE = 200 + + +class MaisakaHeartFlowChatting: + """会话级别的 Maisaka 运行时。""" + + _STATE_RUNNING: Literal["running"] = "running" + _STATE_WAIT: Literal["wait"] = "wait" + _STATE_STOP: Literal["stop"] = "stop" + + def __init__(self, session_id: str): + self.session_id = session_id + chat_stream = chat_manager.get_session_by_session_id(session_id) + if chat_stream is None: + raise ValueError(f"未找到会话 {session_id} 对应的 Maisaka 运行时") + self.chat_stream: BotChatSession = chat_stream + + session_name = chat_manager.get_session_name(session_id) or session_id + self.session_name = session_name + self.log_prefix = f"[{session_name}]" + self._chat_loop_service = MaisakaChatLoopService( + session_id=session_id, + is_group_chat=self.chat_stream.is_group_session, + ) + self._chat_history: list[LLMContextMessage] = [] + self.history_loop: list[CycleDetail] = [] + + # Keep all original messages for batching and later learning. + self.message_cache: list[SessionMessage] = [] + self._last_processed_index = 0 + self._internal_turn_queue: asyncio.Queue[Literal["message", "timeout"]] = asyncio.Queue() + + self._mcp_manager: Optional[MCPManager] = None + self._mcp_host_bridge: Optional[MCPHostLLMBridge] = None + self._current_cycle_detail: Optional[CycleDetail] = None + self._running = False + self._cycle_counter = 0 + self._internal_loop_task: Optional[asyncio.Task] = None + self._message_turn_scheduled = False + self._deferred_message_turn_task: Optional[asyncio.Task[None]] = None + self._message_debounce_seconds = 1.0 + self._message_debounce_required = False + self._oldest_pending_message_received_at: Optional[float] = None + self._last_message_received_at = 0.0 + self._talk_frequency_adjust = 1.0 + self._reply_latency_measurement_started_at: Optional[float] = None + self._recent_reply_latencies: deque[tuple[float, float]] = deque() + self._wait_timeout_task: Optional[asyncio.Task[None]] = None + self._max_internal_rounds = MAX_INTERNAL_ROUNDS + self._agent_state: Literal["running", "wait", "stop"] = self._STATE_STOP + self._pending_wait_tool_call_id: Optional[str] = None + self._force_next_timing_continue = False + self._force_next_timing_message_id = "" + self._force_next_timing_reason = "" + self._timing_gate_non_continue_cooldown_seconds = max( + 0.0, + float(global_config.chat.timing_gate_non_continue_cooldown_seconds), + ) + self._planner_interrupt_flag: Optional[asyncio.Event] = None + self._planner_interrupt_requested = False + self._planner_interrupt_consecutive_count = 0 + self._current_action_tool_names: set[str] = set() + self.discovered_tool_names: set[str] = set() + self.deferred_tool_specs_by_name: dict[str, ToolSpec] = {} + self._planner_interrupt_max_consecutive_count = max( + 0, + int(global_config.chat.planner_interrupt_max_consecutive_count), + ) + + expr_use, expr_learn, jargon_learn = ExpressionConfigUtils.get_expression_config_for_chat(session_id) + self._enable_expression_use = expr_use + self._enable_expression_learning = expr_learn + self._enable_jargon_learning = jargon_learn + self._min_extraction_interval = 30 + self._last_expression_extraction_time = 0.0 + self._expression_learner = ExpressionLearner(session_id) + self._jargon_miner = JargonMiner(session_id, session_name=session_name) + + self._reasoning_engine = MaisakaReasoningEngine(self) + self._monitor_session_start_task: Optional[asyncio.Task[None]] = None + self._tool_registry = ToolRegistry() + self._reply_effect_tracker = ReplyEffectTracker( + session_id=self.session_id, + session_name=self.session_name, + chat_stream=self.chat_stream, + judge_runner=self._run_reply_effect_judge, + ) + self._register_tool_providers() + self._emit_monitor_session_start() + + @property + def _max_context_size(self) -> int: + """返回当前会话实时生效的上下文窗口大小。""" + + configured_context_size = ( + global_config.chat.max_context_size + if self.chat_stream.is_group_session + else global_config.chat.max_private_context_size + ) + return max(1, int(configured_context_size)) + + def _emit_monitor_session_start(self) -> None: + """向 WebUI 监控面板同步当前会话的展示标识。""" + + try: + self._monitor_session_start_task = asyncio.create_task( + emit_session_start( + session_id=self.session_id, + session_name=self.session_name, + is_group_chat=self.chat_stream.is_group_session, + group_id=self.chat_stream.group_id, + user_id=self.chat_stream.user_id, + platform=self.chat_stream.platform, + ) + ) + except RuntimeError: + logger.debug("MaiSaka 监控会话开始事件未发送:当前没有运行中的事件循环") + + @staticmethod + def _is_reply_effect_tracking_enabled() -> bool: + """判断是否启用回复效果评分追踪。""" + + return bool(global_config.debug.enable_reply_effect_tracking) + + def _update_stage_status(self, stage: str, detail: str = "", *, round_text: str = "") -> None: + """更新当前会话的阶段状态。""" + + update_stage_status( + session_id=self.session_id, + session_name=self.session_name, + stage=stage, + detail=detail, + round_text=round_text, + agent_state=self._agent_state, + ) + + async def start(self) -> None: + """启动运行时主循环。""" + if self._running: + self._ensure_background_tasks_running() + return + + if global_config.mcp.enable: + await self._init_mcp() + + self._running = True + self._ensure_background_tasks_running() + self._schedule_message_turn() + self._update_stage_status("空闲", "等待消息触发") + logger.info(f"{self.log_prefix} Maisaka 运行时已启动") + + async def stop(self) -> None: + """停止运行时主循环。""" + if not self._running: + return + + self._running = False + self._message_turn_scheduled = False + self._message_debounce_required = False + self._cancel_deferred_message_turn_task() + self._cancel_wait_timeout_task() + while not self._internal_turn_queue.empty(): + _ = self._internal_turn_queue.get_nowait() + + if self._internal_loop_task is not None: + self._internal_loop_task.cancel() + try: + await self._internal_loop_task + except asyncio.CancelledError: + pass + finally: + self._internal_loop_task = None + + if self._is_reply_effect_tracking_enabled(): + await self._reply_effect_tracker.finalize_all("runtime_stop") + await self._tool_registry.close() + self._mcp_manager = None + self._mcp_host_bridge = None + remove_stage_status(self.session_id) + + logger.info(f"{self.log_prefix} Maisaka 运行时已停止") + + def adjust_talk_frequency(self, frequency: float) -> None: + """调整当前会话的回复频率倍率。""" + self._talk_frequency_adjust = max(0.01, float(frequency)) + self._schedule_message_turn() + + def append_sent_message_to_chat_history( + self, + message: SessionMessage, + *, + source_kind: str = "guided_reply", + ) -> bool: + """将一条已发送成功的消息同步到 Maisaka 内部历史。""" + + try: + from .context_messages import SessionBackedMessage + from .history_utils import build_prefixed_message_sequence, build_session_message_visible_text + from .planner_message_utils import build_planner_prefix + + user_info = message.message_info.user_info + speaker_name = user_info.user_cardname or user_info.user_nickname or user_info.user_id + planner_prefix = build_planner_prefix( + timestamp=message.timestamp, + user_name=speaker_name, + group_card=user_info.user_cardname or "", + message_id=message.message_id, + include_message_id=not message.is_notify and bool(message.message_id), + ) + history_message = SessionBackedMessage.from_session_message( + message, + raw_message=build_prefixed_message_sequence(message.raw_message, planner_prefix), + visible_text=build_session_message_visible_text( + message, + include_reply_components=source_kind != "guided_reply", + ), + source_kind=source_kind, + ) + self._chat_history.append(history_message) + self._emit_monitor_message_sent( + message=message, + speaker_name=speaker_name, + source_kind=source_kind, + ) + return True + except Exception as exc: + logger.warning( + f"{self.log_prefix} 同步已发送消息到 Maisaka 历史失败: " + f"message_id={message.message_id} error={exc}" + ) + return False + + def _emit_monitor_message_sent( + self, + *, + message: SessionMessage, + speaker_name: str, + source_kind: str, + ) -> None: + """异步广播 MaiSaka 自己发出的消息,供 WebUI 实时展示。""" + + try: + asyncio.create_task( + emit_message_sent( + session_id=self.session_id, + speaker_name=speaker_name, + content=(message.processed_plain_text or "").strip(), + message_id=message.message_id, + timestamp=message.timestamp.timestamp(), + source_kind=source_kind, + ) + ) + except RuntimeError as exc: + logger.debug(f"{self.log_prefix} 广播已发送消息到监控面板失败: {exc}") + + async def register_message(self, message: SessionMessage) -> None: + """缓存一条新消息并唤醒主循环。""" + if self._running: + self._ensure_background_tasks_running() + received_at = time.time() + self._last_message_received_at = received_at + if self._oldest_pending_message_received_at is None: + self._oldest_pending_message_received_at = received_at + self._update_message_trigger_state(message) + self.message_cache.append(message) + self._prune_processed_message_cache() + if self._is_reply_effect_tracking_enabled(): + asyncio.create_task(self._reply_effect_tracker.observe_user_message(message)) + if self._agent_state == self._STATE_RUNNING: + self._message_debounce_required = True + if self._agent_state == self._STATE_RUNNING and self._planner_interrupt_flag is not None: + if self._planner_interrupt_requested: + logger.info( + f"{self.log_prefix} 收到新消息,但当前请求已发起过一次规划器打断," + f"本次不重复打断; 消息编号={message.message_id} " + f"连续打断次数={self._planner_interrupt_consecutive_count}/" + f"{self._planner_interrupt_max_consecutive_count}" + ) + elif self._planner_interrupt_consecutive_count >= self._planner_interrupt_max_consecutive_count: + logger.info( + f"{self.log_prefix} 收到新消息,但已达到规划器连续打断上限," + f"将等待当前请求自然完成; 消息编号={message.message_id} " + f"连续打断次数={self._planner_interrupt_consecutive_count}/" + f"{self._planner_interrupt_max_consecutive_count}" + ) + else: + self._planner_interrupt_requested = True + self._planner_interrupt_consecutive_count += 1 + logger.info( + f"{self.log_prefix} 收到新消息,发起规划器打断; " + f"消息编号={message.message_id} 缓存条数={len(self.message_cache)} " + f"时间戳={time.time():.3f} " + f"连续打断次数={self._planner_interrupt_consecutive_count}/" + f"{self._planner_interrupt_max_consecutive_count}" + ) + self._planner_interrupt_flag.set() + if self._running: + self._schedule_message_turn() + + def _get_effective_reply_frequency(self) -> float: + """返回当前会话生效的回复频率。""" + talk_value = max( + 0.01, + float( + ChatConfigUtils.get_talk_value( + self.session_id, + is_group_chat=self.chat_stream.is_group_session, + ) + ), + ) + return max(0.01, talk_value * self._talk_frequency_adjust) + + async def track_reply_effect( + self, + *, + tool_call_id: str, + target_message: SessionMessage, + set_quote: bool, + reply_text: str, + reply_segments: list[str], + planner_reasoning: str, + reference_info: str, + tool_context: Optional[dict[str, Any]] = None, + send_results: Optional[list[dict[str, Any]]] = None, + reply_metadata: Optional[dict[str, Any]] = None, + replyer_context_messages: Optional[Sequence[LLMContextMessage]] = None, + ) -> None: + """登记一次已成功发送的 reply 工具回复,供后续用户反馈评分。""" + + if not self._is_reply_effect_tracking_enabled(): + return + + try: + context_snapshot = self._build_reply_effect_context_snapshot( + context_messages=replyer_context_messages, + exclude_reply_segments=reply_segments if replyer_context_messages is None else None, + ) + enriched_reply_metadata = dict(reply_metadata or {}) + enriched_reply_metadata["replyer_context_count"] = ( + len(replyer_context_messages) if replyer_context_messages is not None else len(self._chat_history) + ) + enriched_reply_metadata["recorded_context_count"] = len(context_snapshot) + await self._reply_effect_tracker.record_reply( + tool_call_id=tool_call_id, + target_message=target_message, + set_quote=set_quote, + reply_text=reply_text, + reply_segments=reply_segments, + planner_reasoning=planner_reasoning, + reference_info=reference_info, + tool_context=tool_context, + send_results=send_results, + reply_metadata=enriched_reply_metadata, + context_snapshot=context_snapshot, + ) + except Exception as exc: + logger.warning(f"{self.log_prefix} 创建回复效果观察记录失败: {exc}") + + def _build_reply_effect_context_snapshot( + self, + *, + context_messages: Optional[Sequence[LLMContextMessage]] = None, + exclude_reply_segments: Optional[Sequence[str]] = None, + ) -> list[dict[str, Any]]: + """构建回复效果观察使用的上下文快照。 + + 优先记录 replyer 当次生成时实际收到的完整上下文列表;只有旧调用未传入时才回退到当前运行时历史。 + """ + + source_messages = list(context_messages) if context_messages is not None else list(self._chat_history) + snapshot: list[dict[str, Any]] = [] + excluded_segments = [segment.strip() for segment in (exclude_reply_segments or []) if segment.strip()] + for message in source_messages: + text = str(message.processed_plain_text or "").strip() + if not text: + continue + if message.source == "guided_reply" and any(segment in text for segment in excluded_segments): + continue + snapshot.append( + { + "message_id": message_id_from_context_message(message), + "source": message.source, + "role": message.role, + "timestamp": message.timestamp.isoformat(timespec="seconds"), + "text": text, + "quote_target_ids": extract_quote_target_ids(getattr(message, "raw_message", None)), + "attachments": extract_visual_attachments_from_sequence(getattr(message, "raw_message", None)), + } + ) + return snapshot + + def _get_message_trigger_threshold(self) -> int: + """根据回复频率折算出触发一轮循环所需的消息数。""" + effective_frequency = min(1.0, self._get_effective_reply_frequency()) + return max(1, int(ceil(1.0 / effective_frequency))) + + def _get_pending_message_count(self) -> int: + """统计当前尚未进入内部循环的新消息数量。""" + pending_messages = self.message_cache[self._last_processed_index :] + if not pending_messages: + return 0 + + seen_message_ids: set[str] = set() + for message in pending_messages: + seen_message_ids.add(message.message_id) + return len(seen_message_ids) + + def _prune_recent_reply_latencies(self, now: Optional[float] = None) -> None: + """仅保留最近 10 分钟内的回复时长记录。""" + current_time = time.time() if now is None else now + expire_before = current_time - 600.0 + while self._recent_reply_latencies and self._recent_reply_latencies[0][0] < expire_before: + self._recent_reply_latencies.popleft() + + def _get_recent_average_reply_latency(self) -> Optional[float]: + """获取最近 10 分钟平均消息回复时长。""" + self._prune_recent_reply_latencies() + if not self._recent_reply_latencies: + return None + + total_duration = sum(duration for _, duration in self._recent_reply_latencies) + return total_duration / len(self._recent_reply_latencies) + + def _record_reply_sent(self) -> None: + """在成功发送 reply 后记录本轮消息回复时长。""" + if self._reply_latency_measurement_started_at is None: + return + + reply_duration = max(0.0, time.time() - self._reply_latency_measurement_started_at) + self._reply_latency_measurement_started_at = None + self._recent_reply_latencies.append((time.time(), reply_duration)) + self._prune_recent_reply_latencies() + logger.debug( + f"{self.log_prefix} 已记录消息回复时长: {reply_duration:.2f} 秒 " + f"最近10分钟样本数={len(self._recent_reply_latencies)}" + ) + + def find_source_message_by_id(self, message_id: str) -> Optional[SessionMessage]: + """从 Maisaka 历史中查找指定消息编号对应的原始消息。""" + normalized_message_id = str(message_id or "").strip() + if not normalized_message_id: + return None + + for history_message in reversed(self._chat_history): + if str(getattr(history_message, "message_id", "") or "").strip() != normalized_message_id: + continue + + original_message = getattr(history_message, "original_message", None) + if original_message is None: + continue + return original_message + + return None + + def _prune_processed_message_cache(self) -> None: + """裁剪 runtime 与表达学习器都已经消费过的旧消息。""" + excess_count = len(self.message_cache) - MAX_RETAINED_MESSAGE_CACHE_SIZE + if excess_count <= 0: + return + + removable_count = min( + excess_count, + self._last_processed_index, + self._expression_learner.last_processed_index, + ) + if removable_count <= 0: + return + + del self.message_cache[:removable_count] + self._last_processed_index = max(0, self._last_processed_index - removable_count) + self._expression_learner.discard_processed_prefix(removable_count) + logger.debug( + f"{self.log_prefix} 已清理 Maisaka 旧消息缓存: " + f"清理数量={removable_count} 保留数量={len(self.message_cache)}" + ) + + def _should_trigger_message_turn_by_idle_compensation( + self, + *, + pending_count: int, + trigger_threshold: int, + ) -> bool: + """在新消息不足阈值时,按空窗时间折算补齐触发条件。""" + average_reply_latency = self._get_recent_average_reply_latency() + if average_reply_latency is None or average_reply_latency <= 0: + return False + + idle_seconds = max(0.0, time.time() - self._last_message_received_at) + equivalent_message_count = pending_count + idle_seconds / average_reply_latency + return equivalent_message_count >= trigger_threshold + + def _cancel_deferred_message_turn_task(self) -> None: + """取消等待空窗补偿触发的延迟任务。""" + if self._deferred_message_turn_task is None: + return + self._deferred_message_turn_task.cancel() + self._deferred_message_turn_task = None + + async def _schedule_deferred_message_turn(self, delay_seconds: float) -> None: + """在预计满足空窗补偿条件时再次检查是否应触发循环。""" + try: + if delay_seconds > 0: + await asyncio.sleep(delay_seconds) + if not self._running: + return + self._schedule_message_turn() + except asyncio.CancelledError: + return + finally: + self._deferred_message_turn_task = None + + def _update_message_trigger_state(self, message: SessionMessage) -> None: + """补齐消息中的 @/提及 标记,并在命中时启用强制 continue。""" + + detected_mentioned, detected_at, reply_probability_boost = is_mentioned_bot_in_message(message) + if detected_at: + message.is_at = True + if detected_mentioned: + message.is_mentioned = True + + should_force_reply = ( + reply_probability_boost >= 1.0 + or (message.is_at and global_config.chat.inevitable_at_reply) + or (message.is_mentioned and global_config.chat.mentioned_bot_reply) + ) + if not should_force_reply or (not message.is_at and not message.is_mentioned): + return + + self._arm_force_next_timing_continue( + message, + is_at=message.is_at, + is_mentioned=message.is_mentioned, + ) + + def _arm_force_next_timing_continue( + self, + message: SessionMessage, + *, + is_at: bool, + is_mentioned: bool, + ) -> None: + """在检测到 @ 或提及时,要求下一次 Timing Gate 直接 continue。""" + + trigger_reason = "@消息" if is_at else "提及消息" if is_mentioned else "触发消息" + was_armed = self._force_next_timing_continue + self._force_next_timing_continue = True + self._force_next_timing_message_id = message.message_id + self._force_next_timing_reason = trigger_reason + + if was_armed: + logger.info( + f"{self.log_prefix} 检测到新的{trigger_reason},刷新强制 continue 状态;" + f"消息编号={message.message_id}" + ) + return + + logger.info( + f"{self.log_prefix} 检测到{trigger_reason},下一次 Timing Gate 将直接视作 continue;" + f"消息编号={message.message_id}" + ) + + def _consume_force_next_timing_continue_reason(self) -> str | None: + """消费一次性 Timing Gate continue 状态,并返回原因描述。""" + + if not self._force_next_timing_continue: + return None + + trigger_reason = self._force_next_timing_reason or "@/提及消息" + trigger_message_id = self._force_next_timing_message_id or "unknown" + reason = ( + f"检测到新的{trigger_reason}(消息编号={trigger_message_id})," + "本轮直接跳过 Timing Gate 并视作 continue。" + ) + logger.info( + f"{self.log_prefix} 已结束本次强制 continue,恢复 Timing Gate;" + f"触发原因={trigger_reason} " + f"触发消息编号={trigger_message_id}" + ) + self._force_next_timing_continue = False + self._force_next_timing_message_id = "" + self._force_next_timing_reason = "" + return reason + + def _has_forced_timing_trigger(self) -> bool: + """判断是否已有 @/提及必回触发,需绕过普通频率阈值。""" + + return self._force_next_timing_continue + + async def _wait_for_timing_gate_non_continue_cooldown(self, elapsed_seconds: float) -> None: + """仅对 Timing Gate 的 no_reply 动作应用冷却窗口。""" + + cooldown_seconds = self._timing_gate_non_continue_cooldown_seconds + if cooldown_seconds <= 0: + return + + remaining_seconds = cooldown_seconds - max(0.0, elapsed_seconds) + if remaining_seconds <= 0: + return + + logger.info(f"{self.log_prefix} Timing Gate 非 continue 冷却中,等待 {remaining_seconds:.2f} 秒后结束") + await asyncio.sleep(remaining_seconds) + + def _bind_planner_interrupt_flag(self, interrupt_flag: asyncio.Event) -> None: + """绑定当前可打断请求使用的中断标记。""" + self._planner_interrupt_flag = interrupt_flag + self._planner_interrupt_requested = False + + def _unbind_planner_interrupt_flag( + self, + interrupt_flag: asyncio.Event, + *, + interrupted: bool, + ) -> None: + """解绑当前可打断请求的中断标记,并维护连续打断计数。""" + if self._planner_interrupt_flag is interrupt_flag: + self._planner_interrupt_flag = None + self._planner_interrupt_requested = False + if not interrupted: + self._planner_interrupt_consecutive_count = 0 + + def _ensure_background_tasks_running(self) -> None: + """确保后台任务仍在运行,若崩溃则自动拉起。""" + if not self._running: + return + + if self._internal_loop_task is None or self._internal_loop_task.done(): + is_restart = self._internal_loop_task is not None + if self._internal_loop_task is not None and not self._internal_loop_task.cancelled(): + try: + exc = self._internal_loop_task.exception() + except Exception: + exc = None + if exc is not None: + logger.error(f"{self.log_prefix} 内部循环任务异常退出: {exc}") + self._internal_loop_task = asyncio.create_task(self._reasoning_engine.run_loop()) + if is_restart: + logger.warning(f"{self.log_prefix} 已重新拉起 Maisaka 内部循环任务") + else: + logger.debug(f"{self.log_prefix} 已启动 Maisaka 内部循环任务") + + def _register_tool_providers(self) -> None: + """注册 Maisaka 运行时默认启用的工具 Provider。""" + + self._tool_registry.register_provider( + MaisakaBuiltinToolProvider(self._reasoning_engine.build_builtin_tool_handlers()) + ) + self._tool_registry.register_provider(PluginToolProvider()) + self._chat_loop_service.set_tool_registry(self._tool_registry) + + async def run_sub_agent( + self, + *, + context_message_limit: int, + drop_head_context_count: int = 0, + system_prompt: str, + request_kind: str = "sub_agent", + extra_messages: Optional[Sequence[LLMContextMessage]] = None, + interrupt_flag: asyncio.Event | None = None, + model_task_name: str = "planner", + response_format: RespFormat | None = None, + tool_definitions: Optional[Sequence[ToolDefinitionInput]] = None, + ) -> ChatResponse: + """运行一个复制上下文的临时子代理,并在完成后立即销毁。""" + + selected_history, _ = MaisakaChatLoopService.select_llm_context_messages( + self._chat_history, + request_kind=request_kind, + max_context_size=context_message_limit, + ) + sub_agent_history = self._drop_head_context_messages( + selected_history, + drop_head_context_count, + trim_threshold_context_count=context_message_limit, + ) + if extra_messages: + sub_agent_history.extend(list(extra_messages)) + + sub_agent = MaisakaChatLoopService( + chat_system_prompt=system_prompt, + session_id=self.session_id, + is_group_chat=self.chat_stream.is_group_session, + model_task_name=model_task_name, + ) + sub_agent.set_interrupt_flag(interrupt_flag) + return await sub_agent.chat_loop_step( + sub_agent_history, + request_kind=request_kind, + response_format=response_format, + tool_definitions=[] if tool_definitions is None else tool_definitions, + ) + + @staticmethod + def _drop_head_context_messages( + chat_history: Sequence[LLMContextMessage], + drop_context_count: int, + *, + trim_threshold_context_count: int | None = None, + ) -> list[LLMContextMessage]: + """从已选上下文头部丢弃指定数量的普通上下文消息。""" + + if drop_context_count <= 0: + return list(chat_history) + + context_message_count = sum(1 for message in chat_history if message.count_in_context) + if trim_threshold_context_count is not None and context_message_count <= trim_threshold_context_count: + return list(chat_history) + + if context_message_count <= drop_context_count: + return list(chat_history) + + first_kept_index = 0 + dropped_context_count = 0 + while ( + first_kept_index < len(chat_history) + and dropped_context_count < drop_context_count + ): + message = chat_history[first_kept_index] + if message.count_in_context: + dropped_context_count += 1 + first_kept_index += 1 + + trimmed_history = list(chat_history[first_kept_index:]) + trimmed_history, _ = drop_leading_orphan_tool_results(trimmed_history) + return trimmed_history + + async def _run_reply_effect_judge(self, prompt: str) -> str: + """运行回复效果观察器使用的临时 LLM 评审。""" + + judge_message = ReferenceMessage( + content=prompt, + timestamp=datetime.now(), + reference_type=ReferenceMessageType.TOOL_HINT, + remaining_uses_value=1, + display_prefix="[回复效果评分任务]", + ) + response = await self.run_sub_agent( + context_message_limit=1, + system_prompt="你是回复效果评分器。请严格按用户给出的 JSON 格式输出,不要输出 JSON 之外的内容。", + request_kind="reply_effect_judge", + extra_messages=[judge_message], + tool_definitions=[], + ) + return (response.content or "").strip() + + def set_current_action_tool_names(self, tool_names: Sequence[str]) -> None: + """记录当前 Action Loop 已实际暴露给 planner 的工具名集合。""" + + self._current_action_tool_names = {tool_name for tool_name in tool_names if str(tool_name).strip()} + + def is_action_tool_currently_available(self, tool_name: str) -> bool: + """判断指定工具在当前 Action Loop 轮次中是否真实可用。""" + + normalized_name = str(tool_name).strip() + return bool(normalized_name) and normalized_name in self._current_action_tool_names + + def update_deferred_tool_specs(self, deferred_tool_specs: Sequence[ToolSpec]) -> None: + """刷新当前会话的 deferred tools 池,并清理失效的已发现工具。""" + + next_specs_by_name: dict[str, ToolSpec] = {} + for tool_spec in deferred_tool_specs: + normalized_name = tool_spec.name.strip() + if not normalized_name: + continue + next_specs_by_name[normalized_name] = tool_spec + + self.deferred_tool_specs_by_name = next_specs_by_name + self.discovered_tool_names.intersection_update(next_specs_by_name.keys()) + + def sync_discovered_deferred_tools_with_context( + self, + selected_history: Sequence[LLMContextMessage], + ) -> None: + """根据当前实际上下文中的 tool_search 调用链同步已发现 deferred tools。 + + 已激活 deferred tool 必须能在本轮上下文中找到对应的 tool_search call 与 result。 + 当这条调用链被上下文窗口裁掉后,工具会重新折回 deferred tools 提示中。 + """ + + visible_tool_names = self._extract_visible_tool_search_discoveries(selected_history) + self.discovered_tool_names = visible_tool_names.intersection(self.deferred_tool_specs_by_name.keys()) + + def _extract_visible_tool_search_discoveries( + self, + selected_history: Sequence[LLMContextMessage], + ) -> set[str]: + """提取当前上下文中仍有完整 tool_search call/result 支撑的工具名。""" + + tool_search_call_ids = { + tool_call.call_id + for message in selected_history + if isinstance(message, AssistantMessage) + for tool_call in message.tool_calls + if tool_call.func_name == "tool_search" and tool_call.call_id + } + if not tool_search_call_ids: + return set() + + discovered_tool_names: set[str] = set() + for message in selected_history: + if not isinstance(message, ToolResultMessage): + continue + if message.tool_name != "tool_search" or message.tool_call_id not in tool_search_call_ids: + continue + if not message.success: + continue + discovered_tool_names.update(self._parse_tool_search_result_tool_names(message.content)) + return discovered_tool_names + + def _parse_tool_search_result_tool_names(self, content: str) -> set[str]: + """从 tool_search 的历史结果文本中解析有效 deferred tool 名称。""" + + discovered_tool_names: set[str] = set() + try: + structured_content = json.loads(content) + except (TypeError, ValueError): + structured_content = None + + if isinstance(structured_content, dict): + raw_tool_names = structured_content.get("matched_tool_names") + if isinstance(raw_tool_names, list): + for raw_tool_name in raw_tool_names: + normalized_name = str(raw_tool_name).strip() + if normalized_name in self.deferred_tool_specs_by_name: + discovered_tool_names.add(normalized_name) + + for raw_line in content.splitlines(): + normalized_line = raw_line.strip() + if not normalized_line.startswith("- "): + continue + normalized_name = normalized_line[2:].strip() + if normalized_name in self.deferred_tool_specs_by_name: + discovered_tool_names.add(normalized_name) + + return discovered_tool_names + + def get_discovered_deferred_tool_specs(self) -> list[ToolSpec]: + """返回当前会话中已发现、且仍然有效的 deferred tools。""" + + return [ + tool_spec + for tool_name, tool_spec in self.deferred_tool_specs_by_name.items() + if tool_name in self.discovered_tool_names + ] + + def build_deferred_tools_reminder(self) -> str: + """构造供 planner 使用的 deferred tools 提示消息。""" + + undiscovered_tool_specs = [ + tool_spec + for tool_name, tool_spec in self.deferred_tool_specs_by_name.items() + if tool_name not in self.discovered_tool_names + ] + if not undiscovered_tool_specs: + return "" + + tool_lines: list[str] = [] + for index, tool_spec in enumerate(undiscovered_tool_specs, start=1): + tool_name = tool_spec.name.strip() + tool_description = tool_spec.brief_description.strip() + if tool_description: + tool_lines.append(f"{index}. {tool_name}: {tool_description}") + else: + tool_lines.append(f"{index}. {tool_name}") + + reminder_lines = [ + "", + "以下工具当前未直接暴露给你,但可以通过 tool_search 工具发现并在后续轮次中使用:", + *tool_lines, + "", + "如需其中某个工具,请先调用 tool_search。tool_search 只负责发现工具,不直接执行业务。", + "", + ] + return "\n".join(reminder_lines) + + def search_deferred_tool_specs( + self, + query: str, + *, + limit: int, + ) -> list[ToolSpec]: + """按名称或简要描述搜索 deferred tools。""" + + normalized_query = " ".join(query.lower().split()).strip() + if not normalized_query: + return [] + + scored_matches: list[tuple[int, str, ToolSpec]] = [] + query_terms = [term for term in normalized_query.replace("_", " ").replace("-", " ").split() if term] + for tool_name, tool_spec in self.deferred_tool_specs_by_name.items(): + lower_name = tool_name.lower() + lower_description = tool_spec.brief_description.lower() + score = 0 + + if normalized_query == lower_name: + score += 1000 + if lower_name.startswith(normalized_query): + score += 300 + if normalized_query in lower_name: + score += 200 + if normalized_query in lower_description: + score += 100 + + for query_term in query_terms: + if query_term in lower_name: + score += 25 + if query_term in lower_description: + score += 10 + + if score <= 0: + continue + + scored_matches.append((score, tool_name, tool_spec)) + + scored_matches.sort(key=lambda item: (-item[0], item[1])) + return [tool_spec for _, _, tool_spec in scored_matches[: max(1, limit)]] + + def discover_deferred_tools(self, tool_names: Sequence[str]) -> list[str]: + """将指定 deferred tools 标记为已发现,并返回本次新发现的工具名。""" + + newly_discovered_tool_names: list[str] = [] + for raw_tool_name in tool_names: + normalized_name = str(raw_tool_name).strip() + if not normalized_name or normalized_name not in self.deferred_tool_specs_by_name: + continue + if normalized_name in self.discovered_tool_names: + continue + self.discovered_tool_names.add(normalized_name) + newly_discovered_tool_names.append(normalized_name) + return newly_discovered_tool_names + + def _has_pending_messages(self) -> bool: + return self._last_processed_index < len(self.message_cache) + + def _schedule_message_turn(self) -> None: + """为当前待处理消息安排一次内部 turn。""" + if self._agent_state == self._STATE_WAIT: + return + + if not self._has_pending_messages() or self._message_turn_scheduled: + return + + pending_count = self._get_pending_message_count() + if pending_count <= 0: + return + + if self._has_forced_timing_trigger(): + self._cancel_deferred_message_turn_task() + self._message_turn_scheduled = True + self._internal_turn_queue.put_nowait("message") + return + + trigger_threshold = self._get_message_trigger_threshold() + if pending_count >= trigger_threshold or self._should_trigger_message_turn_by_idle_compensation( + pending_count=pending_count, + trigger_threshold=trigger_threshold, + ): + self._cancel_deferred_message_turn_task() + self._message_turn_scheduled = True + self._internal_turn_queue.put_nowait("message") + return + + average_reply_latency = self._get_recent_average_reply_latency() + if average_reply_latency is None or average_reply_latency <= 0: + return + + idle_seconds = max(0.0, time.time() - self._last_message_received_at) + delay_seconds = max(0.0, (trigger_threshold - pending_count) * average_reply_latency - idle_seconds) + self._cancel_deferred_message_turn_task() + self._deferred_message_turn_task = asyncio.create_task( + self._schedule_deferred_message_turn(delay_seconds) + ) + + def _collect_pending_messages(self) -> list[SessionMessage]: + """从消息缓存中收集一批尚未处理的消息。""" + start_index = self._last_processed_index + pending_messages = self.message_cache[start_index:] + if not pending_messages: + return [] + + unique_messages: list[SessionMessage] = [] + seen_message_ids: set[str] = set() + for message in pending_messages: + message_id = message.message_id + if message_id in seen_message_ids: + continue + seen_message_ids.add(message_id) + unique_messages.append(message) + + self._last_processed_index = len(self.message_cache) + # logger.info( + # f"{self.log_prefix} 已从消息缓存区[{start_index}:{self._last_processed_index}] " + # f"收集 {len(unique_messages)} 条新消息" + # ) + if unique_messages and self._reply_latency_measurement_started_at is None: + self._reply_latency_measurement_started_at = ( + self._oldest_pending_message_received_at or self._last_message_received_at + ) + self._oldest_pending_message_received_at = None + return unique_messages + + async def _wait_for_message_quiet_period(self) -> None: + """等待消息静默窗口结束后,再启动由打断触发的新一轮。""" + if not self._message_debounce_required: + return + + if self._message_debounce_seconds <= 0: + self._message_debounce_required = False + return + + while self._running: + elapsed = time.time() - self._last_message_received_at + remaining = self._message_debounce_seconds - elapsed + if remaining <= 0: + break + await asyncio.sleep(remaining) + + self._message_debounce_required = False + + def _enter_stop_state(self) -> None: + """切换到停止状态。""" + self._agent_state = self._STATE_STOP + self._pending_wait_tool_call_id = None + self._cancel_wait_timeout_task() + + def _enter_wait_state(self, seconds: Optional[float] = None, tool_call_id: Optional[str] = None) -> None: + """切换到等待状态。""" + self._agent_state = self._STATE_WAIT + self._pending_wait_tool_call_id = tool_call_id + self._message_turn_scheduled = False + self._cancel_deferred_message_turn_task() + self._cancel_wait_timeout_task() + if seconds is not None: + self._wait_timeout_task = asyncio.create_task( + self._schedule_wait_timeout(seconds=seconds, tool_call_id=tool_call_id) + ) + + def _cancel_wait_timeout_task(self) -> None: + """取消当前 wait 对应的超时任务。""" + if self._wait_timeout_task is None: + return + self._wait_timeout_task.cancel() + self._wait_timeout_task = None + + async def _schedule_wait_timeout(self, seconds: float, tool_call_id: Optional[str]) -> None: + """在 wait 到期后向内部循环投递 timeout 触发。""" + try: + if seconds > 0: + await asyncio.sleep(seconds) + if not self._running: + return + if self._agent_state != self._STATE_WAIT: + return + if self._pending_wait_tool_call_id != tool_call_id: + return + + logger.debug(f"{self.log_prefix} Maisaka 等待已超时") + self._agent_state = self._STATE_RUNNING + await self._internal_turn_queue.put("timeout") + except asyncio.CancelledError: + return + finally: + if self._wait_timeout_task is not None and self._pending_wait_tool_call_id == tool_call_id: + self._wait_timeout_task = None + + async def _trigger_batch_learning(self, messages: list[SessionMessage]) -> None: + """按同一批消息触发表达方式和黑话学习。""" + processed_end_index = len(self.message_cache) + if not self._enable_expression_learning: + self._expression_learner.mark_all_processed(self.message_cache) + self._prune_processed_message_cache() + return + + try: + await self._trigger_expression_learning(messages) + except Exception as exc: + logger.error(f"{self.log_prefix} 表达学习任务异常退出: {exc}") + self._expression_learner.mark_processed_until(processed_end_index) + finally: + self._prune_processed_message_cache() + + def _should_trigger_learning( + self, + *, + enabled: bool, + feature_name: str, + last_extraction_time: float, + pending_count: int, + min_messages_for_extraction: int, + ) -> bool: + """判断周期性学习任务是否满足执行条件。""" + + if not enabled: + logger.debug(f"{self.log_prefix} {feature_name}未启用,跳过本轮学习") + return False + + elapsed = time.time() - last_extraction_time + if elapsed < self._min_extraction_interval: + logger.debug( + f"{self.log_prefix} {feature_name}触发间隔不足: " + f"已过={elapsed:.2f} 秒 阈值={self._min_extraction_interval} 秒" + ) + return False + + if pending_count < min_messages_for_extraction: + logger.debug( + f"{self.log_prefix} {feature_name}待处理消息不足: " + f"待处理={pending_count} 阈值={min_messages_for_extraction} " + f"缓存总量={len(self.message_cache)}" + ) + return False + + return True + + async def _trigger_expression_learning(self, messages: list[SessionMessage]) -> None: + """触发表达方式学习""" + pending_count = self._expression_learner.get_pending_count(self.message_cache) + if not self._should_trigger_learning( + enabled=self._enable_expression_learning, + feature_name="表达学习", + last_extraction_time=self._last_expression_extraction_time, + pending_count=pending_count, + min_messages_for_extraction=self._expression_learner.min_messages_for_extraction, + ): + return + + self._last_expression_extraction_time = time.time() + logger.info( + f"{self.log_prefix} 触发表达方式学习: " + f"消息数量={len(messages)} 待处理消息数量={pending_count} " + f"缓存总量={len(self.message_cache)} " + f"是否启用黑话学习={self._enable_jargon_learning}" + ) + + try: + jargon_miner = self._jargon_miner if self._enable_jargon_learning else None + learnt_style = await self._expression_learner.learn(self.message_cache, jargon_miner) + if learnt_style: + logger.info(f"{self.log_prefix} 表达方式学习成功") + else: + logger.debug(f"{self.log_prefix} 表达方式学习失败") + except Exception: + logger.exception(f"{self.log_prefix} 表达方式学习异常") + + async def _init_mcp(self) -> None: + """初始化 MCP 工具并注册到统一工具层。""" + if not build_mcp_server_runtime_configs(global_config.mcp): + logger.debug(f"{self.log_prefix} 未配置可用的 MCP 服务,跳过 Maisaka MCP 初始化") + return + + self._mcp_host_bridge = MCPHostLLMBridge( + sampling_task_name=global_config.mcp.client.sampling.task_name, + ) + self._mcp_manager = await MCPManager.from_app_config( + global_config.mcp, + host_callbacks=self._mcp_host_bridge.build_callbacks(), + ) + if self._mcp_manager is None: + logger.warning(f"{self.log_prefix} Maisaka MCP 管理器初始化失败,MCP 工具不会注册") + return + + mcp_tool_specs = self._mcp_manager.get_tool_specs() + if not mcp_tool_specs: + logger.info(f"{self.log_prefix} Maisaka 没有可供使用的 MCP 工具") + return + + self._tool_registry.register_provider(MCPToolProvider(self._mcp_manager)) + logger.info( + f"{self.log_prefix} 已向 Maisaka 加载 {len(mcp_tool_specs)} 个 MCP 工具。\n" + f"{self._mcp_manager.get_feature_summary()}" + ) + + def _build_runtime_user_info(self) -> UserInfo: + if self.chat_stream.user_id: + return UserInfo( + user_id=self.chat_stream.user_id, + user_nickname=global_config.maisaka.cli_user_name.strip() or "用户", + user_cardname=None, + ) + return UserInfo(user_id="maisaka_user", user_nickname="用户", user_cardname=None) + + def _build_group_info(self, message: Optional[SessionMessage] = None) -> Optional[GroupInfo]: + group_info = None + if message is not None: + group_info = message.message_info.group_info + elif self.chat_stream.context and self.chat_stream.context.message: + group_info = self.chat_stream.context.message.message_info.group_info + + if group_info is None: + return None + + return GroupInfo(group_id=group_info.group_id, group_name=group_info.group_name) + + def _render_context_usage_panel( + self, + *, + cycle_id: Optional[int] = None, + time_records: Optional[dict[str, float]] = None, + timing_selected_history_count: Optional[int] = None, + timing_prompt_tokens: Optional[int] = None, + timing_action: str = "", + timing_response: str = "", + timing_tool_calls: Optional[list[Any]] = None, + timing_tool_results: Optional[list[str]] = None, + timing_tool_detail_results: Optional[list[dict[str, Any]]] = None, + timing_prompt_section: Optional[RenderableType] = None, + planner_selected_history_count: Optional[int] = None, + planner_prompt_tokens: Optional[int] = None, + planner_response: str = "", + planner_tool_calls: Optional[list[Any]] = None, + planner_tool_results: Optional[list[str]] = None, + planner_tool_detail_results: Optional[list[dict[str, Any]]] = None, + planner_prompt_section: Optional[RenderableType] = None, + planner_extra_lines: Optional[list[str]] = None, + ) -> None: + """在终端展示当前聊天流本轮 cycle 的最终结果。""" + if not global_config.debug.show_maisaka_thinking: + return + + body_lines = [ + f"聊天流名称:{getattr(self, 'session_name', self.session_id)}", + f"聊天流ID:{self.session_id}", + ] + + panel_title = "MaiSaka 循环" + if cycle_id is not None: + panel_title = f"{panel_title} [{cycle_id}]" + panel_subtitle = self._build_cycle_time_records_text(time_records or {}) + renderables: list[RenderableType] = [Text("\n".join(body_lines))] + timing_panel = self._build_cycle_stage_panel( + title="Timing Gate", + border_style="bright_magenta", + selected_history_count=timing_selected_history_count, + prompt_tokens=timing_prompt_tokens, + response_text=timing_response, + prompt_section=timing_prompt_section, + extra_lines=None, + ) + if timing_panel is not None: + renderables.append(timing_panel) + + timing_tool_cards = self._build_tool_activity_cards( + stage_title="Timing Tool", + tool_calls=timing_tool_calls, + tool_results=timing_tool_results, + tool_detail_results=timing_tool_detail_results, + planner_style=False, + ) + if timing_tool_cards: + renderables.extend(timing_tool_cards) + + planner_panel = self._build_cycle_stage_panel( + title="Planner", + border_style="green", + selected_history_count=planner_selected_history_count, + prompt_tokens=planner_prompt_tokens, + response_text=planner_response, + prompt_section=planner_prompt_section, + extra_lines=planner_extra_lines, + ) + if planner_panel is not None: + renderables.append(planner_panel) + + planner_tool_cards = self._build_tool_activity_cards( + stage_title="Planner Tool", + tool_calls=planner_tool_calls, + tool_results=planner_tool_results, + tool_detail_results=planner_tool_detail_results, + planner_style=True, + ) + if planner_tool_cards: + renderables.extend(planner_tool_cards) + + console.print( + Panel( + Group(*renderables), + title=panel_title, + subtitle=panel_subtitle, + border_style="bright_blue", + padding=(0, 1), + ) + ) + + def _build_cycle_stage_panel( + self, + *, + title: str, + border_style: str, + selected_history_count: Optional[int], + prompt_tokens: Optional[int], + response_text: str = "", + prompt_section: Optional[RenderableType] = None, + extra_lines: Optional[list[str]] = None, + ) -> Optional[Panel]: + """构建单个 cycle 阶段的展示卡片。""" + + has_content = any([ + selected_history_count is not None, + prompt_tokens is not None, + bool(response_text.strip()), + prompt_section is not None, + bool(extra_lines), + ]) + if not has_content: + return None + + body_lines: list[str] = [] + if prompt_tokens is not None: + body_lines.append(f"本次请求token消耗:{format_token_count(prompt_tokens)}") + if extra_lines: + body_lines.extend([line for line in extra_lines if isinstance(line, str) and line.strip()]) + + renderables: list[RenderableType] = [] + if body_lines: + renderables.append(Text("\n".join(body_lines))) + if prompt_section is not None: + renderables.append(prompt_section) + + normalized_response = response_text.strip() + if normalized_response: + renderables.append( + Panel( + Text(normalized_response), + title="Maisaka 返回", + border_style=border_style, + padding=(0, 1), + ) + ) + + return Panel( + Group(*renderables), + title=title, + border_style=border_style, + padding=(0, 1), + ) + + def _build_tool_activity_cards( + self, + *, + stage_title: str, + tool_calls: Optional[list[Any]] = None, + tool_results: Optional[list[str]] = None, + tool_detail_results: Optional[list[dict[str, Any]]] = None, + planner_style: bool = False, + ) -> list[RenderableType]: + """构建与阶段同级的工具执行卡片列表。""" + + detail_results = tool_detail_results or [] + cards = self._build_tool_detail_cards( + detail_results, + stage_title=stage_title, + planner_style=planner_style, + ) + if cards: + return cards + + # 兼容旧数据结构:若尚无 detail,则降级为简单文本卡片。 + fallback_lines = self._filter_redundant_tool_results( + tool_results=tool_results or [], + tool_detail_results=detail_results, + ) + if not fallback_lines and tool_calls: + fallback_lines = build_tool_call_summary_lines(tool_calls) + if not fallback_lines: + return [] + + fallback_border_style = "yellow" + return [ + Panel( + Text("\n".join(fallback_lines)), + title=stage_title, + border_style=fallback_border_style, + padding=(0, 1), + ) + ] + + @staticmethod + def _build_cycle_time_records_text(time_records: dict[str, float]) -> str: + """构建循环最外层面板展示的阶段耗时文本。""" + + if not time_records: + return "流程耗时:无" + + label_map = { + "timing_gate": "Timing Gate", + "planner": "Planner", + "tool_calls": "工具执行", + } + ordered_keys = ["timing_gate", "planner", "tool_calls"] + + parts: list[str] = [] + for key in ordered_keys: + duration = time_records.get(key) + if isinstance(duration, (int, float)): + parts.append(f"{label_map.get(key, key)} {float(duration):.2f} s") + + for key, duration in time_records.items(): + if key in ordered_keys or not isinstance(duration, (int, float)): + continue + parts.append(f"{label_map.get(key, key)} {float(duration):.2f} s") + + if not parts: + return "流程耗时:无" + return "流程耗时:" + " | ".join(parts) + + @staticmethod + def _filter_redundant_tool_results( + *, + tool_results: list[str], + tool_detail_results: list[dict[str, Any]], + ) -> list[str]: + """过滤掉已经在详情卡片中展示过的工具摘要。""" + + detailed_summaries = { + str(tool_result.get("summary") or "").strip() + for tool_result in tool_detail_results + if isinstance(tool_result.get("detail"), dict) and tool_result.get("detail") + } + return [ + result.strip() + for result in tool_results + if isinstance(result, str) + and result.strip() + and result.strip() not in detailed_summaries + ] + + @staticmethod + def _build_tool_metrics_text(metrics: dict[str, Any]) -> str: + """将工具监控 metrics 转换为便于 CLI 阅读的文本。""" + + lines: list[str] = [] + model_name = str(metrics.get("model_name") or "").strip() + if model_name: + lines.append(f"模型:{model_name}") + + prompt_tokens = metrics.get("prompt_tokens") + completion_tokens = metrics.get("completion_tokens") + total_tokens = metrics.get("total_tokens") + if isinstance(prompt_tokens, int) or isinstance(completion_tokens, int) or isinstance(total_tokens, int): + lines.append( + "Token:" + f"输入 {format_token_count(int(prompt_tokens or 0))} / " + f"输出 {format_token_count(int(completion_tokens or 0))} / " + f"总计 {format_token_count(int(total_tokens or 0))}" + ) + + prompt_ms = metrics.get("prompt_ms") + llm_ms = metrics.get("llm_ms") + overall_ms = metrics.get("overall_ms") + timing_parts: list[str] = [] + if isinstance(prompt_ms, (int, float)): + timing_parts.append(f"prompt {round(float(prompt_ms), 2)} ms") + if isinstance(llm_ms, (int, float)): + timing_parts.append(f"llm {round(float(llm_ms), 2)} ms") + if isinstance(overall_ms, (int, float)): + timing_parts.append(f"overall {round(float(overall_ms), 2)} ms") + if timing_parts: + lines.append("耗时:" + " / ".join(timing_parts)) + + return "\n".join(lines) + + @staticmethod + def _get_tool_detail_labels(tool_name: str) -> dict[str, str]: + """返回不同工具对应的详情区标题与预览类别。""" + + normalized_tool_name = str(tool_name or "").strip().lower() + if normalized_tool_name == "reply": + return { + "prompt_title": "Reply Prompt", + "reasoning_title": "Reply 思考", + "output_title": "Reply 输出", + "prompt_category": "replyer", + "request_kind": "replyer", + } + if normalized_tool_name == "send_emoji": + return { + "prompt_title": "Emotion Prompt", + "reasoning_title": "Emotion 思考", + "output_title": "Emotion 输出", + "prompt_category": "emotion", + "request_kind": "emotion", + } + display_name = normalized_tool_name or "tool" + return { + "prompt_title": f"{display_name} Prompt", + "reasoning_title": f"{display_name} 思考", + "output_title": f"{display_name} 输出", + "prompt_category": display_name, + "request_kind": "sub_agent", + } + + def _build_tool_prompt_access_panel( + self, + *, + tool_name: str, + prompt_text: str, + request_messages: Optional[list[Any]] = None, + tool_call_id: str, + border_style: str = "bright_yellow", + ) -> Panel: + """将工具 prompt 渲染为可点击查看的预览入口。""" + + labels = self._get_tool_detail_labels(tool_name) + subtitle = f"会话ID: {self.session_id}" + if tool_call_id: + subtitle += f"\n调用ID: {tool_call_id}" + + if isinstance(request_messages, list) and request_messages: + try: + normalized_messages = deserialize_prompt_messages(request_messages) + except Exception as exc: + logger.warning(f"工具 {tool_name} 的 request_messages 无法反序列化,已回退为文本预览: {exc}") + else: + return Panel( + PromptCLIVisualizer.build_prompt_access_panel( + normalized_messages, + category=labels["prompt_category"], + chat_id=self.session_id, + request_kind=labels["request_kind"], + selection_reason=subtitle, + ), + title=labels["prompt_title"], + border_style=border_style, + padding=(0, 1), + ) + + return Panel( + PromptCLIVisualizer.build_text_access_panel( + prompt_text, + category=labels["prompt_category"], + chat_id=self.session_id, + request_kind=labels["request_kind"], + subtitle=subtitle, + ), + title=labels["prompt_title"], + border_style=border_style, + padding=(0, 1), + ) + + def _normalize_tool_card_body_lines(self, body: Any) -> list[str]: + """将工具卡片正文规范化为行列表。""" + + if isinstance(body, str): + return [line for line in body.splitlines() if line.strip()] + if isinstance(body, list): + return [ + str(item).strip() + for item in body + if str(item).strip() + ] + return [] + + def _build_custom_tool_sub_cards( + self, + sub_cards: Any, + *, + default_border_style: str, + ) -> list[RenderableType]: + """构建工具自定义子卡片。""" + + if not isinstance(sub_cards, list): + return [] + + renderables: list[RenderableType] = [] + for sub_card in sub_cards: + if not isinstance(sub_card, dict): + continue + title = str(sub_card.get("title") or "").strip() or "附加信息" + border_style = str(sub_card.get("border_style") or "").strip() or default_border_style + body_lines = self._normalize_tool_card_body_lines( + sub_card.get("body_lines", sub_card.get("content", "")) + ) + if not body_lines: + continue + renderables.append( + Panel( + Text("\n".join(body_lines)), + title=title, + border_style=border_style, + padding=(0, 1), + ) + ) + return renderables + + def _build_default_tool_detail_parts( + self, + *, + tool_name: str, + tool_call_id: str, + tool_args: Any, + summary: str, + duration_ms: Any, + detail: dict[str, Any], + planner_style: bool, + ) -> list[RenderableType]: + """构建工具卡片默认内容块。""" + + argument_border_style = "yellow" + metrics_border_style = "bright_yellow" + prompt_border_style = "bright_yellow" + reasoning_border_style = "yellow" + output_border_style = "bright_yellow" + extra_info_border_style = "yellow" + detail_labels = self._get_tool_detail_labels(tool_name) + + parts: list[RenderableType] = [] + header_lines: list[str] = [] + if summary: + header_lines.append(summary) + if tool_call_id: + header_lines.append(f"调用ID:{tool_call_id}") + if isinstance(duration_ms, (int, float)): + header_lines.append(f"执行耗时:{round(float(duration_ms), 2)} ms") + if header_lines: + parts.append(Text("\n".join(header_lines))) + + if isinstance(tool_args, dict) and tool_args: + parts.append( + Panel( + Pretty(tool_args, expand_all=True), + title="工具参数", + border_style=argument_border_style, + padding=(0, 1), + ) + ) + + metrics = detail.get("metrics") + if isinstance(metrics, dict): + metrics_text = self._build_tool_metrics_text(metrics) + if metrics_text: + parts.append( + Panel( + Text(metrics_text), + title="执行指标", + border_style=metrics_border_style, + padding=(0, 1), + ) + ) + + prompt_text = str(detail.get("prompt_text") or "").strip() + if prompt_text: + parts.append( + self._build_tool_prompt_access_panel( + tool_name=tool_name, + prompt_text=prompt_text, + request_messages=detail.get("request_messages") if isinstance(detail.get("request_messages"), list) else None, + tool_call_id=tool_call_id, + border_style=prompt_border_style, + ) + ) + + reasoning_text = str(detail.get("reasoning_text") or "").strip() + if reasoning_text: + parts.append( + Panel( + Text(reasoning_text), + title=detail_labels["reasoning_title"], + border_style=reasoning_border_style, + padding=(0, 1), + ) + ) + + output_text = str(detail.get("output_text") or "").strip() + if output_text: + parts.append( + Panel( + Text(output_text), + title=detail_labels["output_title"], + border_style=output_border_style, + padding=(0, 1), + ) + ) + + extra_sections = detail.get("extra_sections") + if isinstance(extra_sections, list): + for section in extra_sections: + if not isinstance(section, dict): + continue + section_title = str(section.get("title") or "").strip() or "附加信息" + section_content = str(section.get("content") or "").strip() + if not section_content: + continue + parts.append( + Panel( + Text(section_content), + title=section_title, + border_style=extra_info_border_style, + padding=(0, 1), + ) + ) + + return parts + + def _build_tool_detail_cards( + self, + tool_detail_results: list[dict[str, Any]], + *, + stage_title: str, + planner_style: bool = False, + ) -> list[RenderableType]: + """将 tool monitor detail 渲染为与 Planner/Timing 平级的工具卡片。""" + + detail_panel_border_style = "yellow" + sub_card_border_style = "bright_yellow" + + panels: list[RenderableType] = [] + for tool_result in tool_detail_results: + detail = tool_result.get("detail") + detail_dict = detail if isinstance(detail, dict) else {} + tool_name = str(tool_result.get("tool_name") or "unknown").strip() or "unknown" + tool_title = str(tool_result.get("tool_title") or "").strip() or tool_name + tool_call_id = str(tool_result.get("tool_call_id") or "").strip() + tool_args = tool_result.get("tool_args") + summary = str(tool_result.get("summary") or "").strip() + duration_ms = tool_result.get("duration_ms") + custom_card = tool_result.get("card") + + parts: list[RenderableType] = [] + custom_title = "" + card_border_style = detail_panel_border_style + replace_default_children = False + if isinstance(custom_card, dict): + custom_title = str(custom_card.get("title") or "").strip() + card_border_style = str(custom_card.get("border_style") or "").strip() or detail_panel_border_style + replace_default_children = bool(custom_card.get("replace_default_children", False)) + custom_body_lines = self._normalize_tool_card_body_lines( + custom_card.get("body_lines", custom_card.get("content", "")) + ) + if custom_body_lines: + parts.append(Text("\n".join(custom_body_lines))) + + if not replace_default_children: + parts.extend( + self._build_default_tool_detail_parts( + tool_name=tool_name, + tool_call_id=tool_call_id, + tool_args=tool_args, + summary=summary, + duration_ms=duration_ms, + detail=detail_dict, + planner_style=planner_style, + ) + ) + + if isinstance(custom_card, dict): + parts.extend( + self._build_custom_tool_sub_cards( + custom_card.get("sub_cards"), + default_border_style=sub_card_border_style, + ) + ) + parts.extend( + self._build_custom_tool_sub_cards( + tool_result.get("sub_cards"), + default_border_style=sub_card_border_style, + ) + ) + + if parts: + panels.append( + Panel( + Group(*parts), + title=custom_title or f"{stage_title} · {tool_title}", + border_style=card_border_style, + padding=(0, 1), + ) + ) + + return panels + + def _log_cycle_started(self, cycle_detail: CycleDetail, round_index: int) -> None: + logger.debug( + f"{self.log_prefix} MaiSaka 轮次开始: 循环编号={cycle_detail.cycle_id} " + f"回合={round_index + 1}/{self._max_internal_rounds} " + f"上下文消息数={len(self._chat_history)}" + ) + + def _log_cycle_completed(self, cycle_detail: CycleDetail, timer_strings: list[str]) -> None: + end_time = cycle_detail.end_time if cycle_detail.end_time is not None else cycle_detail.start_time + logger.debug( + f"{self.log_prefix} MaiSaka 轮次结束: 循环编号={cycle_detail.cycle_id} " + f"总耗时={end_time - cycle_detail.start_time:.2f} 秒; " + f"阶段耗时={', '.join(timer_strings) if timer_strings else '无'}" + ) + + def _log_history_trimmed(self, removed_count: int, user_message_count: int) -> None: + logger.debug( + f"{self.log_prefix} 已裁剪 {removed_count} 条历史消息; " + # f"剩余计入上下文的消息数={user_message_count}" + ) + + def _log_internal_loop_cancelled(self) -> None: + logger.info(f"{self.log_prefix} Maisaka 内部循环已取消") diff --git a/src/maisaka/tool_provider.py b/src/maisaka/tool_provider.py new file mode 100644 index 00000000..908e5ad6 --- /dev/null +++ b/src/maisaka/tool_provider.py @@ -0,0 +1,74 @@ +"""Maisaka 内置工具 Provider。""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Dict, Optional + +from src.core.tooling import ( + ToolAvailabilityContext, + ToolExecutionContext, + ToolExecutionResult, + ToolInvocation, + ToolProvider, + ToolSpec, +) + +from .builtin_tool import get_all_builtin_tool_specs + +BuiltinToolHandler = Callable[[ToolInvocation, Optional[ToolExecutionContext]], Awaitable[ToolExecutionResult]] + + +class MaisakaBuiltinToolProvider(ToolProvider): + """Maisaka 内置工具提供者。""" + + provider_name = "maisaka_builtin" + provider_type = "builtin" + + def __init__(self, handlers: Optional[Dict[str, BuiltinToolHandler]] = None) -> None: + """初始化内置工具 Provider。 + + Args: + handlers: 工具名到异步处理器的映射。 + """ + + self._handlers = dict(handlers or {}) + + async def list_tools( + self, + context: Optional[ToolAvailabilityContext] = None, + ) -> list[ToolSpec]: + """列出全部内置工具。""" + + return list(get_all_builtin_tool_specs(context)) + + async def invoke( + self, + invocation: ToolInvocation, + context: Optional[ToolExecutionContext] = None, + ) -> ToolExecutionResult: + """执行指定内置工具。 + + Args: + invocation: 工具调用请求。 + context: 执行上下文。 + + Returns: + ToolExecutionResult: 工具执行结果。 + """ + + handler = self._handlers.get(invocation.tool_name) + if handler is None: + return ToolExecutionResult( + tool_name=invocation.tool_name, + success=False, + error_message=f"未找到内置工具处理器:{invocation.tool_name}", + ) + return await handler(invocation, context) + + async def close(self) -> None: + """关闭 Provider。 + + 内置 Provider 无需释放额外资源。 + """ + diff --git a/src/maisaka/visual_mode_utils.py b/src/maisaka/visual_mode_utils.py new file mode 100644 index 00000000..d9c15a6e --- /dev/null +++ b/src/maisaka/visual_mode_utils.py @@ -0,0 +1,43 @@ +from src.common.logger import get_logger +from src.config.config import config_manager, global_config + +logger = get_logger("maisaka_visual_mode") + + +def resolve_enable_visual_planner() -> bool: + """根据 planner 配置解析当前是否应启用视觉消息。""" + + planner_mode = global_config.visual.planner_mode + planner_task_config = config_manager.get_model_config().model_task_config.planner + models_by_name = {model.name: model for model in config_manager.get_model_config().models} + + if planner_mode == "text": + return False + + planner_models: list[str] = list(planner_task_config.model_list) + missing_models = [model_name for model_name in planner_models if model_name not in models_by_name] + non_visual_models = [ + model_name for model_name in planner_models if model_name in models_by_name and not models_by_name[model_name].visual + ] + + if planner_mode == "multimodal": + if missing_models: + raise ValueError( + "planner_mode=multimodal,但 planner 任务存在未定义的模型:" + f"{', '.join(missing_models)}" + ) + if non_visual_models: + raise ValueError( + "planner_mode=multimodal,但 planner 任务存在未开启 visual 的模型:" + f"{', '.join(non_visual_models)}" + ) + return True + + if missing_models: + logger.warning( + "planner_mode=auto 时发现 planner 任务存在未定义模型:" + f"{', '.join(missing_models)},将退化为纯文本 planner" + ) + return False + + return bool(planner_models) and not non_visual_models diff --git a/src/manager/async_task_manager.py b/src/manager/async_task_manager.py new file mode 100644 index 00000000..0a2c0d21 --- /dev/null +++ b/src/manager/async_task_manager.py @@ -0,0 +1,193 @@ +from abc import abstractmethod + +import asyncio +from asyncio import Task, Event, Lock +from typing import Callable, Dict + +from src.common.logger import get_logger + +logger = get_logger("async_task_manager") + + +class AsyncTask: + """异步任务基类""" + + def __init__(self, task_name: str | None = None, wait_before_start: int = 0, run_interval: int = 0): + self.task_name: str = task_name or self.__class__.__name__ + """任务名称""" + + self.wait_before_start: int = wait_before_start + """运行任务前是否进行等待(单位:秒,设为0则不等待)""" + + self.run_interval: int = run_interval + """多次运行的时间间隔(单位:秒,设为0则仅运行一次)""" + + @abstractmethod + async def run(self): + """ + 任务的执行过程 + """ + pass + + async def start_task(self, abort_flag: asyncio.Event): + if self.wait_before_start > 0: + # 等待指定时间后开始任务 + await asyncio.sleep(self.wait_before_start) + + while not abort_flag.is_set(): + await self.run() + if self.run_interval > 0: + await asyncio.sleep(self.run_interval) + else: + break + + +class AsyncTaskManager: + """异步任务管理器""" + + def __init__(self): + self.tasks: Dict[str, Task] = {} + """任务列表""" + + self.abort_flag: Event = Event() + """是否中止任务标志""" + + self._lock: Lock = Lock() + """异步锁,当可能出现await时需要加锁""" + + def _remove_task_call_back(self, task: Task): + """ + call_back: 任务完成后移除任务 + """ + task_name = task.get_name() + if task_name in self.tasks: + # 任务完成后移除任务 + del self.tasks[task_name] + logger.debug(f"已移除任务 '{task_name}'") + else: + logger.warning(f"尝试移除不存在的任务 '{task_name}'") + + @staticmethod + def _default_finish_call_back(task: Task): + """ + call_back: 默认的任务完成回调函数 + """ + try: + task.result() + logger.debug(f"任务 '{task.get_name()}' 完成") + except asyncio.CancelledError: + logger.debug(f"任务 '{task.get_name()}' 被取消") + except Exception as e: + logger.error(f"任务 '{task.get_name()}' 执行时发生异常: {e}", exc_info=True) + + async def add_task(self, task: AsyncTask, call_back: Callable[[asyncio.Task], None] | None = None): + """ + 添加任务 + """ + if not issubclass(task.__class__, AsyncTask): + raise TypeError(f"task '{task.__class__.__name__}' 必须是继承 AsyncTask 的子类") + + async with self._lock: # 由于可能需要await等待任务完成,所以需要加异步锁 + if task.task_name in self.tasks: + logger.warning(f"已存在名称为 '{task.task_name}' 的任务,正在尝试取消并替换") + old_task = self.tasks[task.task_name] + old_task.cancel() # 取消已存在的任务 + + # 添加超时保护,避免无限等待 + try: + await asyncio.wait_for(old_task, timeout=5.0) + except asyncio.TimeoutError: + logger.warning(f"等待任务 '{task.task_name}' 完成超时") + except asyncio.CancelledError: + logger.info(f"任务 '{task.task_name}' 已成功取消") + except Exception as e: + logger.error(f"等待任务 '{task.task_name}' 完成时发生异常: {e}") + + logger.info(f"成功结束任务 '{task.task_name}'") + + # 创建新任务 + task_inst = asyncio.create_task(task.start_task(self.abort_flag)) + task_inst.set_name(task.task_name) + task_inst.add_done_callback(self._remove_task_call_back) # 添加完成回调函数-完成任务后自动移除任务 + task_inst.add_done_callback( + call_back or self._default_finish_call_back + ) # 添加完成回调函数-用户自定义,或默认的FallBack + + self.tasks[task.task_name] = task_inst # 将任务添加到任务列表 + logger.debug(f"已启动任务 '{task.task_name}'") + + def get_tasks_status(self) -> Dict[str, Dict[str, str]]: + """ + 获取所有任务的状态 + """ + return {task_name: {"status": "done" if task.done() else "running"} for task_name, task in self.tasks.items()} + + async def stop_and_wait_all_tasks(self): + """ + 终止所有任务并等待它们完成(该方法会阻塞其它尝试add_task()的操作) + """ + async with self._lock: # 由于可能需要await等待任务完成,所以需要加异步锁 + # 设置中止标志 + self.abort_flag.set() + + # 首先收集所有任务的引用,避免在迭代过程中字典被修改 + task_items = list(self.tasks.items()) + + # 取消所有任务 + for name, inst in task_items: + if not inst.done(): + try: + inst.cancel() + logger.debug(f"已请求取消任务 '{name}'") + except Exception as e: + logger.warning(f"取消任务 '{name}' 时发生异常: {e}") + + # 等待所有任务完成,添加超时保护 + for task_name, task_inst in task_items: + if not task_inst.done(): + try: + await asyncio.wait_for(task_inst, timeout=10.0) + logger.debug(f"任务 '{task_name}' 已完成") + except asyncio.TimeoutError: + logger.warning(f"等待任务 '{task_name}' 完成超时") + except asyncio.CancelledError: + logger.info(f"任务 '{task_name}' 已取消") + except Exception as e: + logger.error(f"任务 '{task_name}' 执行时发生异常: {e}", exc_info=True) + + # 清空任务列表 + self.tasks.clear() + self.abort_flag.clear() + logger.info("所有异步任务已停止") + + def debug_task_status(self): + """ + 调试函数:打印所有任务的状态信息 + """ + logger.info("=== 异步任务状态调试信息 ===") + logger.info(f"当前管理的任务数量: {len(self.tasks)}") + logger.info(f"中止标志状态: {self.abort_flag.is_set()}") + + for task_name, task in self.tasks.items(): + status = [] + if task.done(): + status.append("已完成") + if task.cancelled(): + status.append("已取消") + elif task.exception(): + status.append(f"异常: {task.exception()}") + else: + status.append("正常完成") + else: + status.append("运行中") + + logger.info(f"任务 '{task_name}': {', '.join(status)}") + + # 检查所有asyncio任务 + all_tasks = asyncio.all_tasks() + logger.info(f"当前事件循环中的所有任务数量: {len(all_tasks)}") + logger.info("=== 调试信息结束 ===") + + +async_task_manager = AsyncTaskManager() +"""全局异步任务管理器实例""" diff --git a/src/manager/local_store_manager.py b/src/manager/local_store_manager.py new file mode 100644 index 00000000..0f7a2a71 --- /dev/null +++ b/src/manager/local_store_manager.py @@ -0,0 +1,75 @@ +import json +import os + +from src.common.logger import get_logger + +LOCAL_STORE_FILE_PATH = "data/local_store.json" + +logger = get_logger("local_storage") + + +class LocalStoreManager: + file_path: str + """本地存储路径""" + + store: dict[str, str | list | dict | int | float | bool] + """本地存储数据""" + + def __init__(self, local_store_path: str | None = None): + self.file_path = local_store_path or LOCAL_STORE_FILE_PATH + self.store = {} + self.load_local_store() + + def __getitem__(self, item: str) -> str | list | dict | int | float | bool | None: + """获取本地存储数据""" + return self.store.get(item) + + def __setitem__(self, key: str, value: str | list | dict | int | float | bool): + """设置本地存储数据""" + self.store[key] = value + self.save_local_store() + + def __delitem__(self, key: str): + """删除本地存储数据""" + if key in self.store: + del self.store[key] + self.save_local_store() + else: + logger.warning(f"尝试删除不存在的键: {key}") + + def __contains__(self, item: str) -> bool: + """检查本地存储数据是否存在""" + return item in self.store + + def load_local_store(self): + """加载本地存储数据""" + if os.path.exists(self.file_path): + # 存在本地存储文件,加载数据 + logger.info("正在阅读记事本......我在看,我真的在看!") + logger.debug(f"加载本地存储数据: {self.file_path}") + try: + with open(self.file_path, "r", encoding="utf-8") as f: + self.store = json.load(f) + logger.info("全都记起来了!") + except json.JSONDecodeError: + logger.warning("啊咧?记事本被弄脏了,正在重建记事本......") + self.store = {} + with open(self.file_path, "w", encoding="utf-8") as f: + json.dump({}, f, ensure_ascii=False, indent=4) + logger.info("记事本重建成功!") + else: + # 不存在本地存储文件,创建新的目录和文件 + logger.warning("啊咧?记事本不存在,正在创建新的记事本......") + os.makedirs(os.path.dirname(self.file_path), exist_ok=True) + with open(self.file_path, "w", encoding="utf-8") as f: + json.dump({}, f, ensure_ascii=False, indent=4) + logger.info("记事本创建成功!") + + def save_local_store(self): + """保存本地存储数据""" + logger.debug(f"保存本地存储数据: {self.file_path}") + with open(self.file_path, "w", encoding="utf-8") as f: + json.dump(self.store, f, ensure_ascii=False, indent=4) + + +local_storage = LocalStoreManager("data/local_store.json") # 全局单例化 diff --git a/src/mcp_module/__init__.py b/src/mcp_module/__init__.py new file mode 100644 index 00000000..0fd5bee7 --- /dev/null +++ b/src/mcp_module/__init__.py @@ -0,0 +1,19 @@ +""" +MCP (Model Context Protocol) 客户端包。 + +提供 MCPManager 用于管理 MCP 服务器连接、发现工具、调用工具。 + +用法: + from src.config.config import global_config + from .manager import MCPManager + + manager = await MCPManager.from_app_config(global_config.mcp) + if manager: + tools = manager.get_openai_tools() # 获取 OpenAI 格式工具列表 + result = await manager.call_tool(name, args) # 调用工具 + await manager.close() # 关闭连接 +""" + +from .manager import MCPManager + +__all__ = ["MCPManager"] diff --git a/src/mcp_module/config.py b/src/mcp_module/config.py new file mode 100644 index 00000000..2bc2621c --- /dev/null +++ b/src/mcp_module/config.py @@ -0,0 +1,162 @@ +"""MCP 运行时配置转换。 + +负责将主程序官方配置中的 MCP 配置转换为运行时使用的结构化对象。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from src.config.official_configs import MCPConfig + + +@dataclass(slots=True) +class MCPAuthorizationRuntimeConfig: + """MCP HTTP 认证运行时配置。""" + + mode: Literal["none", "bearer"] = "none" + bearer_token: str = "" + + +@dataclass(slots=True) +class MCPRootRuntimeConfig: + """MCP Root 运行时配置。""" + + uri: str + name: str = "" + + +@dataclass(slots=True) +class MCPClientRuntimeConfig: + """MCP 客户端宿主能力运行时配置。""" + + client_name: str = "MaiBot" + client_version: str = "1.0.0" + enable_roots: bool = False + roots: list[MCPRootRuntimeConfig] = field(default_factory=list) + enable_sampling: bool = False + sampling_task_name: str = "planner" + sampling_include_context_support: bool = False + sampling_tool_support: bool = False + enable_elicitation: bool = False + elicitation_allow_form: bool = True + elicitation_allow_url: bool = False + + +@dataclass(slots=True) +class MCPServerRuntimeConfig: + """单个 MCP 服务器的运行时配置。""" + + name: str + transport: Literal["stdio", "streamable_http", "sse"] = "stdio" + command: str = "" + args: list[str] = field(default_factory=list) + env: dict[str, str] = field(default_factory=dict) + url: str = "" + headers: dict[str, str] = field(default_factory=dict) + http_timeout_seconds: float = 30.0 + read_timeout_seconds: float = 300.0 + authorization: MCPAuthorizationRuntimeConfig = field(default_factory=MCPAuthorizationRuntimeConfig) + + @property + def transport_type(self) -> str: + """返回当前服务器的传输类型。 + + Returns: + str: ``stdio``、``streamable_http``、``sse`` 或 ``unknown``。 + """ + + if self.transport == "stdio" and self.command: + return "stdio" + if self.transport == "streamable_http" and self.url: + return "streamable_http" + if self.transport == "sse" and self.url: + return "sse" + return "unknown" + + def build_http_headers(self) -> dict[str, str]: + """构建远程 HTTP 连接需要附加的请求头。 + + Returns: + dict[str, str]: 归一化后的请求头集合。 + """ + + headers = {str(key): str(value) for key, value in self.headers.items()} + if self.authorization.mode == "bearer" and self.authorization.bearer_token.strip(): + headers["Authorization"] = f"Bearer {self.authorization.bearer_token.strip()}" + return headers + + +def build_mcp_client_runtime_config(mcp_config: "MCPConfig") -> MCPClientRuntimeConfig: + """将官方 MCP 客户端配置转换为运行时结构。 + + Args: + mcp_config: 主程序中的 MCP 官方配置对象。 + + Returns: + MCPClientRuntimeConfig: MCP 客户端宿主能力运行时配置。 + """ + + roots = [ + MCPRootRuntimeConfig( + uri=root.uri.strip(), + name=root.name.strip(), + ) + for root in mcp_config.client.roots.items + if root.enabled and root.uri.strip() + ] + + return MCPClientRuntimeConfig( + client_name=mcp_config.client.client_name.strip() or "MaiBot", + client_version=mcp_config.client.client_version.strip() or "1.0.0", + enable_roots=mcp_config.client.roots.enable and bool(roots), + roots=roots, + enable_sampling=mcp_config.client.sampling.enable, + sampling_task_name=mcp_config.client.sampling.task_name.strip() or "planner", + sampling_include_context_support=mcp_config.client.sampling.include_context_support, + sampling_tool_support=mcp_config.client.sampling.tool_support, + enable_elicitation=mcp_config.client.elicitation.enable, + elicitation_allow_form=mcp_config.client.elicitation.allow_form, + elicitation_allow_url=mcp_config.client.elicitation.allow_url, + ) + + +def build_mcp_server_runtime_configs(mcp_config: "MCPConfig") -> list[MCPServerRuntimeConfig]: + """将官方 MCP 配置转换为运行时配置列表。 + + Args: + mcp_config: 主程序中的 MCP 官方配置对象。 + + Returns: + list[MCPServerRuntimeConfig]: 启用且配置完整的 MCP 服务器列表。 + """ + + if not mcp_config.enable: + return [] + + runtime_configs: list[MCPServerRuntimeConfig] = [] + for server in mcp_config.servers: + if not server.enabled: + continue + + runtime_configs.append( + MCPServerRuntimeConfig( + name=server.name.strip(), + transport=server.transport, + command=server.command.strip(), + args=[str(argument) for argument in server.args], + env={str(key): str(value) for key, value in server.env.items()}, + url=server.url.strip(), + headers={str(key): str(value) for key, value in server.headers.items()}, + http_timeout_seconds=float(server.http_timeout_seconds), + read_timeout_seconds=float(server.read_timeout_seconds), + authorization=MCPAuthorizationRuntimeConfig( + mode=server.authorization.mode, + bearer_token=server.authorization.bearer_token.strip(), + ), + ) + ) + + return runtime_configs diff --git a/src/mcp_module/connection.py b/src/mcp_module/connection.py new file mode 100644 index 00000000..02a3823d --- /dev/null +++ b/src/mcp_module/connection.py @@ -0,0 +1,608 @@ +""" +MaiSaka - 单个 MCP 服务器连接管理 +封装单个 MCP 服务器的连接生命周期:连接 → 发现能力 → 调用工具/读取资源 → 断开。 +""" + +from __future__ import annotations + +from contextlib import AsyncExitStack +from datetime import timedelta +from typing import TYPE_CHECKING, Any, Callable, Optional, cast + +import httpx + +from src.cli.console import console +from src.core.tooling import ToolExecutionResult + +from .config import MCPClientRuntimeConfig, MCPServerRuntimeConfig +from .hooks import MCPHostCallbacks +from .models import ( + MCPPromptResult, + MCPResourceReadResult, + build_prompt_result, + build_resource_read_result, + build_tool_content_items, +) + +if TYPE_CHECKING: + from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT + +# ──────────────────── MCP SDK 可选导入 ──────────────────── +# +# mcp 是可选依赖。如果未安装,MCP_AVAILABLE = False, +# MCPManager.from_app_config() 会检测到并返回 None,不影响主程序运行。 + +try: + from mcp import ClientSession, types as mcp_types + + try: + from mcp.client.stdio import StdioServerParameters + except ImportError: + from mcp import StdioServerParameters # type: ignore[attr-defined] + + from mcp.client.stdio import stdio_client + from mcp.client.streamable_http import streamable_http_client + + try: + from mcp.client.sse import sse_client + + SSE_AVAILABLE = True + except ImportError: + SSE_AVAILABLE = False + sse_client = None # type: ignore[assignment] + + MCP_AVAILABLE = True + STREAMABLE_HTTP_AVAILABLE = True +except ImportError: + MCP_AVAILABLE = False + STREAMABLE_HTTP_AVAILABLE = False + SSE_AVAILABLE = False + ClientSession = None # type: ignore[assignment,misc] + StdioServerParameters = None # type: ignore[assignment,misc] + mcp_types = None # type: ignore[assignment] + stdio_client = None # type: ignore[assignment] + streamable_http_client = None # type: ignore[assignment] + sse_client = None # type: ignore[assignment] + + +class MCPConnection: + """管理单个 MCP 服务器的连接生命周期。""" + + def __init__( + self, + config: MCPServerRuntimeConfig, + client_config: MCPClientRuntimeConfig, + host_callbacks: Optional[MCPHostCallbacks] = None, + ) -> None: + """初始化单个 MCP 连接。 + + Args: + config: 当前服务器的运行时配置。 + client_config: MCP 客户端宿主能力运行时配置。 + host_callbacks: 宿主侧能力回调集合。 + """ + + self.config = config + self.client_config = client_config + self.host_callbacks = host_callbacks or MCPHostCallbacks() + + self.session: Optional[Any] = None + self.server_capabilities: Optional[Any] = None + self.tools: list[Any] = [] + self.prompts: list[Any] = [] + self.resources: list[Any] = [] + self.resource_templates: list[Any] = [] + self.protocol_version: str = "" + + self._http_client: Optional[httpx.AsyncClient] = None + self._session_id_getter: Optional[Callable[[], str | None]] = None + self._exit_stack = AsyncExitStack() + + @property + def session_id(self) -> str: + """返回当前连接协商得到的 MCP 会话标识。 + + Returns: + str: 当前会话 ID;无会话时返回空字符串。 + """ + + if self._session_id_getter is None: + return "" + return self._session_id_getter() or "" + + async def connect(self) -> bool: + """连接到 MCP 服务器并发现可用能力。 + + Returns: + bool: `True` 表示连接成功,`False` 表示失败。 + """ + + if not MCP_AVAILABLE: + console.print("[warning]⚠️ 未安装 mcp SDK,请运行: pip install mcp[/warning]") + return False + + try: + await self._exit_stack.__aenter__() + read_stream, write_stream = await self._connect_transport() + session = await self._create_client_session(read_stream, write_stream) + self.session = session + initialize_result = await session.initialize() + self.server_capabilities = getattr(initialize_result, "capabilities", None) + self.protocol_version = str(getattr(initialize_result, "protocolVersion", "") or "") + + await self._load_server_features() + return True + + except Exception as exc: + console.print(f"[warning]⚠️ MCP 服务器 '{self.config.name}' 连接失败: {exc}[/warning]") + await self.close() + return False + + async def _connect_transport(self) -> tuple[Any, Any]: + """根据配置建立底层传输连接。 + + Returns: + tuple[Any, Any]: 读写流对象。 + """ + + if self.config.transport_type == "stdio": + return await self._connect_stdio() + if self.config.transport_type == "streamable_http": + return await self._connect_streamable_http() + if self.config.transport_type == "sse": + return await self._connect_sse() + + raise ValueError(f"MCP 服务器 '{self.config.name}' 使用了未知传输类型: {self.config.transport}") + + async def _connect_stdio(self) -> tuple[Any, Any]: + """建立 stdio 传输连接。 + + Returns: + tuple[Any, Any]: 读写流对象。 + """ + + if StdioServerParameters is None or stdio_client is None: + raise RuntimeError("当前环境未安装可用的 MCP stdio 客户端") + if not self.config.command: + raise ValueError(f"MCP 服务器 '{self.config.name}' 缺少 stdio command 配置") + + params = StdioServerParameters( + command=self.config.command, + args=self.config.args, + env=self.config.env, + ) + return await self._exit_stack.enter_async_context(stdio_client(params)) + + async def _connect_streamable_http(self) -> tuple[Any, Any]: + """建立 Streamable HTTP 传输连接。 + + Returns: + tuple[Any, Any]: 读写流对象。 + """ + + if not STREAMABLE_HTTP_AVAILABLE or streamable_http_client is None: + raise ImportError("当前环境未安装可用的 MCP Streamable HTTP 客户端") + if not self.config.url: + raise ValueError(f"MCP 服务器 '{self.config.name}' 缺少 Streamable HTTP url 配置") + + self._http_client = await self._exit_stack.enter_async_context(self._build_http_client()) + read_stream, write_stream, session_id_getter = await self._exit_stack.enter_async_context( + streamable_http_client( + url=self.config.url, + http_client=self._http_client, + terminate_on_close=True, + ) + ) + self._session_id_getter = session_id_getter + return read_stream, write_stream + + async def _connect_sse(self) -> tuple[Any, Any]: + """建立 SSE 传输连接。 + + Returns: + tuple[Any, Any]: 读写流对象。 + """ + + if not SSE_AVAILABLE or sse_client is None: + raise ImportError("当前环境未安装可用的 MCP SSE 客户端") + if not self.config.url: + raise ValueError(f"MCP 服务器 '{self.config.name}' 缺少 SSE url 配置") + + read_stream, write_stream = await self._exit_stack.enter_async_context( + sse_client( + url=self.config.url, + headers=self.config.build_http_headers(), + timeout=self.config.http_timeout_seconds, + sse_read_timeout=self.config.read_timeout_seconds, + httpx_client_factory=self._build_http_client, + ) + ) + return read_stream, write_stream + + def _build_http_client( + self, + headers: dict[str, str] | None = None, + timeout: httpx.Timeout | None = None, + auth: httpx.Auth | None = None, + ) -> httpx.AsyncClient: + """构建 httpx 客户端。 + + Args: + headers: 合并到配置请求头的额外请求头。 + timeout: 覆盖的 httpx 超时配置。 + auth: 附加认证。 + + Returns: + httpx.AsyncClient: 预配置的异步 HTTP 客户端。 + """ + + del auth + merged_headers = self.config.build_http_headers() + if headers: + merged_headers.update(headers) + return httpx.AsyncClient( + headers=merged_headers, + timeout=timeout or httpx.Timeout(self.config.http_timeout_seconds), + ) + + async def _create_client_session(self, read_stream: Any, write_stream: Any) -> Any: + """创建并返回 MCP `ClientSession`。 + + Args: + read_stream: 底层读取流。 + write_stream: 底层写入流。 + + Returns: + Any: 已初始化的 MCP `ClientSession` 实例。 + """ + + if ClientSession is None: + raise RuntimeError("当前环境未安装可用的 MCP ClientSession") + + list_roots_callback = self._build_list_roots_callback() + sampling_callback = ( + self.host_callbacks.sampling_callback + if self.client_config.enable_sampling and self.host_callbacks.sampling_callback is not None + else None + ) + elicitation_callback = ( + self.host_callbacks.elicitation_callback + if self.client_config.enable_elicitation and self.host_callbacks.elicitation_callback is not None + else None + ) + logging_callback = cast(Optional["LoggingFnT"], self.host_callbacks.logging_callback) + message_handler = cast(Optional["MessageHandlerFnT"], self.host_callbacks.message_handler) + + if self.client_config.enable_sampling and sampling_callback is None: + console.print( + f"[warning]⚠️ MCP 服务器 '{self.config.name}' 已启用 sampling 配置,但宿主未提供 sampling 回调,当前不会声明该能力[/warning]" + ) + if self.client_config.enable_elicitation and elicitation_callback is None: + console.print( + f"[warning]⚠️ MCP 服务器 '{self.config.name}' 已启用 elicitation 配置,但宿主未提供 elicitation 回调,当前不会声明该能力[/warning]" + ) + + session = await self._exit_stack.enter_async_context( + ClientSession( + read_stream, + write_stream, + read_timeout_seconds=timedelta(seconds=self.config.read_timeout_seconds), + sampling_callback=cast(Optional["SamplingFnT"], sampling_callback), + elicitation_callback=cast(Optional["ElicitationFnT"], elicitation_callback), + list_roots_callback=cast(Optional["ListRootsFnT"], list_roots_callback), + logging_callback=logging_callback, + message_handler=message_handler, + client_info=self._build_client_info(), + sampling_capabilities=self._build_sampling_capabilities(sampling_callback), + ) + ) + return session + + def _build_client_info(self) -> Any: + """构建 MCP 客户端实现信息。 + + Returns: + Any: MCP SDK 的 `Implementation` 对象。 + """ + + if mcp_types is None: + raise RuntimeError("当前环境未安装可用的 MCP types 模块") + + return mcp_types.Implementation( + name=self.client_config.client_name, + version=self.client_config.client_version, + ) + + def _build_sampling_capabilities(self, sampling_callback: Any) -> Any | None: + """构建 Sampling 能力声明。 + + Args: + sampling_callback: 当前宿主侧的 Sampling 回调。 + + Returns: + Any | None: Sampling 能力对象;未启用时返回 ``None``。 + """ + + if mcp_types is None: + return None + if sampling_callback is None: + return None + + context_capability = ( + mcp_types.SamplingContextCapability() + if self.client_config.sampling_include_context_support + else None + ) + tools_capability = ( + mcp_types.SamplingToolsCapability() + if self.client_config.sampling_tool_support + else None + ) + return mcp_types.SamplingCapability( + context=context_capability, + tools=tools_capability, + ) + + def _build_list_roots_callback(self) -> Any | None: + """构建 Roots 列表回调。 + + Returns: + Any | None: 符合 MCP SDK 要求的回调;未启用时返回 ``None``。 + """ + + if mcp_types is None: + return None + if not self.client_config.enable_roots or not self.client_config.roots: + return None + + async def _list_roots(context: Any) -> Any: + """返回当前客户端声明的 Roots 列表。 + + Args: + context: MCP 请求上下文。 + + Returns: + Any: MCP `ListRootsResult` 对象。 + """ + + del context + types_module = mcp_types + if types_module is None: + raise RuntimeError("当前环境未安装可用的 MCP types 模块") + roots = [ + types_module.Root(uri=cast(Any, root.uri), name=root.name or None) + for root in self.client_config.roots + ] + return types_module.ListRootsResult(roots=roots) + + return _list_roots + + async def _load_server_features(self) -> None: + """根据服务端能力声明加载工具、Prompt 与 Resource。""" + + self.tools = await self._list_tools() if self.supports_tools() else [] + self.prompts = await self._list_prompts() if self.supports_prompts() else [] + self.resources = await self._list_resources() if self.supports_resources() else [] + self.resource_templates = ( + await self._list_resource_templates() if self.supports_resources() else [] + ) + + def supports_tools(self) -> bool: + """判断服务端是否声明支持 Tools。 + + Returns: + bool: 是否支持 Tools。 + """ + + return bool(self.server_capabilities is not None and getattr(self.server_capabilities, "tools", None) is not None) + + def supports_prompts(self) -> bool: + """判断服务端是否声明支持 Prompts。 + + Returns: + bool: 是否支持 Prompts。 + """ + + return bool( + self.server_capabilities is not None and getattr(self.server_capabilities, "prompts", None) is not None + ) + + def supports_resources(self) -> bool: + """判断服务端是否声明支持 Resources。 + + Returns: + bool: 是否支持 Resources。 + """ + + return bool( + self.server_capabilities is not None and getattr(self.server_capabilities, "resources", None) is not None + ) + + async def _list_tools(self) -> list[Any]: + """分页加载服务端暴露的全部工具。 + + Returns: + list[Any]: MCP SDK 的原始工具对象列表。 + """ + + if self.session is None: + return [] + + tools: list[Any] = [] + cursor: Optional[str] = None + while True: + result = await self.session.list_tools(cursor=cursor) + tools.extend(list(getattr(result, "tools", []) or [])) + cursor = getattr(result, "nextCursor", None) + if not cursor: + break + return tools + + async def _list_prompts(self) -> list[Any]: + """分页加载服务端暴露的全部 Prompt。 + + Returns: + list[Any]: MCP SDK 的原始 Prompt 对象列表。 + """ + + if self.session is None: + return [] + + prompts: list[Any] = [] + cursor: Optional[str] = None + while True: + result = await self.session.list_prompts(cursor=cursor) + prompts.extend(list(getattr(result, "prompts", []) or [])) + cursor = getattr(result, "nextCursor", None) + if not cursor: + break + return prompts + + async def _list_resources(self) -> list[Any]: + """分页加载服务端暴露的全部 Resource。 + + Returns: + list[Any]: MCP SDK 的原始 Resource 对象列表。 + """ + + if self.session is None: + return [] + + resources: list[Any] = [] + cursor: Optional[str] = None + while True: + result = await self.session.list_resources(cursor=cursor) + resources.extend(list(getattr(result, "resources", []) or [])) + cursor = getattr(result, "nextCursor", None) + if not cursor: + break + return resources + + async def _list_resource_templates(self) -> list[Any]: + """分页加载服务端暴露的全部 Resource Template。 + + Returns: + list[Any]: MCP SDK 的原始 Resource Template 对象列表。 + """ + + if self.session is None: + return [] + + resource_templates: list[Any] = [] + cursor: Optional[str] = None + while True: + result = await self.session.list_resource_templates(cursor=cursor) + resource_templates.extend(list(getattr(result, "resourceTemplates", []) or [])) + cursor = getattr(result, "nextCursor", None) + if not cursor: + break + return resource_templates + + async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> ToolExecutionResult: + """调用 MCP 工具并返回统一执行结果。 + + Args: + tool_name: 工具名称。 + arguments: 工具参数字典。 + + Returns: + ToolExecutionResult: 统一执行结果。 + """ + + if self.session is None: + return ToolExecutionResult( + tool_name=tool_name, + success=False, + error_message=f"MCP 服务器 '{self.config.name}' 未连接", + metadata={"server_name": self.config.name}, + ) + + try: + result = await self.session.call_tool( + tool_name, + arguments=arguments, + read_timeout_seconds=timedelta(seconds=self.config.read_timeout_seconds), + ) + except Exception as exc: + return ToolExecutionResult( + tool_name=tool_name, + success=False, + error_message=f"MCP 工具 '{tool_name}' 执行失败: {exc}", + metadata={"server_name": self.config.name}, + ) + + content_items = build_tool_content_items(list(getattr(result, "content", []) or [])) + text_parts = [item.text.strip() for item in content_items if item.content_type == "text" and item.text.strip()] + structured_content = getattr(result, "structuredContent", None) + is_error = bool(getattr(result, "isError", False)) + history_content = "\n".join(text_parts).strip() + error_message = history_content if is_error else "" + + return ToolExecutionResult( + tool_name=tool_name, + success=not is_error, + content=history_content if not is_error else "", + error_message=error_message, + structured_content=structured_content, + content_items=content_items, + metadata={ + "server_name": self.config.name, + "protocol_version": self.protocol_version, + "session_id": self.session_id, + }, + ) + + async def get_prompt( + self, + prompt_name: str, + arguments: Optional[dict[str, str]] = None, + ) -> MCPPromptResult: + """读取指定 MCP Prompt 的内容。 + + Args: + prompt_name: Prompt 名称。 + arguments: Prompt 参数字典。 + + Returns: + MCPPromptResult: 统一 Prompt 结果。 + """ + + if self.session is None: + raise RuntimeError(f"MCP 服务器 '{self.config.name}' 未连接") + + result = await self.session.get_prompt(prompt_name, arguments=arguments) + return build_prompt_result(result, prompt_name=prompt_name, server_name=self.config.name) + + async def read_resource(self, uri: str) -> MCPResourceReadResult: + """读取指定 MCP Resource 的内容。 + + Args: + uri: 资源 URI。 + + Returns: + MCPResourceReadResult: 统一资源读取结果。 + """ + + if self.session is None: + raise RuntimeError(f"MCP 服务器 '{self.config.name}' 未连接") + + result = await self.session.read_resource(uri) + return build_resource_read_result(result, uri=uri, server_name=self.config.name) + + async def close(self) -> None: + """关闭连接并释放资源。""" + + try: + await self._exit_stack.aclose() + except Exception: + pass + + self.session = None + self.server_capabilities = None + self.tools = [] + self.prompts = [] + self.resources = [] + self.resource_templates = [] + self.protocol_version = "" + self._http_client = None + self._session_id_getter = None diff --git a/src/mcp_module/hooks.py b/src/mcp_module/hooks.py new file mode 100644 index 00000000..c1890390 --- /dev/null +++ b/src/mcp_module/hooks.py @@ -0,0 +1,20 @@ +"""MCP 宿主回调声明。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Awaitable, Callable + + +@dataclass(slots=True) +class MCPHostCallbacks: + """MCP 宿主回调集合。 + + 该对象用于向 `MCPConnection` 注入宿主侧可选能力, + 例如 Sampling、Elicitation、日志消费和自定义消息处理。 + """ + + sampling_callback: Callable[..., Awaitable[Any]] | None = None + elicitation_callback: Callable[..., Awaitable[Any]] | None = None + logging_callback: Callable[..., Awaitable[None]] | None = None + message_handler: Callable[..., Awaitable[None]] | None = None diff --git a/src/mcp_module/host_llm_bridge.py b/src/mcp_module/host_llm_bridge.py new file mode 100644 index 00000000..a4507a7e --- /dev/null +++ b/src/mcp_module/host_llm_bridge.py @@ -0,0 +1,595 @@ +"""MCP 宿主侧大模型桥接服务。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +import json + +from src.common.data_models.llm_service_data_models import LLMGenerationOptions, LLMResponseResult +from src.common.logger import get_logger +from src.core.tooling import build_tool_detailed_description +from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType +from src.llm_models.payload_content.tool_option import ToolCall, ToolDefinitionInput +from src.services.llm_service import LLMServiceClient + +from .hooks import MCPHostCallbacks +from .models import build_tool_content_items + +if TYPE_CHECKING: + from src.llm_models.model_client.base_client import BaseClient + +try: + from mcp import types as mcp_types + + MCP_TYPES_AVAILABLE = True +except ImportError: + mcp_types = None # type: ignore[assignment] + MCP_TYPES_AVAILABLE = False + +logger = get_logger("mcp_host_llm_bridge") + + +class MCPHostLLMBridge: + """将 MCP Sampling 请求桥接到主程序大模型调用链。""" + + def __init__(self, sampling_task_name: str = "planner") -> None: + """初始化 MCP 宿主侧大模型桥接服务。 + + Args: + sampling_task_name: 执行 Sampling 请求时使用的模型任务名。 + """ + + self._sampling_task_name = sampling_task_name.strip() or "planner" + self._sampling_client = LLMServiceClient( + task_name=self._sampling_task_name, + request_type="mcp_sampling", + ) + + def build_callbacks(self) -> MCPHostCallbacks: + """构建可注入给 MCP 连接层的宿主回调集合。 + + Returns: + MCPHostCallbacks: 包含 Sampling 回调的宿主回调集合。 + """ + + return MCPHostCallbacks( + sampling_callback=self.handle_sampling_request, + ) + + async def handle_sampling_request(self, context: Any, params: Any) -> Any: + """处理服务端发起的 MCP Sampling 请求。 + + Args: + context: MCP SDK 传入的请求上下文。 + params: `sampling/createMessage` 请求参数。 + + Returns: + Any: MCP `CreateMessageResult`、`CreateMessageResultWithTools` 或 `ErrorData`。 + """ + + del context + if not MCP_TYPES_AVAILABLE or mcp_types is None: + raise RuntimeError("当前环境未安装可用的 MCP types 模块") + + try: + tool_choice_mode = self._get_tool_choice_mode(params) + tool_definitions = self._build_tool_definitions( + raw_tools=getattr(params, "tools", None), + tool_choice_mode=tool_choice_mode, + ) + message_factory = self._build_message_factory( + raw_messages=list(getattr(params, "messages", []) or []), + system_prompt=self._build_system_prompt( + raw_system_prompt=str(getattr(params, "systemPrompt", "") or ""), + tool_choice_mode=tool_choice_mode, + tool_definitions=tool_definitions, + ), + ) + + generation_result = await self._sampling_client.generate_response_with_messages( + message_factory=message_factory, + options=LLMGenerationOptions( + temperature=self._coerce_float(getattr(params, "temperature", None)), + max_tokens=int(getattr(params, "maxTokens", 1024) or 1024), + tool_options=tool_definitions, + ), + ) + + if tool_choice_mode == "required" and tool_definitions and not generation_result.tool_calls: + return mcp_types.ErrorData( + code=mcp_types.INTERNAL_ERROR, + message="Sampling 要求必须调用工具,但模型未返回任何工具调用", + ) + + return self._build_sampling_result( + generation_result=generation_result, + tools_enabled=bool(tool_definitions), + ) + + except Exception as exc: + logger.exception(f"MCP Sampling 调用失败: {exc}") + return mcp_types.ErrorData( + code=mcp_types.INTERNAL_ERROR, + message=f"MCP Sampling 调用失败: {exc}", + ) + + @staticmethod + def _coerce_float(raw_value: Any) -> float | None: + """将任意原始值转换为浮点数。 + + Args: + raw_value: 原始输入值。 + + Returns: + float | None: 转换后的浮点数;无法转换时返回 ``None``。 + """ + + if raw_value is None: + return None + if isinstance(raw_value, int | float): + return float(raw_value) + return None + + @staticmethod + def _get_tool_choice_mode(params: Any) -> str: + """读取 Sampling 请求中的工具选择模式。 + + Args: + params: Sampling 请求参数对象。 + + Returns: + str: `auto`、`required` 或 `none`;缺省时返回 `auto`。 + """ + + tool_choice = getattr(params, "toolChoice", None) + mode = str(getattr(tool_choice, "mode", "") or "").strip().lower() + if mode in {"required", "none"}: + return mode + return "auto" + + def _build_system_prompt( + self, + raw_system_prompt: str, + tool_choice_mode: str, + tool_definitions: list[ToolDefinitionInput] | None, + ) -> str: + """构建发送给主程序大模型的系统提示词。 + + Args: + raw_system_prompt: 服务端请求中的系统提示词。 + tool_choice_mode: 当前工具选择模式。 + tool_definitions: 参与本次 Sampling 的工具定义。 + + Returns: + str: 最终系统提示词。 + """ + + prompt_parts: list[str] = [] + if raw_system_prompt.strip(): + prompt_parts.append(raw_system_prompt.strip()) + if tool_choice_mode == "required" and tool_definitions: + prompt_parts.append("本轮回答必须至少调用一个工具;不要直接结束回答。") + return "\n\n".join(part for part in prompt_parts if part).strip() + + def _build_message_factory( + self, + raw_messages: list[Any], + system_prompt: str, + ) -> Any: + """构建 MCP Sampling 使用的消息工厂。 + + Args: + raw_messages: MCP Sampling 原始消息列表。 + system_prompt: 规范化后的系统提示词。 + + Returns: + Any: 供 `LLMServiceClient` 使用的消息工厂。 + """ + + def _message_factory(client: "BaseClient") -> list[Message]: + """延迟构建内部消息列表。 + + Args: + client: 当前被选中的底层模型客户端。 + + Returns: + list[Message]: 内部统一消息列表。 + """ + + messages: list[Message] = [] + if system_prompt.strip(): + messages.append( + MessageBuilder() + .set_role(RoleType.System) + .add_text_content(system_prompt.strip()) + .build() + ) + + for raw_message in raw_messages: + messages.extend(self._convert_sampling_message(raw_message, client)) + return messages + + return _message_factory + + def _convert_sampling_message(self, raw_message: Any, client: "BaseClient") -> list[Message]: + """将单条 MCP Sampling 消息转换为内部消息列表。 + + Args: + raw_message: MCP Sampling 原始消息对象。 + client: 当前底层模型客户端。 + + Returns: + list[Message]: 转换后的内部消息列表。 + """ + + role = str(getattr(raw_message, "role", "") or "").strip().lower() + content_blocks = self._get_content_blocks(getattr(raw_message, "content", None)) + + if role == "assistant": + assistant_message = self._build_assistant_message(content_blocks, client) + return [assistant_message] if assistant_message is not None else [] + + if role == "user": + return self._build_user_messages(content_blocks, client) + + raise ValueError(f"不支持的 MCP Sampling 消息角色: {role}") + + @staticmethod + def _get_content_blocks(raw_content: Any) -> list[Any]: + """将 MCP Sampling 消息内容统一为列表。 + + Args: + raw_content: 原始内容字段。 + + Returns: + list[Any]: 统一后的内容块列表。 + """ + + if raw_content is None: + return [] + if isinstance(raw_content, list): + return list(raw_content) + return [raw_content] + + def _build_assistant_message(self, content_blocks: list[Any], client: "BaseClient") -> Optional[Message]: + """构建内部 assistant 消息。 + + Args: + content_blocks: MCP assistant 内容块列表。 + client: 当前底层模型客户端。 + + Returns: + Optional[Message]: 转换后的内部 assistant 消息;无有效内容时返回 ``None``。 + """ + + message_builder = MessageBuilder().set_role(RoleType.Assistant) + tool_calls: list[ToolCall] = [] + has_visible_content = False + + for content_block in content_blocks: + content_type = self._get_content_type(content_block) + if content_type == "tool_use": + tool_calls.append( + ToolCall( + call_id=str(getattr(content_block, "id", "") or ""), + func_name=str(getattr(content_block, "name", "") or ""), + args=self._normalize_tool_call_arguments(getattr(content_block, "input", None)), + ) + ) + continue + + has_visible_content = self._append_sampling_content_to_builder( + message_builder=message_builder, + content_block=content_block, + client=client, + ) or has_visible_content + + if tool_calls: + message_builder.set_tool_calls(tool_calls) + + if not has_visible_content and not tool_calls: + return None + return message_builder.build() + + def _build_user_messages(self, content_blocks: list[Any], client: "BaseClient") -> list[Message]: + """构建内部 user/tool 消息序列。 + + Args: + content_blocks: MCP user 内容块列表。 + client: 当前底层模型客户端。 + + Returns: + list[Message]: 转换后的内部消息序列。 + """ + + messages: list[Message] = [] + message_builder = MessageBuilder().set_role(RoleType.User) + has_user_content = False + + def flush_user_message() -> None: + """在当前存在用户可见内容时落盘一条 user 消息。""" + + nonlocal message_builder, has_user_content + if not has_user_content: + return + messages.append(message_builder.build()) + message_builder = MessageBuilder().set_role(RoleType.User) + has_user_content = False + + for content_block in content_blocks: + content_type = self._get_content_type(content_block) + if content_type == "tool_result": + flush_user_message() + messages.append(self._build_tool_result_message(content_block)) + continue + + has_user_content = self._append_sampling_content_to_builder( + message_builder=message_builder, + content_block=content_block, + client=client, + ) or has_user_content + + flush_user_message() + return messages + + @staticmethod + def _get_content_type(content_block: Any) -> str: + """读取 MCP 内容块类型。 + + Args: + content_block: MCP 内容块对象。 + + Returns: + str: 规范化后的内容块类型。 + """ + + return str(getattr(content_block, "type", "text") or "text").strip().lower() + + def _append_sampling_content_to_builder( + self, + message_builder: MessageBuilder, + content_block: Any, + client: "BaseClient", + ) -> bool: + """将 MCP 普通内容块追加到内部消息构建器。 + + Args: + message_builder: 内部消息构建器。 + content_block: MCP 内容块对象。 + client: 当前底层模型客户端。 + + Returns: + bool: 是否成功追加了可见内容。 + """ + + content_type = self._get_content_type(content_block) + if content_type == "text": + text_content = str(getattr(content_block, "text", "") or "") + if text_content.strip(): + message_builder.add_text_content(text_content) + return True + return False + + if content_type == "image": + image_data = str(getattr(content_block, "data", "") or "") + image_mime_type = str(getattr(content_block, "mimeType", "") or "") + image_format = self._normalize_image_format(image_mime_type) + if image_data and image_format: + message_builder.add_image_content( + image_format=image_format, + image_base64=image_data, + support_formats=client.get_support_image_formats(), + ) + return True + + message_builder.add_text_content( + f"[图片内容:mime_type={image_mime_type or 'unknown'},当前客户端无法直接透传]" + ) + return True + + if content_type == "audio": + audio_mime_type = str(getattr(content_block, "mimeType", "") or "") + message_builder.add_text_content(f"[音频内容:mime_type={audio_mime_type or 'unknown'}]") + return True + + return False + + @staticmethod + def _normalize_image_format(mime_type: str) -> str: + """将图片 MIME 类型转换为内部图片格式名称。 + + Args: + mime_type: MCP 图片 MIME 类型。 + + Returns: + str: 内部支持的图片格式名;不支持时返回空字符串。 + """ + + normalized_mime_type = mime_type.strip().lower() + if normalized_mime_type == "image/png": + return "png" + if normalized_mime_type in {"image/jpeg", "image/jpg"}: + return "jpeg" + if normalized_mime_type == "image/webp": + return "webp" + if normalized_mime_type == "image/gif": + return "gif" + return "" + + def _build_tool_result_message(self, content_block: Any) -> Message: + """将 MCP `tool_result` 内容块转换为内部 Tool 消息。 + + Args: + content_block: MCP `tool_result` 内容块对象。 + + Returns: + Message: 转换后的内部 Tool 消息。 + """ + + message_builder = MessageBuilder().set_role(RoleType.Tool) + message_builder.set_tool_call_id(str(getattr(content_block, "toolUseId", "") or "tool_result")) + summary_text = self._summarize_tool_result_content(content_block) + message_builder.add_text_content(summary_text or "工具执行完成。") + return message_builder.build() + + def _summarize_tool_result_content(self, content_block: Any) -> str: + """汇总 MCP `tool_result` 内容块中的结果文本。 + + Args: + content_block: MCP `tool_result` 内容块对象。 + + Returns: + str: 适合发送给主程序模型的工具结果摘要文本。 + """ + + raw_contents = list(getattr(content_block, "content", []) or []) + content_items = build_tool_content_items(raw_contents) + parts = [item.build_history_text().strip() for item in content_items if item.build_history_text().strip()] + + structured_content = getattr(content_block, "structuredContent", None) + if structured_content is not None: + try: + parts.append(json.dumps(structured_content, ensure_ascii=False)) + except (TypeError, ValueError): + parts.append(str(structured_content)) + + summary_text = "\n".join(part for part in parts if part).strip() + if bool(getattr(content_block, "isError", False)) and summary_text: + return f"工具执行失败:\n{summary_text}" + if bool(getattr(content_block, "isError", False)): + return "工具执行失败。" + return summary_text + + @staticmethod + def _normalize_tool_call_arguments(raw_arguments: Any) -> dict[str, Any]: + """将原始工具调用参数规范化为字典。 + + Args: + raw_arguments: 原始工具参数。 + + Returns: + dict[str, Any]: 规范化后的参数字典。 + """ + + if isinstance(raw_arguments, dict): + return dict(raw_arguments) + if raw_arguments is None: + return {} + return {"value": raw_arguments} + + def _build_tool_definitions( + self, + raw_tools: Any, + tool_choice_mode: str, + ) -> list[ToolDefinitionInput] | None: + """将 MCP Sampling 工具定义转换为主程序内部工具定义。 + + Args: + raw_tools: MCP Sampling 请求中的工具列表。 + tool_choice_mode: 当前工具选择模式。 + + Returns: + list[ToolDefinitionInput] | None: 可传给主程序模型层的工具定义列表。 + """ + + if tool_choice_mode == "none": + return None + if not isinstance(raw_tools, list) or not raw_tools: + return None + + tool_definitions: list[ToolDefinitionInput] = [] + for raw_tool in raw_tools: + tool_name = str(getattr(raw_tool, "name", "") or "").strip() + if not tool_name: + continue + + parameters_schema = ( + dict(getattr(raw_tool, "inputSchema", {}) or {}) if getattr(raw_tool, "inputSchema", None) else {} + ) + if "$schema" in parameters_schema: + parameters_schema.pop("$schema") + + title = str(getattr(raw_tool, "title", "") or "").strip() + description = str(getattr(raw_tool, "description", "") or "").strip() + brief_description = description or title or f"工具 {tool_name}" + detailed_description = build_tool_detailed_description( + parameters_schema, + fallback_description=f"工具名称:{tool_name}", + ) + + tool_definitions.append( + { + "name": tool_name, + "description": brief_description, + "parameters_schema": parameters_schema or {"type": "object", "properties": {}}, + } + ) + + return tool_definitions or None + + def _build_sampling_result( + self, + generation_result: LLMResponseResult, + tools_enabled: bool, + ) -> Any: + """将主程序模型响应转换为 MCP Sampling 结果。 + + Args: + generation_result: 主程序统一大模型响应结果。 + tools_enabled: 当前是否允许模型使用工具。 + + Returns: + Any: MCP `CreateMessageResult` 或 `CreateMessageResultWithTools`。 + """ + + if not MCP_TYPES_AVAILABLE or mcp_types is None: + raise RuntimeError("当前环境未安装可用的 MCP types 模块") + + text_content = str(generation_result.response or "") + tool_calls = list(generation_result.tool_calls or []) + model_name = generation_result.model_name or self._sampling_task_name + + if tools_enabled: + content_blocks: list[Any] = [] + if text_content.strip(): + content_blocks.append( + mcp_types.TextContent( + type="text", + text=text_content, + ) + ) + for tool_call in tool_calls: + content_blocks.append( + mcp_types.ToolUseContent( + type="tool_use", + name=tool_call.func_name, + id=tool_call.call_id, + input=dict(tool_call.args or {}), + ) + ) + + if not content_blocks: + content_blocks.append( + mcp_types.TextContent( + type="text", + text="", + ) + ) + + return mcp_types.CreateMessageResultWithTools( + role="assistant", + content=content_blocks[0] if len(content_blocks) == 1 else content_blocks, + model=model_name, + stopReason="toolUse" if tool_calls else "endTurn", + ) + + return mcp_types.CreateMessageResult( + role="assistant", + content=mcp_types.TextContent( + type="text", + text=text_content, + ), + model=model_name, + stopReason="endTurn", + ) diff --git a/src/mcp_module/manager.py b/src/mcp_module/manager.py new file mode 100644 index 00000000..4c7ec5de --- /dev/null +++ b/src/mcp_module/manager.py @@ -0,0 +1,590 @@ +""" +MaiSaka - MCP 管理器 +管理所有 MCP 服务器连接,提供统一的工具、Prompt 与 Resource 访问入口。 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +from src.cli.console import console +from src.core.tooling import ( + ToolExecutionResult, + ToolInvocation, + ToolSpec, + build_tool_detailed_description, +) + +from .config import ( + MCPClientRuntimeConfig, + MCPServerRuntimeConfig, + build_mcp_client_runtime_config, + build_mcp_server_runtime_configs, +) +from .connection import MCPConnection, MCP_AVAILABLE +from .hooks import MCPHostCallbacks +from .models import ( + MCPPromptResult, + MCPPromptSpec, + MCPResourceReadResult, + MCPResourceSpec, + MCPResourceTemplateSpec, + build_prompt_spec, + build_resource_spec, + build_resource_template_spec, + build_tool_annotation, + build_tool_icon, +) + +if TYPE_CHECKING: + from src.config.official_configs import MCPConfig + +# 内置工具名称集合 —— MCP 工具不允许与这些名称冲突 +BUILTIN_TOOL_NAMES = frozenset( + { + "reply", + "no_reply", + "stop", + "create_table", + "list_tables", + "view_table", + } +) + + +class MCPManager: + """MCP 服务器连接管理器。""" + + def __init__( + self, + client_config: MCPClientRuntimeConfig, + host_callbacks: Optional[MCPHostCallbacks] = None, + ) -> None: + """初始化 MCP 管理器。 + + Args: + client_config: MCP 客户端宿主能力运行时配置。 + host_callbacks: 宿主侧能力回调集合。 + """ + + self._client_config = client_config + self._host_callbacks = host_callbacks or MCPHostCallbacks() + self._connections: dict[str, MCPConnection] = {} + self._tool_to_server: dict[str, str] = {} + self._prompt_to_server: dict[str, str] = {} + self._resource_to_server: dict[str, str] = {} + self._resource_template_to_server: dict[str, str] = {} + + @classmethod + async def from_app_config( + cls, + mcp_config: "MCPConfig", + host_callbacks: Optional[MCPHostCallbacks] = None, + ) -> Optional["MCPManager"]: + """从官方配置创建并初始化 `MCPManager`。 + + Args: + mcp_config: 主程序中的 MCP 配置对象。 + host_callbacks: 宿主侧能力回调集合。 + + Returns: + Optional[MCPManager]: 初始化完成的管理器;无可用配置或全部连接失败时返回 ``None``。 + """ + + configs = build_mcp_server_runtime_configs(mcp_config) + if not configs: + return None + + if not MCP_AVAILABLE: + console.print("[warning]⚠️ 发现 MCP 配置但未安装 mcp SDK,请运行: pip install mcp[/warning]") + return None + + manager = cls( + client_config=build_mcp_client_runtime_config(mcp_config), + host_callbacks=host_callbacks, + ) + await manager._connect_all(configs) + + if not manager._connections: + console.print("[warning]⚠️ 所有 MCP 服务器连接失败[/warning]") + return None + + return manager + + async def _connect_all(self, configs: list[MCPServerRuntimeConfig]) -> None: + """连接全部已配置的 MCP 服务器。 + + Args: + configs: 服务器运行时配置列表。 + + Returns: + None + """ + + for config in configs: + connection = MCPConnection(config, self._client_config, self._host_callbacks) + success = await connection.connect() + if not success: + continue + + self._connections[config.name] = connection + registered_tool_count = self._register_tools(config.name, connection) + registered_prompt_count = self._register_prompts(config.name, connection) + registered_resource_count = self._register_resources(config.name, connection) + registered_template_count = self._register_resource_templates(config.name, connection) + console.print( + "[success]✓ MCP 服务器 " + f"'{config.name}' 已连接[/success] " + f"[muted](工具 {registered_tool_count} / Prompt {registered_prompt_count} / " + f"资源 {registered_resource_count} / 模板 {registered_template_count})[/muted]" + ) + + def _register_tools(self, server_name: str, connection: MCPConnection) -> int: + """注册单个服务器暴露的 MCP 工具。 + + Args: + server_name: 服务器名称。 + connection: 对应连接对象。 + + Returns: + int: 成功注册的工具数量。 + """ + + registered_count = 0 + for tool in connection.tools: + tool_name = str(tool.name) + + if tool_name in BUILTIN_TOOL_NAMES: + console.print( + f"[warning]⚠️ MCP 工具 '{tool_name}' (来自 {server_name}) 与内置工具冲突,已跳过[/warning]" + ) + continue + + if tool_name in self._tool_to_server: + existing_server = self._tool_to_server[tool_name] + console.print( + f"[warning]⚠️ MCP 工具 '{tool_name}' (来自 {server_name}) 与 {existing_server} 冲突,已跳过[/warning]" + ) + continue + + self._tool_to_server[tool_name] = server_name + registered_count += 1 + return registered_count + + def _register_prompts(self, server_name: str, connection: MCPConnection) -> int: + """注册单个服务器暴露的 MCP Prompt。 + + Args: + server_name: 服务器名称。 + connection: 对应连接对象。 + + Returns: + int: 成功注册的 Prompt 数量。 + """ + + registered_count = 0 + for prompt in connection.prompts: + prompt_name = str(prompt.name) + if prompt_name in self._prompt_to_server: + existing_server = self._prompt_to_server[prompt_name] + console.print( + f"[warning]⚠️ MCP Prompt '{prompt_name}' (来自 {server_name}) 与 {existing_server} 冲突,已跳过[/warning]" + ) + continue + self._prompt_to_server[prompt_name] = server_name + registered_count += 1 + return registered_count + + def _register_resources(self, server_name: str, connection: MCPConnection) -> int: + """注册单个服务器暴露的 MCP Resource。 + + Args: + server_name: 服务器名称。 + connection: 对应连接对象。 + + Returns: + int: 成功注册的 Resource 数量。 + """ + + registered_count = 0 + for resource in connection.resources: + resource_uri = str(resource.uri) + if resource_uri in self._resource_to_server: + existing_server = self._resource_to_server[resource_uri] + console.print( + f"[warning]⚠️ MCP Resource '{resource_uri}' (来自 {server_name}) 与 {existing_server} 冲突,已跳过[/warning]" + ) + continue + self._resource_to_server[resource_uri] = server_name + registered_count += 1 + return registered_count + + def _register_resource_templates(self, server_name: str, connection: MCPConnection) -> int: + """注册单个服务器暴露的 MCP Resource Template。 + + Args: + server_name: 服务器名称。 + connection: 对应连接对象。 + + Returns: + int: 成功注册的模板数量。 + """ + + registered_count = 0 + for resource_template in connection.resource_templates: + uri_template = str(resource_template.uriTemplate) + if uri_template in self._resource_template_to_server: + existing_server = self._resource_template_to_server[uri_template] + console.print( + "[warning]⚠️ MCP Resource Template " + f"'{uri_template}' (来自 {server_name}) 与 {existing_server} 冲突,已跳过[/warning]" + ) + continue + self._resource_template_to_server[uri_template] = server_name + registered_count += 1 + return registered_count + + def _build_tool_parameters_schema(self, tool: Any) -> dict[str, Any] | None: + """构造单个 MCP 工具的参数 Schema。 + + Args: + tool: MCP SDK 返回的原始工具对象。 + + Returns: + dict[str, Any] | None: 参数 Schema。 + """ + + parameters_schema = ( + dict(tool.inputSchema) + if hasattr(tool, "inputSchema") and tool.inputSchema + else {"type": "object", "properties": {}} + ) + parameters_schema.pop("$schema", None) + return parameters_schema + + def _build_tool_output_schema(self, tool: Any) -> dict[str, Any] | None: + """构造单个 MCP 工具的输出 Schema。 + + Args: + tool: MCP SDK 返回的原始工具对象。 + + Returns: + dict[str, Any] | None: 输出 Schema。 + """ + + output_schema = dict(tool.outputSchema) if hasattr(tool, "outputSchema") and tool.outputSchema else None + if isinstance(output_schema, dict): + output_schema.pop("$schema", None) + return output_schema + + def get_tool_specs(self) -> list[ToolSpec]: + """获取全部已注册 MCP 工具的统一声明。 + + Returns: + list[ToolSpec]: MCP 工具声明列表。 + """ + + tool_specs: list[ToolSpec] = [] + for server_name, connection in self._connections.items(): + for tool in connection.tools: + if self._tool_to_server.get(tool.name) != server_name: + continue + + parameters_schema = self._build_tool_parameters_schema(tool) + output_schema = self._build_tool_output_schema(tool) + brief_description = str(tool.description or f"来自 {server_name} 的 MCP 工具").strip() + tool_specs.append( + ToolSpec( + name=str(tool.name), + title=str(getattr(tool, "title", "") or ""), + brief_description=brief_description, + detailed_description=build_tool_detailed_description( + parameters_schema, + fallback_description=f"工具来源:MCP 服务 {server_name}。", + ), + parameters_schema=parameters_schema, + output_schema=output_schema, + provider_name="mcp", + provider_type="mcp", + icons=[build_tool_icon(item) for item in getattr(tool, "icons", []) or []], + annotation=build_tool_annotation(getattr(tool, "annotations", None)), + metadata={"server_name": server_name} | (getattr(tool, "meta", {}) or {}), + ) + ) + return tool_specs + + def get_prompt_specs(self) -> list[MCPPromptSpec]: + """获取全部已注册 MCP Prompt 声明。 + + Returns: + list[MCPPromptSpec]: Prompt 声明列表。 + """ + + prompt_specs: list[MCPPromptSpec] = [] + for server_name, connection in self._connections.items(): + for prompt in connection.prompts: + if self._prompt_to_server.get(prompt.name) != server_name: + continue + prompt_specs.append(build_prompt_spec(prompt, server_name)) + return prompt_specs + + def get_resource_specs(self) -> list[MCPResourceSpec]: + """获取全部已注册 MCP Resource 声明。 + + Returns: + list[MCPResourceSpec]: Resource 声明列表。 + """ + + resource_specs: list[MCPResourceSpec] = [] + for server_name, connection in self._connections.items(): + for resource in connection.resources: + if self._resource_to_server.get(resource.uri) != server_name: + continue + resource_specs.append(build_resource_spec(resource, server_name)) + return resource_specs + + def get_resource_template_specs(self) -> list[MCPResourceTemplateSpec]: + """获取全部已注册 MCP Resource Template 声明。 + + Returns: + list[MCPResourceTemplateSpec]: Resource Template 声明列表。 + """ + + resource_template_specs: list[MCPResourceTemplateSpec] = [] + for server_name, connection in self._connections.items(): + for resource_template in connection.resource_templates: + if self._resource_template_to_server.get(resource_template.uriTemplate) != server_name: + continue + resource_template_specs.append(build_resource_template_spec(resource_template, server_name)) + return resource_template_specs + + def get_openai_tools(self) -> list[dict[str, Any]]: + """获取兼容旧模型层的 MCP 工具定义。 + + Returns: + list[dict[str, Any]]: OpenAI function tool 格式列表。 + """ + + return [ + { + "type": "function", + "function": { + "name": tool_spec.name, + "description": tool_spec.build_llm_description(), + "parameters": tool_spec.parameters_schema or {"type": "object", "properties": {}}, + }, + } + for tool_spec in self.get_tool_specs() + ] + + def is_mcp_tool(self, tool_name: str) -> bool: + """判断给定名称是否为已注册 MCP 工具。 + + Args: + tool_name: 工具名称。 + + Returns: + bool: 是否存在。 + """ + + return tool_name in self._tool_to_server + + def is_mcp_prompt(self, prompt_name: str) -> bool: + """判断给定名称是否为已注册 MCP Prompt。 + + Args: + prompt_name: Prompt 名称。 + + Returns: + bool: 是否存在。 + """ + + return prompt_name in self._prompt_to_server + + def is_mcp_resource(self, uri: str) -> bool: + """判断给定 URI 是否为已注册 MCP Resource。 + + Args: + uri: 资源 URI。 + + Returns: + bool: 是否存在。 + """ + + return uri in self._resource_to_server + + async def call_tool_invocation(self, invocation: ToolInvocation) -> ToolExecutionResult: + """执行统一的 MCP 工具调用。 + + Args: + invocation: 统一工具调用请求。 + + Returns: + ToolExecutionResult: 统一工具执行结果。 + """ + + tool_name = invocation.tool_name + server_name = self._tool_to_server.get(tool_name) + if not server_name or server_name not in self._connections: + return ToolExecutionResult( + tool_name=tool_name, + success=False, + error_message=f"MCP 工具 '{tool_name}' 未找到", + ) + + connection = self._connections[server_name] + return await connection.call_tool(tool_name, invocation.arguments) + + async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> str: + """兼容旧接口,返回 MCP 工具的文本结果。 + + Args: + tool_name: 工具名称。 + arguments: 工具参数。 + + Returns: + str: 工具结果文本。 + """ + + result = await self.call_tool_invocation( + ToolInvocation( + tool_name=tool_name, + arguments=arguments, + ) + ) + return result.get_history_content() + + async def get_prompt( + self, + prompt_name: str, + arguments: Optional[dict[str, str]] = None, + ) -> MCPPromptResult: + """读取指定 Prompt 的内容。 + + Args: + prompt_name: Prompt 名称。 + arguments: Prompt 参数字典。 + + Returns: + MCPPromptResult: Prompt 获取结果。 + """ + + server_name = self._prompt_to_server.get(prompt_name) + if not server_name or server_name not in self._connections: + raise KeyError(f"MCP Prompt '{prompt_name}' 未找到") + + connection = self._connections[server_name] + return await connection.get_prompt(prompt_name, arguments=arguments) + + async def read_resource(self, uri: str) -> MCPResourceReadResult: + """读取指定 Resource 的内容。 + + Args: + uri: 资源 URI。 + + Returns: + MCPResourceReadResult: 资源读取结果。 + """ + + server_name = self._resource_to_server.get(uri) + if not server_name or server_name not in self._connections: + raise KeyError(f"MCP Resource '{uri}' 未找到") + + connection = self._connections[server_name] + return await connection.read_resource(uri) + + def get_tool_summary(self) -> str: + """获取所有已注册 MCP 工具的摘要信息。 + + Returns: + str: 工具摘要文本。 + """ + + parts: list[str] = [] + for server_name, connection in self._connections.items(): + tool_names = [ + str(tool.name) + for tool in connection.tools + if self._tool_to_server.get(tool.name) == server_name + ] + if tool_names: + parts.append(f" • {server_name}: {', '.join(tool_names)}") + return "\n".join(parts) + + def get_feature_summary(self) -> str: + """获取所有服务器能力的总体摘要。 + + Returns: + str: 多行摘要文本。 + """ + + parts: list[str] = [] + for server_name, connection in self._connections.items(): + tool_count = sum(1 for tool in connection.tools if self._tool_to_server.get(tool.name) == server_name) + prompt_count = sum( + 1 for prompt in connection.prompts if self._prompt_to_server.get(prompt.name) == server_name + ) + resource_count = sum( + 1 for resource in connection.resources if self._resource_to_server.get(resource.uri) == server_name + ) + template_count = sum( + 1 + for resource_template in connection.resource_templates + if self._resource_template_to_server.get(resource_template.uriTemplate) == server_name + ) + parts.append( + f" • {server_name}: 工具 {tool_count} / Prompt {prompt_count} / " + f"资源 {resource_count} / 模板 {template_count}" + ) + return "\n".join(parts) + + @property + def server_count(self) -> int: + """返回已连接 MCP 服务器数量。 + + Returns: + int: 服务器数量。 + """ + + return len(self._connections) + + @property + def tool_count(self) -> int: + """返回已注册 MCP 工具总数。 + + Returns: + int: 工具数量。 + """ + + return len(self._tool_to_server) + + @property + def prompt_count(self) -> int: + """返回已注册 MCP Prompt 总数。 + + Returns: + int: Prompt 数量。 + """ + + return len(self._prompt_to_server) + + @property + def resource_count(self) -> int: + """返回已注册 MCP Resource 总数。 + + Returns: + int: Resource 数量。 + """ + + return len(self._resource_to_server) + + async def close(self) -> None: + """关闭所有 MCP 服务器连接。""" + + for connection in self._connections.values(): + await connection.close() + self._connections.clear() + self._tool_to_server.clear() + self._prompt_to_server.clear() + self._resource_to_server.clear() + self._resource_template_to_server.clear() diff --git a/src/mcp_module/models.py b/src/mcp_module/models.py new file mode 100644 index 00000000..5550b8df --- /dev/null +++ b/src/mcp_module/models.py @@ -0,0 +1,418 @@ +"""MCP 结构化模型与转换工具。 + +负责在 MCP SDK 原始对象与主程序内部数据模型之间进行转换, +避免连接层和管理器层直接操作大量弱类型字段。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional + +from src.core.tooling import ToolAnnotation, ToolContentItem, ToolIcon + + +def _dump_model_metadata(raw_value: Any) -> dict[str, Any]: + """提取任意 MCP 模型对象中的元数据字典。 + + Args: + raw_value: MCP SDK 返回的原始对象。 + + Returns: + dict[str, Any]: 归一化后的元数据字典。 + """ + + metadata = getattr(raw_value, "meta", None) + if isinstance(metadata, dict): + return dict(metadata) + return {} + + +def build_tool_icon(raw_icon: Any) -> ToolIcon: + """将 MCP 图标对象转换为统一图标模型。 + + Args: + raw_icon: MCP SDK 返回的图标对象。 + + Returns: + ToolIcon: 统一图标模型。 + """ + + sizes_value = getattr(raw_icon, "sizes", None) + sizes = [str(item) for item in sizes_value] if isinstance(sizes_value, list) else [] + return ToolIcon( + src=str(getattr(raw_icon, "src", "") or ""), + mime_type=str(getattr(raw_icon, "mimeType", "") or ""), + sizes=sizes, + ) + + +def build_tool_annotation(raw_annotation: Any) -> Optional[ToolAnnotation]: + """将 MCP 注解对象转换为统一注解模型。 + + Args: + raw_annotation: MCP SDK 返回的注解对象。 + + Returns: + Optional[ToolAnnotation]: 统一注解模型;无有效内容时返回 ``None``。 + """ + + if raw_annotation is None: + return None + + audience_value = getattr(raw_annotation, "audience", None) + audience = [str(item) for item in audience_value] if isinstance(audience_value, list) else [] + priority_value = getattr(raw_annotation, "priority", None) + priority = float(priority_value) if isinstance(priority_value, int | float) else None + metadata = _dump_model_metadata(raw_annotation) + + if not audience and priority is None and not metadata: + return None + + return ToolAnnotation( + audience=audience, + priority=priority, + metadata=metadata, + ) + + +def build_tool_content_item(raw_content: Any) -> ToolContentItem: + """将 MCP 内容块转换为统一工具内容项。 + + Args: + raw_content: MCP SDK 返回的内容块对象。 + + Returns: + ToolContentItem: 统一工具内容项。 + """ + + content_type = str(getattr(raw_content, "type", "") or "").strip().lower() + annotation = build_tool_annotation(getattr(raw_content, "annotations", None)) + metadata = _dump_model_metadata(raw_content) + + if content_type == "text" or hasattr(raw_content, "text"): + return ToolContentItem( + content_type="text", + text=str(getattr(raw_content, "text", "") or ""), + annotation=annotation, + metadata=metadata, + ) + + if content_type == "image": + return ToolContentItem( + content_type="image", + data=str(getattr(raw_content, "data", "") or ""), + mime_type=str(getattr(raw_content, "mimeType", "") or ""), + annotation=annotation, + metadata=metadata, + ) + + if content_type == "audio": + return ToolContentItem( + content_type="audio", + data=str(getattr(raw_content, "data", "") or ""), + mime_type=str(getattr(raw_content, "mimeType", "") or ""), + annotation=annotation, + metadata=metadata, + ) + + if content_type == "resource_link": + return ToolContentItem( + content_type="resource_link", + uri=str(getattr(raw_content, "uri", "") or ""), + name=str(getattr(raw_content, "name", "") or ""), + description=str(getattr(raw_content, "description", "") or ""), + mime_type=str(getattr(raw_content, "mimeType", "") or ""), + annotation=annotation, + metadata=metadata, + ) + + if content_type == "resource" or hasattr(raw_content, "resource"): + resource = getattr(raw_content, "resource", None) + resource_metadata = metadata | _dump_model_metadata(resource) + return ToolContentItem( + content_type="resource", + text=str(getattr(resource, "text", "") or ""), + data=str(getattr(resource, "blob", "") or ""), + mime_type=str(getattr(resource, "mimeType", "") or ""), + uri=str(getattr(resource, "uri", "") or ""), + name=str(getattr(resource, "name", "") or ""), + annotation=annotation, + metadata=resource_metadata, + ) + + if hasattr(raw_content, "data"): + return ToolContentItem( + content_type="binary", + data=str(getattr(raw_content, "data", "") or ""), + mime_type=str(getattr(raw_content, "mimeType", "") or ""), + annotation=annotation, + metadata=metadata, + ) + + return ToolContentItem( + content_type="unknown", + text=str(raw_content), + annotation=annotation, + metadata=metadata, + ) + + +def build_tool_content_items(raw_contents: list[Any] | None) -> list[ToolContentItem]: + """批量转换 MCP 内容块列表。 + + Args: + raw_contents: MCP SDK 返回的内容块列表。 + + Returns: + list[ToolContentItem]: 转换后的统一内容项列表。 + """ + + if not raw_contents: + return [] + return [build_tool_content_item(item) for item in raw_contents] + + +@dataclass(slots=True) +class MCPPromptArgumentSpec: + """MCP Prompt 参数声明。""" + + name: str + description: str = "" + required: bool = False + + +@dataclass(slots=True) +class MCPPromptSpec: + """MCP Prompt 声明。""" + + name: str + server_name: str + title: str = "" + description: str = "" + arguments: list[MCPPromptArgumentSpec] = field(default_factory=list) + icons: list[ToolIcon] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class MCPPromptMessage: + """MCP Prompt 消息。""" + + role: str + content: ToolContentItem + + +@dataclass(slots=True) +class MCPPromptResult: + """MCP Prompt 获取结果。""" + + prompt_name: str + server_name: str + description: str = "" + messages: list[MCPPromptMessage] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class MCPResourceSpec: + """MCP Resource 声明。""" + + uri: str + server_name: str + name: str + title: str = "" + description: str = "" + mime_type: str = "" + size: int | None = None + icons: list[ToolIcon] = field(default_factory=list) + annotation: ToolAnnotation | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class MCPResourceTemplateSpec: + """MCP Resource Template 声明。""" + + uri_template: str + server_name: str + name: str + title: str = "" + description: str = "" + mime_type: str = "" + icons: list[ToolIcon] = field(default_factory=list) + annotation: ToolAnnotation | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class MCPResourceReadResult: + """MCP Resource 读取结果。""" + + uri: str + server_name: str + contents: list[ToolContentItem] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + + +def build_prompt_argument_spec(raw_argument: Any) -> MCPPromptArgumentSpec: + """将 MCP Prompt 参数对象转换为统一结构。 + + Args: + raw_argument: MCP SDK 返回的 Prompt 参数对象。 + + Returns: + MCPPromptArgumentSpec: 统一 Prompt 参数结构。 + """ + + return MCPPromptArgumentSpec( + name=str(getattr(raw_argument, "name", "") or ""), + description=str(getattr(raw_argument, "description", "") or ""), + required=bool(getattr(raw_argument, "required", False)), + ) + + +def build_prompt_spec(raw_prompt: Any, server_name: str) -> MCPPromptSpec: + """将 MCP Prompt 定义转换为统一结构。 + + Args: + raw_prompt: MCP SDK 返回的 Prompt 对象。 + server_name: Prompt 所属的服务器名称。 + + Returns: + MCPPromptSpec: 统一 Prompt 定义。 + """ + + raw_arguments = getattr(raw_prompt, "arguments", None) + raw_icons = getattr(raw_prompt, "icons", None) + return MCPPromptSpec( + name=str(getattr(raw_prompt, "name", "") or ""), + server_name=server_name, + title=str(getattr(raw_prompt, "title", "") or ""), + description=str(getattr(raw_prompt, "description", "") or ""), + arguments=[build_prompt_argument_spec(item) for item in raw_arguments] if isinstance(raw_arguments, list) else [], + icons=[build_tool_icon(item) for item in raw_icons] if isinstance(raw_icons, list) else [], + metadata=_dump_model_metadata(raw_prompt), + ) + + +def build_prompt_result(raw_result: Any, prompt_name: str, server_name: str) -> MCPPromptResult: + """将 MCP Prompt 获取结果转换为统一结构。 + + Args: + raw_result: MCP SDK 返回的 Prompt 结果对象。 + prompt_name: Prompt 名称。 + server_name: Prompt 所属服务器名称。 + + Returns: + MCPPromptResult: 统一 Prompt 获取结果。 + """ + + messages: list[MCPPromptMessage] = [] + raw_messages = getattr(raw_result, "messages", None) + if isinstance(raw_messages, list): + for raw_message in raw_messages: + messages.append( + MCPPromptMessage( + role=str(getattr(raw_message, "role", "") or ""), + content=build_tool_content_item(getattr(raw_message, "content", None)), + ) + ) + + return MCPPromptResult( + prompt_name=prompt_name, + server_name=server_name, + description=str(getattr(raw_result, "description", "") or ""), + messages=messages, + metadata=_dump_model_metadata(raw_result), + ) + + +def build_resource_spec(raw_resource: Any, server_name: str) -> MCPResourceSpec: + """将 MCP Resource 定义转换为统一结构。 + + Args: + raw_resource: MCP SDK 返回的 Resource 对象。 + server_name: Resource 所属服务器名称。 + + Returns: + MCPResourceSpec: 统一 Resource 定义。 + """ + + raw_icons = getattr(raw_resource, "icons", None) + size_value = getattr(raw_resource, "size", None) + size = int(size_value) if isinstance(size_value, int | float) else None + return MCPResourceSpec( + uri=str(getattr(raw_resource, "uri", "") or ""), + server_name=server_name, + name=str(getattr(raw_resource, "name", "") or ""), + title=str(getattr(raw_resource, "title", "") or ""), + description=str(getattr(raw_resource, "description", "") or ""), + mime_type=str(getattr(raw_resource, "mimeType", "") or ""), + size=size, + icons=[build_tool_icon(item) for item in raw_icons] if isinstance(raw_icons, list) else [], + annotation=build_tool_annotation(getattr(raw_resource, "annotations", None)), + metadata=_dump_model_metadata(raw_resource), + ) + + +def build_resource_template_spec(raw_template: Any, server_name: str) -> MCPResourceTemplateSpec: + """将 MCP Resource Template 定义转换为统一结构。 + + Args: + raw_template: MCP SDK 返回的 ResourceTemplate 对象。 + server_name: 模板所属服务器名称。 + + Returns: + MCPResourceTemplateSpec: 统一模板定义。 + """ + + raw_icons = getattr(raw_template, "icons", None) + return MCPResourceTemplateSpec( + uri_template=str(getattr(raw_template, "uriTemplate", "") or ""), + server_name=server_name, + name=str(getattr(raw_template, "name", "") or ""), + title=str(getattr(raw_template, "title", "") or ""), + description=str(getattr(raw_template, "description", "") or ""), + mime_type=str(getattr(raw_template, "mimeType", "") or ""), + icons=[build_tool_icon(item) for item in raw_icons] if isinstance(raw_icons, list) else [], + annotation=build_tool_annotation(getattr(raw_template, "annotations", None)), + metadata=_dump_model_metadata(raw_template), + ) + + +def build_resource_read_result(raw_result: Any, uri: str, server_name: str) -> MCPResourceReadResult: + """将 MCP Resource 读取结果转换为统一结构。 + + Args: + raw_result: MCP SDK 返回的读取结果对象。 + uri: 被读取的资源 URI。 + server_name: 资源所属服务器名称。 + + Returns: + MCPResourceReadResult: 统一资源读取结果。 + """ + + contents: list[ToolContentItem] = [] + raw_contents = getattr(raw_result, "contents", None) + if isinstance(raw_contents, list): + for raw_content in raw_contents: + metadata = _dump_model_metadata(raw_content) + contents.append( + ToolContentItem( + content_type="resource", + text=str(getattr(raw_content, "text", "") or ""), + data=str(getattr(raw_content, "blob", "") or ""), + mime_type=str(getattr(raw_content, "mimeType", "") or ""), + uri=str(getattr(raw_content, "uri", "") or uri), + annotation=None, + metadata=metadata, + ) + ) + + return MCPResourceReadResult( + uri=uri, + server_name=server_name, + contents=contents, + metadata=_dump_model_metadata(raw_result), + ) diff --git a/src/mcp_module/provider.py b/src/mcp_module/provider.py new file mode 100644 index 00000000..9f8e0cd3 --- /dev/null +++ b/src/mcp_module/provider.py @@ -0,0 +1,65 @@ +"""MCP 工具 Provider。""" + +from __future__ import annotations + +from typing import Optional + +from src.core.tooling import ( + ToolAvailabilityContext, + ToolExecutionContext, + ToolExecutionResult, + ToolInvocation, + ToolProvider, + ToolSpec, +) + +from .manager import MCPManager + + +class MCPToolProvider(ToolProvider): + """基于 MCPManager 的工具 Provider。""" + + provider_name = "mcp" + provider_type = "mcp" + + def __init__(self, manager: MCPManager) -> None: + """初始化 MCP 工具 Provider。 + + Args: + manager: MCP 管理器实例。 + """ + + self._manager = manager + + async def list_tools( + self, + context: Optional[ToolAvailabilityContext] = None, + ) -> list[ToolSpec]: + """列出全部 MCP 工具。""" + + del context + return self._manager.get_tool_specs() + + async def invoke( + self, + invocation: ToolInvocation, + context: Optional[ToolExecutionContext] = None, + ) -> ToolExecutionResult: + """执行指定 MCP 工具。 + + Args: + invocation: 工具调用请求。 + context: 执行上下文。 + + Returns: + ToolExecutionResult: 工具执行结果。 + """ + + del context + return await self._manager.call_tool_invocation(invocation) + + async def close(self) -> None: + """关闭 Provider 并释放 MCP 连接。""" + + await self._manager.close() + diff --git a/src/person_info/__init__.py b/src/person_info/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/person_info/person_info.py b/src/person_info/person_info.py new file mode 100644 index 00000000..467967e8 --- /dev/null +++ b/src/person_info/person_info.py @@ -0,0 +1,598 @@ +from datetime import datetime +from typing import List, Optional, Union + +import hashlib +import json +import time + +from sqlmodel import col, select + +from src.chat.message_receive.chat_manager import chat_manager as _chat_manager +from src.common.data_models.person_info_data_model import dump_group_cardname_records, parse_group_cardname_json +from src.common.database.database import get_db_session +from src.common.database.database_model import PersonInfo +from src.common.logger import get_logger +from src.config.config import global_config +from src.services.memory_service import memory_service + + +logger = get_logger("person_info") + + +def _to_group_cardname_records(group_cardname_json: Optional[str]) -> list[dict[str, str]]: + """将数据库中的群名片 JSON 转换为 `Person` 内部使用的结构。 + + Args: + group_cardname_json: 数据库存储的群名片 JSON 字符串。 + + Returns: + list[dict[str, str]]: 统一使用 `group_cardname` 键名的群名片列表。 + + Raises: + json.JSONDecodeError: 当 JSON 文本格式非法时抛出。 + TypeError: 当输入值类型不符合 `json.loads()` 要求时抛出。 + """ + group_cardname_list = parse_group_cardname_json(group_cardname_json) + if not group_cardname_list: + return [] + + return [ + { + "group_id": group_cardname.group_id, + "group_cardname": group_cardname.group_cardname, + } + for group_cardname in group_cardname_list + ] + + +def get_person_id(platform: str, user_id: Union[int, str]) -> str: + """获取唯一id""" + if "-" in platform: + platform = platform.split("-")[1] + components = [platform, str(user_id)] + key = "_".join(components) + return hashlib.md5(key.encode()).hexdigest() + + +def get_person_id_by_person_name(person_name: str) -> str: + """根据用户名获取用户ID""" + try: + with get_db_session(auto_commit=False) as session: + statement = select(PersonInfo.person_id).where(col(PersonInfo.person_name) == person_name).limit(1) + person_id = session.exec(statement).first() + return str(person_id) if person_id else "" + except Exception as e: + logger.error(f"根据用户名 {person_name} 获取用户ID时出错: {e}") + return "" + + +def resolve_person_id_for_memory( + *, + person_name: str = "", + platform: str = "", + user_id: Union[int, str, None] = None, + strict_known: bool = False, +) -> str: + """解析长期记忆检索/写入使用的人物 ID。 + + 解析顺序: + 1. 优先按 `person_name` 映射数据库中的 `person_id` + 2. 回退到 `platform + user_id` 生成稳定 `person_id` + 3. 若 `strict_known=True`,则要求该 `person_id` 已被认识 + """ + clean_name = str(person_name or "").strip() + if clean_name: + if by_name := get_person_id_by_person_name(clean_name): + return by_name + + clean_platform = str(platform or "").strip() + clean_user_id = str(user_id or "").strip() + if clean_platform and clean_user_id: + candidate = get_person_id(clean_platform, clean_user_id) + if strict_known and not is_person_known(person_id=candidate): + return "" + return candidate + + return "" + + +def is_person_known( + person_id: Optional[str] = None, + user_id: Optional[str] = None, + platform: Optional[str] = None, + person_name: Optional[str] = None, +) -> bool: # sourcery skip: extract-duplicate-method + if person_id: + with get_db_session() as session: + statement = select(PersonInfo).where(col(PersonInfo.person_id) == person_id).limit(1) + person = session.exec(statement).first() + return person.is_known if person else False + elif user_id and platform: + person_id = get_person_id(platform, user_id) + with get_db_session() as session: + statement = select(PersonInfo).where(col(PersonInfo.person_id) == person_id).limit(1) + person = session.exec(statement).first() + return person.is_known if person else False + elif person_name: + person_id = get_person_id_by_person_name(person_name) + with get_db_session() as session: + statement = select(PersonInfo).where(col(PersonInfo.person_id) == person_id).limit(1) + person = session.exec(statement).first() + return person.is_known if person else False + else: + return False + + +def calculate_string_similarity(s1: str, s2: str) -> float: + """ + 计算两个字符串的相似度 + + Args: + s1: 第一个字符串 + s2: 第二个字符串 + + Returns: + float: 相似度,范围0-1,1表示完全相同 + """ + if s1 == s2: + return 1.0 + + if not s1 or not s2: + return 0.0 + + # 计算Levenshtein距离 + + distance = levenshtein_distance(s1, s2) + max_len = max(len(s1), len(s2)) + + # 计算相似度:1 - (编辑距离 / 最大长度) + similarity = 1 - (distance / max_len if max_len > 0 else 0) + return similarity + + +def levenshtein_distance(s1: str, s2: str) -> int: + """ + 计算两个字符串的编辑距离 + + Args: + s1: 第一个字符串 + s2: 第二个字符串 + + Returns: + int: 编辑距离 + """ + if len(s1) < len(s2): + return levenshtein_distance(s2, s1) + + if len(s2) == 0: + return len(s1) + + previous_row = range(len(s2) + 1) + for i, c1 in enumerate(s1): + current_row = [i + 1] + for j, c2 in enumerate(s2): + insertions = previous_row[j + 1] + 1 + deletions = current_row[j] + 1 + substitutions = previous_row[j] + (c1 != c2) + current_row.append(min(insertions, deletions, substitutions)) + previous_row = current_row + + return previous_row[-1] + + +class Person: + @classmethod + def register_person( + cls, + platform: str, + user_id: str, + nickname: str, + group_id: Optional[str] = None, + group_nick_name: Optional[str] = None, + ): + """ + 注册新用户的类方法 + 必须输入 platform、user_id 和 nickname 参数 + + Args: + platform: 平台名称 + user_id: 用户ID + nickname: 用户昵称 + group_id: 群号(可选,仅在群聊时提供) + group_nick_name: 群昵称(可选,仅在群聊时提供) + + Returns: + Person: 新注册的Person实例 + """ + if not platform or not user_id or not nickname: + logger.error("注册用户失败:platform、user_id 和 nickname 都是必需参数") + return None + + # 生成唯一的person_id + person_id = get_person_id(platform, user_id) + + if is_person_known(person_id=person_id): + logger.debug(f"用户 {nickname} 已存在") + person = Person(person_id=person_id) + # 如果是群聊,更新群昵称 + if group_id and group_nick_name: + person.add_group_nick_name(group_id, group_nick_name) + return person + + # 创建Person实例 + person = cls.__new__(cls) + + # 设置基本属性 + person.person_id = person_id + person.platform = platform + person.user_id = user_id + person.nickname = nickname + + # 初始化默认值 + person.is_known = True # 注册后立即标记为已认识 + person.person_name = nickname # 使用nickname作为初始person_name + person.name_reason = "用户注册时设置的昵称" + person.know_times = 1 + person.know_since = time.time() + person.last_know = time.time() + person.memory_points = [] + person.group_cardname_list = [] # 初始化群名片列表 + + # 如果是群聊,添加群昵称 + if group_id and group_nick_name: + person.add_group_nick_name(group_id, group_nick_name) + + # 同步到数据库 + person.sync_to_database() + + logger.info(f"成功注册新用户:{person_id},平台:{platform},昵称:{nickname}") + + return person + + def _is_bot_self(self, platform: str, user_id: str) -> bool: + """判断给定的平台和用户ID是否是机器人自己 + + 这个函数统一处理所有平台(包括 QQ、Telegram、WebUI 等)的机器人识别逻辑。 + + Args: + platform: 消息平台(如 "qq", "telegram", "webui" 等) + user_id: 用户ID + + Returns: + bool: 如果是机器人自己则返回 True,否则返回 False + """ + from src.chat.utils.utils import is_bot_self + + return is_bot_self(platform, user_id) + + def __init__(self, platform: str = "", user_id: str = "", person_id: str = "", person_name: str = ""): + # 使用统一的机器人识别函数(支持多平台,包括 WebUI) + if self._is_bot_self(platform, user_id): + self.is_known = True + self.person_id = get_person_id(platform, user_id) + self.user_id = user_id + self.platform = platform + self.nickname = global_config.bot.nickname + self.person_name = global_config.bot.nickname + self.group_cardname_list: list[dict[str, str]] = [] + return + + self.user_id = "" + self.platform = "" + + if person_id: + self.person_id = person_id + elif person_name: + self.person_id = get_person_id_by_person_name(person_name) + if not self.person_id: + self.is_known = False + logger.warning(f"根据用户名 {person_name} 获取用户ID时,不存在用户{person_name}") + return + elif platform and user_id: + self.person_id = get_person_id(platform, user_id) + self.user_id = user_id + self.platform = platform + else: + logger.error("Person 初始化失败,缺少必要参数") + raise ValueError("Person 初始化失败,缺少必要参数") + + if not is_person_known(person_id=self.person_id): + self.is_known = False + logger.debug(f"用户 {platform}:{user_id}:{person_name}:{person_id} 尚未认识") + self.person_name = f"未知用户{self.person_id[:4]}" + return + # raise ValueError(f"用户 {platform}:{user_id}:{person_name}:{person_id} 尚未认识") + + self.is_known = False + + # 初始化默认值 + self.nickname = "" + self.person_name: Optional[str] = None + self.name_reason: Optional[str] = None + self.know_times = 0 + self.know_since = None + self.last_know: Optional[float] = None + self.memory_points = [] + self.group_cardname_list: list[dict[str, str]] = [] # 群名片列表,存储 {"group_id": str, "group_cardname": str} + + # 从数据库加载数据 + self.load_from_database() + + def del_memory(self, category: str, memory_content: str, similarity_threshold: float = 0.95): + """ + 删除指定分类和记忆内容的记忆点 + + Args: + category: 记忆分类 + memory_content: 要删除的记忆内容 + similarity_threshold: 相似度阈值,默认0.95(95%) + + Returns: + int: 删除的记忆点数量 + """ + if not self.memory_points: + return 0 + + deleted_count = 0 + memory_points_to_keep = [] + + for memory_point in self.memory_points: + # 跳过None值 + if memory_point is None: + continue + # 解析记忆点 + parts = memory_point.split(":", 2) # 最多分割2次,保留记忆内容中的冒号 + if len(parts) < 3: + # 格式不正确,保留原样 + memory_points_to_keep.append(memory_point) + continue + + memory_category = parts[0].strip() + memory_text = parts[1].strip() + _memory_weight = parts[2].strip() + + # 检查分类是否匹配 + if memory_category != category: + memory_points_to_keep.append(memory_point) + continue + + # 计算记忆内容的相似度 + similarity = calculate_string_similarity(memory_content, memory_text) + + # 如果相似度达到阈值,则删除(不添加到保留列表) + if similarity >= similarity_threshold: + deleted_count += 1 + logger.debug(f"删除记忆点: {memory_point} (相似度: {similarity:.4f})") + else: + memory_points_to_keep.append(memory_point) + + # 更新memory_points + self.memory_points = memory_points_to_keep + + # 同步到数据库 + if deleted_count > 0: + self.sync_to_database() + logger.info(f"成功删除 {deleted_count} 个记忆点,分类: {category}") + + return deleted_count + + def add_group_nick_name(self, group_id: str, group_nick_name: str): + """ + 添加或更新群昵称 + + Args: + group_id: 群号 + group_nick_name: 群昵称 + """ + if not group_id or not group_nick_name: + return + + # 检查是否已存在该群号的记录 + for item in self.group_cardname_list: + if item.get("group_id") == group_id: + # 更新现有记录 + item["group_cardname"] = group_nick_name + self.sync_to_database() + logger.debug(f"更新用户 {self.person_id} 在群 {group_id} 的群昵称为 {group_nick_name}") + return + + # 添加新记录 + self.group_cardname_list.append({"group_id": group_id, "group_cardname": group_nick_name}) + self.sync_to_database() + logger.debug(f"添加用户 {self.person_id} 在群 {group_id} 的群昵称 {group_nick_name}") + + def load_from_database(self): + """从数据库加载个人信息数据""" + try: + with get_db_session() as session: + statement = select(PersonInfo).where(col(PersonInfo.person_id) == self.person_id).limit(1) + record = session.exec(statement).first() + + if record: + self.user_id = record.user_id or "" + self.platform = record.platform or "" + self.is_known = record.is_known or False + self.nickname = record.user_nickname or "" + self.person_name = record.person_name or self.nickname + self.name_reason = record.name_reason or None + self.know_times = record.know_counts or 0 + + # 处理points字段(JSON格式的列表) + if record.memory_points: + try: + loaded_points = json.loads(record.memory_points) + # 过滤掉None值,确保数据质量 + if isinstance(loaded_points, list): + self.memory_points = [point for point in loaded_points if point is not None] + else: + self.memory_points = [] + except (json.JSONDecodeError, TypeError): + logger.warning(f"解析用户 {self.person_id} 的points字段失败,使用默认值") + self.memory_points = [] + else: + self.memory_points = [] + + # 处理 group_cardname 字段(JSON 格式的列表) + if record.group_cardname: + try: + self.group_cardname_list = _to_group_cardname_records(record.group_cardname) + except (json.JSONDecodeError, TypeError): + logger.warning(f"解析用户 {self.person_id} 的group_cardname字段失败,使用默认值") + self.group_cardname_list = [] + else: + self.group_cardname_list = [] + + logger.debug(f"已从数据库加载用户 {self.person_id} 的信息") + else: + self.sync_to_database() + logger.info(f"用户 {self.person_id} 在数据库中不存在,使用默认值并创建") + + except Exception as e: + logger.error(f"从数据库加载用户 {self.person_id} 信息时出错: {e}") + # 出错时保持默认值 + + def sync_to_database(self): + """将所有属性同步回数据库""" + if not self.is_known: + return + try: + memory_points_value = ( + json.dumps([point for point in self.memory_points if point is not None], ensure_ascii=False) + if self.memory_points + else json.dumps([], ensure_ascii=False) + ) + group_cardname_value = dump_group_cardname_records(self.group_cardname_list) + first_known_time = datetime.fromtimestamp(self.know_since) if self.know_since else None + last_known_time = datetime.fromtimestamp(self.last_know) if self.last_know else None + + with get_db_session() as session: + statement = select(PersonInfo).where(col(PersonInfo.person_id) == self.person_id).limit(1) + record = session.exec(statement).first() + + if record: + record.person_id = self.person_id + record.is_known = self.is_known + record.platform = self.platform + record.user_id = self.user_id + record.user_nickname = self.nickname + record.person_name = self.person_name + record.name_reason = self.name_reason + record.know_counts = self.know_times + record.first_known_time = first_known_time + record.last_known_time = last_known_time + record.memory_points = memory_points_value + record.group_cardname = group_cardname_value + session.add(record) + logger.debug(f"已同步用户 {self.person_id} 的信息到数据库") + else: + record = PersonInfo( + person_id=self.person_id, + is_known=self.is_known, + platform=self.platform, + user_id=self.user_id, + user_nickname=self.nickname, + person_name=self.person_name, + name_reason=self.name_reason, + know_counts=self.know_times, + first_known_time=first_known_time, + last_known_time=last_known_time, + memory_points=memory_points_value, + group_cardname=group_cardname_value, + ) + session.add(record) + logger.debug(f"已创建用户 {self.person_id} 的信息到数据库") + + except Exception as e: + logger.error(f"同步用户 {self.person_id} 信息到数据库时出错: {e}") + + +async def store_person_memory_from_answer( + person_name: str, + memory_content: str, + chat_id: str, + *, + evidence_source: str = "user_supported", + evidence_message_ids: Optional[List[str]] = None, +) -> None: + """将人物事实写入长期记忆系统。 + + Args: + person_name: 人物名称 + memory_content: 记忆内容 + chat_id: 聊天ID + """ + clean_content = str(memory_content or "").strip() + if not clean_content: + logger.debug("人物事实写回跳过:memory_content 为空") + return + + clean_chat_id = str(chat_id or "").strip() + if not clean_chat_id: + logger.warning("人物事实写回失败:chat_id 为空") + return + + clean_person_name = str(person_name or "").strip() + try: + # 从 chat_id 获取 session + session = _chat_manager.get_session_by_session_id(clean_chat_id) + if not session: + logger.warning(f"无法获取session for chat_id: {clean_chat_id}") + return + + session_platform = str(getattr(session, "platform", "") or "").strip() + session_user_id = str(getattr(session, "user_id", "") or "").strip() + session_group_id = str(getattr(session, "group_id", "") or "").strip() + + person_id = resolve_person_id_for_memory( + person_name=clean_person_name, + platform=session_platform, + user_id=session_user_id, + ) + if not person_id: + logger.warning(f"无法确定person_id for person_name: {clean_person_name}, chat_id: {clean_chat_id}") + return + + person = Person(person_id=person_id) + if not person.is_known: + logger.warning(f"用户 {clean_person_name or person_id} (person_id: {person_id}) 尚未认识,跳过写回") + return + + participant_name = str(getattr(person, "person_name", "") or getattr(person, "nickname", "") or "").strip() + if not participant_name: + participant_name = clean_person_name or person_id + + payload_fingerprint = hashlib.md5(f"{person_id}|{clean_chat_id}|{clean_content}".encode()).hexdigest() + external_id = f"person_fact:{person_id}:{payload_fingerprint}" + + result = await memory_service.ingest_text( + external_id=external_id, + source_type="person_fact", + text=clean_content, + chat_id=clean_chat_id, + person_ids=[person_id], + participants=[participant_name], + tags=["person_fact"], + metadata={ + "person_id": person_id, + "person_name": participant_name, + "writeback_source": "memory_flow_service", + "evidence_source": str(evidence_source or "user_supported"), + "evidence_message_ids": evidence_message_ids or [], + }, + respect_filter=True, + user_id=session_user_id, + group_id=session_group_id, + ) + + if getattr(result, "success", False): + logger.info( + f"成功写回人物事实到长期记忆: person={participant_name} person_id={person_id} chat_id={clean_chat_id}" + ) + else: + logger.warning( + f"人物事实写回长期记忆失败: person={participant_name} person_id={person_id} " + f"chat_id={clean_chat_id} detail={getattr(result, 'detail', '')}" + ) + + except Exception as e: + logger.error(f"存储人物记忆失败: {e}") diff --git a/src/platform_io/__init__.py b/src/platform_io/__init__.py new file mode 100644 index 00000000..c91535d1 --- /dev/null +++ b/src/platform_io/__init__.py @@ -0,0 +1,34 @@ +"""导出 Platform IO 层的公开入口。 + +当前仍处于地基阶段,调用方应优先从这里导入共享类型和全局管理器, +而不是直接依赖更底层的私有子模块。 +""" + +from .manager import PlatformIOManager, get_platform_io_manager +from .route_key_factory import RouteKeyFactory +from .routing import RouteTable +from .types import ( + DeliveryBatch, + DeliveryReceipt, + DeliveryStatus, + DriverDescriptor, + DriverKind, + InboundMessageEnvelope, + RouteBinding, + RouteKey, +) + +__all__ = [ + "DeliveryBatch", + "DeliveryReceipt", + "DeliveryStatus", + "DriverDescriptor", + "DriverKind", + "InboundMessageEnvelope", + "PlatformIOManager", + "RouteKeyFactory", + "RouteBinding", + "RouteKey", + "RouteTable", + "get_platform_io_manager", +] diff --git a/src/platform_io/dedupe.py b/src/platform_io/dedupe.py new file mode 100644 index 00000000..4c5c55a2 --- /dev/null +++ b/src/platform_io/dedupe.py @@ -0,0 +1,133 @@ +"""提供 Platform IO 的轻量入站消息去重能力。 + +当前实现基于 ``dict + heapq``: +- ``dict`` 保存去重键到过期时间的映射 +- ``heapq`` 维护按过期时间排序的小顶堆 + +这样就不需要在每次检查时全表扫描,而是通过懒清理逐步弹出已经过期 +或已经失效的堆节点。 +""" + +from typing import Dict, List, Tuple + +import heapq +import time + + +class MessageDeduplicator: + """使用基于 TTL 的内存缓存进行入站消息去重。 + + 主要用于解决同一条外部消息被重复送入 Core 的问题,例如双路径并存、 + 适配器重试、重连或重复回调等场景。Broker 可以借助这个组件在进入 + Core 前先拦住重复投递,避免重复处理、重复回复和重复入库。 + + 当前实现使用 ``dict + heapq`` 维护过期时间: + - ``dict`` 负责 ``O(1)`` 级别的去重键查找 + - ``heapq`` 负责按过期时间顺序做懒清理 + + 这比“每次调用都全表扫描过期项”的实现更适合高吞吐消息场景。 + + Notes: + 复杂度说明如下,设 ``n`` 为当前缓存中的有效去重键数量: + + - 单次 ``mark_seen()`` 在常见路径下的时间复杂度接近 ``O(log n)`` + - 从长期摊还角度看,``mark_seen()`` 的时间复杂度也接近 ``O(log n)`` + - 如果某次调用恰好触发一批过期键的集中清理,则该次调用的最坏时间复杂度 + 可达到 ``O(k log n)``,其中 ``k`` 为本次被弹出或清理的键数量 + - 空间复杂度为 ``O(n)`` + """ + + def __init__(self, ttl_seconds: float = 300.0, max_entries: int = 10000) -> None: + """初始化去重器。 + + Args: + ttl_seconds: 每个去重键在缓存中的保留时长,单位为秒。 + max_entries: 缓存允许保留的最大有效键数量,超出后会触发 + 机会性淘汰。 + + Raises: + ValueError: 当 ``ttl_seconds`` 或 ``max_entries`` 非正数时抛出。 + """ + if ttl_seconds <= 0: + raise ValueError("ttl_seconds 必须大于 0") + if max_entries <= 0: + raise ValueError("max_entries 必须大于 0") + + self._ttl_seconds = ttl_seconds + self._max_entries = max_entries + self._expire_heap: List[Tuple[float, str]] = [] + self._seen: Dict[str, float] = {} + + def mark_seen(self, dedupe_key: str) -> bool: + """标记一条去重键已经出现过。 + + Args: + dedupe_key: 能稳定标识一条外部入站消息的去重键。 + + Returns: + bool: 若该键在当前 TTL 窗口内首次出现则返回 ``True``, + 否则返回 ``False``。 + + Notes: + 方法会先基于小顶堆做一次懒清理,再判断当前键是否仍在有效期内。 + 如果缓存已达到上限,则会优先淘汰“最早过期的仍然有效的键”。 + + 复杂度方面,常见路径下该方法接近 ``O(log n)``;如果恰好需要 + 集中清理一批过期键,则单次调用最坏可达到 ``O(k log n)``。 + """ + now = time.monotonic() + self._purge_expired(now) + + expires_at = self._seen.get(dedupe_key) + if expires_at is not None and expires_at > now: + return False + + if len(self._seen) >= self._max_entries: + self._evict_earliest_live() + + expires_at = now + self._ttl_seconds + self._seen[dedupe_key] = expires_at + heapq.heappush(self._expire_heap, (expires_at, dedupe_key)) + return True + + def clear(self) -> None: + """清空全部去重缓存。""" + self._expire_heap.clear() + self._seen.clear() + + def _purge_expired(self, now: float) -> None: + """从缓存中清理已经过期的去重键。 + + Args: + now: 当前单调时钟时间戳。 + + Notes: + 堆中可能存在旧版本节点。例如同一个 ``dedupe_key`` 被重新写入后, + 旧的过期时间节点仍会留在堆里。这里会通过和 ``dict`` 中当前值比对, + 跳过这类失效节点。 + """ + while self._expire_heap and self._expire_heap[0][0] <= now: + expires_at, dedupe_key = heapq.heappop(self._expire_heap) + current_expires_at = self._seen.get(dedupe_key) + if current_expires_at is None: + continue + if current_expires_at != expires_at: + continue + self._seen.pop(dedupe_key, None) + + def _evict_earliest_live(self) -> None: + """当缓存达到容量上限时,淘汰一条最早过期的有效键。 + + Notes: + 堆顶可能是已经过期或已失效的旧节点,因此这里同样需要循环弹出, + 直到找到一条当前仍然在 ``dict`` 中生效的键。 + """ + while self._expire_heap: + expires_at, dedupe_key = heapq.heappop(self._expire_heap) + current_expires_at = self._seen.get(dedupe_key) + if current_expires_at is None: + continue + if current_expires_at != expires_at: + continue + self._seen.pop(dedupe_key, None) + return diff --git a/src/platform_io/drivers/__init__.py b/src/platform_io/drivers/__init__.py new file mode 100644 index 00000000..b12120cf --- /dev/null +++ b/src/platform_io/drivers/__init__.py @@ -0,0 +1,11 @@ +"""导出 Platform IO 层的公开驱动类型。""" + +from .base import PlatformIODriver +from .legacy_driver import LegacyPlatformDriver +from .plugin_driver import PluginPlatformDriver + +__all__ = [ + "LegacyPlatformDriver", + "PlatformIODriver", + "PluginPlatformDriver", +] diff --git a/src/platform_io/drivers/base.py b/src/platform_io/drivers/base.py new file mode 100644 index 00000000..c6173d8c --- /dev/null +++ b/src/platform_io/drivers/base.py @@ -0,0 +1,104 @@ +"""定义 Platform IO 传输驱动的基础抽象协议。""" + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional + +from src.platform_io.types import DeliveryReceipt, DriverDescriptor, InboundMessageEnvelope, RouteKey + +if TYPE_CHECKING: + from src.chat.message_receive.message import SessionMessage + +InboundHandler = Callable[[InboundMessageEnvelope], Awaitable[bool]] + + +class PlatformIODriver(ABC): + """定义所有 Platform IO 驱动都必须实现的最小契约。 + + 当前实现故意保持接口很小,让中间层可以先落地,再逐步把 legacy + 与 plugin 路径的真实收发能力迁入这套协议之下。 + """ + + def __init__(self, descriptor: DriverDescriptor) -> None: + """使用驱动描述对象初始化驱动。 + + Args: + descriptor: 注册到 Broker 中的静态驱动元数据。 + """ + self._descriptor = descriptor + self._inbound_handler: Optional[InboundHandler] = None + + @property + def descriptor(self) -> DriverDescriptor: + """返回当前驱动的描述对象。 + + Returns: + DriverDescriptor: 当前驱动实例对应的描述对象。 + """ + return self._descriptor + + @property + def driver_id(self) -> str: + """返回驱动标识。 + + Returns: + str: 当前驱动的唯一 ID。 + """ + return self._descriptor.driver_id + + def set_inbound_handler(self, handler: InboundHandler) -> None: + """注册入站消息交回 Broker 的回调函数。 + + Args: + handler: 将规范化入站封装继续转发给 Broker 的异步回调。 + """ + self._inbound_handler = handler + + def clear_inbound_handler(self) -> None: + """清除当前注册的入站回调函数。""" + self._inbound_handler = None + + async def emit_inbound(self, envelope: InboundMessageEnvelope) -> bool: + """将一条入站封装转交给 Broker 回调。 + + Args: + envelope: 由驱动产出的规范化入站封装。 + + Returns: + bool: 若 Broker 接受该入站消息则返回 ``True``,否则返回 ``False``。 + """ + + if self._inbound_handler is None: + return False + return await self._inbound_handler(envelope) + + async def start(self) -> None: + """启动驱动生命周期。 + + 子类后续若需要初始化逻辑,可以覆盖这个钩子。 + """ + return None + + async def stop(self) -> None: + """停止驱动生命周期。 + + 子类后续若需要清理逻辑,可以覆盖这个钩子。 + """ + return None + + @abstractmethod + async def send_message( + self, + message: "SessionMessage", + route_key: RouteKey, + metadata: Optional[Dict[str, Any]] = None, + ) -> DeliveryReceipt: + """通过具体驱动发送一条消息。 + + Args: + message: 要投递的内部会话消息。 + route_key: Broker 为本次投递选中的路由键。 + metadata: 本次出站投递可选的 Broker 侧元数据。 + + Returns: + DeliveryReceipt: 规范化后的投递结果。 + """ diff --git a/src/platform_io/drivers/legacy_driver.py b/src/platform_io/drivers/legacy_driver.py new file mode 100644 index 00000000..ef90c772 --- /dev/null +++ b/src/platform_io/drivers/legacy_driver.py @@ -0,0 +1,92 @@ +"""提供 Platform IO 的 legacy 传输驱动实现。""" + +from typing import TYPE_CHECKING, Any, Dict, Optional + +from src.platform_io.drivers.base import PlatformIODriver +from src.platform_io.types import DeliveryReceipt, DeliveryStatus, DriverDescriptor, DriverKind, RouteKey + +if TYPE_CHECKING: + from src.chat.message_receive.message import SessionMessage + + +class LegacyPlatformDriver(PlatformIODriver): + """面向 ``UniversalMessageSender`` 旧链的 Platform IO 驱动。""" + + def __init__( + self, + driver_id: str, + platform: str, + account_id: Optional[str] = None, + scope: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> None: + """初始化一个 legacy 驱动描述对象。 + + Args: + driver_id: Broker 内的唯一驱动 ID。 + platform: 该 legacy 适配器链路负责的平台。 + account_id: 可选的账号 ID。 + scope: 可选的额外路由作用域。 + metadata: 可选的额外驱动元数据。 + """ + descriptor = DriverDescriptor( + driver_id=driver_id, + kind=DriverKind.LEGACY, + platform=platform, + account_id=account_id, + scope=scope, + metadata=metadata or {}, + ) + super().__init__(descriptor) + + async def send_message( + self, + message: "SessionMessage", + route_key: RouteKey, + metadata: Optional[Dict[str, Any]] = None, + ) -> DeliveryReceipt: + """通过旧链发送一条已经过预处理的消息。 + + Args: + message: 要投递的内部会话消息。 + route_key: Broker 为本次投递选择的路由键。 + metadata: 本次出站投递可选的 Broker 侧元数据。 + + Returns: + DeliveryReceipt: 规范化后的发送回执。 + """ + from src.chat.message_receive.uni_message_sender import send_prepared_message_to_platform + + show_log = False + if isinstance(metadata, dict): + show_log = bool(metadata.get("show_log", False)) + + try: + sent = await send_prepared_message_to_platform(message, show_log=show_log) + except Exception as exc: + return DeliveryReceipt( + internal_message_id=message.message_id, + route_key=route_key, + status=DeliveryStatus.FAILED, + driver_id=self.driver_id, + driver_kind=self.descriptor.kind, + error=str(exc), + ) + + if not sent: + return DeliveryReceipt( + internal_message_id=message.message_id, + route_key=route_key, + status=DeliveryStatus.FAILED, + driver_id=self.driver_id, + driver_kind=self.descriptor.kind, + error="旧链发送失败", + ) + + return DeliveryReceipt( + internal_message_id=message.message_id, + route_key=route_key, + status=DeliveryStatus.SENT, + driver_id=self.driver_id, + driver_kind=self.descriptor.kind, + ) diff --git a/src/platform_io/drivers/plugin_driver.py b/src/platform_io/drivers/plugin_driver.py new file mode 100644 index 00000000..c03204ad --- /dev/null +++ b/src/platform_io/drivers/plugin_driver.py @@ -0,0 +1,211 @@ +"""提供 Platform IO 的插件消息网关驱动实现。""" + +from typing import TYPE_CHECKING, Any, Dict, Optional, Protocol + +from src.platform_io.drivers.base import PlatformIODriver +from src.platform_io.types import DeliveryReceipt, DeliveryStatus, DriverDescriptor, DriverKind, RouteKey + +if TYPE_CHECKING: + from src.chat.message_receive.message import SessionMessage + + +class _GatewaySupervisorProtocol(Protocol): + """消息网关驱动依赖的 Supervisor 最小协议。""" + + async def invoke_message_gateway( + self, + plugin_id: str, + component_name: str, + args: Optional[Dict[str, Any]] = None, + timeout_ms: int = 30000, + ) -> Any: + """调用插件声明的消息网关方法。""" + + +class PluginPlatformDriver(PlatformIODriver): + """面向插件消息网关链路的 Platform IO 驱动。""" + + def __init__( + self, + driver_id: str, + platform: str, + supervisor: _GatewaySupervisorProtocol, + component_name: str, + *, + supports_send: bool, + account_id: Optional[str] = None, + scope: Optional[str] = None, + plugin_id: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> None: + """初始化一个插件消息网关驱动。 + + Args: + driver_id: Broker 内的唯一驱动 ID。 + platform: 该消息网关负责的平台名称。 + supervisor: 持有该插件的 Supervisor。 + component_name: 出站时要调用的网关组件名称。 + supports_send: 当前驱动是否具备出站能力。 + account_id: 可选的账号 ID 或 self ID。 + scope: 可选的额外路由作用域。 + plugin_id: 拥有该实现的插件 ID。 + metadata: 可选的额外驱动元数据。 + """ + + descriptor = DriverDescriptor( + driver_id=driver_id, + kind=DriverKind.PLUGIN, + platform=platform, + account_id=account_id, + scope=scope, + plugin_id=plugin_id, + metadata=metadata or {}, + ) + super().__init__(descriptor) + self._supervisor = supervisor + self._component_name = component_name + self._supports_send = supports_send + + async def send_message( + self, + message: "SessionMessage", + route_key: RouteKey, + metadata: Optional[Dict[str, Any]] = None, + ) -> DeliveryReceipt: + """通过插件消息网关发送消息。 + + Args: + message: 要投递的内部会话消息。 + route_key: Broker 为本次投递选择的路由键。 + metadata: 可选的发送元数据。 + + Returns: + DeliveryReceipt: 规范化后的发送回执。 + """ + + if not self._supports_send: + return DeliveryReceipt( + internal_message_id=message.message_id, + route_key=route_key, + status=DeliveryStatus.FAILED, + driver_id=self.driver_id, + driver_kind=self.descriptor.kind, + error="当前消息网关仅支持接收,不支持发送", + ) + + from src.plugin_runtime.host.message_utils import PluginMessageUtils + + plugin_id = self.descriptor.plugin_id or "" + if not plugin_id: + return DeliveryReceipt( + internal_message_id=message.message_id, + route_key=route_key, + status=DeliveryStatus.FAILED, + driver_id=self.driver_id, + driver_kind=self.descriptor.kind, + error="插件消息网关驱动缺少 plugin_id", + ) + + try: + message_dict = PluginMessageUtils._session_message_to_dict(message) + response = await self._supervisor.invoke_message_gateway( + plugin_id=plugin_id, + component_name=self._component_name, + args={ + "message": message_dict, + "route": { + "platform": route_key.platform, + "account_id": route_key.account_id, + "scope": route_key.scope, + }, + "metadata": metadata or {}, + }, + timeout_ms=30000, + ) + except Exception as exc: + return DeliveryReceipt( + internal_message_id=message.message_id, + route_key=route_key, + status=DeliveryStatus.FAILED, + driver_id=self.driver_id, + driver_kind=self.descriptor.kind, + error=str(exc), + ) + + return self._build_receipt(message.message_id, route_key, response) + + def _build_receipt(self, internal_message_id: str, route_key: RouteKey, response: Any) -> DeliveryReceipt: + """将网关调用响应归一化为出站回执。 + + Args: + internal_message_id: 内部消息 ID。 + route_key: 本次投递的路由键。 + response: Supervisor 返回的 RPC 响应对象。 + + Returns: + DeliveryReceipt: 标准化后的出站回执。 + """ + + if getattr(response, "error", None): + error = response.error.get("message", "消息网关发送失败") + return DeliveryReceipt( + internal_message_id=internal_message_id, + route_key=route_key, + status=DeliveryStatus.FAILED, + driver_id=self.driver_id, + driver_kind=self.descriptor.kind, + error=error, + ) + + payload = getattr(response, "payload", {}) + invoke_success = bool(payload.get("success", False)) if isinstance(payload, dict) else False + if not invoke_success: + return DeliveryReceipt( + internal_message_id=internal_message_id, + route_key=route_key, + status=DeliveryStatus.FAILED, + driver_id=self.driver_id, + driver_kind=self.descriptor.kind, + error=str(payload.get("result", "消息网关发送失败")) if isinstance(payload, dict) else "消息网关发送失败", + ) + + result = payload.get("result") if isinstance(payload, dict) else None + if isinstance(result, dict): + if result.get("success") is False: + return DeliveryReceipt( + internal_message_id=internal_message_id, + route_key=route_key, + status=DeliveryStatus.FAILED, + driver_id=self.driver_id, + driver_kind=self.descriptor.kind, + error=str(result.get("error", "消息网关发送失败")), + metadata=result.get("metadata", {}) if isinstance(result.get("metadata"), dict) else {}, + ) + external_message_id = str(result.get("external_message_id") or result.get("message_id") or "") or None + return DeliveryReceipt( + internal_message_id=internal_message_id, + route_key=route_key, + status=DeliveryStatus.SENT, + driver_id=self.driver_id, + driver_kind=self.descriptor.kind, + external_message_id=external_message_id, + metadata=result.get("metadata", {}) if isinstance(result.get("metadata"), dict) else {}, + ) + + if isinstance(result, str) and result.strip(): + return DeliveryReceipt( + internal_message_id=internal_message_id, + route_key=route_key, + status=DeliveryStatus.SENT, + driver_id=self.driver_id, + driver_kind=self.descriptor.kind, + external_message_id=result.strip(), + ) + + return DeliveryReceipt( + internal_message_id=internal_message_id, + route_key=route_key, + status=DeliveryStatus.SENT, + driver_id=self.driver_id, + driver_kind=self.descriptor.kind, + ) diff --git a/src/platform_io/manager.py b/src/platform_io/manager.py new file mode 100644 index 00000000..dee553a6 --- /dev/null +++ b/src/platform_io/manager.py @@ -0,0 +1,611 @@ +"""提供 Platform IO 层的中心 Broker 管理器。""" + +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional + +from src.common.logger import get_logger +from src.platform_io.drivers.base import PlatformIODriver + +from .dedupe import MessageDeduplicator +from .outbound_tracker import OutboundTracker +from .route_key_factory import RouteKeyFactory +from .registry import DriverRegistry +from .routing import RouteTable +from .types import DeliveryBatch, DeliveryReceipt, DeliveryStatus, InboundMessageEnvelope, RouteBinding, RouteKey + +if TYPE_CHECKING: + from src.chat.message_receive.message import SessionMessage + +logger = get_logger("platform_io.manager") + +InboundDispatcher = Callable[[InboundMessageEnvelope], Awaitable[None]] + + +class PlatformIOManager: + """统一协调平台消息 IO 的路由、去重与状态跟踪。 + + 与旧实现不同,这个管理器不再负责“多条链路谁该接管平台”的裁决, + 只维护发送表和接收表两张轻量路由表: + + - 发送时:解析所有命中的发送绑定并全部投递。 + - 接收时:只校验当前驱动是否已登记为可接收链路,然后全部放行给上层。 + - 去重时:仅对单条链路做技术性重放抑制,不做跨链路语义去重。 + """ + + def __init__(self) -> None: + """初始化 Broker 管理器及其内存状态。""" + self._driver_registry = DriverRegistry() + self._send_route_table = RouteTable() + self._receive_route_table = RouteTable() + self._legacy_send_drivers: Dict[str, PlatformIODriver] = {} + self._deduplicator = MessageDeduplicator() + self._outbound_tracker = OutboundTracker() + self._inbound_dispatcher: Optional[InboundDispatcher] = None + self._started = False + + @property + def is_started(self) -> bool: + """返回 Broker 当前是否已进入运行态。 + + Returns: + bool: 若 Broker 已启动则返回 ``True``。 + """ + return self._started + + async def start(self) -> None: + """启动 Broker,并依次启动当前已注册的全部驱动。 + + Raises: + Exception: 当某个驱动启动失败时,异常会继续上抛;已成功启动的驱动 + 会被自动回滚停止。 + """ + if self._started: + return + + started_drivers: List[PlatformIODriver] = [] + try: + for driver in self._driver_registry.list(): + await driver.start() + started_drivers.append(driver) + except Exception: + for driver in reversed(started_drivers): + try: + await driver.stop() + except Exception: + logger.exception(f"回滚驱动停止失败: driver_id={driver.driver_id}") + raise + + self._started = True + + async def ensure_send_pipeline_ready(self) -> None: + """确保出站发送管线已准备就绪。 + + 该方法会先同步 legacy fallback driver,再在需要时启动 Broker。 + send service 应只调用这一层准备入口,而不是自行判断旧链或插件链。 + """ + await self._sync_legacy_send_drivers() + if not self._started: + await self.start() + + async def stop(self) -> None: + """停止 Broker,并按逆序停止全部已注册驱动。 + + 停止完成后,会同步清空仅对当前运行周期有效的去重缓存和出站跟踪状态, + 避免下一次启动时继续沿用上一个运行周期的瞬时内存数据。 + + Raises: + RuntimeError: 当一个或多个驱动停止失败时抛出汇总异常。 + """ + if not self._started: + return + + stop_errors: List[str] = [] + for driver in reversed(self._driver_registry.list()): + try: + await driver.stop() + except Exception as exc: + stop_errors.append(f"{driver.driver_id}: {exc}") + logger.exception(f"驱动停止失败: driver_id={driver.driver_id}") + + self._started = False + self._deduplicator.clear() + self._outbound_tracker.clear() + if stop_errors: + raise RuntimeError(f"部分驱动停止失败: {'; '.join(stop_errors)}") + + async def add_driver(self, driver: PlatformIODriver) -> None: + """向运行中的 Broker 注册并启动一个驱动。 + + 如果 Broker 尚未启动,则该方法等价于 ``register_driver()``。 + + Args: + driver: 要添加的驱动实例。 + + Raises: + Exception: 当驱动启动失败时,注册会自动回滚,异常继续上抛。 + """ + self._register_driver_internal(driver) + if not self._started: + return + + try: + await driver.start() + except Exception: + self._unregister_driver_internal(driver.driver_id) + raise + + async def remove_driver(self, driver_id: str) -> Optional[PlatformIODriver]: + """从运行中的 Broker 停止并移除一个驱动。 + + 如果 Broker 尚未启动,则该方法等价于 ``unregister_driver()``。 + + Args: + driver_id: 要移除的驱动 ID。 + + Returns: + Optional[PlatformIODriver]: 若驱动存在,则返回被移除的驱动实例。 + + Raises: + Exception: 当 Broker 运行中且驱动停止失败时,异常会继续上抛。 + """ + if not self._started: + return self.unregister_driver(driver_id) + + driver = self._driver_registry.get(driver_id) + if driver is None: + return None + + await driver.stop() + return self._unregister_driver_internal(driver_id) + + @property + def driver_registry(self) -> DriverRegistry: + """返回管理器持有的驱动注册表。 + + Returns: + DriverRegistry: 用于保存全部已注册驱动的注册表。 + """ + return self._driver_registry + + @property + def send_route_table(self) -> RouteTable: + """返回发送路由表。""" + + return self._send_route_table + + @property + def receive_route_table(self) -> RouteTable: + """返回接收路由表。""" + + return self._receive_route_table + + @property + def deduplicator(self) -> MessageDeduplicator: + """返回管理器持有的入站去重器。 + + Returns: + MessageDeduplicator: 用于抑制重复入站的去重器。 + """ + return self._deduplicator + + @property + def outbound_tracker(self) -> OutboundTracker: + """返回管理器持有的出站跟踪器。 + + Returns: + OutboundTracker: 用于记录出站 pending 状态与回执的跟踪器。 + """ + return self._outbound_tracker + + def set_inbound_dispatcher(self, dispatcher: InboundDispatcher) -> None: + """设置统一的入站分发回调。 + + Args: + dispatcher: 接收已通过 Broker 审核的入站封装,并继续送入 + Core 下一处理阶段的异步回调。 + """ + + self._inbound_dispatcher = dispatcher + + def clear_inbound_dispatcher(self) -> None: + """清除当前的入站分发回调。""" + self._inbound_dispatcher = None + + @property + def has_inbound_dispatcher(self) -> bool: + """返回当前是否已经配置入站分发回调。 + + Returns: + bool: 若已经配置入站分发回调则返回 ``True``。 + """ + return self._inbound_dispatcher is not None + + def register_driver(self, driver: PlatformIODriver) -> None: + """注册驱动,并把它的入站回调挂到 Broker。 + + Args: + driver: 要注册的驱动实例。 + + Raises: + RuntimeError: 当 Broker 已经处于运行态时抛出。此时应改用 + ``add_driver()`` 以保证驱动生命周期和注册状态一致。 + """ + if self._started: + raise RuntimeError("Broker 运行中不允许直接 register_driver,请改用 add_driver()") + + self._register_driver_internal(driver) + + def _register_driver_internal(self, driver: PlatformIODriver) -> None: + """执行不带运行态限制的内部驱动注册。 + + Args: + driver: 要注册的驱动实例。 + """ + driver.set_inbound_handler(self.accept_inbound) + self._driver_registry.register(driver) + + def unregister_driver(self, driver_id: str) -> Optional[PlatformIODriver]: + """从 Broker 注销一个驱动。 + + Args: + driver_id: 要移除的驱动 ID。 + + Returns: + Optional[PlatformIODriver]: 若驱动存在,则返回被移除的驱动实例。 + + Raises: + RuntimeError: 当 Broker 已经处于运行态时抛出。此时应改用 + ``remove_driver()``,避免驱动停止与路由解绑脱节。 + """ + if self._started: + raise RuntimeError("Broker 运行中不允许直接 unregister_driver,请改用 remove_driver()") + + return self._unregister_driver_internal(driver_id) + + def _unregister_driver_internal(self, driver_id: str) -> Optional[PlatformIODriver]: + """执行不带运行态限制的内部驱动注销。 + + Args: + driver_id: 要移除的驱动 ID。 + + Returns: + Optional[PlatformIODriver]: 若驱动存在,则返回被移除的驱动实例。 + """ + removed_driver = self._driver_registry.unregister(driver_id) + if removed_driver is None: + return None + + removed_driver.clear_inbound_handler() + self._send_route_table.remove_bindings_by_driver(driver_id) + self._receive_route_table.remove_bindings_by_driver(driver_id) + self._legacy_send_drivers = { + platform: driver + for platform, driver in self._legacy_send_drivers.items() + if driver.driver_id != driver_id + } + return removed_driver + + async def _sync_legacy_send_drivers(self) -> None: + """根据当前配置同步 legacy fallback driver。""" + from src.chat.utils.utils import get_all_bot_accounts + from src.platform_io.drivers.legacy_driver import LegacyPlatformDriver + + desired_accounts = get_all_bot_accounts() + desired_platforms = set(desired_accounts.keys()) + current_platforms = set(self._legacy_send_drivers.keys()) + + for platform in sorted(current_platforms - desired_platforms): + await self._remove_legacy_send_driver(platform) + + for platform, account_id in desired_accounts.items(): + existing_driver = self._legacy_send_drivers.get(platform) + if existing_driver is not None and existing_driver.descriptor.account_id == account_id: + continue + + if existing_driver is not None: + await self._remove_legacy_send_driver(platform) + + driver = LegacyPlatformDriver( + driver_id=f"legacy.send.{platform}", + platform=platform, + account_id=account_id, + ) + if self._started: + await self.add_driver(driver) + else: + self.register_driver(driver) + self._legacy_send_drivers[platform] = driver + + async def _remove_legacy_send_driver(self, platform: str) -> None: + """移除指定平台的 legacy fallback driver。 + + Args: + platform: 要移除的目标平台。 + """ + driver = self._legacy_send_drivers.get(platform) + if driver is None: + return + + if self._started: + await self.remove_driver(driver.driver_id) + else: + self.unregister_driver(driver.driver_id) + self._legacy_send_drivers.pop(platform, None) + + def bind_send_route(self, binding: RouteBinding) -> None: + """为某个路由键绑定发送驱动。 + + Args: + binding: 要保存的路由绑定。 + + Raises: + ValueError: 当绑定引用了不存在的驱动,或者绑定与驱动描述不一致时抛出。 + """ + driver = self._driver_registry.get(binding.driver_id) + if driver is None: + raise ValueError(f"驱动 {binding.driver_id} 未注册,无法绑定路由") + + self._validate_binding_against_driver(binding, driver) + self._send_route_table.bind(binding) + + def bind_receive_route(self, binding: RouteBinding) -> None: + """为某个路由键绑定接收驱动。 + + Args: + binding: 要保存的路由绑定。 + + Raises: + ValueError: 当绑定引用了不存在的驱动,或者绑定与驱动描述不一致时抛出。 + """ + driver = self._driver_registry.get(binding.driver_id) + if driver is None: + raise ValueError(f"驱动 {binding.driver_id} 未注册,无法绑定路由") + + self._validate_binding_against_driver(binding, driver) + self._receive_route_table.bind(binding) + + def unbind_send_route(self, route_key: RouteKey, driver_id: Optional[str] = None) -> None: + """移除发送路由绑定。 + + Args: + route_key: 要移除绑定的路由键。 + driver_id: 可选的特定驱动 ID。 + """ + + self._send_route_table.unbind(route_key, driver_id) + + def unbind_receive_route(self, route_key: RouteKey, driver_id: Optional[str] = None) -> None: + """移除接收路由绑定。 + + Args: + route_key: 要移除绑定的路由键。 + driver_id: 可选的特定驱动 ID。 + """ + + self._receive_route_table.unbind(route_key, driver_id) + + def resolve_drivers(self, route_key: RouteKey) -> List[PlatformIODriver]: + """解析某个路由键当前命中的全部发送驱动。 + + Args: + route_key: 要解析的路由键。 + + Returns: + List[PlatformIODriver]: 当前命中的全部发送驱动。 + """ + + drivers: List[PlatformIODriver] = [] + seen_driver_ids: set[str] = set() + for binding in self._send_route_table.resolve_bindings(route_key): + driver = self._driver_registry.get(binding.driver_id) + if driver is not None and driver.driver_id not in seen_driver_ids: + drivers.append(driver) + seen_driver_ids.add(driver.driver_id) + + fallback_driver = self._legacy_send_drivers.get(route_key.platform) + if fallback_driver is not None: + descriptor = fallback_driver.descriptor + account_matches = descriptor.account_id is None or route_key.account_id in (None, descriptor.account_id) + scope_matches = descriptor.scope is None or route_key.scope in (None, descriptor.scope) + if account_matches and scope_matches and fallback_driver.driver_id not in seen_driver_ids: + drivers.append(fallback_driver) + + return drivers + + @staticmethod + def build_route_key_from_message(message: "SessionMessage") -> RouteKey: + """根据 ``SessionMessage`` 构造路由键。 + + Args: + message: 内部会话消息对象。 + + Returns: + RouteKey: 由消息内容提取出的规范化路由键。 + """ + return RouteKeyFactory.from_session_message(message) + + @staticmethod + def build_route_key_from_message_dict(message_dict: Dict[str, Any]) -> RouteKey: + """根据消息字典构造路由键。 + + Args: + message_dict: Host 与插件之间传输的消息字典。 + + Returns: + RouteKey: 由消息字典提取出的规范化路由键。 + """ + return RouteKeyFactory.from_message_dict(message_dict) + + async def accept_inbound(self, envelope: InboundMessageEnvelope) -> bool: + """处理一条由驱动上报的入站封装。 + + Args: + envelope: 由传输驱动产出的入站封装。 + + Returns: + bool: 若消息被接受并继续转发给入站分发器,则返回 ``True``, + 否则返回 ``False``。 + """ + + if not self._receive_route_table.has_binding_for_driver(envelope.route_key, envelope.driver_id): + logger.info( + f"忽略未登记到接收路由表的入站消息: route={envelope.route_key} " + f"driver={envelope.driver_id}" + ) + return False + + if self._inbound_dispatcher is None: + logger.debug("PlatformIOManager 尚未配置 inbound dispatcher,暂不继续分发") + return False + + dedupe_key = self._build_inbound_dedupe_key(envelope) + if dedupe_key is not None: + if not self._deduplicator.mark_seen(dedupe_key): + logger.info(f"忽略重复入站消息: dedupe_key={dedupe_key}") + return False + + await self._inbound_dispatcher(envelope) + return True + + async def send_message( + self, + message: "SessionMessage", + route_key: RouteKey, + metadata: Optional[Dict[str, Any]] = None, + ) -> DeliveryBatch: + """通过 Broker 选中的全部发送驱动广播一条消息。 + + Args: + message: 要投递的内部会话消息。 + route_key: 本次出站投递选择的路由键。 + metadata: 可选的额外 Broker 侧元数据。 + + Returns: + DeliveryBatch: 规范化后的批量出站回执。 + """ + drivers = self.resolve_drivers(route_key) + if not drivers: + return DeliveryBatch(internal_message_id=message.message_id, route_key=route_key) + + receipts: List[DeliveryReceipt] = [] + for driver in drivers: + try: + self._outbound_tracker.begin_tracking( + internal_message_id=message.message_id, + route_key=route_key, + driver_id=driver.driver_id, + metadata=metadata, + ) + except ValueError as exc: + receipts.append( + DeliveryReceipt( + internal_message_id=message.message_id, + route_key=route_key, + status=DeliveryStatus.FAILED, + driver_id=driver.driver_id, + driver_kind=driver.descriptor.kind, + error=str(exc), + ) + ) + continue + + try: + receipt = await driver.send_message(message=message, route_key=route_key, metadata=metadata) + except Exception as exc: + receipt = DeliveryReceipt( + internal_message_id=message.message_id, + route_key=route_key, + status=DeliveryStatus.FAILED, + driver_id=driver.driver_id, + driver_kind=driver.descriptor.kind, + error=str(exc), + ) + + self._outbound_tracker.finish_tracking(receipt) + receipts.append(receipt) + + return DeliveryBatch( + internal_message_id=message.message_id, + route_key=route_key, + receipts=receipts, + ) + + @staticmethod + def _build_inbound_dedupe_key(envelope: InboundMessageEnvelope) -> Optional[str]: + """构造用于入站抑制的去重键。 + + Args: + envelope: 当前正在处理的入站封装。 + + Returns: + Optional[str]: 若可以构造稳定去重键则返回该键,否则返回 ``None``。 + + Notes: + 这里仅接受上游显式提供的稳定消息身份,例如 ``dedupe_key``、 + 平台侧 ``external_message_id`` 或已经完成规范化的 + ``session_message.message_id``。Broker 不再根据 ``payload`` 内容 + 猜测语义去重键,避免把“短时间内两条内容刚好完全相同”的合法消息 + 误判为重复入站。 + """ + raw_dedupe_key = envelope.dedupe_key or envelope.external_message_id + if raw_dedupe_key is None and envelope.session_message is not None: + raw_dedupe_key = envelope.session_message.message_id + if raw_dedupe_key is None: + return None + + normalized_dedupe_key = str(raw_dedupe_key).strip() + if not normalized_dedupe_key: + return None + + return f"{envelope.driver_id}:{normalized_dedupe_key}" + + @staticmethod + def _validate_binding_against_driver(binding: RouteBinding, driver: PlatformIODriver) -> None: + """校验路由绑定与驱动描述是否一致。 + + Args: + binding: 待校验的路由绑定。 + driver: 被绑定的驱动实例。 + + Raises: + ValueError: 当绑定类型、平台或更细粒度路由维度与驱动描述冲突时抛出。 + """ + descriptor = driver.descriptor + if binding.driver_kind != descriptor.kind: + raise ValueError( + f"路由绑定的 driver_kind={binding.driver_kind} 与驱动 {driver.driver_id} 的类型 " + f"{descriptor.kind} 不一致" + ) + + if binding.route_key.platform != descriptor.platform: + raise ValueError( + f"路由绑定的平台 {binding.route_key.platform} 与驱动 {driver.driver_id} 的平台 " + f"{descriptor.platform} 不一致" + ) + + if descriptor.account_id is not None and binding.route_key.account_id not in (None, descriptor.account_id): + raise ValueError( + f"路由绑定的 account_id={binding.route_key.account_id} 与驱动 {driver.driver_id} 的 " + f"account_id={descriptor.account_id} 冲突" + ) + + if descriptor.scope is not None and binding.route_key.scope not in (None, descriptor.scope): + raise ValueError( + f"路由绑定的 scope={binding.route_key.scope} 与驱动 {driver.driver_id} 的 " + f"scope={descriptor.scope} 冲突" + ) + + +_platform_io_manager: Optional[PlatformIOManager] = None + + +def get_platform_io_manager() -> PlatformIOManager: + """返回全局 ``PlatformIOManager`` 单例。 + + Returns: + PlatformIOManager: 进程级共享的 Broker 管理器实例。 + """ + + global _platform_io_manager + if _platform_io_manager is None: + _platform_io_manager = PlatformIOManager() + return _platform_io_manager diff --git a/src/platform_io/outbound_tracker.py b/src/platform_io/outbound_tracker.py new file mode 100644 index 00000000..3725691f --- /dev/null +++ b/src/platform_io/outbound_tracker.py @@ -0,0 +1,286 @@ +"""跟踪 Platform IO 层的出站投递状态。 + +当前实现基于两组 ``dict + heapq``: +- ``_pending`` 和 ``_pending_expire_heap`` 负责管理待完成的出站记录 +- ``_receipts_by_external_id`` 和 ``_receipt_expire_heap`` 负责管理已完成回执索引 + +这样就不需要在每次读写时全表扫描过期项,而是通过懒清理逐步弹出已经过期 +或已经失效的堆节点。 +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +import heapq +import time + +from .types import DeliveryReceipt, RouteKey + + +@dataclass(slots=True) +class PendingOutboundRecord: + """表示一条仍在等待完成的出站投递记录。 + + Attributes: + internal_message_id: 正在跟踪的内部 ``SessionMessage.message_id``。 + route_key: 该出站投递开始时使用的路由键。 + driver_id: 负责这次出站投递的驱动 ID。 + created_at: 开始跟踪时记录的单调时钟时间戳。 + expires_at: 该待完成记录预计过期的单调时钟时间戳。 + metadata: 与待完成记录一同保留的额外 Broker 侧元数据。 + """ + + internal_message_id: str + route_key: RouteKey + driver_id: str + created_at: float = field(default_factory=time.monotonic) + expires_at: float = 0.0 + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class StoredDeliveryReceipt: + """表示一条已完成并暂存的出站回执。 + + Attributes: + receipt: 规范化后的出站投递回执。 + stored_at: 回执被写入索引时记录的单调时钟时间戳。 + expires_at: 该回执索引预计过期的单调时钟时间戳。 + """ + + receipt: DeliveryReceipt + stored_at: float = field(default_factory=time.monotonic) + expires_at: float = 0.0 + + +class OutboundTracker: + """统一跟踪出站消息的 pending 状态与最终回执。 + + 主要用于解决出站消息在发送过程中“状态散落在不同路径里”的问题: + - 发送开始后,需要在最终回执返回前保留一份 pending 状态 + - 平台返回 ``external_message_id`` 后,需要保留一段时间的回执索引 + + 当前实现使用 ``dict + heapq`` 做 TTL 管理: + - ``dict`` 提供 ``O(1)`` 级别的主键查询 + - ``heapq`` 提供按过期时间排序的懒清理能力 + + 这比“每次 begin/finish/get 都全表扫描”的实现更适合高吞吐出站场景。 + + Notes: + 复杂度说明如下,设 ``p`` 为当前有效 pending 数量,``r`` 为当前有效回执数量: + + - ``begin_tracking()``、``finish_tracking()`` 的常见路径时间复杂度接近 + ``O(log p)`` 或 ``O(log r)`` + - ``get_pending()``、``get_receipt_by_external_id()`` 的查询本身是 ``O(1)`` + ,连同懒清理一起看,长期摊还复杂度接近 ``O(log n)`` + - 如果某次调用恰好触发一批过期节点的集中清理,则该次调用的最坏时间复杂度 + 可达到 ``O(k log n)``,其中 ``k`` 为本次被弹出的节点数量 + - 空间复杂度为 ``O(p + r)`` + """ + + def __init__(self, ttl_seconds: float = 1800.0) -> None: + """初始化出站跟踪器。 + + Args: + ttl_seconds: 待完成记录与按外部消息 ID 建立的回执索引保留时长, + 单位为秒。 + + Raises: + ValueError: 当 ``ttl_seconds`` 非正数时抛出。 + """ + if ttl_seconds <= 0: + raise ValueError("ttl_seconds 必须大于 0") + + self._ttl_seconds = ttl_seconds + self._pending: Dict[Tuple[str, str], PendingOutboundRecord] = {} + self._pending_expire_heap: List[Tuple[float, str, str]] = [] + self._receipts_by_external_id: Dict[str, StoredDeliveryReceipt] = {} + self._receipt_expire_heap: List[Tuple[float, str]] = [] + + @staticmethod + def _build_pending_key(internal_message_id: str, driver_id: str) -> Tuple[str, str]: + """构造单条出站跟踪记录的唯一键。 + + Args: + internal_message_id: 内部消息 ID。 + driver_id: 负责当前投递的驱动 ID。 + + Returns: + Tuple[str, str]: ``(internal_message_id, driver_id)`` 组合键。 + """ + return internal_message_id, driver_id + + def begin_tracking( + self, + internal_message_id: str, + route_key: RouteKey, + driver_id: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> PendingOutboundRecord: + """开始跟踪一次出站投递。 + + Args: + internal_message_id: 正在投递的内部消息 ID。 + route_key: 这次出站投递选择的路由键。 + driver_id: 负责本次投递的驱动 ID。 + metadata: 可选的额外元数据,会一并保存在待完成记录中。 + + Returns: + PendingOutboundRecord: 新创建的待完成记录。 + + Raises: + ValueError: 当同一个 ``internal_message_id`` 与 ``driver_id`` 组合已经存在 + 未完成记录时抛出。 + """ + now = time.monotonic() + self._cleanup_expired(now) + pending_key = self._build_pending_key(internal_message_id, driver_id) + + if pending_key in self._pending: + raise ValueError(f"消息 {internal_message_id} 在驱动 {driver_id} 上已存在未完成的出站跟踪记录") + + expires_at = now + self._ttl_seconds + record = PendingOutboundRecord( + internal_message_id=internal_message_id, + route_key=route_key, + driver_id=driver_id, + created_at=now, + expires_at=expires_at, + metadata=metadata or {}, + ) + self._pending[pending_key] = record + heapq.heappush(self._pending_expire_heap, (expires_at, internal_message_id, driver_id)) + return record + + def finish_tracking(self, receipt: DeliveryReceipt) -> Optional[PendingOutboundRecord]: + """使用最终回执结束一条出站跟踪。 + + Args: + receipt: 规范化后的最终投递回执。 + + Returns: + Optional[PendingOutboundRecord]: 若此前存在待完成记录,则返回该记录。 + """ + now = time.monotonic() + self._cleanup_expired(now) + + pending_record: Optional[PendingOutboundRecord] = None + if receipt.driver_id: + pending_key = self._build_pending_key(receipt.internal_message_id, receipt.driver_id) + pending_record = self._pending.pop(pending_key, None) + else: + matched_records = [ + key + for key, record in self._pending.items() + if record.internal_message_id == receipt.internal_message_id + ] + if len(matched_records) == 1: + pending_record = self._pending.pop(matched_records[0], None) + + if receipt.external_message_id: + expires_at = now + self._ttl_seconds + self._receipts_by_external_id[receipt.external_message_id] = StoredDeliveryReceipt( + receipt=receipt, + stored_at=now, + expires_at=expires_at, + ) + heapq.heappush(self._receipt_expire_heap, (expires_at, receipt.external_message_id)) + return pending_record + + def get_pending( + self, + internal_message_id: str, + driver_id: Optional[str] = None, + ) -> Optional[PendingOutboundRecord]: + """根据内部消息 ID 查询待完成记录。 + + Args: + internal_message_id: 要查询的内部消息 ID。 + driver_id: 可选的驱动 ID;提供后仅返回该驱动上的待完成记录。 + + Returns: + Optional[PendingOutboundRecord]: 若记录仍存在,则返回对应待完成记录。 + """ + self._cleanup_expired(time.monotonic()) + + if driver_id: + return self._pending.get(self._build_pending_key(internal_message_id, driver_id)) + + matched_records = [ + record + for record in self._pending.values() + if record.internal_message_id == internal_message_id + ] + if len(matched_records) == 1: + return matched_records[0] + return None + + def get_receipt_by_external_id(self, external_message_id: str) -> Optional[DeliveryReceipt]: + """根据外部平台消息 ID 查询已完成回执。 + + Args: + external_message_id: 要查询的平台侧消息 ID。 + + Returns: + Optional[DeliveryReceipt]: 若存在对应回执,则返回该回执。 + """ + self._cleanup_expired(time.monotonic()) + stored_receipt = self._receipts_by_external_id.get(external_message_id) + return stored_receipt.receipt if stored_receipt else None + + def clear(self) -> None: + """清空全部待完成记录与已保存回执。""" + self._pending.clear() + self._pending_expire_heap.clear() + self._receipts_by_external_id.clear() + self._receipt_expire_heap.clear() + + def _cleanup_expired(self, now: float) -> None: + """清理内存中已经过期的待完成记录与已保存回执。 + + Args: + now: 当前单调时钟时间戳。 + """ + self._cleanup_expired_pending(now) + self._cleanup_expired_receipts(now) + + def _cleanup_expired_pending(self, now: float) -> None: + """清理已经过期的待完成记录。 + + Args: + now: 当前单调时钟时间戳。 + + Notes: + 堆中可能存在已经失效的旧节点。例如某条记录提前 ``finish`` 后, + 它原本的过期节点仍可能留在堆里。这里会通过和 ``dict`` 中当前记录的 + ``expires_at`` 对比,跳过这类旧节点。 + """ + while self._pending_expire_heap and self._pending_expire_heap[0][0] <= now: + expires_at, internal_message_id, driver_id = heapq.heappop(self._pending_expire_heap) + pending_key = self._build_pending_key(internal_message_id, driver_id) + current_record = self._pending.get(pending_key) + if current_record is None: + continue + if current_record.expires_at != expires_at: + continue + self._pending.pop(pending_key, None) + + def _cleanup_expired_receipts(self, now: float) -> None: + """清理已经过期的回执索引。 + + Args: + now: 当前单调时钟时间戳。 + + Notes: + 同一个 ``external_message_id`` 在极端情况下可能被重复写入索引, + 因此这里同样需要通过 ``expires_at`` 和当前 ``dict`` 中的值比对, + 跳过已经失效的旧堆节点。 + """ + while self._receipt_expire_heap and self._receipt_expire_heap[0][0] <= now: + expires_at, external_message_id = heapq.heappop(self._receipt_expire_heap) + current_receipt = self._receipts_by_external_id.get(external_message_id) + if current_receipt is None: + continue + if current_receipt.expires_at != expires_at: + continue + self._receipts_by_external_id.pop(external_message_id, None) diff --git a/src/platform_io/registry.py b/src/platform_io/registry.py new file mode 100644 index 00000000..9ad8ea8a --- /dev/null +++ b/src/platform_io/registry.py @@ -0,0 +1,70 @@ +"""提供 Platform IO 的驱动注册与查询能力。""" + +from typing import Dict, List, Optional + +from src.platform_io.drivers.base import PlatformIODriver +from src.platform_io.types import DriverKind + + +class DriverRegistry: + """集中保存已注册的 Platform IO 驱动,并提供基础查询接口。""" + + def __init__(self) -> None: + """初始化一个空的驱动注册表。""" + self._drivers: Dict[str, PlatformIODriver] = {} + + def register(self, driver: PlatformIODriver) -> None: + """注册一个驱动实例。 + + Args: + driver: 要注册的驱动实例。 + + Raises: + ValueError: 当驱动 ID 已经存在时抛出。 + """ + if driver.driver_id in self._drivers: + raise ValueError(f"驱动 {driver.driver_id} 已注册") + self._drivers[driver.driver_id] = driver + + def unregister(self, driver_id: str) -> Optional[PlatformIODriver]: + """按驱动 ID 注销一个驱动。 + + Args: + driver_id: 要移除的驱动 ID。 + + Returns: + Optional[PlatformIODriver]: 若驱动存在,则返回被移除的驱动实例。 + """ + return self._drivers.pop(driver_id, None) + + def get(self, driver_id: str) -> Optional[PlatformIODriver]: + """按驱动 ID 获取驱动实例。 + + Args: + driver_id: 要查询的驱动 ID。 + + Returns: + Optional[PlatformIODriver]: 若存在匹配驱动,则返回该驱动实例。 + """ + return self._drivers.get(driver_id) + + def list(self, *, kind: Optional[DriverKind] = None, platform: Optional[str] = None) -> List[PlatformIODriver]: + """列出已注册驱动,并支持可选过滤。 + + Args: + kind: 可选的驱动类型过滤条件。 + platform: 可选的平台名称过滤条件。 + + Returns: + List[PlatformIODriver]: 符合过滤条件的驱动列表。 + """ + drivers = list(self._drivers.values()) + if kind is not None: + drivers = [driver for driver in drivers if driver.descriptor.kind == kind] + if platform is not None: + drivers = [driver for driver in drivers if driver.descriptor.platform == platform] + return drivers + + def clear(self) -> None: + """清空全部已注册驱动。""" + self._drivers.clear() diff --git a/src/platform_io/route_key_factory.py b/src/platform_io/route_key_factory.py new file mode 100644 index 00000000..05bac6e8 --- /dev/null +++ b/src/platform_io/route_key_factory.py @@ -0,0 +1,150 @@ +"""提供 Platform IO 路由键的统一提取与构造能力。 + +这层的目标不是直接接入具体消息链,而是先把“未来接线时用什么字段构造 +RouteKey”约定下来,避免 legacy 和 plugin 两条链路各自发明一套隐式规则。 +""" + +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple + +from .types import RouteKey + +if TYPE_CHECKING: + from src.chat.message_receive.message import SessionMessage + + +class RouteKeyFactory: + """统一构造 ``RouteKey`` 的工厂。 + + 当前约定会优先从消息字典顶层、``message_info``、``additional_config`` 或传入 metadata 中提取 + 以下字段: + + - account_id: ``platform_io_account_id`` / ``account_id`` / ``self_id`` / ``bot_account`` + - scope: ``platform_io_scope`` / ``route_scope`` / ``adapter_scope`` / ``connection_id`` + + 这样即使上游主链暂时还没有正式的 ``self_id`` 字段,中间层也能先统一 + 约定提取口径,等具体消息链接入时直接复用。 + """ + + ACCOUNT_ID_KEYS = ( + "platform_io_account_id", + "account_id", + "self_id", + "bot_account", + ) + SCOPE_KEYS = ( + "platform_io_scope", + "route_scope", + "adapter_scope", + "connection_id", + ) + + @classmethod + def from_platform( + cls, + platform: str, + *, + account_id: Optional[str] = None, + scope: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> RouteKey: + """根据平台名和可选 metadata 构造 ``RouteKey``。 + + Args: + platform: 平台名称。 + account_id: 显式传入的账号 ID;若为空,则尝试从 metadata 提取。 + scope: 显式传入的路由作用域;若为空,则尝试从 metadata 提取。 + metadata: 可选的元数据字典。 + + Returns: + RouteKey: 构造出的规范化路由键。 + """ + extracted_account_id, extracted_scope = cls.extract_components(metadata) + return RouteKey( + platform=platform, + account_id=account_id or extracted_account_id, + scope=scope or extracted_scope, + ) + + @classmethod + def from_message_dict(cls, message_dict: Dict[str, Any]) -> RouteKey: + """从消息字典中提取 ``RouteKey``。 + + Args: + message_dict: Host 与插件之间传输的消息字典。 + + Returns: + RouteKey: 构造出的规范化路由键。 + + Raises: + ValueError: 当消息字典缺少有效 ``platform`` 字段时抛出。 + """ + platform = str(message_dict.get("platform") or "").strip() + if not platform: + raise ValueError("消息字典缺少有效的 platform 字段,无法构造 RouteKey") + + message_info = message_dict.get("message_info", {}) + additional_config = {} + if isinstance(message_info, dict): + raw_additional_config = message_info.get("additional_config", {}) + if isinstance(raw_additional_config, dict): + additional_config = raw_additional_config + + explicit_account_id, explicit_scope = cls.extract_components(message_dict) + message_info_account_id, message_info_scope = cls.extract_components(message_info) + metadata_account_id, metadata_scope = cls.extract_components(additional_config) + return RouteKey( + platform=platform, + account_id=explicit_account_id or message_info_account_id or metadata_account_id, + scope=explicit_scope or message_info_scope or metadata_scope, + ) + + @classmethod + def from_session_message(cls, message: "SessionMessage") -> RouteKey: + """从 ``SessionMessage`` 中提取 ``RouteKey``。 + + Args: + message: 内部会话消息对象。 + + Returns: + RouteKey: 构造出的规范化路由键。 + """ + additional_config = message.message_info.additional_config or {} + metadata = additional_config if isinstance(additional_config, dict) else {} + return cls.from_platform(message.platform, metadata=metadata) + + @classmethod + def extract_components(cls, mapping: Optional[Dict[str, Any]]) -> Tuple[Optional[str], Optional[str]]: + """从任意字典中提取 ``account_id`` 与 ``scope``。 + + Args: + mapping: 待提取的字典;若为空或不是字典,则返回空结果。 + + Returns: + Tuple[Optional[str], Optional[str]]: ``(account_id, scope)``。 + """ + if not mapping or not isinstance(mapping, dict): + return None, None + + account_id = cls._pick_string(mapping, cls.ACCOUNT_ID_KEYS) + scope = cls._pick_string(mapping, cls.SCOPE_KEYS) + return account_id, scope + + @staticmethod + def _pick_string(mapping: Dict[str, Any], keys: Tuple[str, ...]) -> Optional[str]: + """按优先级从字典里挑选第一个有效字符串。 + + Args: + mapping: 待查询的字典。 + keys: 按优先级排列的候选键名。 + + Returns: + Optional[str]: 第一个规范化后非空的字符串值;若不存在则返回 ``None``。 + """ + for key in keys: + value = mapping.get(key) + if value is None: + continue + normalized = str(value).strip() + if normalized: + return normalized + return None diff --git a/src/platform_io/routing.py b/src/platform_io/routing.py new file mode 100644 index 00000000..2a9b41ef --- /dev/null +++ b/src/platform_io/routing.py @@ -0,0 +1,141 @@ +"""提供 Platform IO 的轻量路由绑定表。""" + +from typing import Dict, List, Optional + +from .types import RouteBinding, RouteKey + + +class RouteTable: + """维护单张路由绑定表。 + + 该实现不负责裁决“唯一 owner”,只负责保存绑定,并按 + ``RouteKey.resolution_order()`` 解析出候选绑定列表。 + """ + + def __init__(self) -> None: + """初始化空路由绑定表。""" + + self._bindings: Dict[RouteKey, Dict[str, RouteBinding]] = {} + + def bind(self, binding: RouteBinding) -> None: + """注册或更新一条路由绑定。 + + Args: + binding: 要保存的路由绑定。 + """ + + self._bindings.setdefault(binding.route_key, {})[binding.driver_id] = binding + + def unbind(self, route_key: RouteKey, driver_id: Optional[str] = None) -> List[RouteBinding]: + """移除指定路由键上的绑定。 + + Args: + route_key: 要移除绑定的路由键。 + driver_id: 可选的驱动 ID;为空时移除该路由键下全部绑定。 + + Returns: + List[RouteBinding]: 被移除的绑定列表。 + """ + + binding_map = self._bindings.get(route_key) + if not binding_map: + return [] + + if driver_id is None: + removed = list(binding_map.values()) + self._bindings.pop(route_key, None) + return self._sort_bindings(removed) + + removed_binding = binding_map.pop(driver_id, None) + if not binding_map: + self._bindings.pop(route_key, None) + return [removed_binding] if removed_binding is not None else [] + + def remove_bindings_by_driver(self, driver_id: str) -> List[RouteBinding]: + """移除某个驱动在整张表上的全部绑定。 + + Args: + driver_id: 要移除绑定的驱动 ID。 + + Returns: + List[RouteBinding]: 被移除的绑定列表。 + """ + + removed_bindings: List[RouteBinding] = [] + empty_route_keys: List[RouteKey] = [] + for route_key, binding_map in self._bindings.items(): + removed_binding = binding_map.pop(driver_id, None) + if removed_binding is not None: + removed_bindings.append(removed_binding) + if not binding_map: + empty_route_keys.append(route_key) + + for route_key in empty_route_keys: + self._bindings.pop(route_key, None) + + return self._sort_bindings(removed_bindings) + + def list_bindings(self, route_key: Optional[RouteKey] = None) -> List[RouteBinding]: + """列出当前路由表中的绑定。 + + Args: + route_key: 可选的路由键过滤条件。 + + Returns: + List[RouteBinding]: 当前绑定列表。 + """ + + if route_key is None: + bindings: List[RouteBinding] = [] + for binding_map in self._bindings.values(): + bindings.extend(binding_map.values()) + return self._sort_bindings(bindings) + + binding_map = self._bindings.get(route_key, {}) + return self._sort_bindings(list(binding_map.values())) + + def resolve_bindings(self, route_key: RouteKey) -> List[RouteBinding]: + """按从具体到宽泛的顺序解析路由候选绑定。 + + Args: + route_key: 待解析的路由键。 + + Returns: + List[RouteBinding]: 去重后的候选绑定列表。 + """ + + resolved_bindings: List[RouteBinding] = [] + seen_driver_ids: set[str] = set() + for candidate_key in route_key.resolution_order(): + for binding in self.list_bindings(candidate_key): + if binding.driver_id in seen_driver_ids: + continue + seen_driver_ids.add(binding.driver_id) + resolved_bindings.append(binding) + return resolved_bindings + + def has_binding_for_driver(self, route_key: RouteKey, driver_id: str) -> bool: + """判断指定驱动是否在当前路由键解析结果中。 + + Args: + route_key: 待解析的路由键。 + driver_id: 目标驱动 ID。 + + Returns: + bool: 若驱动存在于解析结果中则返回 ``True``。 + """ + + return any(binding.driver_id == driver_id for binding in self.resolve_bindings(route_key)) + + @staticmethod + def _sort_bindings(bindings: List[RouteBinding]) -> List[RouteBinding]: + """按优先级降序排列绑定列表。 + + Args: + bindings: 待排序的绑定列表。 + + Returns: + List[RouteBinding]: 排序后的绑定列表。 + """ + + return sorted(bindings, key=lambda item: item.priority, reverse=True) diff --git a/src/platform_io/types.py b/src/platform_io/types.py new file mode 100644 index 00000000..200eca51 --- /dev/null +++ b/src/platform_io/types.py @@ -0,0 +1,264 @@ +"""定义 Platform IO 中间层共享的核心类型。 + +本模块放置路由、驱动、入站与出站等规范化数据结构,供 Broker +层在 legacy 适配器链路和 plugin 适配器链路之间复用。 +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +if TYPE_CHECKING: + from src.chat.message_receive.message import SessionMessage + + +class DriverKind(str, Enum): + """底层收发驱动类型枚举。""" + + LEGACY = "legacy" + PLUGIN = "plugin" + + +class DeliveryStatus(str, Enum): + """统一出站回执状态枚举。""" + + PENDING = "pending" + SENT = "sent" + FAILED = "failed" + DROPPED = "dropped" + + +@dataclass(frozen=True, slots=True) +class RouteKey: + """用于 Platform IO 路由决策的唯一键。 + + 路由解析会按照“从最具体到最宽泛”的顺序进行回退,这样同一平台 + 后续就能自然支持按账号、自定义 scope 等更细粒度的归属控制。 + + Attributes: + platform: 平台名称,例如 ``qq``。 + account_id: 机器人账号 ID 或 self ID,用于区分同平台多身份。 + scope: 额外路由作用域,预留给未来的连接实例、租户或子通道等维度。 + """ + + platform: str + account_id: Optional[str] = None + scope: Optional[str] = None + + def __post_init__(self) -> None: + """规范化并校验路由键字段。 + + Raises: + ValueError: 当 ``platform`` 规范化后为空时抛出。 + """ + platform = str(self.platform).strip() + account_id = str(self.account_id).strip() if self.account_id is not None else None + scope = str(self.scope).strip() if self.scope is not None else None + + if not platform: + raise ValueError("RouteKey.platform 不能为空") + + object.__setattr__(self, "platform", platform) + object.__setattr__(self, "account_id", account_id or None) + object.__setattr__(self, "scope", scope or None) + + def resolution_order(self) -> List["RouteKey"]: + """返回从最具体到最宽泛的路由匹配顺序。 + + Returns: + List[RouteKey]: 按回退优先级排序的候选路由键列表。 + """ + + keys: List[RouteKey] = [self] + + if self.account_id is not None and self.scope is not None: + keys.append(RouteKey(platform=self.platform, account_id=self.account_id, scope=None)) + keys.append(RouteKey(platform=self.platform, account_id=None, scope=self.scope)) + elif self.account_id is not None: + keys.append(RouteKey(platform=self.platform, account_id=None, scope=None)) + elif self.scope is not None: + keys.append(RouteKey(platform=self.platform, account_id=None, scope=None)) + + default_key = RouteKey(platform=self.platform, account_id=None, scope=None) + if default_key not in keys: + keys.append(default_key) + + return keys + + def to_dedupe_scope(self) -> str: + """生成跨驱动共享的去重作用域字符串。 + + Returns: + str: 用于入站消息去重的稳定文本作用域键。 + """ + + account_id = self.account_id or "*" + scope = self.scope or "*" + return f"{self.platform}:{account_id}:{scope}" + + +@dataclass(frozen=True, slots=True) +class DriverDescriptor: + """描述一个已注册的 Platform IO 驱动。 + + Attributes: + driver_id: Broker 层内全局唯一的驱动标识。 + kind: 驱动实现类型,例如 legacy 或 plugin。 + platform: 驱动负责的平台名称。 + account_id: 可选的账号 ID 或 self ID。 + scope: 可选的额外路由作用域。 + plugin_id: 当驱动来自插件适配器时,对应的插件 ID。 + metadata: 预留给路由策略或观测能力的额外驱动元数据。 + """ + + driver_id: str + kind: DriverKind + platform: str + account_id: Optional[str] = None + scope: Optional[str] = None + plugin_id: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + """规范化并校验驱动描述字段。 + + Raises: + ValueError: 当 ``driver_id`` 或 ``platform`` 规范化后为空时抛出。 + """ + driver_id = str(self.driver_id).strip() + platform = str(self.platform).strip() + plugin_id = str(self.plugin_id).strip() if self.plugin_id is not None else None + + if not driver_id: + raise ValueError("DriverDescriptor.driver_id 不能为空") + if not platform: + raise ValueError("DriverDescriptor.platform 不能为空") + + object.__setattr__(self, "driver_id", driver_id) + object.__setattr__(self, "platform", platform) + object.__setattr__(self, "plugin_id", plugin_id or None) + + @property + def route_key(self) -> RouteKey: + """构造该驱动默认代表的路由键。 + + Returns: + RouteKey: 当前驱动描述对应的规范化路由键。 + """ + return RouteKey(platform=self.platform, account_id=self.account_id, scope=self.scope) + + +@dataclass(frozen=True, slots=True) +class RouteBinding: + """表示一条从路由键到驱动的绑定关系。 + + Attributes: + route_key: 该绑定覆盖的路由键。 + driver_id: 拥有该路由的驱动 ID。 + driver_kind: 绑定驱动的类型。 + priority: 当同一路由键存在多条绑定时使用的相对优先级。 + metadata: 预留给未来路由策略的额外绑定元数据。 + """ + + route_key: RouteKey + driver_id: str + driver_kind: DriverKind + priority: int = 0 + metadata: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + """规范化并校验绑定字段。 + + Raises: + ValueError: 当 ``driver_id`` 规范化后为空时抛出。 + """ + driver_id = str(self.driver_id).strip() + if not driver_id: + raise ValueError("RouteBinding.driver_id 不能为空") + object.__setattr__(self, "driver_id", driver_id) + + +@dataclass(slots=True) +class InboundMessageEnvelope: + """封装一次由驱动产出的规范化入站消息。 + + Attributes: + route_key: 该入站消息解析出的路由键。 + driver_id: 产出该消息的驱动 ID。 + driver_kind: 产出该消息的驱动类型。 + external_message_id: 可选的平台侧消息 ID,用于去重。 + dedupe_key: 可选的显式去重键。当外部消息没有稳定 ``message_id`` 时, + 可由上游驱动提供稳定的技术性幂等键。若这里为空,中间层仅会继续 + 回退到 ``external_message_id`` 或 ``session_message.message_id``, + 不会再根据 ``payload`` 内容猜测语义去重键。 + session_message: 可选的、已经完成规范化的 ``SessionMessage`` 对象。 + payload: 可选的原始字典载荷,供延迟转换或调试使用。 + metadata: 额外入站元数据,例如连接信息或追踪上下文。 + """ + + route_key: RouteKey + driver_id: str + driver_kind: DriverKind + external_message_id: Optional[str] = None + dedupe_key: Optional[str] = None + session_message: Optional["SessionMessage"] = None + payload: Optional[Dict[str, Any]] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class DeliveryReceipt: + """表示一次出站投递尝试的统一结果。 + + Attributes: + internal_message_id: Broker 跟踪的内部 ``SessionMessage.message_id``。 + route_key: 本次投递使用的路由键。 + status: 规范化后的投递状态。 + driver_id: 实际处理该投递的驱动 ID,可为空。 + driver_kind: 实际处理该投递的驱动类型,可为空。 + external_message_id: 驱动或适配器返回的平台侧消息 ID,可为空。 + error: 投递失败时的错误信息,可为空。 + metadata: 预留给回执、时间戳或平台特有信息的额外元数据。 + """ + + internal_message_id: str + route_key: RouteKey + status: DeliveryStatus + driver_id: Optional[str] = None + driver_kind: Optional[DriverKind] = None + external_message_id: Optional[str] = None + error: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class DeliveryBatch: + """表示一次广播式出站投递的批量结果。 + + Attributes: + internal_message_id: 内部消息 ID。 + route_key: 本次投递使用的路由键。 + receipts: 各条路由的独立投递回执列表。 + """ + + internal_message_id: str + route_key: RouteKey + receipts: List[DeliveryReceipt] = field(default_factory=list) + + @property + def sent_receipts(self) -> List[DeliveryReceipt]: + """返回全部发送成功的回执。""" + + return [receipt for receipt in self.receipts if receipt.status == DeliveryStatus.SENT] + + @property + def failed_receipts(self) -> List[DeliveryReceipt]: + """返回全部发送失败的回执。""" + + return [receipt for receipt in self.receipts if receipt.status != DeliveryStatus.SENT] + + @property + def has_success(self) -> bool: + """返回当前批量投递是否至少命中一条成功回执。""" + + return bool(self.sent_receipts) diff --git a/src/plugin_runtime/__init__.py b/src/plugin_runtime/__init__.py new file mode 100644 index 00000000..85c9c3f7 --- /dev/null +++ b/src/plugin_runtime/__init__.py @@ -0,0 +1,27 @@ +"""插件运行时包 + +定义 Host ↔ Runner 子进程间传递的环境变量名称常量。 +这些环境变量用于子进程 IPC 通信,值在运行时动态生成。 +""" + +# Host 端在 spawn Runner 子进程时设置、Runner 端启动时读取的环境变量名 +ENV_IPC_ADDRESS = "MAIBOT_IPC_ADDRESS" +"""IPC 传输层监听地址(UDS socket 路径或 TCP host:port)""" + +ENV_SESSION_TOKEN = "MAIBOT_SESSION_TOKEN" +"""本次会话的认证令牌(每次 spawn / reload 重新生成)""" + +ENV_PLUGIN_DIRS = "MAIBOT_PLUGIN_DIRS" +"""Runner 需要加载的插件目录列表(os.pathsep 分隔)""" + +ENV_HOST_VERSION = "MAIBOT_HOST_VERSION" +"""Runner 读取的 Host 应用版本号,用于 manifest 兼容性校验""" + +ENV_EXTERNAL_PLUGIN_IDS = "MAIBOT_EXTERNAL_PLUGIN_IDS" +"""Runner 启动时可视为已满足的外部插件依赖版本映射(JSON 对象)""" + +ENV_BLOCKED_PLUGIN_REASONS = "MAIBOT_BLOCKED_PLUGIN_REASONS" +"""Runner 启动时收到的拒绝加载插件原因映射(JSON 对象)""" + +ENV_GLOBAL_CONFIG_SNAPSHOT = "MAIBOT_GLOBAL_CONFIG_SNAPSHOT" +"""Runner 启动时注入的全局配置快照(JSON 对象)""" diff --git a/src/plugin_runtime/capabilities/__init__.py b/src/plugin_runtime/capabilities/__init__.py new file mode 100644 index 00000000..9ee8b5f4 --- /dev/null +++ b/src/plugin_runtime/capabilities/__init__.py @@ -0,0 +1,11 @@ +from .components import RuntimeComponentCapabilityMixin +from .core import RuntimeCoreCapabilityMixin +from .data import RuntimeDataCapabilityMixin +from .render import RuntimeRenderCapabilityMixin + +__all__ = [ + "RuntimeComponentCapabilityMixin", + "RuntimeCoreCapabilityMixin", + "RuntimeDataCapabilityMixin", + "RuntimeRenderCapabilityMixin", +] diff --git a/src/plugin_runtime/capabilities/components.py b/src/plugin_runtime/capabilities/components.py new file mode 100644 index 00000000..a3caebf9 --- /dev/null +++ b/src/plugin_runtime/capabilities/components.py @@ -0,0 +1,866 @@ +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Protocol, Sequence + +from src.common.logger import get_logger + +logger = get_logger("plugin_runtime.integration") + +if TYPE_CHECKING: + from src.plugin_runtime.host.api_registry import APIEntry + from src.plugin_runtime.host.component_registry import ComponentEntry + from src.plugin_runtime.host.supervisor import PluginSupervisor + + +class _RuntimeComponentManagerProtocol(Protocol): + @property + def supervisors(self) -> List["PluginSupervisor"]: ... + + def _normalize_component_type(self, component_type: str) -> str: ... + + def _is_api_component_type(self, component_type: str) -> bool: ... + + def _serialize_api_entry(self, entry: "APIEntry") -> Dict[str, Any]: ... + + def _serialize_api_component_entry(self, entry: "APIEntry") -> Dict[str, Any]: ... + + def _is_api_visible_to_plugin(self, entry: "APIEntry", caller_plugin_id: str) -> bool: ... + + def _normalize_api_reference(self, api_name: str, version: str = "") -> tuple[str, str]: ... + + def _build_api_unavailable_error(self, entry: "APIEntry") -> str: ... + + def _collect_api_reference_matches( + self, + caller_plugin_id: str, + normalized_api_name: str, + normalized_version: str, + ) -> tuple[List[tuple["PluginSupervisor", "APIEntry"]], List[tuple["PluginSupervisor", "APIEntry"]], bool]: ... + + def _collect_api_toggle_reference_matches( + self, + normalized_name: str, + normalized_version: str, + ) -> List[tuple["PluginSupervisor", "APIEntry"]]: ... + + def _get_supervisor_for_plugin(self, plugin_id: str) -> Optional["PluginSupervisor"]: ... + + def _resolve_api_target( + self, + caller_plugin_id: str, + api_name: str, + version: str = "", + ) -> tuple[Optional["PluginSupervisor"], Optional["APIEntry"], Optional[str]]: ... + + def _resolve_api_toggle_target( + self, + name: str, + version: str = "", + ) -> tuple[Optional["PluginSupervisor"], Optional["APIEntry"], Optional[str]]: ... + + def _resolve_component_toggle_target( + self, name: str, component_type: str + ) -> tuple[Optional["ComponentEntry"], Optional[str]]: ... + + def _find_duplicate_plugin_ids(self, plugin_dirs: List[Path]) -> Dict[str, List[Path]]: ... + + def _iter_plugin_dirs(self) -> Iterable[Path]: ... + + async def load_plugin_globally(self, plugin_id: str, reason: str = "manual") -> bool: ... + + async def reload_plugins_globally(self, plugin_ids: Sequence[str], reason: str = "manual") -> bool: ... + + +class RuntimeComponentCapabilityMixin: + def _collect_api_reference_matches( + self: _RuntimeComponentManagerProtocol, + caller_plugin_id: str, + normalized_api_name: str, + normalized_version: str, + ) -> tuple[List[tuple["PluginSupervisor", "APIEntry"]], List[tuple["PluginSupervisor", "APIEntry"]], bool]: + """按 API 完整名或短名精确收集匹配项。 + + 该辅助方法用于兼容名字中本身包含 ``.`` 的 API。对于这类 API, + 不能简单按最后一个点号拆成 ``plugin_id.api_name``。 + + Args: + caller_plugin_id: 调用方插件 ID。 + normalized_api_name: 已规范化的 API 名称。 + normalized_version: 已规范化的版本号。 + + Returns: + tuple[List[tuple[PluginSupervisor, APIEntry]], List[tuple[PluginSupervisor, APIEntry]], bool]: + 依次为可见且启用的匹配项、可见但已禁用的匹配项、是否存在不可见匹配项。 + """ + + visible_enabled_matches: List[tuple["PluginSupervisor", "APIEntry"]] = [] + visible_disabled_matches: List[tuple["PluginSupervisor", "APIEntry"]] = [] + hidden_match_exists = False + + for supervisor in self.supervisors: + for entry in supervisor.api_registry.get_apis( + version=normalized_version, + enabled_only=False, + ): + if entry.name != normalized_api_name and entry.full_name != normalized_api_name: + continue + if self._is_api_visible_to_plugin(entry, caller_plugin_id): + if entry.enabled: + visible_enabled_matches.append((supervisor, entry)) + else: + visible_disabled_matches.append((supervisor, entry)) + else: + hidden_match_exists = True + + return visible_enabled_matches, visible_disabled_matches, hidden_match_exists + + def _collect_api_toggle_reference_matches( + self: _RuntimeComponentManagerProtocol, + normalized_name: str, + normalized_version: str, + ) -> List[tuple["PluginSupervisor", "APIEntry"]]: + """按 API 完整名或短名精确收集启停操作匹配项。 + + Args: + normalized_name: 已规范化的 API 名称。 + normalized_version: 已规范化的版本号。 + + Returns: + List[tuple[PluginSupervisor, APIEntry]]: 匹配到的 API 条目列表。 + """ + + matches: List[tuple["PluginSupervisor", "APIEntry"]] = [] + for supervisor in self.supervisors: + for entry in supervisor.api_registry.get_apis( + version=normalized_version, + enabled_only=False, + ): + if entry.name == normalized_name or entry.full_name == normalized_name: + matches.append((supervisor, entry)) + return matches + + @staticmethod + def _normalize_component_type(component_type: str) -> str: + """规范化组件类型名称。 + + Args: + component_type: 原始组件类型。 + + Returns: + str: 统一转为大写后的组件类型名。 + """ + + normalized_component_type = str(component_type or "").strip().upper() + if normalized_component_type == "ACTION": + return "TOOL" + return normalized_component_type + + @classmethod + def _is_api_component_type(cls, component_type: str) -> bool: + """判断组件类型是否为 API。 + + Args: + component_type: 原始组件类型。 + + Returns: + bool: 是否为 API 组件类型。 + """ + + return cls._normalize_component_type(component_type) == "API" + + @staticmethod + def _serialize_api_entry(entry: "APIEntry") -> Dict[str, Any]: + """将 API 组件条目序列化为能力返回值。 + + Args: + entry: API 组件条目。 + + Returns: + Dict[str, Any]: 适合通过能力层返回给插件的 API 元信息。 + """ + + return { + "name": entry.name, + "full_name": entry.full_name, + "plugin_id": entry.plugin_id, + "description": entry.description, + "version": entry.version, + "public": entry.public, + "enabled": entry.enabled, + "dynamic": entry.dynamic, + "offline_reason": entry.offline_reason, + "metadata": dict(entry.metadata), + } + + @classmethod + def _serialize_api_component_entry(cls, entry: "APIEntry") -> Dict[str, Any]: + """将 API 条目序列化为通用组件视图。 + + Args: + entry: API 组件条目。 + + Returns: + Dict[str, Any]: 适合 ``component.get_all_plugins`` 返回的组件结构。 + """ + + serialized_entry = cls._serialize_api_entry(entry) + return { + "name": serialized_entry["name"], + "full_name": serialized_entry["full_name"], + "type": "API", + "enabled": serialized_entry["enabled"], + "metadata": serialized_entry["metadata"], + } + + @staticmethod + def _is_api_visible_to_plugin(entry: "APIEntry", caller_plugin_id: str) -> bool: + """判断某个 API 是否对调用方可见。 + + Args: + entry: 目标 API 组件条目。 + caller_plugin_id: 调用方插件 ID。 + + Returns: + bool: 是否允许当前插件可见并调用。 + """ + + return entry.plugin_id == caller_plugin_id or entry.public + + @staticmethod + def _normalize_api_reference(api_name: str, version: str = "") -> tuple[str, str]: + """规范化 API 名称与版本参数。 + + 支持在 ``api_name`` 中直接携带 ``@version`` 后缀。 + """ + + normalized_api_name = str(api_name or "").strip() + normalized_version = str(version or "").strip() + if normalized_api_name and not normalized_version and "@" in normalized_api_name: + candidate_name, candidate_version = normalized_api_name.rsplit("@", 1) + candidate_name = candidate_name.strip() + candidate_version = candidate_version.strip() + if candidate_name and candidate_version: + normalized_api_name = candidate_name + normalized_version = candidate_version + return normalized_api_name, normalized_version + + @staticmethod + def _build_api_unavailable_error(entry: "APIEntry") -> str: + """构造 API 当前不可用时的错误信息。""" + + if entry.offline_reason: + return entry.offline_reason + return f"API {entry.registry_key} 当前不可用" + + def _resolve_api_target( + self: _RuntimeComponentManagerProtocol, + caller_plugin_id: str, + api_name: str, + version: str = "", + ) -> tuple[Optional["PluginSupervisor"], Optional["APIEntry"], Optional[str]]: + """解析 API 名称到唯一可调用的目标组件。 + + Args: + caller_plugin_id: 调用方插件 ID。 + api_name: API 名称,支持 ``plugin_id.api_name`` 或唯一短名。 + version: 可选的 API 版本。 + + Returns: + tuple[Optional[PluginSupervisor], Optional[APIEntry], Optional[str]]: + 解析成功时返回 ``(监督器, API 条目, None)``,失败时返回错误信息。 + """ + + normalized_api_name, normalized_version = self._normalize_api_reference(api_name, version) + if not normalized_api_name: + return None, None, "缺少必要参数 api_name" + + exact_visible_enabled_matches, exact_visible_disabled_matches, exact_hidden_match_exists = ( + self._collect_api_reference_matches(caller_plugin_id, normalized_api_name, normalized_version) + ) + if len(exact_visible_enabled_matches) == 1: + return exact_visible_enabled_matches[0][0], exact_visible_enabled_matches[0][1], None + if len(exact_visible_enabled_matches) > 1: + return None, None, f"API 名称不唯一: {normalized_api_name},请显式指定 version" + if exact_visible_disabled_matches: + if len(exact_visible_disabled_matches) == 1: + return None, None, self._build_api_unavailable_error(exact_visible_disabled_matches[0][1]) + return None, None, f"API {normalized_api_name} 存在多个已下线版本,请显式指定 version" + if exact_hidden_match_exists: + return None, None, f"API {normalized_api_name} 未公开,禁止跨插件调用" + + if "." in normalized_api_name: + target_plugin_id, target_api_name = normalized_api_name.rsplit(".", 1) + try: + supervisor = self._get_supervisor_for_plugin(target_plugin_id) + except RuntimeError as exc: + return None, None, str(exc) + + if supervisor is None: + return None, None, f"未找到 API 提供方插件: {target_plugin_id}" + + entries = supervisor.api_registry.get_apis( + plugin_id=target_plugin_id, + name=target_api_name, + version=normalized_version, + enabled_only=False, + ) + visible_enabled_entries = [ + entry for entry in entries if self._is_api_visible_to_plugin(entry, caller_plugin_id) and entry.enabled + ] + visible_disabled_entries = [ + entry + for entry in entries + if self._is_api_visible_to_plugin(entry, caller_plugin_id) and not entry.enabled + ] + if len(visible_enabled_entries) == 1: + return supervisor, visible_enabled_entries[0], None + if len(visible_enabled_entries) > 1: + return None, None, f"API {normalized_api_name} 存在多个版本,请显式指定 version" + if visible_disabled_entries: + if len(visible_disabled_entries) == 1: + return None, None, self._build_api_unavailable_error(visible_disabled_entries[0]) + return None, None, f"API {normalized_api_name} 存在多个已下线版本,请显式指定 version" + if any(not self._is_api_visible_to_plugin(entry, caller_plugin_id) for entry in entries): + return None, None, f"API {normalized_api_name} 未公开,禁止跨插件调用" + if normalized_version: + return None, None, f"未找到版本为 {normalized_version} 的 API: {normalized_api_name}" + return None, None, f"未找到 API: {normalized_api_name}" + + visible_enabled_matches: List[tuple["PluginSupervisor", "APIEntry"]] = [] + visible_disabled_matches: List[tuple["PluginSupervisor", "APIEntry"]] = [] + hidden_match_exists = False + for supervisor in self.supervisors: + for entry in supervisor.api_registry.get_apis( + name=normalized_api_name, + version=normalized_version, + enabled_only=False, + ): + if self._is_api_visible_to_plugin(entry, caller_plugin_id): + if entry.enabled: + visible_enabled_matches.append((supervisor, entry)) + else: + visible_disabled_matches.append((supervisor, entry)) + else: + hidden_match_exists = True + + if len(visible_enabled_matches) == 1: + return visible_enabled_matches[0][0], visible_enabled_matches[0][1], None + if len(visible_enabled_matches) > 1: + return None, None, f"API 名称不唯一: {normalized_api_name},请使用 plugin_id.api_name 或显式指定 version" + if visible_disabled_matches: + if len(visible_disabled_matches) == 1: + return None, None, self._build_api_unavailable_error(visible_disabled_matches[0][1]) + return None, None, f"API {normalized_api_name} 存在多个已下线版本,请使用 plugin_id.api_name@version" + if hidden_match_exists: + return None, None, f"API {normalized_api_name} 未公开,禁止跨插件调用" + if normalized_version: + return None, None, f"未找到版本为 {normalized_version} 的 API: {normalized_api_name}" + return None, None, f"未找到 API: {normalized_api_name}" + + def _resolve_api_toggle_target( + self: _RuntimeComponentManagerProtocol, + name: str, + version: str = "", + ) -> tuple[Optional["PluginSupervisor"], Optional["APIEntry"], Optional[str]]: + """解析需要启用或禁用的 API 组件。 + + Args: + name: API 名称,支持 ``plugin_id.api_name`` 或唯一短名。 + version: 可选的 API 版本。 + + Returns: + tuple[Optional[PluginSupervisor], Optional[APIEntry], Optional[str]]: + 解析成功时返回 ``(监督器, API 条目, None)``,失败时返回错误信息。 + """ + + normalized_name, normalized_version = self._normalize_api_reference(name, version) + if not normalized_name: + return None, None, "缺少必要参数 name" + + exact_matches = self._collect_api_toggle_reference_matches(normalized_name, normalized_version) + if len(exact_matches) == 1: + return exact_matches[0][0], exact_matches[0][1], None + if len(exact_matches) > 1: + return None, None, f"API 名称不唯一: {normalized_name},请显式指定 version" + + if "." in normalized_name: + plugin_id, api_name = normalized_name.rsplit(".", 1) + try: + supervisor = self._get_supervisor_for_plugin(plugin_id) + except RuntimeError as exc: + return None, None, str(exc) + + if supervisor is None: + return None, None, f"未找到 API 提供方插件: {plugin_id}" + + entries = supervisor.api_registry.get_apis( + plugin_id=plugin_id, + name=api_name, + version=normalized_version, + enabled_only=False, + ) + if len(entries) == 1: + return supervisor, entries[0], None + if entries: + return None, None, f"API {normalized_name} 存在多个版本,请显式指定 version" + return None, None, f"未找到 API: {normalized_name}" + + matches: List[tuple["PluginSupervisor", "APIEntry"]] = [] + for supervisor in self.supervisors: + matches.extend( + (supervisor, entry) + for entry in supervisor.api_registry.get_apis( + name=normalized_name, + version=normalized_version, + enabled_only=False, + ) + ) + + if len(matches) == 1: + return matches[0][0], matches[0][1], None + if len(matches) > 1: + return None, None, f"API 名称不唯一: {normalized_name},请使用 plugin_id.api_name 或显式指定 version" + return None, None, f"未找到 API: {normalized_name}" + + async def _cap_component_get_all_plugins( + self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] + ) -> Any: + result: Dict[str, Any] = {} + for sv in self.supervisors: + for pid, reg in sv._registered_plugins.items(): + if pid in result: + logger.error(f"检测到重复插件 ID {pid},component.get_all_plugins 结果已拒绝聚合") + return {"success": False, "error": f"检测到重复插件 ID: {pid}"} + comps = sv.component_registry.get_components_by_plugin(pid, enabled_only=False) + components_list = [ + { + "name": component.name, + "full_name": component.full_name, + "type": component.component_type, + "enabled": component.enabled, + "metadata": component.metadata, + } + for component in comps + ] + components_list.extend( + self._serialize_api_component_entry(entry) + for entry in sv.api_registry.get_apis(plugin_id=pid, enabled_only=False) + ) + result[pid] = { + "name": pid, + "version": reg.plugin_version, + "description": "", + "author": "", + "enabled": True, + "components": components_list, + } + return {"success": True, "plugins": result} + + async def _cap_component_get_plugin_info( + self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] + ) -> Any: + """获取指定插件的基础信息。 + + Args: + plugin_id: 当前调用方插件 ID。 + capability: 当前能力名称。 + args: 能力调用参数。 + + Returns: + Any: 插件基础信息响应。 + """ + + plugin_name: str = args.get("plugin_name", plugin_id) + try: + sv = self._get_supervisor_for_plugin(plugin_name) + except RuntimeError as exc: + return {"success": False, "error": str(exc)} + + if sv is not None and (reg := sv._registered_plugins.get(plugin_name)) is not None: + return { + "success": True, + "plugin": { + "name": plugin_name, + "version": reg.plugin_version, + "description": "", + "author": "", + "enabled": True, + "default_config": reg.default_config, + "config_schema": reg.config_schema, + }, + } + return {"success": False, "error": f"未找到插件: {plugin_name}"} + + async def _cap_component_get_plugin_config_schema( + self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] + ) -> Any: + """获取指定插件注册时上报的配置 Schema。 + + Args: + plugin_id: 当前调用方插件 ID。 + capability: 当前能力名称。 + args: 能力调用参数。 + + Returns: + Any: 包含配置 Schema 与默认配置的响应。 + """ + + plugin_name: str = args.get("plugin_name", plugin_id) + try: + sv = self._get_supervisor_for_plugin(plugin_name) + except RuntimeError as exc: + return {"success": False, "error": str(exc)} + + if sv is None: + return {"success": False, "error": f"未找到插件: {plugin_name}"} + + registration = sv._registered_plugins.get(plugin_name) + if registration is None: + return {"success": False, "error": f"未找到插件: {plugin_name}"} + + return { + "success": True, + "plugin_id": plugin_name, + "schema": registration.config_schema, + "default_config": registration.default_config, + } + + async def _cap_component_list_loaded_plugins( + self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] + ) -> Any: + plugins: List[str] = [] + for sv in self.supervisors: + plugins.extend(sv._registered_plugins.keys()) + return {"success": True, "plugins": plugins} + + async def _cap_component_list_registered_plugins( + self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] + ) -> Any: + plugins: List[str] = [] + for sv in self.supervisors: + plugins.extend(sv._registered_plugins.keys()) + return {"success": True, "plugins": plugins} + + def _resolve_component_toggle_target( + self: _RuntimeComponentManagerProtocol, name: str, component_type: str + ) -> tuple[Optional["ComponentEntry"], Optional[str]]: + normalized_component_type = self._normalize_component_type(component_type) + short_name_matches: List["ComponentEntry"] = [] + for sv in self.supervisors: + comp = sv.component_registry.get_component(name) + if comp is not None and comp.component_type == normalized_component_type: + return comp, None + + short_name_matches.extend( + candidate + for candidate in sv.component_registry.get_components_by_type( + normalized_component_type, + enabled_only=False, + ) + if candidate.name == name + ) + + if len(short_name_matches) == 1: + return short_name_matches[0], None + if len(short_name_matches) > 1: + return None, f"组件名不唯一: {name} ({normalized_component_type}),请使用完整名 plugin_id.component_name" + return None, f"未找到组件: {name} ({normalized_component_type})" + + async def _cap_component_enable( + self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] + ) -> Any: + name: str = args.get("name", "") + component_type: str = args.get("component_type", "") + version: str = args.get("version", "") + scope: str = args.get("scope", "global") + stream_id: str = args.get("stream_id", "") + if not name or not component_type: + return {"success": False, "error": "缺少必要参数 name 或 component_type"} + if scope != "global" or stream_id: + return {"success": False, "error": "当前仅支持全局组件启用,不支持 scope/stream_id 定位"} + + if self._is_api_component_type(component_type): + supervisor, api_entry, error = self._resolve_api_toggle_target(name, version) + if supervisor is None or api_entry is None: + return {"success": False, "error": error or f"未找到 API: {name}"} + supervisor.api_registry.toggle_api_status(api_entry.registry_key, True) + return {"success": True} + + comp, error = self._resolve_component_toggle_target(name, component_type) + if comp is None: + return {"success": False, "error": error or f"未找到组件: {name} ({component_type})"} + + comp.enabled = True + return {"success": True} + + async def _cap_component_disable( + self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] + ) -> Any: + name: str = args.get("name", "") + component_type: str = args.get("component_type", "") + version: str = args.get("version", "") + scope: str = args.get("scope", "global") + stream_id: str = args.get("stream_id", "") + if not name or not component_type: + return {"success": False, "error": "缺少必要参数 name 或 component_type"} + if scope != "global" or stream_id: + return {"success": False, "error": "当前仅支持全局组件禁用,不支持 scope/stream_id 定位"} + + if self._is_api_component_type(component_type): + supervisor, api_entry, error = self._resolve_api_toggle_target(name, version) + if supervisor is None or api_entry is None: + return {"success": False, "error": error or f"未找到 API: {name}"} + supervisor.api_registry.toggle_api_status(api_entry.registry_key, False) + return {"success": True} + + comp, error = self._resolve_component_toggle_target(name, component_type) + if comp is None: + return {"success": False, "error": error or f"未找到组件: {name} ({component_type})"} + + comp.enabled = False + return {"success": True} + + async def _cap_component_load_plugin( + self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] + ) -> Any: + plugin_name: str = args.get("plugin_name", "") + if not plugin_name: + return {"success": False, "error": "缺少必要参数 plugin_name"} + + if duplicate_plugin_ids := self._find_duplicate_plugin_ids(list(self._iter_plugin_dirs())): + details = "; ".join( + f"{conflict_plugin_id}: {', '.join(str(path) for path in paths)}" + for conflict_plugin_id, paths in sorted(duplicate_plugin_ids.items()) + ) + return {"success": False, "error": f"检测到重复插件 ID,拒绝热重载: {details}"} + + try: + loaded = await self.load_plugin_globally(plugin_name, reason=f"load {plugin_name}") + except Exception as e: + logger.error(f"[cap.component.load_plugin] 热重载失败: {e}") + return {"success": False, "error": str(e)} + + if loaded: + return {"success": True, "count": 1} + return {"success": False, "error": f"插件 {plugin_name} 热重载失败"} + + async def _cap_component_unload_plugin( + self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] + ) -> Any: + return {"success": False, "error": "新运行时不支持单独卸载插件,请使用 reload"} + + async def _cap_component_reload_plugin( + self: _RuntimeComponentManagerProtocol, plugin_id: str, capability: str, args: Dict[str, Any] + ) -> Any: + plugin_name: str = args.get("plugin_name", "") + if not plugin_name: + return {"success": False, "error": "缺少必要参数 plugin_name"} + + if duplicate_plugin_ids := self._find_duplicate_plugin_ids(list(self._iter_plugin_dirs())): + details = "; ".join( + f"{conflict_plugin_id}: {', '.join(str(path) for path in paths)}" + for conflict_plugin_id, paths in sorted(duplicate_plugin_ids.items()) + ) + return {"success": False, "error": f"检测到重复插件 ID,拒绝热重载: {details}"} + + try: + reloaded = await self.reload_plugins_globally([plugin_name], reason=f"reload {plugin_name}") + except Exception as e: + logger.error(f"[cap.component.reload_plugin] 热重载失败: {e}") + return {"success": False, "error": str(e)} + + if reloaded: + return {"success": True} + return {"success": False, "error": f"插件 {plugin_name} 热重载失败"} + + async def _cap_api_call( + self: _RuntimeComponentManagerProtocol, + plugin_id: str, + capability: str, + args: Dict[str, Any], + ) -> Any: + """调用其他插件公开的 API。 + + Args: + plugin_id: 当前调用方插件 ID。 + capability: 能力名称。 + args: 能力参数。 + + Returns: + Any: API 调用结果。 + """ + + del capability + api_name = str(args.get("api_name", "") or "").strip() + version = str(args.get("version", "") or "").strip() + api_args = args.get("args", {}) + if not isinstance(api_args, dict): + return {"success": False, "error": "参数 args 必须为字典"} + + supervisor, entry, error = self._resolve_api_target(plugin_id, api_name, version) + if supervisor is None or entry is None: + return {"success": False, "error": error or "API 解析失败"} + + invoke_args = dict(api_args) + if entry.dynamic: + invoke_args.setdefault("__maibot_api_name__", entry.name) + invoke_args.setdefault("__maibot_api_full_name__", entry.full_name) + invoke_args.setdefault("__maibot_api_version__", entry.version) + + try: + response = await supervisor.invoke_api( + plugin_id=entry.plugin_id, + component_name=entry.handler_name, + args=invoke_args, + timeout_ms=30000, + ) + except Exception as exc: + logger.error(f"[cap.api.call] 调用 API {entry.full_name} 失败: {exc}", exc_info=True) + return {"success": False, "error": str(exc)} + + if response.error: + return {"success": False, "error": response.error.get("message", "API 调用失败")} + + payload = response.payload if isinstance(response.payload, dict) else {} + if not bool(payload.get("success", False)): + result = payload.get("result") + return {"success": False, "error": "" if result is None else str(result)} + return {"success": True, "result": payload.get("result")} + + async def _cap_api_get( + self: _RuntimeComponentManagerProtocol, + plugin_id: str, + capability: str, + args: Dict[str, Any], + ) -> Any: + """获取当前插件可见的单个 API 元信息。 + + Args: + plugin_id: 当前调用方插件 ID。 + capability: 能力名称。 + args: 能力参数。 + + Returns: + Any: API 元信息或 ``None``。 + """ + + del capability + api_name = str(args.get("api_name", "") or "").strip() + version = str(args.get("version", "") or "").strip() + if not api_name: + return {"success": False, "error": "缺少必要参数 api_name"} + + supervisor, entry, _error = self._resolve_api_target(plugin_id, api_name, version) + if supervisor is None or entry is None: + return {"success": True, "api": None} + return {"success": True, "api": self._serialize_api_entry(entry)} + + async def _cap_api_list( + self: _RuntimeComponentManagerProtocol, + plugin_id: str, + capability: str, + args: Dict[str, Any], + ) -> Any: + """列出当前插件可见的 API 列表。 + + Args: + plugin_id: 当前调用方插件 ID。 + capability: 能力名称。 + args: 能力参数。 + + Returns: + Any: API 元信息列表。 + """ + + del capability + target_plugin_id = str(args.get("plugin_id", "") or "").strip() + api_name, version = self._normalize_api_reference( + str(args.get("api_name", args.get("name", "")) or ""), + str(args.get("version", "") or ""), + ) + apis: List[Dict[str, Any]] = [] + for supervisor in self.supervisors: + apis.extend( + self._serialize_api_entry(entry) + for entry in supervisor.api_registry.get_apis( + plugin_id=target_plugin_id or None, + name=api_name, + version=version, + enabled_only=True, + ) + if self._is_api_visible_to_plugin(entry, plugin_id) + ) + + apis.sort(key=lambda item: (str(item["plugin_id"]), str(item["name"]), str(item["version"]))) + return {"success": True, "apis": apis} + + async def _cap_api_replace_dynamic( + self: _RuntimeComponentManagerProtocol, + plugin_id: str, + capability: str, + args: Dict[str, Any], + ) -> Any: + """替换插件自行维护的动态 API 列表。""" + + del capability + raw_apis = args.get("apis", []) + offline_reason = str(args.get("offline_reason", "") or "").strip() or "动态 API 已下线" + if not isinstance(raw_apis, list): + return {"success": False, "error": "参数 apis 必须为列表"} + + try: + supervisor = self._get_supervisor_for_plugin(plugin_id) + except RuntimeError as exc: + return {"success": False, "error": str(exc)} + + if supervisor is None: + return {"success": False, "error": f"未找到插件: {plugin_id}"} + + normalized_components: List[Dict[str, Any]] = [] + seen_registry_keys: set[str] = set() + for index, raw_api in enumerate(raw_apis): + if not isinstance(raw_api, dict): + return {"success": False, "error": f"apis[{index}] 必须为字典"} + + api_name = str(raw_api.get("name", "") or "").strip() + component_type = str(raw_api.get("component_type", raw_api.get("type", "API")) or "").strip() + if not api_name: + return {"success": False, "error": f"apis[{index}] 缺少 name"} + if not self._is_api_component_type(component_type): + return {"success": False, "error": f"apis[{index}] 不是 API 组件"} + + metadata = raw_api.get("metadata", {}) if isinstance(raw_api.get("metadata"), dict) else {} + normalized_metadata = dict(metadata) + normalized_metadata["dynamic"] = True + version = str(normalized_metadata.get("version", "1") or "1").strip() or "1" + registry_key = supervisor.api_registry.build_registry_key(plugin_id, api_name, version) + if registry_key in seen_registry_keys: + return {"success": False, "error": f"动态 API 重复声明: {registry_key}"} + seen_registry_keys.add(registry_key) + + existing_entry = supervisor.api_registry.get_api( + plugin_id, + api_name, + version=version, + enabled_only=False, + ) + if existing_entry is not None and not existing_entry.dynamic: + return {"success": False, "error": f"动态 API 不能覆盖静态 API: {registry_key}"} + + normalized_components.append( + { + "name": api_name, + "component_type": "API", + "metadata": normalized_metadata, + } + ) + + registered_count, offlined_count = supervisor.api_registry.replace_plugin_dynamic_apis( + plugin_id, + normalized_components, + offline_reason=offline_reason, + ) + return { + "success": True, + "count": registered_count, + "offlined": offlined_count, + } diff --git a/src/plugin_runtime/capabilities/core.py b/src/plugin_runtime/capabilities/core.py new file mode 100644 index 00000000..f445b4fc --- /dev/null +++ b/src/plugin_runtime/capabilities/core.py @@ -0,0 +1,403 @@ +from typing import Any, Dict, List + +from src.common.logger import get_logger +from src.config.config import global_config + +logger = get_logger("plugin_runtime.integration") + + +def _get_nested_config_value(source: Any, key: str, default: Any = None) -> Any: + """从嵌套对象或字典中读取配置值。 + + Args: + source: 配置对象或字典。 + key: 以点号分隔的路径。 + default: 未命中时返回的默认值。 + + Returns: + Any: 命中的值;读取失败时返回默认值。 + """ + current = source + try: + for part in key.split("."): + if isinstance(current, dict) and part in current: + current = current[part] + continue + if hasattr(current, part): + current = getattr(current, part) + continue + raise KeyError(part) + return current + except Exception: + return default + + +def _normalize_prompt_arg(prompt: Any) -> str | List[Dict[str, Any]]: + """校验并规范化插件传入的提示参数。 + + Args: + prompt: 原始提示参数。 + + Returns: + str | List[Dict[str, Any]]: 规范化后的提示输入。 + + Raises: + ValueError: 提示参数缺失或结构不受支持时抛出。 + """ + if isinstance(prompt, str): + if not prompt.strip(): + raise ValueError("缺少必要参数 prompt") + return prompt + if isinstance(prompt, list) and prompt: + for index, prompt_message in enumerate(prompt, start=1): + if not isinstance(prompt_message, dict): + raise ValueError(f"prompt 第 {index} 项必须为字典") + return prompt + raise ValueError("缺少必要参数 prompt") + + +class RuntimeCoreCapabilityMixin: + """插件运行时的核心能力混入。""" + + async def _cap_send_text(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + """向指定流发送文本消息。 + + Args: + plugin_id: 插件标识。 + capability: 能力名称。 + args: 能力调用参数。 + + Returns: + Any: 能力执行结果。 + """ + del plugin_id, capability + from src.services import send_service as send_api + + text = str(args.get("text", "")) + stream_id = str(args.get("stream_id", "")) + sync_to_maisaka_history = bool(args.get("sync_to_maisaka_history", False)) + maisaka_source_kind = str(args.get("maisaka_source_kind", "plugin_send") or "plugin_send") + if not text or not stream_id: + return {"success": False, "error": "缺少必要参数 text 或 stream_id"} + + try: + result = await send_api.text_to_stream( + text=text, + stream_id=stream_id, + typing=bool(args.get("typing", False)), + set_reply=bool(args.get("set_reply", False)), + storage_message=bool(args.get("storage_message", True)), + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) + return {"success": result} + except Exception as exc: + logger.error(f"[cap.send.text] 执行失败: {exc}", exc_info=True) + return {"success": False, "error": str(exc)} + + async def _cap_send_emoji(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + """向指定流发送表情图片。 + + Args: + plugin_id: 插件标识。 + capability: 能力名称。 + args: 能力调用参数。 + + Returns: + Any: 能力执行结果。 + """ + del plugin_id, capability + from src.services import send_service as send_api + + emoji_base64 = str(args.get("emoji_base64", "")) + stream_id = str(args.get("stream_id", "")) + sync_to_maisaka_history = bool(args.get("sync_to_maisaka_history", False)) + maisaka_source_kind = str(args.get("maisaka_source_kind", "plugin_send") or "plugin_send") + if not emoji_base64 or not stream_id: + return {"success": False, "error": "缺少必要参数 emoji_base64 或 stream_id"} + + try: + result = await send_api.emoji_to_stream( + emoji_base64=emoji_base64, + stream_id=stream_id, + storage_message=bool(args.get("storage_message", True)), + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) + return {"success": result} + except Exception as exc: + logger.error(f"[cap.send.emoji] 执行失败: {exc}", exc_info=True) + return {"success": False, "error": str(exc)} + + async def _cap_send_image(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + """向指定流发送图片。 + + Args: + plugin_id: 插件标识。 + capability: 能力名称。 + args: 能力调用参数。 + + Returns: + Any: 能力执行结果。 + """ + del plugin_id, capability + from src.services import send_service as send_api + + image_base64 = str(args.get("image_base64", "")) + stream_id = str(args.get("stream_id", "")) + sync_to_maisaka_history = bool(args.get("sync_to_maisaka_history", False)) + maisaka_source_kind = str(args.get("maisaka_source_kind", "plugin_send") or "plugin_send") + if not image_base64 or not stream_id: + return {"success": False, "error": "缺少必要参数 image_base64 或 stream_id"} + + try: + result = await send_api.image_to_stream( + image_base64=image_base64, + stream_id=stream_id, + storage_message=bool(args.get("storage_message", True)), + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) + return {"success": result} + except Exception as exc: + logger.error(f"[cap.send.image] 执行失败: {exc}", exc_info=True) + return {"success": False, "error": str(exc)} + + async def _cap_send_command(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + """向指定流发送命令消息。 + + Args: + plugin_id: 插件标识。 + capability: 能力名称。 + args: 能力调用参数。 + + Returns: + Any: 能力执行结果。 + """ + del plugin_id, capability + from src.services import send_service as send_api + + command = str(args.get("command", "")) + stream_id = str(args.get("stream_id", "")) + sync_to_maisaka_history = bool(args.get("sync_to_maisaka_history", False)) + maisaka_source_kind = str(args.get("maisaka_source_kind", "plugin_send") or "plugin_send") + if not command or not stream_id: + return {"success": False, "error": "缺少必要参数 command 或 stream_id"} + + try: + result = await send_api.custom_to_stream( + message_type="command", + content=command, + stream_id=stream_id, + storage_message=bool(args.get("storage_message", True)), + processed_plain_text=str(args.get("processed_plain_text", "")), + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) + return {"success": result} + except Exception as exc: + logger.error(f"[cap.send.command] 执行失败: {exc}", exc_info=True) + return {"success": False, "error": str(exc)} + + async def _cap_send_custom(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + """向指定流发送自定义消息。 + + Args: + plugin_id: 插件标识。 + capability: 能力名称。 + args: 能力调用参数。 + + Returns: + Any: 能力执行结果。 + """ + del plugin_id, capability + from src.services import send_service as send_api + + message_type = str(args.get("message_type", "") or args.get("custom_type", "")) + content = args.get("content") + if content is None: + content = args.get("data", "") + stream_id = str(args.get("stream_id", "")) + sync_to_maisaka_history = bool(args.get("sync_to_maisaka_history", False)) + maisaka_source_kind = str(args.get("maisaka_source_kind", "plugin_send") or "plugin_send") + if not message_type or not stream_id: + return {"success": False, "error": "缺少必要参数 message_type 或 stream_id"} + + try: + result = await send_api.custom_to_stream( + message_type=message_type, + content=content, + stream_id=stream_id, + processed_plain_text=str(args.get("processed_plain_text", "")), + typing=bool(args.get("typing", False)), + storage_message=bool(args.get("storage_message", True)), + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) + return {"success": result} + except Exception as exc: + logger.error(f"[cap.send.custom] 执行失败: {exc}", exc_info=True) + return {"success": False, "error": str(exc)} + + async def _cap_llm_generate(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + """执行无工具的 LLM 生成能力。 + + Args: + plugin_id: 插件标识。 + capability: 能力名称。 + args: 能力调用参数。 + + Returns: + Any: 标准化后的 LLM 响应结构。 + """ + del capability + from src.services import llm_service as llm_api + + try: + prompt = _normalize_prompt_arg(args.get("prompt")) + task_name = llm_api.resolve_task_name(str(args.get("model", "") or args.get("model_name", ""))) + result = await llm_api.generate( + llm_api.LLMServiceRequest( + task_name=task_name, + request_type=f"plugin.{plugin_id}", + prompt=prompt, + temperature=args.get("temperature"), + max_tokens=args.get("max_tokens"), + ) + ) + return result.to_capability_payload() + except Exception as exc: + logger.error(f"[cap.llm.generate] 执行失败: {exc}", exc_info=True) + return {"success": False, "error": str(exc)} + + async def _cap_llm_generate_with_tools(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + """执行带工具的 LLM 生成能力。 + + Args: + plugin_id: 插件标识。 + capability: 能力名称。 + args: 能力调用参数。 + + Returns: + Any: 标准化后的 LLM 响应结构。 + """ + del capability + from src.services import llm_service as llm_api + + tool_options = args.get("tools") or args.get("tool_options") + if tool_options is not None and not isinstance(tool_options, list): + return {"success": False, "error": "tools 必须为列表"} + + try: + prompt = _normalize_prompt_arg(args.get("prompt")) + task_name = llm_api.resolve_task_name(str(args.get("model", "") or args.get("model_name", ""))) + result = await llm_api.generate( + llm_api.LLMServiceRequest( + task_name=task_name, + request_type=f"plugin.{plugin_id}", + prompt=prompt, + tool_options=tool_options, + temperature=args.get("temperature"), + max_tokens=args.get("max_tokens"), + ) + ) + return result.to_capability_payload() + except Exception as exc: + logger.error(f"[cap.llm.generate_with_tools] 执行失败: {exc}", exc_info=True) + return {"success": False, "error": str(exc)} + + async def _cap_llm_get_available_models(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + """获取当前宿主可用的模型任务列表。 + + Args: + plugin_id: 插件标识。 + capability: 能力名称。 + args: 能力调用参数。 + + Returns: + Any: 可用模型列表。 + """ + del plugin_id, capability, args + from src.services import llm_service as llm_api + + try: + models = llm_api.get_available_models() + return {"success": True, "models": list(models.keys())} + except Exception as exc: + logger.error(f"[cap.llm.get_available_models] 执行失败: {exc}", exc_info=True) + return {"success": False, "error": str(exc)} + + async def _cap_config_get(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + """读取宿主全局配置中的单个字段。 + + Args: + plugin_id: 插件标识。 + capability: 能力名称。 + args: 能力调用参数。 + + Returns: + Any: 配置读取结果。 + """ + del plugin_id, capability + key = str(args.get("key", "")) + default = args.get("default") + if not key: + return {"success": False, "value": None, "error": "缺少必要参数 key"} + + try: + value = _get_nested_config_value(global_config, key, default) + return {"success": True, "value": value} + except Exception as exc: + return {"success": False, "value": None, "error": str(exc)} + + async def _cap_config_get_plugin(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + """读取指定插件的配置。 + + Args: + plugin_id: 当前插件标识。 + capability: 能力名称。 + args: 能力调用参数。 + + Returns: + Any: 配置读取结果。 + """ + del capability + from src.plugin_runtime.component_query import component_query_service + + plugin_name = str(args.get("plugin_name", plugin_id)) + key = str(args.get("key", "")) + default = args.get("default") + + try: + config = component_query_service.get_plugin_config(plugin_name) + if config is None: + return {"success": False, "value": default, "error": f"未找到插件 {plugin_name} 的配置"} + if key: + value = _get_nested_config_value(config, key, default) + return {"success": True, "value": value} + return {"success": True, "value": config} + except Exception as exc: + return {"success": False, "value": default, "error": str(exc)} + + async def _cap_config_get_all(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + """读取指定插件的全部配置。 + + Args: + plugin_id: 当前插件标识。 + capability: 能力名称。 + args: 能力调用参数。 + + Returns: + Any: 配置读取结果。 + """ + del capability + from src.plugin_runtime.component_query import component_query_service + + plugin_name = str(args.get("plugin_name", plugin_id)) + try: + config = component_query_service.get_plugin_config(plugin_name) + if config is None: + return {"success": True, "value": {}} + return {"success": True, "value": config} + except Exception as exc: + return {"success": False, "value": {}, "error": str(exc)} diff --git a/src/plugin_runtime/capabilities/data.py b/src/plugin_runtime/capabilities/data.py new file mode 100644 index 00000000..8762ace4 --- /dev/null +++ b/src/plugin_runtime/capabilities/data.py @@ -0,0 +1,804 @@ +from pathlib import Path +from typing import Any, Dict, List, Optional + +import random +import time + +from src.chat.message_receive.chat_manager import BotChatSession, chat_manager +from src.common.data_models.image_data_model import MaiEmoji +from src.common.logger import get_logger +from src.common.utils.utils_image import ImageUtils +from src.plugin_runtime.host.message_utils import PluginMessageUtils + +logger = get_logger("plugin_runtime.integration") + + +class RuntimeDataCapabilityMixin: + @staticmethod + def _serialize_emoji_payload(emoji: MaiEmoji) -> Optional[Dict[str, str]]: + emoji_base64 = ImageUtils.image_path_to_base64(str(emoji.full_path)) + if not emoji_base64: + return None + + matched_emotion = RuntimeDataCapabilityMixin._normalize_emoji_tags(emoji) + return { + "base64": emoji_base64, + "description": emoji.description, + "emotion": matched_emotion, + } + + + @staticmethod + def _normalize_emoji_tag_text(raw_value: Any) -> List[str]: + """将文本或标签列表转为去重情绪标签列表。""" + if raw_value is None: + return [] + if isinstance(raw_value, list): + values = raw_value + else: + values = [raw_value] + + tags: List[str] = [] + for value in values: + raw_text = str(value) if value is not None else "" + if not raw_text: + continue + tags.extend( + item.strip() for item in raw_text.replace(",", ",").replace("、", ",").replace(";", ",").split(",") + ) + + deduped_tags: List[str] = [] + for tag in tags: + tag_text = str(tag).strip() + if not tag_text: + continue + if tag_text not in deduped_tags: + deduped_tags.append(tag_text) + return deduped_tags + + @staticmethod + def _normalize_emoji_tags(emoji: MaiEmoji) -> str: + """从表情包对象提取兼容旧数据的情绪标签文本。""" + tags = RuntimeDataCapabilityMixin._normalize_emoji_tag_text(emoji.description or emoji.emotion) + return tags[0] if tags else "" + + @staticmethod + def _build_emoji_temp_path() -> Path: + from src.emoji_system.emoji_manager import EMOJI_DIR + + EMOJI_DIR.mkdir(parents=True, exist_ok=True) + return EMOJI_DIR / f"emoji_cap_{int(time.time() * 1000000)}.png" + + async def _cap_database_query(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.services import database_service + + model_name: str = args.get("model_name", "") + if not model_name: + return {"success": False, "error": "缺少必要参数 model_name"} + + try: + import src.common.database.database_model as db_models + + model_class = getattr(db_models, model_name, None) + if model_class is None: + return {"success": False, "error": f"未找到数据模型: {model_name}"} + + query_type = args.get("query_type", "get") + if query_type == "get": + result = await database_service.db_get( + model_class=model_class, + filters=args.get("filters"), + limit=args.get("limit"), + order_by=args.get("order_by"), + single_result=args.get("single_result", False), + ) + elif query_type == "create": + if not (data := args.get("data")): + return {"success": False, "error": "create 需要 data"} + result = await database_service.db_save(model_class=model_class, data=data) + elif query_type == "update": + if not (data := args.get("data")): + return {"success": False, "error": "update 需要 data"} + result = await database_service.db_update( + model_class=model_class, + data=data, + filters=args.get("filters"), + ) + elif query_type == "delete": + result = await database_service.db_delete(model_class=model_class, filters=args.get("filters")) + elif query_type == "count": + result = await database_service.db_count(model_class=model_class, filters=args.get("filters")) + else: + return {"success": False, "error": f"不支持的 query_type: {query_type}"} + return result + except Exception as e: + logger.error(f"[cap.database.query] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_database_save(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.services import database_service + + model_name: str = args.get("model_name", "") + data: Optional[Dict[str, Any]] = args.get("data") + if not model_name or not data: + return {"success": False, "error": "缺少必要参数 model_name 或 data"} + + try: + import src.common.database.database_model as db_models + + model_class = getattr(db_models, model_name, None) + if model_class is None: + return {"success": False, "error": f"未找到数据模型: {model_name}"} + + result = await database_service.db_save( + model_class=model_class, + data=data, + key_field=args.get("key_field"), + key_value=args.get("key_value"), + ) + return result + except Exception as e: + logger.error(f"[cap.database.save] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_database_get(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.services import database_service + + model_name: str = args.get("model_name", "") + if not model_name: + return {"success": False, "error": "缺少必要参数 model_name"} + + try: + import src.common.database.database_model as db_models + + model_class = getattr(db_models, model_name, None) + if model_class is None: + return {"success": False, "error": f"未找到数据模型: {model_name}"} + + result = await database_service.db_get( + model_class=model_class, + filters=args.get("filters"), + limit=args.get("limit"), + order_by=args.get("order_by"), + single_result=args.get("single_result", False), + ) + return result + except Exception as e: + logger.error(f"[cap.database.get] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_database_delete(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.services import database_service + + model_name: str = args.get("model_name", "") + filters = args.get("filters", {}) + if not model_name: + return {"success": False, "error": "缺少必要参数 model_name"} + if not filters: + return {"success": False, "error": "缺少必要参数 filters(不允许无条件删除)"} + + try: + import src.common.database.database_model as db_models + + model_class = getattr(db_models, model_name, None) + if model_class is None: + return {"success": False, "error": f"未找到数据模型: {model_name}"} + + result = await database_service.db_delete(model_class=model_class, filters=filters) + return result + except Exception as e: + logger.error(f"[cap.database.delete] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_database_count(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.services import database_service + + model_name: str = args.get("model_name", "") + if not model_name: + return {"success": False, "error": "缺少必要参数 model_name"} + + try: + import src.common.database.database_model as db_models + + model_class = getattr(db_models, model_name, None) + if model_class is None: + return {"success": False, "error": f"未找到数据模型: {model_name}"} + + result = await database_service.db_count(model_class=model_class, filters=args.get("filters")) + return result + except Exception as e: + logger.error(f"[cap.database.count] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + def _list_sessions(self, platform: str, is_group_session: Optional[bool] = None) -> List[BotChatSession]: + return [ + session + for session in chat_manager.sessions.values() + if (platform == "all_platforms" or session.platform == platform) + and (is_group_session is None or session.is_group_session == is_group_session) + ] + + @staticmethod + def _serialize_stream(stream: BotChatSession) -> Dict[str, Any]: + return { + "session_id": stream.session_id, + "platform": stream.platform, + "user_id": stream.user_id, + "group_id": stream.group_id, + "is_group_session": stream.is_group_session, + } + + async def _cap_chat_get_all_streams(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + platform: str = args.get("platform", "qq") + try: + streams = self._list_sessions(platform=platform) + return {"success": True, "streams": [self._serialize_stream(item) for item in streams]} + except Exception as e: + logger.error(f"[cap.chat.get_all_streams] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_chat_get_group_streams(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + platform: str = args.get("platform", "qq") + try: + streams = self._list_sessions(platform=platform, is_group_session=True) + return {"success": True, "streams": [self._serialize_stream(item) for item in streams]} + except Exception as e: + logger.error(f"[cap.chat.get_group_streams] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_chat_get_private_streams(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + platform: str = args.get("platform", "qq") + try: + streams = self._list_sessions(platform=platform, is_group_session=False) + return {"success": True, "streams": [self._serialize_stream(item) for item in streams]} + except Exception as e: + logger.error(f"[cap.chat.get_private_streams] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_chat_get_stream_by_group_id(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + group_id: str = args.get("group_id", "") + if not group_id: + return {"success": False, "error": "缺少必要参数 group_id"} + + platform: str = args.get("platform", "qq") + try: + stream = next( + ( + item + for item in self._list_sessions(platform=platform, is_group_session=True) + if str(item.group_id) == str(group_id) + ), + None, + ) + return {"success": True, "stream": None if stream is None else self._serialize_stream(stream)} + except Exception as e: + logger.error(f"[cap.chat.get_stream_by_group_id] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_chat_get_stream_by_user_id(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + user_id: str = args.get("user_id", "") + if not user_id: + return {"success": False, "error": "缺少必要参数 user_id"} + + platform: str = args.get("platform", "qq") + try: + stream = next( + ( + item + for item in self._list_sessions(platform=platform, is_group_session=False) + if str(item.user_id) == str(user_id) + ), + None, + ) + return {"success": True, "stream": None if stream is None else self._serialize_stream(stream)} + except Exception as e: + logger.error(f"[cap.chat.get_stream_by_user_id] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + @staticmethod + def _serialize_messages(messages: list, include_binary_data: bool = True) -> List[Any]: + result: List[Any] = [] + for msg in messages: + if all(hasattr(msg, attr) for attr in ("message_id", "timestamp", "platform", "message_info", "raw_message")): + result.append( + dict(PluginMessageUtils._session_message_to_dict(msg, include_binary_data=include_binary_data)) + ) + elif hasattr(msg, "model_dump"): + result.append(msg.model_dump()) + elif hasattr(msg, "__dict__"): + result.append(dict(msg.__dict__)) + else: + result.append(str(msg)) + return result + + async def _cap_message_get_by_id(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.services import message_service + + message_id = str(args.get("message_id") or args.get("msg_id") or "").strip() + if not message_id: + return {"success": False, "error": "缺少必要参数 message_id"} + + try: + message = message_service.get_message_by_id( + message_id=message_id, + chat_id=str(args.get("chat_id") or args.get("stream_id") or "").strip() or None, + ) + include_binary_data = bool(args.get("include_binary_data", False)) + serialized_message = ( + self._serialize_messages([message], include_binary_data=include_binary_data)[0] + if message is not None + else None + ) + return {"success": True, "message": serialized_message} + except Exception as e: + logger.error(f"[cap.message.get_by_id] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_message_get_by_time(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.services import message_service + + try: + messages = message_service.get_messages_by_time( + start_time=float(args.get("start_time", 0.0)), + end_time=float(args.get("end_time", 0.0)), + limit=args.get("limit", 0), + limit_mode=args.get("limit_mode", "latest"), + filter_mai=args.get("filter_mai", False), + ) + return { + "success": True, + "messages": self._serialize_messages( + messages, + include_binary_data=bool(args.get("include_binary_data", False)), + ), + } + except Exception as e: + logger.error(f"[cap.message.get_by_time] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_message_get_by_time_in_chat(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.services import message_service + + chat_id: str = args.get("chat_id", "") + if not chat_id: + return {"success": False, "error": "缺少必要参数 chat_id"} + + try: + messages = message_service.get_messages_by_time_in_chat( + chat_id=chat_id, + start_time=float(args.get("start_time", 0.0)), + end_time=float(args.get("end_time", 0.0)), + limit=args.get("limit", 0), + limit_mode=args.get("limit_mode", "latest"), + filter_mai=args.get("filter_mai", False), + filter_command=args.get("filter_command", False), + ) + return { + "success": True, + "messages": self._serialize_messages( + messages, + include_binary_data=bool(args.get("include_binary_data", False)), + ), + } + except Exception as e: + logger.error(f"[cap.message.get_by_time_in_chat] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_message_get_recent(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.services import message_service + + chat_id: str = args.get("chat_id", "") + if not chat_id: + return {"success": False, "error": "缺少必要参数 chat_id"} + + try: + hours = float(args.get("hours", 24.0)) + if hours < 0: + return {"success": False, "error": "hours 不能是负数"} + current_time = time.time() + messages = message_service.get_messages_by_time_in_chat( + chat_id=chat_id, + start_time=current_time - hours * 3600, + end_time=current_time, + limit=args.get("limit", 100), + limit_mode=args.get("limit_mode", "latest"), + filter_mai=args.get("filter_mai", False), + ) + return { + "success": True, + "messages": self._serialize_messages( + messages, + include_binary_data=bool(args.get("include_binary_data", False)), + ), + } + except Exception as e: + logger.error(f"[cap.message.get_recent] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_message_count_new(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.services import message_service + + chat_id: str = args.get("chat_id", "") + if not chat_id: + return {"success": False, "error": "缺少必要参数 chat_id"} + + try: + since = args.get("since") + start_time = float(since) if since is not None else float(args.get("start_time", 0.0)) + count = message_service.count_new_messages( + chat_id=chat_id, + start_time=start_time, + end_time=args.get("end_time"), + ) + return {"success": True, "count": count} + except Exception as e: + logger.error(f"[cap.message.count_new] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_message_build_readable(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.services import message_service + + try: + messages = args.get("messages") + if messages is None: + if not (chat_id := args.get("chat_id", "")): + return {"success": False, "error": "缺少必要参数: messages 或 chat_id"} + messages = message_service.get_messages_by_time_in_chat( + chat_id=chat_id, + start_time=float(args.get("start_time", 0.0)), + end_time=float(args.get("end_time", 0.0)), + limit=args.get("limit", 0), + ) + + readable = message_service.build_readable_messages( + messages=messages, + replace_bot_name=args.get("replace_bot_name", True), + timestamp_mode=args.get("timestamp_mode", "relative"), + truncate=args.get("truncate", False), + ) + return {"success": True, "text": readable} + except Exception as e: + logger.error(f"[cap.message.build_readable] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_person_get_id(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.person_info.person_info import Person + + platform: str = args.get("platform", "") + user_id = args.get("user_id", "") + if not platform or not user_id: + return {"success": False, "error": "缺少必要参数 platform 或 user_id"} + + try: + pid = Person(platform=platform, user_id=str(user_id)).person_id + return {"success": True, "person_id": pid} + except Exception as e: + logger.error(f"[cap.person.get_id] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_person_get_value(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.person_info.person_info import Person + + person_id: str = args.get("person_id", "") + field_name: str = args.get("field_name", "") + if not person_id or not field_name: + return {"success": False, "error": "缺少必要参数 person_id 或 field_name"} + + try: + person = Person(person_id=person_id) + value = getattr(person, field_name) + if value is None: + value = args.get("default") + return {"success": True, "value": value} + except Exception as e: + logger.error(f"[cap.person.get_value] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_person_get_id_by_name(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.person_info.person_info import Person + + person_name: str = args.get("person_name", "") + if not person_name: + return {"success": False, "error": "缺少必要参数 person_name"} + + try: + pid = Person(person_name=person_name).person_id + return {"success": True, "person_id": pid} + except Exception as e: + logger.error(f"[cap.person.get_id_by_name] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_emoji_get_by_description(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.emoji_system.emoji_manager import emoji_manager + + description: str = args.get("description", "") + if not description: + return {"success": False, "error": "缺少必要参数 description"} + + try: + emoji = await emoji_manager.get_emoji_for_emotion(description) + if emoji is None: + return {"success": True, "emoji": None} + serialized = self._serialize_emoji_payload(emoji) + if serialized is None: + return {"success": True, "emoji": None} + return { + "success": True, + "emoji": serialized, + } + except Exception as e: + logger.error(f"[cap.emoji.get_by_description] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_emoji_get_random(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.emoji_system.emoji_manager import emoji_manager + + count: int = args.get("count", 1) + try: + if count < 0: + return {"success": False, "error": "count 不能为负数"} + + emojis_source = list(emoji_manager.emojis) + if count == 0 or not emojis_source: + return {"success": True, "emojis": []} + + selected = random.sample(emojis_source, min(count, len(emojis_source))) + emojis: List[Dict[str, str]] = [] + for emoji in selected: + emoji_manager.update_emoji_usage(emoji) + serialized = self._serialize_emoji_payload(emoji) + if serialized is not None: + if not serialized["emotion"]: + serialized["emotion"] = "随机表情" + emojis.append(serialized) + return {"success": True, "emojis": emojis} + except Exception as e: + logger.error(f"[cap.emoji.get_random] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_emoji_get_count(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + try: + from src.emoji_system.emoji_manager import emoji_manager + + return {"success": True, "count": len(emoji_manager.emojis)} + except Exception as e: + logger.error(f"[cap.emoji.get_count] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_emoji_get_emotions(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + try: + from src.emoji_system.emoji_manager import emoji_manager + + emotions = sorted( + { + str(emotion).strip() + for emoji in emoji_manager.emojis + for emotion in RuntimeDataCapabilityMixin._normalize_emoji_tag_text( + emoji.description or emoji.emotion + ) + if str(emotion).strip() + } + ) + return {"success": True, "emotions": emotions} + except Exception as e: + logger.error(f"[cap.emoji.get_emotions] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_emoji_get_all(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + try: + from src.emoji_system.emoji_manager import emoji_manager + + emojis = [] + for emoji in emoji_manager.emojis: + serialized = self._serialize_emoji_payload(emoji) + if serialized is not None: + if not serialized["emotion"]: + serialized["emotion"] = "随机表情" + emojis.append(serialized) + return {"success": True, "emojis": emojis} + except Exception as e: + logger.error(f"[cap.emoji.get_all] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_emoji_get_info(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + try: + from src.emoji_system.emoji_manager import emoji_manager + from src.config.config import global_config + + current_count = len(emoji_manager.emojis) + return { + "success": True, + "info": { + "current_count": current_count, + "max_count": global_config.emoji.max_reg_num, + "available_emojis": current_count, + }, + } + except Exception as e: + logger.error(f"[cap.emoji.get_info] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_emoji_register(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.emoji_system.emoji_manager import emoji_manager + + emoji_base64: str = args.get("emoji_base64", "") + if not emoji_base64: + return {"success": False, "error": "缺少必要参数 emoji_base64"} + + try: + count_before = len(emoji_manager.emojis) + temp_file_path = self._build_emoji_temp_path() + if not ImageUtils.base64_to_image(emoji_base64, str(temp_file_path)): + return {"success": False, "message": "无法保存图片文件", "description": None, "emotions": None, "replaced": None, "hash": None} + + register_status = await emoji_manager.register_emoji_by_filename(temp_file_path) + if register_status == "failed": + return { + "success": False, + "message": "表情包注册失败,可能因为重复、格式不支持或审核未通过", + "description": None, + "emotions": None, + "replaced": None, + "hash": None, + } + if register_status == "skipped": + return { + "success": True, + "message": "表情包已注册,已跳过本次注册", + "description": None, + "emotions": None, + "replaced": False, + "hash": None, + } + + count_after = len(emoji_manager.emojis) + replaced = count_after <= count_before + new_emoji = next( + ( + item + for item in reversed(emoji_manager.emojis) + if temp_file_path.name == item.file_name or temp_file_path.name in str(item.full_path) + ), + None, + ) + return { + "success": True, + "message": f"表情包注册成功 {'(替换旧表情包)' if replaced else '(新增表情包)'}", + "description": None if new_emoji is None else new_emoji.description, + "emotions": None + if new_emoji is None + else RuntimeDataCapabilityMixin._normalize_emoji_tag_text(new_emoji.description or new_emoji.emotion), + "replaced": replaced, + "hash": None if new_emoji is None else new_emoji.file_hash, + } + except Exception as e: + logger.error(f"[cap.emoji.register] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_emoji_delete(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.emoji_system.emoji_manager import emoji_manager + + emoji_hash: str = args.get("emoji_hash", "") + if not emoji_hash: + return {"success": False, "error": "缺少必要参数 emoji_hash"} + + try: + emoji = emoji_manager.get_emoji_by_hash(emoji_hash) + if emoji is None: + return {"success": False, "message": f"未找到表情包: {emoji_hash}", "hash": emoji_hash} + + success = emoji_manager.delete_emoji(emoji, not bool(emoji.description and emoji.description.strip())) + if not success: + return {"success": False, "message": f"删除表情包失败: {emoji_hash}", "hash": emoji_hash} + + emoji_manager.emojis = [item for item in emoji_manager.emojis if item.file_hash != emoji_hash] + emoji_manager._emoji_num = len(emoji_manager.emojis) + return {"success": True, "message": f"成功删除表情包: {emoji_hash}", "hash": emoji_hash} + except Exception as e: + logger.error(f"[cap.emoji.delete] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + @staticmethod + def _get_frequency_adjust_value(chat_id: str) -> float: + from src.chat.heart_flow.heartflow_manager import heartflow_manager + + heartflow_chat = heartflow_manager.heartflow_chat_list.get(chat_id) + return 1.0 if heartflow_chat is None else heartflow_chat._talk_frequency_adjust + + async def _cap_frequency_get_current_talk_value(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.common.utils.utils_config import ChatConfigUtils + + chat_id: str = args.get("chat_id", "") + if not chat_id: + return {"success": False, "error": "缺少必要参数 chat_id"} + + try: + value = self._get_frequency_adjust_value(chat_id) * ChatConfigUtils.get_talk_value(chat_id) + return {"success": True, "value": value} + except Exception as e: + logger.error(f"[cap.frequency.get_current_talk_value] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_frequency_set_adjust(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.chat.heart_flow.heartflow_manager import heartflow_manager + + chat_id: str = args.get("chat_id", "") + value = args.get("value") + if not chat_id or value is None: + return {"success": False, "error": "缺少必要参数 chat_id 或 value"} + + try: + heartflow_manager.adjust_talk_frequency(chat_id, float(value)) + return {"success": True} + except Exception as e: + logger.error(f"[cap.frequency.set_adjust] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_frequency_get_adjust(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + chat_id: str = args.get("chat_id", "") + if not chat_id: + return {"success": False, "error": "缺少必要参数 chat_id"} + + try: + value = self._get_frequency_adjust_value(chat_id) + return {"success": True, "value": value} + except Exception as e: + logger.error(f"[cap.frequency.get_adjust] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_tool_get_definitions(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + from src.plugin_runtime.component_query import component_query_service + + try: + tools = component_query_service.get_llm_available_tools() + return { + "success": True, + "tools": [{"name": name, "definition": info.get_llm_definition()} for name, info in tools.items()], + } + except Exception as e: + logger.error(f"[cap.tool.get_definitions] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + async def _cap_knowledge_search(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + query: str = args.get("query", "") + if not query: + return {"success": False, "error": "缺少必要参数 query"} + + limit = args.get("limit", 5) + try: + limit_value = max(1, int(limit)) + except (TypeError, ValueError): + limit_value = 5 + + mode = str(args.get("mode", "search") or "search").strip() or "search" + chat_id = str(args.get("chat_id", "") or "").strip() + person_id = str(args.get("person_id", "") or "").strip() + user_id = str(args.get("user_id", "") or "").strip() + group_id = str(args.get("group_id", "") or "").strip() + respect_filter = bool(args.get("respect_filter", True)) + time_start = args.get("time_start") + time_end = args.get("time_end") + + try: + from src.services.memory_service import memory_service + + result = await memory_service.search( + query, + limit=limit_value, + mode=mode, + chat_id=chat_id, + person_id=person_id, + time_start=time_start, + time_end=time_end, + respect_filter=respect_filter, + user_id=user_id, + group_id=group_id, + ) + if not result.success: + return {"success": False, "error": result.error or "长期记忆检索失败"} + knowledge_info = result.to_text(limit=limit_value) + content = f"你知道这些知识: {knowledge_info}" if knowledge_info else f"你不太了解有关{query}的知识" + return {"success": True, "content": content} + except Exception as e: + logger.error(f"[cap.knowledge.search] 执行失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} diff --git a/src/plugin_runtime/capabilities/registry.py b/src/plugin_runtime/capabilities/registry.py new file mode 100644 index 00000000..10dfd00b --- /dev/null +++ b/src/plugin_runtime/capabilities/registry.py @@ -0,0 +1,96 @@ +from typing import TYPE_CHECKING + +from src.common.logger import get_logger +from src.plugin_runtime.host.capability_service import CapabilityImpl +from src.plugin_runtime.host.supervisor import PluginSupervisor + +if TYPE_CHECKING: + from src.plugin_runtime.integration import PluginRuntimeManager + +logger = get_logger("plugin_runtime.integration") + + +def register_capability_impls(manager: "PluginRuntimeManager", supervisor: PluginSupervisor) -> None: + """向指定 Supervisor 注册主程序提供的能力实现。""" + cap_service = supervisor.capability_service + + def _register(name: str, impl: CapabilityImpl) -> None: + """注册单个能力实现。 + + Args: + name: 能力名称。 + impl: 能力实现函数。 + """ + cap_service.register_capability(name, impl) + + _register("send.text", manager._cap_send_text) + _register("send.emoji", manager._cap_send_emoji) + _register("send.image", manager._cap_send_image) + _register("send.command", manager._cap_send_command) + _register("send.custom", manager._cap_send_custom) + + _register("llm.generate", manager._cap_llm_generate) + _register("llm.generate_with_tools", manager._cap_llm_generate_with_tools) + _register("llm.get_available_models", manager._cap_llm_get_available_models) + + _register("config.get", manager._cap_config_get) + _register("config.get_plugin", manager._cap_config_get_plugin) + _register("config.get_all", manager._cap_config_get_all) + + _register("database.query", manager._cap_database_query) + _register("database.save", manager._cap_database_save) + _register("database.get", manager._cap_database_get) + _register("database.delete", manager._cap_database_delete) + _register("database.count", manager._cap_database_count) + + _register("chat.get_all_streams", manager._cap_chat_get_all_streams) + _register("chat.get_group_streams", manager._cap_chat_get_group_streams) + _register("chat.get_private_streams", manager._cap_chat_get_private_streams) + _register("chat.get_stream_by_group_id", manager._cap_chat_get_stream_by_group_id) + _register("chat.get_stream_by_user_id", manager._cap_chat_get_stream_by_user_id) + + _register("message.get_by_time", manager._cap_message_get_by_time) + _register("message.get_by_time_in_chat", manager._cap_message_get_by_time_in_chat) + _register("message.get_by_id", manager._cap_message_get_by_id) + _register("message.get_recent", manager._cap_message_get_recent) + _register("message.count_new", manager._cap_message_count_new) + _register("message.build_readable", manager._cap_message_build_readable) + + _register("person.get_id", manager._cap_person_get_id) + _register("person.get_value", manager._cap_person_get_value) + _register("person.get_id_by_name", manager._cap_person_get_id_by_name) + + _register("emoji.get_by_description", manager._cap_emoji_get_by_description) + _register("emoji.get_random", manager._cap_emoji_get_random) + _register("emoji.get_count", manager._cap_emoji_get_count) + _register("emoji.get_emotions", manager._cap_emoji_get_emotions) + _register("emoji.get_all", manager._cap_emoji_get_all) + _register("emoji.get_info", manager._cap_emoji_get_info) + _register("emoji.register", manager._cap_emoji_register) + _register("emoji.delete", manager._cap_emoji_delete) + + _register("frequency.get_current_talk_value", manager._cap_frequency_get_current_talk_value) + _register("frequency.set_adjust", manager._cap_frequency_set_adjust) + _register("frequency.get_adjust", manager._cap_frequency_get_adjust) + + _register("tool.get_definitions", manager._cap_tool_get_definitions) + + _register("api.call", manager._cap_api_call) + _register("api.get", manager._cap_api_get) + _register("api.list", manager._cap_api_list) + _register("api.replace_dynamic", manager._cap_api_replace_dynamic) + + _register("component.get_all_plugins", manager._cap_component_get_all_plugins) + _register("component.get_plugin_info", manager._cap_component_get_plugin_info) + _register("component.get_plugin_config_schema", manager._cap_component_get_plugin_config_schema) + _register("component.list_loaded_plugins", manager._cap_component_list_loaded_plugins) + _register("component.list_registered_plugins", manager._cap_component_list_registered_plugins) + _register("component.enable", manager._cap_component_enable) + _register("component.disable", manager._cap_component_disable) + _register("component.load_plugin", manager._cap_component_load_plugin) + _register("component.unload_plugin", manager._cap_component_unload_plugin) + _register("component.reload_plugin", manager._cap_component_reload_plugin) + + _register("knowledge.search", manager._cap_knowledge_search) + _register("render.html2png", manager._cap_render_html2png) + logger.debug("已注册全部主程序能力实现") diff --git a/src/plugin_runtime/capabilities/render.py b/src/plugin_runtime/capabilities/render.py new file mode 100644 index 00000000..0e6086e6 --- /dev/null +++ b/src/plugin_runtime/capabilities/render.py @@ -0,0 +1,121 @@ +"""插件运行时的浏览器渲染能力。""" + +from typing import Any, Dict + +from src.common.logger import get_logger +from src.services.html_render_service import HtmlRenderRequest, get_html_render_service + +logger = get_logger("plugin_runtime.integration") + + +class RuntimeRenderCapabilityMixin: + """插件运行时的浏览器渲染能力混入。""" + + @staticmethod + def _coerce_int(value: Any, default: int) -> int: + """将任意值尽量转换为整数。 + + Args: + value: 原始输入值。 + default: 转换失败时返回的默认值。 + + Returns: + int: 规范化后的整数结果。 + """ + + try: + return int(value) + except (TypeError, ValueError): + return default + + @staticmethod + def _coerce_float(value: Any, default: float) -> float: + """将任意值尽量转换为浮点数。 + + Args: + value: 原始输入值。 + default: 转换失败时返回的默认值。 + + Returns: + float: 规范化后的浮点结果。 + """ + + try: + return float(value) + except (TypeError, ValueError): + return default + + @staticmethod + def _coerce_bool(value: Any, default: bool = False) -> bool: + """将任意值转换为布尔值。 + + Args: + value: 原始输入值。 + default: 输入为空时返回的默认值。 + + Returns: + bool: 规范化后的布尔结果。 + """ + + if value is None: + return default + if isinstance(value, str): + normalized_value = value.strip().lower() + if normalized_value in {"0", "false", "no", "off"}: + return False + if normalized_value in {"1", "true", "yes", "on"}: + return True + return bool(value) + + def _build_html_render_request(self, args: Dict[str, Any]) -> HtmlRenderRequest: + """根据 capability 调用参数构造渲染请求。 + + Args: + args: capability 调用参数。 + + Returns: + HtmlRenderRequest: 结构化后的渲染请求。 + """ + + viewport = args.get("viewport", {}) + viewport_width = 900 + viewport_height = 500 + if isinstance(viewport, dict): + viewport_width = self._coerce_int(viewport.get("width"), viewport_width) + viewport_height = self._coerce_int(viewport.get("height"), viewport_height) + + return HtmlRenderRequest( + html=str(args.get("html", "") or ""), + selector=str(args.get("selector", "body") or "body"), + viewport_width=viewport_width, + viewport_height=viewport_height, + device_scale_factor=self._coerce_float(args.get("device_scale_factor"), 2.0), + full_page=self._coerce_bool(args.get("full_page"), False), + omit_background=self._coerce_bool(args.get("omit_background"), False), + wait_until=str(args.get("wait_until", "load") or "load"), + wait_for_selector=str(args.get("wait_for_selector", "") or ""), + wait_for_timeout_ms=self._coerce_int(args.get("wait_for_timeout_ms"), 0), + timeout_ms=self._coerce_int(args.get("timeout_ms"), 0), + allow_network=self._coerce_bool(args.get("allow_network"), False), + ) + + async def _cap_render_html2png(self, plugin_id: str, capability: str, args: Dict[str, Any]) -> Any: + """将 HTML 内容渲染为 PNG 图片。 + + Args: + plugin_id: 调用该能力的插件 ID。 + capability: 当前能力名称。 + args: 能力调用参数。 + + Returns: + Any: 标准化后的能力返回结构。 + """ + + del plugin_id, capability + try: + request = self._build_html_render_request(args) + result = await get_html_render_service().render_html_to_png(request) + return {"success": True, "result": result.to_payload()} + except Exception as exc: + logger.error(f"[cap.render.html2png] 执行失败: {exc}", exc_info=True) + return {"success": False, "error": str(exc)} diff --git a/src/plugin_runtime/component_query.py b/src/plugin_runtime/component_query.py new file mode 100644 index 00000000..f5c3e1f7 --- /dev/null +++ b/src/plugin_runtime/component_query.py @@ -0,0 +1,985 @@ +"""插件运行时统一组件查询服务。 + +该模块统一从插件运行时的 Host ComponentRegistry 中聚合只读视图, +供 HFC、ToolExecutor 和运行时能力层查询与调用。 +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional, Tuple, cast + +from src.common.logger import get_logger +from src.core.tooling import ( + ToolAvailabilityContext, + ToolExecutionContext, + ToolExecutionResult, + ToolInvocation, + ToolSpec, + build_tool_detailed_description, +) +from src.core.types import ActionActivationType, ActionInfo, CommandInfo, ComponentInfo, ComponentType, ToolInfo +from src.llm_models.payload_content.tool_option import normalize_tool_option + +if TYPE_CHECKING: + from src.plugin_runtime.host.component_registry import ActionEntry, CommandEntry, ComponentEntry, ToolEntry + from src.plugin_runtime.host.supervisor import PluginSupervisor + from src.plugin_runtime.integration import PluginRuntimeManager + +logger = get_logger("plugin_runtime.component_query") + +ActionExecutor = Callable[..., Awaitable[Any]] +CommandExecutor = Callable[..., Awaitable[Tuple[bool, Optional[str], bool]]] +ToolExecutor = Callable[..., Awaitable[Any]] + +_HOST_COMPONENT_TYPE_MAP: Dict[ComponentType, str] = { + ComponentType.ACTION: "ACTION", + ComponentType.COMMAND: "COMMAND", + ComponentType.TOOL: "TOOL", +} + + +class ComponentQueryService: + """插件运行时统一组件查询服务。 + + 该对象不维护独立状态,只读取插件系统中的注册结果。 + 所有注册、删除、配置写入等写操作都被显式禁用。 + """ + + @staticmethod + def _get_runtime_manager() -> "PluginRuntimeManager": + """获取插件运行时管理器单例。 + + Returns: + PluginRuntimeManager: 当前全局插件运行时管理器。 + """ + + from src.plugin_runtime.integration import get_plugin_runtime_manager + + return get_plugin_runtime_manager() + + def _iter_supervisors(self) -> list["PluginSupervisor"]: + """获取当前所有活跃的插件运行时监督器。 + + Returns: + list[PluginSupervisor]: 当前运行中的监督器列表。 + """ + + runtime_manager = self._get_runtime_manager() + return list(runtime_manager.supervisors) + + def _iter_component_entries( + self, + component_type: ComponentType, + *, + enabled_only: bool = True, + context: Optional[ToolAvailabilityContext] = None, + ) -> list[tuple["PluginSupervisor", "ComponentEntry"]]: + """遍历指定类型的全部组件条目。 + + Args: + component_type: 目标组件类型。 + enabled_only: 是否仅返回启用状态的组件。 + + Returns: + list[tuple[PluginSupervisor, ComponentEntry]]: ``(监督器, 组件条目)`` 列表。 + """ + + host_component_type = _HOST_COMPONENT_TYPE_MAP.get(component_type) + if host_component_type is None: + return [] + + session_id = context.session_id if context is not None else None + is_group_chat = context.is_group_chat if context is not None else None + group_id = context.group_id if context is not None else None + platform = context.platform if context is not None else None + collected_entries: list[tuple["PluginSupervisor", "ComponentEntry"]] = [] + for supervisor in self._iter_supervisors(): + for component in supervisor.component_registry.get_components_by_type( + host_component_type, + enabled_only=enabled_only, + session_id=session_id, + is_group_chat=is_group_chat, + group_id=group_id, + platform=platform, + ): + collected_entries.append((supervisor, component)) + return collected_entries + + @staticmethod + def _coerce_action_activation_type(raw_value: Any) -> ActionActivationType: + """规范化动作激活类型。 + + Args: + raw_value: 原始激活类型值。 + + Returns: + ActionActivationType: 规范化后的激活类型枚举。 + """ + + normalized_value = str(raw_value or "").strip().lower() + if normalized_value == ActionActivationType.NEVER.value: + return ActionActivationType.NEVER + if normalized_value == ActionActivationType.RANDOM.value: + return ActionActivationType.RANDOM + if normalized_value == ActionActivationType.KEYWORD.value: + return ActionActivationType.KEYWORD + return ActionActivationType.ALWAYS + + @staticmethod + def _coerce_float(value: Any, default: float = 0.0) -> float: + """将任意值安全转换为浮点数。 + + Args: + value: 待转换的输入值。 + default: 转换失败时返回的默认值。 + + Returns: + float: 转换后的浮点结果。 + """ + + try: + return float(value) + except (TypeError, ValueError): + return default + + @staticmethod + def _build_action_info(entry: "ActionEntry") -> ActionInfo: + """将运行时 Action 条目转换为核心动作信息。 + + Args: + entry: 插件运行时中的 Action 条目。 + + Returns: + ActionInfo: 供核心 Planner 使用的动作信息。 + """ + + metadata = dict(entry.metadata) + raw_action_parameters = metadata.get("action_parameters") + action_parameters = ( + {str(param_name): str(param_description) for param_name, param_description in raw_action_parameters.items()} + if isinstance(raw_action_parameters, dict) + else {} + ) + action_require = [ + str(item) for item in (metadata.get("action_require") or []) if item is not None and str(item).strip() + ] + associated_types = [ + str(item) for item in (metadata.get("associated_types") or []) if item is not None and str(item).strip() + ] + activation_keywords = [ + str(item) for item in (metadata.get("activation_keywords") or []) if item is not None and str(item).strip() + ] + + return ActionInfo( + name=entry.name, + description=str(metadata.get("description", "") or ""), + enabled=bool(entry.enabled), + plugin_name=entry.plugin_id, + action_parameters=action_parameters, + action_require=action_require, + associated_types=associated_types, + activation_type=ComponentQueryService._coerce_action_activation_type(metadata.get("activation_type")), + random_activation_probability=ComponentQueryService._coerce_float( + metadata.get("activation_probability"), + 0.0, + ), + activation_keywords=activation_keywords, + parallel_action=bool(metadata.get("parallel_action", False)), + ) + + @staticmethod + def _build_command_info(entry: "CommandEntry") -> CommandInfo: + """将运行时 Command 条目转换为核心命令信息。 + + Args: + entry: 插件运行时中的 Command 条目。 + + Returns: + CommandInfo: 供核心命令链使用的命令信息。 + """ + + metadata = dict(entry.metadata) + return CommandInfo( + name=entry.name, + description=str(metadata.get("description", "") or ""), + enabled=bool(entry.enabled), + plugin_name=entry.plugin_id, + ) + + @staticmethod + def _build_tool_definition(entry: "ToolEntry") -> dict[str, Any]: + """将运行时 Tool 条目转换为原始工具定义字典。 + + Args: + entry: 插件运行时中的 Tool 条目。 + + Returns: + dict[str, Any]: 可交给 `normalize_tool_option()` 的原始工具定义。 + """ + raw_definition: dict[str, Any] = { + "name": entry.name, + "description": entry.description, + } + if isinstance(entry.parameters_raw, dict) and entry.parameters_raw: + raw_definition["parameters_schema"] = entry.parameters_raw + return raw_definition + if isinstance(entry.parameters, list) and entry.parameters: + raw_definition["parameters"] = entry.parameters + return raw_definition + if isinstance(entry.parameters_raw, list) and entry.parameters_raw: + raw_definition["parameters"] = entry.parameters_raw + return raw_definition + return raw_definition + + @staticmethod + def _build_tool_parameters_schema(entry: "ToolEntry") -> dict[str, Any] | None: + """将运行时 Tool 条目转换为对象级参数 Schema。 + + Args: + entry: 插件运行时中的 Tool 条目。 + + Returns: + dict[str, Any] | None: 规范化后的对象级参数 Schema。 + """ + normalized_option = normalize_tool_option(ComponentQueryService._build_tool_definition(entry)) + return normalized_option.parameters_schema + + @staticmethod + def _build_tool_info(entry: "ToolEntry") -> ToolInfo: + """将运行时 Tool 条目转换为核心工具信息。 + + Args: + entry: 插件运行时中的 Tool 条目。 + + Returns: + ToolInfo: 供 ToolExecutor 与能力层使用的工具信息。 + """ + + return ToolInfo( + name=entry.name, + description=entry.brief_description or entry.description, + enabled=bool(entry.enabled), + plugin_name=entry.plugin_id, + parameters_schema=ComponentQueryService._build_tool_parameters_schema(entry), + ) + + @staticmethod + def _build_tool_spec(entry: "ToolEntry") -> ToolSpec: + """将运行时 Tool 条目转换为统一工具声明。 + + Args: + entry: 插件运行时中的 Tool 条目。 + + Returns: + ToolSpec: 统一工具声明。 + """ + + parameters_schema = ComponentQueryService._build_tool_parameters_schema(entry) + return ToolSpec( + name=entry.name, + brief_description=entry.brief_description or entry.description or f"工具 {entry.name}", + detailed_description=entry.detailed_description or build_tool_detailed_description(parameters_schema), + parameters_schema=parameters_schema, + provider_name=entry.plugin_id, + provider_type="plugin", + metadata={ + "plugin_id": entry.plugin_id, + "invoke_method": entry.invoke_method, + "legacy_component_type": entry.legacy_component_type, + }, + ) + + @staticmethod + def _log_duplicate_component(component_type: ComponentType, component_name: str) -> None: + """记录重复组件名称冲突。 + + Args: + component_type: 组件类型。 + component_name: 发生冲突的组件名称。 + """ + + logger.warning(f"检测到重复{component_type.value}名称 {component_name},将只保留首个匹配项") + + def _get_unique_component_entry( + self, + component_type: ComponentType, + name: str, + ) -> Optional[tuple["PluginSupervisor", "ComponentEntry"]]: + """按组件短名解析唯一条目。 + + Args: + component_type: 目标组件类型。 + name: 组件短名。 + + Returns: + Optional[tuple[PluginSupervisor, ComponentEntry]]: 唯一命中的组件条目。 + """ + + matched_entries = [ + (supervisor, entry) + for supervisor, entry in self._iter_component_entries(component_type) + if entry.name == name + ] + if not matched_entries: + return None + if len(matched_entries) > 1: + self._log_duplicate_component(component_type, name) + return matched_entries[0] + + def _collect_unique_component_infos( + self, + component_type: ComponentType, + ) -> Dict[str, ComponentInfo]: + """收集某类组件的唯一信息视图。 + + Args: + component_type: 目标组件类型。 + + Returns: + Dict[str, ComponentInfo]: 组件名到核心组件信息的映射。 + """ + + collected_components: Dict[str, ComponentInfo] = {} + for _supervisor, entry in self._iter_component_entries(component_type): + if entry.name in collected_components: + self._log_duplicate_component(component_type, entry.name) + continue + + if component_type == ComponentType.ACTION: + collected_components[entry.name] = self._build_action_info(entry) # type: ignore[arg-type] + elif component_type == ComponentType.COMMAND: + collected_components[entry.name] = self._build_command_info(entry) # type: ignore[arg-type] + elif component_type == ComponentType.TOOL: + collected_components[entry.name] = self._build_tool_info(entry) # type: ignore[arg-type] + return collected_components + + @staticmethod + def _extract_stream_id_from_action_kwargs(kwargs: Dict[str, Any]) -> str: + """从旧 ActionManager 参数中提取聊天流 ID。 + + Args: + kwargs: 旧动作执行器收到的关键字参数。 + + Returns: + str: 提取出的 ``stream_id``。 + """ + + chat_stream = kwargs.get("chat_stream") + if chat_stream is not None: + try: + return str(chat_stream.session_id) + except AttributeError: + pass + + return str(kwargs.get("stream_id", "") or "") + + @staticmethod + def _build_action_executor(supervisor: "PluginSupervisor", plugin_id: str, component_name: str) -> ActionExecutor: + """构造动作执行 RPC 闭包。 + + Args: + supervisor: 负责该组件的监督器。 + plugin_id: 插件 ID。 + component_name: 组件名称。 + + Returns: + ActionExecutor: 兼容旧 Planner 的异步执行器。 + """ + + async def _executor(**kwargs: Any) -> tuple[bool, str]: + """将核心动作调用桥接到插件运行时。 + + Args: + **kwargs: 旧 ActionManager 传入的上下文参数。 + + Returns: + tuple[bool, str]: ``(是否成功, 结果说明)``。 + """ + + invoke_args: Dict[str, Any] = {} + action_data = kwargs.get("action_data") + if isinstance(action_data, dict): + invoke_args.update(action_data) + + stream_id = ComponentQueryService._extract_stream_id_from_action_kwargs(kwargs) + invoke_args["action_data"] = action_data if isinstance(action_data, dict) else {} + invoke_args["stream_id"] = stream_id + invoke_args["chat_id"] = stream_id + invoke_args["reasoning"] = str(kwargs.get("action_reasoning", "") or "") + + if (thinking_id := kwargs.get("thinking_id")) is not None: + invoke_args["thinking_id"] = str(thinking_id) + if isinstance(kwargs.get("cycle_timers"), dict): + invoke_args["cycle_timers"] = kwargs["cycle_timers"] + if isinstance(kwargs.get("plugin_config"), dict): + invoke_args["plugin_config"] = kwargs["plugin_config"] + if isinstance(kwargs.get("log_prefix"), str): + invoke_args["log_prefix"] = kwargs["log_prefix"] + if isinstance(kwargs.get("shutting_down"), bool): + invoke_args["shutting_down"] = kwargs["shutting_down"] + + try: + response = await supervisor.invoke_plugin( + method="plugin.invoke_action", + plugin_id=plugin_id, + component_name=component_name, + args=invoke_args, + timeout_ms=30000, + ) + except Exception as exc: + logger.error(f"运行时 Action {plugin_id}.{component_name} 执行失败: {exc}", exc_info=True) + return False, str(exc) + + payload = response.payload if isinstance(response.payload, dict) else {} + success = bool(payload.get("success", False)) + result = payload.get("result") + if isinstance(result, (list, tuple)): + if len(result) >= 2: + return bool(result[0]), "" if result[1] is None else str(result[1]) + if len(result) == 1: + return bool(result[0]), "" + if success: + return True, "" if result is None else str(result) + return False, "" if result is None else str(result) + + return _executor + + @staticmethod + def _build_command_executor( + supervisor: "PluginSupervisor", + plugin_id: str, + component_name: str, + metadata: Dict[str, Any], + ) -> CommandExecutor: + """构造命令执行 RPC 闭包。 + + Args: + supervisor: 负责该组件的监督器。 + plugin_id: 插件 ID。 + component_name: 组件名称。 + metadata: 命令组件元数据。 + + Returns: + CommandExecutor: 兼容旧消息命令链的执行器。 + """ + + async def _executor(**kwargs: Any) -> tuple[bool, Optional[str], bool]: + """将核心命令调用桥接到插件运行时。 + + Args: + **kwargs: 命令执行上下文参数。 + + Returns: + tuple[bool, Optional[str], bool]: ``(是否成功, 返回文本, 是否拦截后续消息)``。 + """ + + message = kwargs.get("message") + matched_groups = kwargs.get("matched_groups") + plugin_config = kwargs.get("plugin_config") + message_info = getattr(message, "message_info", None) + group_info = getattr(message_info, "group_info", None) + user_info = getattr(message_info, "user_info", None) + invoke_args: Dict[str, Any] = { + "text": str(getattr(message, "processed_plain_text", "") or ""), + "stream_id": str(getattr(message, "session_id", "") or ""), + "group_id": str(getattr(group_info, "group_id", "") or ""), + "user_id": str(getattr(user_info, "user_id", "") or ""), + "matched_groups": matched_groups if isinstance(matched_groups, dict) else {}, + } + if isinstance(plugin_config, dict): + invoke_args["plugin_config"] = plugin_config + + try: + response = await supervisor.invoke_plugin( + method="plugin.invoke_command", + plugin_id=plugin_id, + component_name=component_name, + args=invoke_args, + timeout_ms=30000, + ) + except Exception as exc: + logger.error(f"运行时 Command {plugin_id}.{component_name} 执行失败: {exc}", exc_info=True) + return False, str(exc), True + + payload = response.payload if isinstance(response.payload, dict) else {} + success = bool(payload.get("success", False)) + result = payload.get("result") + intercept = bool(metadata.get("intercept_message_level", 0)) + response_text: Optional[str] + + if isinstance(result, (list, tuple)) and len(result) >= 3: + response_text = None if result[1] is None else str(result[1]) + intercept = bool(result[2]) + else: + response_text = None if result is None else str(result) + + return success, response_text, intercept + + return _executor + + @staticmethod + def _build_tool_executor( + supervisor: "PluginSupervisor", + plugin_id: str, + component_name: str, + invoke_method: str = "plugin.invoke_tool", + ) -> ToolExecutor: + """构造工具执行 RPC 闭包。 + + Args: + supervisor: 负责该组件的监督器。 + plugin_id: 插件 ID。 + component_name: 组件名称。 + + Returns: + ToolExecutor: 兼容旧 ToolExecutor 的异步执行器。 + """ + + async def _executor(function_args: Dict[str, Any]) -> Any: + """将核心工具调用桥接到插件运行时。 + + Args: + function_args: 工具调用参数。 + + Returns: + Any: 插件工具返回结果;若结果不是字典,则会包装为 ``{"content": ...}``。 + """ + + try: + response = await supervisor.invoke_plugin( + method=invoke_method, + plugin_id=plugin_id, + component_name=component_name, + args=function_args, + timeout_ms=30000, + ) + except Exception as exc: + logger.error(f"运行时 Tool {plugin_id}.{component_name} 执行失败: {exc}", exc_info=True) + return {"content": f"工具 {component_name} 执行失败: {exc}"} + + payload = response.payload if isinstance(response.payload, dict) else {} + result = payload.get("result") + if isinstance(result, dict): + return result + return {"content": "" if result is None else str(result)} + + return _executor + + def get_action_info(self, name: str) -> Optional[ActionInfo]: + """获取指定动作的信息。 + + Args: + name: 动作名称。 + + Returns: + Optional[ActionInfo]: 匹配到的动作信息。 + """ + + matched_entry = self._get_unique_component_entry(ComponentType.ACTION, name) + if matched_entry is None: + return None + _supervisor, entry = matched_entry + return self._build_action_info(entry) # type: ignore[arg-type] + + def get_action_executor(self, name: str) -> Optional[ActionExecutor]: + """获取指定动作的执行器。 + + Args: + name: 动作名称。 + + Returns: + Optional[ActionExecutor]: 运行时 RPC 执行闭包。 + """ + + matched_entry = self._get_unique_component_entry(ComponentType.ACTION, name) + if matched_entry is None: + return None + supervisor, entry = matched_entry + return self._build_action_executor(supervisor, entry.plugin_id, entry.name) + + def get_default_actions(self) -> Dict[str, ActionInfo]: + """获取当前默认启用的动作集合。 + + Returns: + Dict[str, ActionInfo]: 动作名到动作信息的映射。 + """ + + action_infos = self._collect_unique_component_infos(ComponentType.ACTION) + return {name: info for name, info in action_infos.items() if isinstance(info, ActionInfo) and info.enabled} + + def find_command_by_text(self, text: str) -> Optional[Tuple[CommandExecutor, dict, CommandInfo]]: + """根据文本查找匹配的命令。 + + Args: + text: 待匹配的文本内容。 + + Returns: + Optional[Tuple[CommandExecutor, dict, CommandInfo]]: 匹配结果。 + """ + + for supervisor in self._iter_supervisors(): + match_result = supervisor.component_registry.find_command_by_text(text) + if match_result is None: + continue + + entry, matched_groups = match_result + command_info = self._build_command_info(entry) # type: ignore[arg-type] + command_executor = self._build_command_executor( + supervisor, + entry.plugin_id, + entry.name, + dict(entry.metadata), + ) + return command_executor, matched_groups, command_info + return None + + def get_tool_info(self, name: str) -> Optional[ToolInfo]: + """获取指定工具的信息。 + + Args: + name: 工具名称。 + + Returns: + Optional[ToolInfo]: 匹配到的工具信息。 + """ + + matched_entry = self._get_unique_component_entry(ComponentType.TOOL, name) + if matched_entry is None: + return None + _supervisor, entry = matched_entry + return self._build_tool_info(entry) # type: ignore[arg-type] + + def get_tool_executor(self, name: str) -> Optional[ToolExecutor]: + """获取指定工具的执行器。 + + Args: + name: 工具名称。 + + Returns: + Optional[ToolExecutor]: 运行时 RPC 执行闭包。 + """ + + matched_entry = self._get_unique_component_entry(ComponentType.TOOL, name) + if matched_entry is None: + return None + supervisor, entry = matched_entry + tool_entry = cast("ToolEntry", entry) + return self._build_tool_executor(supervisor, tool_entry.plugin_id, tool_entry.name, tool_entry.invoke_method) + + def get_llm_available_tool_specs( + self, + context: Optional[ToolAvailabilityContext] = None, + ) -> Dict[str, ToolSpec]: + """获取当前可供 LLM 使用的统一工具声明集合。 + + Returns: + Dict[str, ToolSpec]: 工具名到工具声明的映射。 + """ + + collected_specs: Dict[str, ToolSpec] = {} + for _supervisor, entry in self._iter_component_entries(ComponentType.TOOL, context=context): + if entry.name in collected_specs: + self._log_duplicate_component(ComponentType.TOOL, entry.name) + continue + collected_specs[entry.name] = self._build_tool_spec(entry) # type: ignore[arg-type] + return collected_specs + + @staticmethod + def _build_tool_context_payload(context: Optional[ToolExecutionContext]) -> Dict[str, Any]: + """提取插件工具可复用的会话上下文字段。""" + + if context is None: + return {} + + payload: Dict[str, Any] = {} + stream_id = str(context.stream_id or context.session_id or "").strip() + if stream_id: + payload["stream_id"] = stream_id + payload["chat_id"] = stream_id + + anchor_message = context.metadata.get("anchor_message") + message_info = getattr(anchor_message, "message_info", None) + group_info = getattr(message_info, "group_info", None) + user_info = getattr(message_info, "user_info", None) + + group_id = str(getattr(group_info, "group_id", "") or "").strip() + user_id = str(getattr(user_info, "user_id", "") or "").strip() + if group_id: + payload["group_id"] = group_id + if user_id: + payload["user_id"] = user_id + return payload + + @staticmethod + def _build_tool_invocation_payload( + entry: "ToolEntry", + invocation: ToolInvocation, + context: Optional[ToolExecutionContext], + ) -> Dict[str, Any]: + """构造插件工具执行时发送给 Runner 的参数。 + + Args: + entry: 目标工具条目。 + invocation: 统一工具调用请求。 + context: 统一工具执行上下文。 + + Returns: + Dict[str, Any]: 发往 Runner 的参数字典。 + """ + + payload = dict(invocation.arguments) + context_payload = ComponentQueryService._build_tool_context_payload(context) + if entry.invoke_method == "plugin.invoke_action": + stream_id = str( + context_payload.get("stream_id") + or (context.stream_id if context is not None else invocation.stream_id) + or invocation.stream_id + ).strip() + reasoning = context.reasoning if context is not None else invocation.reasoning + payload = { + **payload, + **{key: value for key, value in context_payload.items() if key not in payload or not payload.get(key)}, + "stream_id": stream_id, + "chat_id": stream_id, + "reasoning": reasoning, + "action_data": dict(invocation.arguments), + } + return payload + + for key, value in context_payload.items(): + if key not in payload or not payload.get(key): + payload[key] = value + return payload + + @staticmethod + def _parse_tool_invoke_result( + entry: "ToolEntry", + result: Any, + ) -> ToolExecutionResult: + """将插件组件返回值转换为统一工具执行结果。 + + Args: + entry: 目标工具条目。 + result: 插件组件原始返回值。 + + Returns: + ToolExecutionResult: 统一执行结果。 + """ + + if isinstance(result, dict): + success = bool(result.get("success", True)) + content = str(result.get("content", result.get("message", "")) or "").strip() + error_message = "" + if not success: + error_message = str(result.get("error", result.get("message", "插件工具执行失败")) or "").strip() + return ToolExecutionResult( + tool_name=entry.name, + success=success, + content=content, + error_message=error_message, + structured_content=result, + metadata={"plugin_id": entry.plugin_id}, + ) + + if isinstance(result, (list, tuple)) and result: + if isinstance(result[0], bool): + success = bool(result[0]) + message = "" if len(result) < 2 or result[1] is None else str(result[1]).strip() + return ToolExecutionResult( + tool_name=entry.name, + success=success, + content=message if success else "", + error_message="" if success else message, + structured_content=list(result), + metadata={"plugin_id": entry.plugin_id}, + ) + + normalized_content = "" if result is None else str(result).strip() + return ToolExecutionResult( + tool_name=entry.name, + success=True, + content=normalized_content, + structured_content=result, + metadata={"plugin_id": entry.plugin_id}, + ) + + async def invoke_tool_as_tool( + self, + invocation: ToolInvocation, + context: Optional[ToolExecutionContext] = None, + ) -> ToolExecutionResult: + """按统一工具语义执行插件工具。 + + Args: + invocation: 统一工具调用请求。 + context: 执行上下文。 + + Returns: + ToolExecutionResult: 统一工具执行结果。 + """ + + matched_entry = self._get_unique_component_entry(ComponentType.TOOL, invocation.tool_name) + if matched_entry is None: + return ToolExecutionResult( + tool_name=invocation.tool_name, + success=False, + error_message=f"未找到插件工具:{invocation.tool_name}", + ) + + supervisor, entry = matched_entry + tool_entry = cast("ToolEntry", entry) + invoke_payload = self._build_tool_invocation_payload(tool_entry, invocation, context) + + try: + response = await supervisor.invoke_plugin( + method=tool_entry.invoke_method, + plugin_id=tool_entry.plugin_id, + component_name=tool_entry.name, + args=invoke_payload, + timeout_ms=30000, + ) + except Exception as exc: + logger.error(f"运行时工具 {tool_entry.plugin_id}.{tool_entry.name} 执行失败: {exc}", exc_info=True) + return ToolExecutionResult( + tool_name=tool_entry.name, + success=False, + error_message=str(exc), + metadata={"plugin_id": tool_entry.plugin_id}, + ) + + payload = response.payload if isinstance(response.payload, dict) else {} + transport_success = bool(payload.get("success", False)) + result = payload.get("result") + if not transport_success: + return ToolExecutionResult( + tool_name=tool_entry.name, + success=False, + error_message="" if result is None else str(result), + structured_content=result, + metadata={"plugin_id": tool_entry.plugin_id}, + ) + return self._parse_tool_invoke_result(tool_entry, result) + + def get_llm_available_tools(self) -> Dict[str, ToolInfo]: + """获取当前可供 LLM 选择的工具集合。 + + Returns: + Dict[str, ToolInfo]: 工具名到工具信息的映射。 + """ + + tool_infos = self._collect_unique_component_infos(ComponentType.TOOL) + return {name: info for name, info in tool_infos.items() if isinstance(info, ToolInfo) and info.enabled} + + def get_components_by_type(self, component_type: ComponentType) -> Dict[str, ComponentInfo]: + """获取某类组件的全部信息。 + + Args: + component_type: 组件类型。 + + Returns: + Dict[str, ComponentInfo]: 组件名到组件信息的映射。 + """ + + return self._collect_unique_component_infos(component_type) + + def get_plugin_config(self, plugin_name: str) -> Optional[dict]: + """读取指定插件的配置文件内容。 + + Args: + plugin_name: 插件名称。 + + Returns: + Optional[dict]: 读取成功时返回配置字典;未找到时返回 ``None``。 + """ + + runtime_manager = self._get_runtime_manager() + try: + supervisor = runtime_manager._get_supervisor_for_plugin(plugin_name) + except RuntimeError as exc: + logger.error(f"读取插件配置失败: {exc}") + return None + + if supervisor is None: + return None + + try: + return runtime_manager._load_plugin_config_for_supervisor(supervisor, plugin_name) + except Exception as exc: + logger.error(f"读取插件 {plugin_name} 配置失败: {exc}", exc_info=True) + return None + + def get_plugin_default_config(self, plugin_name: str) -> Optional[dict]: + """获取指定插件注册时上报的默认配置。 + + Args: + plugin_name: 插件名称。 + + Returns: + Optional[dict]: 默认配置字典;未找到时返回 ``None``。 + """ + + runtime_manager = self._get_runtime_manager() + try: + supervisor = runtime_manager._get_supervisor_for_plugin(plugin_name) + except RuntimeError as exc: + logger.error(f"读取插件默认配置失败: {exc}") + return None + + if supervisor is None: + return None + + registration = supervisor._registered_plugins.get(plugin_name) + if registration is None: + return None + return dict(registration.default_config) + + def get_plugin_config_schema(self, plugin_name: str) -> Optional[dict]: + """获取指定插件注册时上报的配置 Schema。 + + Args: + plugin_name: 插件名称。 + + Returns: + Optional[dict]: 配置 Schema;未找到时返回 ``None``。 + """ + + runtime_manager = self._get_runtime_manager() + try: + supervisor = runtime_manager._get_supervisor_for_plugin(plugin_name) + except RuntimeError as exc: + logger.error(f"读取插件配置 Schema 失败: {exc}") + return None + + if supervisor is None: + return None + + registration = supervisor._registered_plugins.get(plugin_name) + if registration is None: + return None + return dict(registration.config_schema) + + def list_hook_specs(self) -> list[dict[str, Any]]: + """返回当前运行时公开的 Hook 规格清单。 + + Returns: + list[dict[str, Any]]: 可直接序列化给 WebUI 的 Hook 规格列表。 + """ + + runtime_manager = self._get_runtime_manager() + return [ + { + "name": spec.name, + "description": spec.description, + "parameters_schema": deepcopy(spec.parameters_schema), + "default_timeout_ms": spec.default_timeout_ms, + "allow_blocking": spec.allow_blocking, + "allow_observe": spec.allow_observe, + "allow_abort": spec.allow_abort, + "allow_kwargs_mutation": spec.allow_kwargs_mutation, + } + for spec in runtime_manager.list_hook_specs() + ] + + +component_query_service = ComponentQueryService() diff --git a/src/plugin_runtime/dependency_pipeline.py b/src/plugin_runtime/dependency_pipeline.py new file mode 100644 index 00000000..4acbd5ef --- /dev/null +++ b/src/plugin_runtime/dependency_pipeline.py @@ -0,0 +1,441 @@ +"""插件 Python 依赖流水线。 + +负责在 Host 侧统一完成以下工作: +1. 扫描插件 Manifest; +2. 检测插件与主程序、插件与插件之间的 Python 依赖冲突; +3. 为可加载插件自动安装缺失的 Python 依赖; +4. 产出最终的拒绝加载列表,供运行时使用。 +""" + +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +import asyncio +import shutil +import subprocess +import sys + +from packaging.utils import canonicalize_name + +from src.common.logger import get_logger +from src.plugin_runtime.runner.manifest_validator import ManifestValidator, PluginManifest + + +logger = get_logger("plugin_runtime.dependency_pipeline") + + +@dataclass(frozen=True) +class PackageDependencyUsage: + """记录单个插件对某个 Python 包的依赖声明。""" + + package_name: str + plugin_id: str + version_spec: str + + +@dataclass(frozen=True) +class CombinedPackageRequirement: + """表示一个已经合并后的 Python 包安装需求。""" + + package_name: str + plugin_ids: Tuple[str, ...] + requirement_text: str + version_spec: str + + +@dataclass(frozen=True) +class DependencyPipelinePlan: + """表示一次依赖分析后得到的计划。""" + + blocked_plugin_reasons: Dict[str, str] + install_requirements: Tuple[CombinedPackageRequirement, ...] + + +@dataclass(frozen=True) +class DependencyPipelineResult: + """表示一次依赖流水线执行后的结果。""" + + blocked_plugin_reasons: Dict[str, str] + environment_changed: bool + install_requirements: Tuple[CombinedPackageRequirement, ...] + + +class PluginDependencyPipeline: + """插件依赖流水线。 + + 该类不负责插件启停,只负责对插件目录进行依赖分析,并在必要时 + 使用 ``uv`` 为可加载插件补齐缺失的 Python 依赖。 + """ + + def __init__(self, project_root: Optional[Path] = None) -> None: + """初始化依赖流水线。 + + Args: + project_root: 项目根目录;留空时自动推断。 + """ + + self._project_root: Path = project_root or Path(__file__).resolve().parents[2] + self._manifest_validator: ManifestValidator = ManifestValidator( + project_root=self._project_root, + validate_python_package_dependencies=False, + ) + + async def execute(self, plugin_dirs: Iterable[Path]) -> DependencyPipelineResult: + """执行完整的依赖分析与自动安装流程。 + + Args: + plugin_dirs: 需要扫描的插件根目录集合。 + + Returns: + DependencyPipelineResult: 最终的阻止加载结果与环境变更状态。 + """ + + plan = self.build_plan(plugin_dirs) + if not plan.install_requirements: + return DependencyPipelineResult( + blocked_plugin_reasons=dict(plan.blocked_plugin_reasons), + environment_changed=False, + install_requirements=plan.install_requirements, + ) + + install_succeeded, error_message = await self._install_requirements(plan.install_requirements) + if install_succeeded: + return DependencyPipelineResult( + blocked_plugin_reasons=dict(plan.blocked_plugin_reasons), + environment_changed=True, + install_requirements=plan.install_requirements, + ) + + blocked_plugin_reasons = dict(plan.blocked_plugin_reasons) + affected_plugin_ids = sorted( + { + plugin_id + for requirement in plan.install_requirements + for plugin_id in requirement.plugin_ids + } + ) + for plugin_id in affected_plugin_ids: + self._append_block_reason( + blocked_plugin_reasons, + plugin_id, + f"自动安装 Python 依赖失败: {error_message}", + ) + + return DependencyPipelineResult( + blocked_plugin_reasons=blocked_plugin_reasons, + environment_changed=False, + install_requirements=plan.install_requirements, + ) + + def build_plan(self, plugin_dirs: Iterable[Path]) -> DependencyPipelinePlan: + """构建依赖分析计划。 + + Args: + plugin_dirs: 需要扫描的插件根目录集合。 + + Returns: + DependencyPipelinePlan: 分析后的阻止加载列表与安装计划。 + """ + + manifests = self._collect_manifests(plugin_dirs) + blocked_plugin_reasons = self._detect_host_conflicts(manifests) + plugin_conflict_reasons = self._detect_plugin_conflicts(manifests, blocked_plugin_reasons) + for plugin_id, reason in plugin_conflict_reasons.items(): + self._append_block_reason(blocked_plugin_reasons, plugin_id, reason) + + install_requirements = self._build_install_requirements(manifests, blocked_plugin_reasons) + return DependencyPipelinePlan( + blocked_plugin_reasons=blocked_plugin_reasons, + install_requirements=install_requirements, + ) + + def _collect_manifests(self, plugin_dirs: Iterable[Path]) -> Dict[str, PluginManifest]: + """收集所有可成功解析的插件 Manifest。 + + Args: + plugin_dirs: 需要扫描的插件根目录集合。 + + Returns: + Dict[str, PluginManifest]: 以插件 ID 为键的 Manifest 映射。 + """ + + manifests: Dict[str, PluginManifest] = {} + for _plugin_path, manifest in self._manifest_validator.iter_plugin_manifests(plugin_dirs): + manifests[manifest.id] = manifest + return manifests + + def _detect_host_conflicts(self, manifests: Dict[str, PluginManifest]) -> Dict[str, str]: + """检测插件与主程序依赖之间的冲突。 + + Args: + manifests: 当前已解析到的插件 Manifest 映射。 + + Returns: + Dict[str, str]: 需要被阻止加载的插件及原因。 + """ + + host_requirements = self._manifest_validator.load_host_dependency_requirements() + blocked_plugin_reasons: Dict[str, str] = {} + + for manifest in manifests.values(): + for dependency in manifest.python_package_dependencies: + package_specifier = self._manifest_validator.build_specifier_set(dependency.version_spec) + if package_specifier is None: + self._append_block_reason( + blocked_plugin_reasons, + manifest.id, + f"Python 包依赖声明无效: {dependency.name}{dependency.version_spec}", + ) + continue + + normalized_package_name = canonicalize_name(dependency.name) + host_requirement = host_requirements.get(normalized_package_name) + if host_requirement is None: + continue + + if self._manifest_validator.requirements_may_overlap( + host_requirement.specifier, + package_specifier, + ): + continue + + host_specifier_text = str(host_requirement.specifier or "") or "任意版本" + self._append_block_reason( + blocked_plugin_reasons, + manifest.id, + ( + f"Python 包依赖与主程序冲突: {dependency.name} 需要 " + f"{dependency.version_spec},主程序约束为 {host_specifier_text}" + ), + ) + + return blocked_plugin_reasons + + def _detect_plugin_conflicts( + self, + manifests: Dict[str, PluginManifest], + blocked_plugin_reasons: Dict[str, str], + ) -> Dict[str, str]: + """检测插件之间的 Python 依赖冲突。 + + Args: + manifests: 当前已解析到的插件 Manifest 映射。 + blocked_plugin_reasons: 已经因为其他原因被阻止加载的插件。 + + Returns: + Dict[str, str]: 新增的插件冲突原因映射。 + """ + + blocked_by_plugin_conflicts: Dict[str, str] = {} + dependency_usages = self._collect_package_usages(manifests, blocked_plugin_reasons) + + for _package_name, usages in dependency_usages.items(): + display_package_name = usages[0].package_name + for index, left_usage in enumerate(usages): + for right_usage in usages[index + 1 :]: + left_specifier = self._manifest_validator.build_specifier_set(left_usage.version_spec) + right_specifier = self._manifest_validator.build_specifier_set(right_usage.version_spec) + if left_specifier is None or right_specifier is None: + continue + + if self._manifest_validator.requirements_may_overlap(left_specifier, right_specifier): + continue + + left_reason = ( + f"Python 包依赖冲突: 与插件 {right_usage.plugin_id} 在 {display_package_name} 上的约束不兼容 " + f"({left_usage.version_spec} vs {right_usage.version_spec})" + ) + right_reason = ( + f"Python 包依赖冲突: 与插件 {left_usage.plugin_id} 在 {display_package_name} 上的约束不兼容 " + f"({right_usage.version_spec} vs {left_usage.version_spec})" + ) + self._append_block_reason(blocked_by_plugin_conflicts, left_usage.plugin_id, left_reason) + self._append_block_reason(blocked_by_plugin_conflicts, right_usage.plugin_id, right_reason) + + return blocked_by_plugin_conflicts + + def _collect_package_usages( + self, + manifests: Dict[str, PluginManifest], + blocked_plugin_reasons: Dict[str, str], + ) -> Dict[str, List[PackageDependencyUsage]]: + """收集所有未被阻止加载插件的包依赖声明。 + + Args: + manifests: 当前已解析到的插件 Manifest 映射。 + blocked_plugin_reasons: 已经被阻止加载的插件及原因。 + + Returns: + Dict[str, List[PackageDependencyUsage]]: 按规范化包名分组后的依赖声明。 + """ + + dependency_usages: Dict[str, List[PackageDependencyUsage]] = {} + for manifest in manifests.values(): + if manifest.id in blocked_plugin_reasons: + continue + + for dependency in manifest.python_package_dependencies: + normalized_package_name = canonicalize_name(dependency.name) + dependency_usages.setdefault(normalized_package_name, []).append( + PackageDependencyUsage( + package_name=dependency.name, + plugin_id=manifest.id, + version_spec=dependency.version_spec, + ) + ) + + return dependency_usages + + def _build_install_requirements( + self, + manifests: Dict[str, PluginManifest], + blocked_plugin_reasons: Dict[str, str], + ) -> Tuple[CombinedPackageRequirement, ...]: + """构建需要安装到当前环境的 Python 包需求列表。 + + Args: + manifests: 当前已解析到的插件 Manifest 映射。 + blocked_plugin_reasons: 已经被阻止加载的插件及原因。 + + Returns: + Tuple[CombinedPackageRequirement, ...]: 需要安装或调整版本的依赖列表。 + """ + + combined_requirements: List[CombinedPackageRequirement] = [] + dependency_usages = self._collect_package_usages(manifests, blocked_plugin_reasons) + + for usages in dependency_usages.values(): + merged_specifier_text = self._merge_specifier_texts([usage.version_spec for usage in usages]) + package_name = usages[0].package_name + requirement_text = f"{package_name}{merged_specifier_text}" + installed_version = self._manifest_validator.get_installed_package_version(package_name) + if installed_version is not None and self._manifest_validator.version_matches_specifier( + installed_version, + merged_specifier_text, + ): + continue + + combined_requirements.append( + CombinedPackageRequirement( + package_name=package_name, + plugin_ids=tuple(sorted({usage.plugin_id for usage in usages})), + requirement_text=requirement_text, + version_spec=merged_specifier_text, + ) + ) + + return tuple(sorted(combined_requirements, key=lambda requirement: canonicalize_name(requirement.package_name))) + + @staticmethod + def _merge_specifier_texts(specifier_texts: Sequence[str]) -> str: + """合并多个版本约束文本。 + + Args: + specifier_texts: 需要合并的版本约束文本序列。 + + Returns: + str: 合并后的版本约束文本。 + """ + + merged_parts: List[str] = [] + for specifier_text in specifier_texts: + for part in str(specifier_text or "").split(","): + normalized_part = part.strip() + if not normalized_part or normalized_part in merged_parts: + continue + merged_parts.append(normalized_part) + return f"{','.join(merged_parts)}" if merged_parts else "" + + async def _install_requirements(self, requirements: Sequence[CombinedPackageRequirement]) -> Tuple[bool, str]: + """安装指定的 Python 包需求列表。 + + Args: + requirements: 需要安装的依赖列表。 + + Returns: + Tuple[bool, str]: 安装是否成功,以及错误摘要。 + """ + + requirement_texts = [requirement.requirement_text for requirement in requirements] + if not requirement_texts: + return True, "" + + logger.info(f"开始自动安装插件 Python 依赖: {', '.join(requirement_texts)}") + command = self._build_install_command(requirement_texts) + + try: + completed_process = await asyncio.to_thread( + subprocess.run, + command, + capture_output=True, + check=False, + cwd=self._project_root, + text=True, + ) + except Exception as exc: + return False, str(exc) + + if completed_process.returncode == 0: + logger.info("插件 Python 依赖自动安装完成") + return True, "" + + output = self._summarize_install_error(completed_process.stdout, completed_process.stderr) + return False, output or f"命令执行失败,退出码 {completed_process.returncode}" + + @staticmethod + def _build_install_command(requirement_texts: Sequence[str]) -> List[str]: + """构造依赖安装命令。 + + Args: + requirement_texts: 待安装的依赖文本序列。 + + Returns: + List[str]: 适用于 ``subprocess.run`` 的命令参数列表。 + """ + + if shutil.which("uv"): + return ["uv", "pip", "install", "--python", sys.executable, *requirement_texts] + return [sys.executable, "-m", "pip", "install", *requirement_texts] + + @staticmethod + def _summarize_install_error(stdout: str, stderr: str) -> str: + """提炼安装失败输出。 + + Args: + stdout: 标准输出内容。 + stderr: 标准错误内容。 + + Returns: + str: 简短的错误摘要。 + """ + + merged_output = "\n".join(part.strip() for part in (stderr, stdout) if part and part.strip()).strip() + if not merged_output: + return "" + lines = [line.strip() for line in merged_output.splitlines() if line.strip()] + return " | ".join(lines[-5:]) + + @staticmethod + def _append_block_reason( + blocked_plugin_reasons: Dict[str, str], + plugin_id: str, + reason: str, + ) -> None: + """向阻止加载映射中追加原因。 + + Args: + blocked_plugin_reasons: 待更新的阻止加载映射。 + plugin_id: 目标插件 ID。 + reason: 需要追加的原因文本。 + """ + + existing_reason = blocked_plugin_reasons.get(plugin_id) + if existing_reason is None: + blocked_plugin_reasons[plugin_id] = reason + return + + existing_parts = [part.strip() for part in existing_reason.split(";") if part.strip()] + if reason in existing_parts: + return + blocked_plugin_reasons[plugin_id] = f"{existing_reason};{reason}" diff --git a/src/plugin_runtime/hook_catalog.py b/src/plugin_runtime/hook_catalog.py new file mode 100644 index 00000000..a9428be6 --- /dev/null +++ b/src/plugin_runtime/hook_catalog.py @@ -0,0 +1,52 @@ +"""内置命名 Hook 目录注册器。""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import List + +from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry + + +HookSpecRegistrar = Callable[[HookSpecRegistry], List[HookSpec]] +"""单个业务模块向注册中心写入 Hook 规格的注册器签名。""" + + +def _get_builtin_hook_spec_registrars() -> List[HookSpecRegistrar]: + """返回当前内置 Hook 规格注册器列表。 + + Returns: + List[HookSpecRegistrar]: 已启用的内置 Hook 注册器列表。 + """ + + from src.chat.message_receive.bot import register_chat_hook_specs + from src.emoji_system.emoji_manager import register_emoji_hook_specs + from src.learners.expression_learner import register_expression_hook_specs + from src.learners.jargon_miner import register_jargon_hook_specs + from src.maisaka.chat_loop_service import register_maisaka_hook_specs + from src.services.send_service import register_send_service_hook_specs + + return [ + register_chat_hook_specs, + register_emoji_hook_specs, + register_jargon_hook_specs, + register_expression_hook_specs, + register_send_service_hook_specs, + register_maisaka_hook_specs, + ] + + +def register_builtin_hook_specs(registry: HookSpecRegistry) -> List[HookSpec]: + """向注册中心写入全部内置 Hook 规格。 + + Args: + registry: 目标 Hook 规格注册中心。 + + Returns: + List[HookSpec]: 本次完成注册后的全部内置 Hook 规格。 + """ + + registered_specs: List[HookSpec] = [] + for registrar in _get_builtin_hook_spec_registrars(): + registered_specs.extend(registrar(registry)) + return registered_specs diff --git a/src/plugin_runtime/hook_payloads.py b/src/plugin_runtime/hook_payloads.py new file mode 100644 index 00000000..6e9cc9e7 --- /dev/null +++ b/src/plugin_runtime/hook_payloads.py @@ -0,0 +1,181 @@ +"""运行时 Hook 载荷序列化辅助。""" + +from __future__ import annotations + +from typing import Any, Dict, List, Sequence + +from src.chat.message_receive.message import SessionMessage +from src.common.data_models.llm_service_data_models import PromptMessage +from src.llm_models.payload_content.message import Message +from src.llm_models.payload_content.tool_option import ToolCall, ToolDefinitionInput, normalize_tool_options +from src.plugin_runtime.host.message_utils import PluginMessageUtils + + +def serialize_session_message(message: SessionMessage) -> Dict[str, Any]: + """将会话消息序列化为 Hook 可传输载荷。 + + Args: + message: 待序列化的会话消息。 + + Returns: + Dict[str, Any]: 可通过插件运行时传输的消息字典。 + """ + + return dict(PluginMessageUtils._session_message_to_dict(message)) + + +def deserialize_session_message(raw_message: Any) -> SessionMessage: + """从 Hook 载荷恢复会话消息。 + + Args: + raw_message: Hook 返回的消息字典。 + + Returns: + SessionMessage: 恢复后的会话消息对象。 + + Raises: + ValueError: 消息结构不合法时抛出。 + """ + + if not isinstance(raw_message, dict): + raise ValueError("Hook 返回的 `message` 必须是字典") + return PluginMessageUtils._build_session_message_from_dict(raw_message) + + +def serialize_tool_calls(tool_calls: Sequence[ToolCall] | None) -> List[Dict[str, Any]]: + """将工具调用列表序列化为 Hook 可传输载荷。 + + Args: + tool_calls: 原始工具调用列表。 + + Returns: + List[Dict[str, Any]]: 序列化后的工具调用列表。 + """ + + if not tool_calls: + return [] + + return [ + { + "id": tool_call.call_id, + "function": { + "name": tool_call.func_name, + "arguments": dict(tool_call.args or {}), + }, + **({"extra_content": tool_call.extra_content} if tool_call.extra_content else {}), + } + for tool_call in tool_calls + ] + + +def deserialize_tool_calls(raw_tool_calls: Any) -> List[ToolCall]: + """从 Hook 载荷恢复工具调用列表。 + + Args: + raw_tool_calls: Hook 返回的工具调用列表。 + + Returns: + List[ToolCall]: 恢复后的工具调用列表。 + + Raises: + ValueError: 结构不合法时抛出。 + """ + + if raw_tool_calls in (None, []): + return [] + if not isinstance(raw_tool_calls, list): + raise ValueError("Hook 返回的 `tool_calls` 必须是列表") + + normalized_tool_calls: List[ToolCall] = [] + for raw_tool_call in raw_tool_calls: + if not isinstance(raw_tool_call, dict): + raise ValueError("Hook 返回的工具调用项必须是字典") + + function_info = raw_tool_call.get("function", {}) + if isinstance(function_info, dict): + function_name = function_info.get("name") + function_arguments = function_info.get("arguments") + else: + function_name = raw_tool_call.get("name") + function_arguments = raw_tool_call.get("arguments") + + call_id = raw_tool_call.get("id") or raw_tool_call.get("call_id") + if not isinstance(call_id, str) or not isinstance(function_name, str): + raise ValueError("Hook 返回的工具调用缺少 `id` 或函数名称") + + extra_content = raw_tool_call.get("extra_content") + normalized_tool_calls.append( + ToolCall( + call_id=call_id, + func_name=function_name, + args=function_arguments if isinstance(function_arguments, dict) else {}, + extra_content=extra_content if isinstance(extra_content, dict) else None, + ) + ) + return normalized_tool_calls + + +def serialize_prompt_messages(messages: Sequence[Message]) -> List[PromptMessage]: + """将 LLM 消息列表序列化为 Hook 可传输载荷。 + + Args: + messages: 原始 LLM 消息列表。 + + Returns: + List[PromptMessage]: 序列化后的消息字典列表。 + """ + + serialized_messages: List[PromptMessage] = [] + for message in messages: + serialized_message: PromptMessage = { + "role": message.role.value, + "content": message.content, + } + if message.tool_call_id: + serialized_message["tool_call_id"] = message.tool_call_id + if message.tool_calls: + serialized_message["tool_calls"] = serialize_tool_calls(message.tool_calls) + serialized_messages.append(serialized_message) + return serialized_messages + + +def deserialize_prompt_messages(raw_messages: Any) -> List[Message]: + """从 Hook 载荷恢复 LLM 消息列表。 + + Args: + raw_messages: Hook 返回的消息列表。 + + Returns: + List[Message]: 恢复后的 LLM 消息列表。 + + Raises: + ValueError: 结构不合法时抛出。 + """ + + if not isinstance(raw_messages, list): + raise ValueError("Hook 返回的 `messages` 必须是列表") + + from src.services.llm_service import _build_message_from_dict + + normalized_messages: List[Message] = [] + for raw_message in raw_messages: + if not isinstance(raw_message, dict): + raise ValueError("Hook 返回的消息项必须是字典") + normalized_messages.append(_build_message_from_dict(raw_message)) + return normalized_messages + + +def serialize_tool_definitions(tool_definitions: Sequence[ToolDefinitionInput]) -> List[Dict[str, Any]]: + """将工具定义列表序列化为 Hook 可传输载荷。 + + Args: + tool_definitions: 原始工具定义列表。 + + Returns: + List[Dict[str, Any]]: 序列化后的工具定义列表。 + """ + + normalized_tool_options = normalize_tool_options(list(tool_definitions)) + if not normalized_tool_options: + return [] + return [tool_option.to_openai_function_schema() for tool_option in normalized_tool_options] diff --git a/src/plugin_runtime/hook_schema_utils.py b/src/plugin_runtime/hook_schema_utils.py new file mode 100644 index 00000000..c92d15ea --- /dev/null +++ b/src/plugin_runtime/hook_schema_utils.py @@ -0,0 +1,31 @@ +"""Hook 参数模型构造辅助。""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Dict, Sequence + + +def build_object_schema( + properties: Dict[str, Dict[str, Any]], + *, + required: Sequence[str] | None = None, +) -> Dict[str, Any]: + """构造对象级 JSON Schema。 + + Args: + properties: 字段定义映射。 + required: 必填字段名列表。 + + Returns: + Dict[str, Any]: 标准化后的对象级 Schema。 + """ + + schema: Dict[str, Any] = { + "type": "object", + "properties": deepcopy(properties), + } + normalized_required = [str(item).strip() for item in (required or []) if str(item).strip()] + if normalized_required: + schema["required"] = normalized_required + return schema diff --git a/src/plugin_runtime/host/__init__.py b/src/plugin_runtime/host/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/plugin_runtime/host/__init__.py @@ -0,0 +1 @@ + diff --git a/src/plugin_runtime/host/api_registry.py b/src/plugin_runtime/host/api_registry.py new file mode 100644 index 00000000..1cbc05f6 --- /dev/null +++ b/src/plugin_runtime/host/api_registry.py @@ -0,0 +1,349 @@ +"""Host 侧插件 API 动态注册表。""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Set, Tuple + +from src.common.logger import get_logger + +logger = get_logger("plugin_runtime.host.api_registry") + + +@dataclass(slots=True) +class APIEntry: + """API 组件条目。""" + + name: str + plugin_id: str + description: str = "" + version: str = "1" + public: bool = False + metadata: Dict[str, Any] = field(default_factory=dict) + enabled: bool = True + handler_name: str = "" + dynamic: bool = False + offline_reason: str = "" + disabled_session: Set[str] = field(default_factory=set) + full_name: str = field(init=False) + registry_key: str = field(init=False) + + def __post_init__(self) -> None: + """规范化 API 条目字段。""" + + self.name = str(self.name or "").strip() + self.plugin_id = str(self.plugin_id or "").strip() + self.description = str(self.description or "").strip() + self.version = str(self.version or "1").strip() or "1" + self.handler_name = str(self.handler_name or self.name).strip() or self.name + self.offline_reason = str(self.offline_reason or "").strip() + self.full_name = f"{self.plugin_id}.{self.name}" + self.registry_key = APIRegistry.build_registry_key(self.plugin_id, self.name, self.version) + + @classmethod + def from_metadata(cls, name: str, plugin_id: str, metadata: Dict[str, Any]) -> "APIEntry": + """根据 Runner 上报的元数据构造 API 条目。""" + + safe_metadata = dict(metadata) + return cls( + name=name, + plugin_id=plugin_id, + description=str(safe_metadata.get("description", "") or ""), + version=str(safe_metadata.get("version", "1") or "1"), + public=bool(safe_metadata.get("public", False)), + metadata=safe_metadata, + enabled=bool(safe_metadata.get("enabled", True)), + handler_name=str(safe_metadata.get("handler_name", name) or name), + dynamic=bool(safe_metadata.get("dynamic", False)), + offline_reason=str(safe_metadata.get("offline_reason", "") or ""), + ) + + +class APIRegistry: + """Host 侧插件 API 动态注册表。 + + 该注册表不直接面向 Runner,而是复用插件组件注册/卸载事件, + 维护面向 API 调用场景的专用索引。 + """ + + def __init__(self) -> None: + """初始化 API 注册表。""" + + self._apis: Dict[str, APIEntry] = {} + self._by_full_name: Dict[str, List[APIEntry]] = {} + self._by_plugin: Dict[str, List[APIEntry]] = {} + self._by_name: Dict[str, List[APIEntry]] = {} + + def clear(self) -> None: + """清空全部 API 注册状态。""" + + self._apis.clear() + self._by_full_name.clear() + self._by_plugin.clear() + self._by_name.clear() + + @staticmethod + def _is_api_component(component_type: Any) -> bool: + """判断组件声明是否属于 API。""" + + return str(component_type or "").strip().upper() == "API" + + @staticmethod + def _normalize_query_version(version: Any) -> str: + """规范化查询使用的版本字符串。""" + + return str(version or "").strip() + + @classmethod + def _split_reference(cls, reference: str, version: Any = "") -> Tuple[str, str]: + """解析可能带 ``@version`` 后缀的 API 引用。""" + + normalized_reference = str(reference or "").strip() + normalized_version = cls._normalize_query_version(version) + if normalized_reference and not normalized_version and "@" in normalized_reference: + candidate_reference, candidate_version = normalized_reference.rsplit("@", 1) + candidate_reference = candidate_reference.strip() + candidate_version = candidate_version.strip() + if candidate_reference and candidate_version: + normalized_reference = candidate_reference + normalized_version = candidate_version + return normalized_reference, normalized_version + + @staticmethod + def build_registry_key(plugin_id: str, name: str, version: str) -> str: + """构造 API 注册表唯一键。""" + + normalized_full_name = f"{str(plugin_id or '').strip()}.{str(name or '').strip()}" + normalized_version = str(version or "1").strip() or "1" + return f"{normalized_full_name}@{normalized_version}" + + @staticmethod + def check_api_enabled(entry: APIEntry, session_id: Optional[str] = None) -> bool: + """判断 API 条目当前是否处于启用状态。""" + + if session_id and session_id in entry.disabled_session: + return False + return entry.enabled + + def register_api(self, name: str, plugin_id: str, metadata: Dict[str, Any]) -> bool: + """注册单个 API 条目。""" + + normalized_name = str(name or "").strip() + if not normalized_name: + logger.warning(f"插件 {plugin_id} 存在空 API 名称声明,已忽略") + return False + + entry = APIEntry.from_metadata(name=normalized_name, plugin_id=plugin_id, metadata=metadata) + existing_entry = self._apis.get(entry.registry_key) + if existing_entry is not None: + logger.warning(f"API {entry.registry_key} 已存在,覆盖旧条目") + self._remove_entry(existing_entry) + + self._apis[entry.registry_key] = entry + self._by_full_name.setdefault(entry.full_name, []).append(entry) + self._by_plugin.setdefault(plugin_id, []).append(entry) + self._by_name.setdefault(entry.name, []).append(entry) + return True + + def register_plugin_apis(self, plugin_id: str, components: List[Dict[str, Any]]) -> int: + """批量注册某个插件声明的全部 API。""" + + count = 0 + for component in components: + if not self._is_api_component(component.get("component_type")): + continue + if self.register_api( + name=str(component.get("name", "") or ""), + plugin_id=plugin_id, + metadata=component.get("metadata", {}) if isinstance(component.get("metadata"), dict) else {}, + ): + count += 1 + return count + + def replace_plugin_dynamic_apis( + self, + plugin_id: str, + components: List[Dict[str, Any]], + *, + offline_reason: str = "动态 API 已下线", + ) -> Tuple[int, int]: + """替换指定插件当前声明的动态 API 集合。""" + + normalized_offline_reason = str(offline_reason or "").strip() or "动态 API 已下线" + desired_registry_keys: Set[str] = set() + registered_count = 0 + + for component in components: + if not self._is_api_component(component.get("component_type")): + continue + metadata = component.get("metadata", {}) if isinstance(component.get("metadata"), dict) else {} + dynamic_metadata = dict(metadata) + dynamic_metadata["dynamic"] = True + dynamic_metadata.pop("offline_reason", None) + + entry = APIEntry.from_metadata( + name=str(component.get("name", "") or ""), + plugin_id=plugin_id, + metadata=dynamic_metadata, + ) + desired_registry_keys.add(entry.registry_key) + if self.register_api(entry.name, plugin_id, dynamic_metadata): + registered_count += 1 + + offlined_count = 0 + for entry in list(self._by_plugin.get(plugin_id, [])): + if not entry.dynamic or entry.registry_key in desired_registry_keys: + continue + entry.enabled = False + entry.offline_reason = normalized_offline_reason + entry.metadata["offline_reason"] = normalized_offline_reason + offlined_count += 1 + + return registered_count, offlined_count + + def _remove_entry(self, entry: APIEntry) -> None: + """从全部索引中移除单个 API 条目。""" + + self._apis.pop(entry.registry_key, None) + + full_name_entries = self._by_full_name.get(entry.full_name) + if full_name_entries is not None: + self._by_full_name[entry.full_name] = [ + candidate for candidate in full_name_entries if candidate is not entry + ] + if not self._by_full_name[entry.full_name]: + self._by_full_name.pop(entry.full_name, None) + + plugin_entries = self._by_plugin.get(entry.plugin_id) + if plugin_entries is not None: + self._by_plugin[entry.plugin_id] = [candidate for candidate in plugin_entries if candidate is not entry] + if not self._by_plugin[entry.plugin_id]: + self._by_plugin.pop(entry.plugin_id, None) + + name_entries = self._by_name.get(entry.name) + if name_entries is not None: + self._by_name[entry.name] = [candidate for candidate in name_entries if candidate is not entry] + if not self._by_name[entry.name]: + self._by_name.pop(entry.name, None) + + def remove_apis_by_plugin(self, plugin_id: str) -> int: + """移除某个插件的全部 API。""" + + entries = list(self._by_plugin.get(plugin_id, [])) + for entry in entries: + self._remove_entry(entry) + return len(entries) + + def get_api_by_full_name( + self, + full_name: str, + *, + version: str = "", + enabled_only: bool = True, + session_id: Optional[str] = None, + ) -> Optional[APIEntry]: + """按完整名查询单个 API。""" + + normalized_full_name, normalized_version = self._split_reference(full_name, version) + if not normalized_full_name: + return None + + if normalized_version: + entry = self._apis.get(f"{normalized_full_name}@{normalized_version}") + if entry is None: + return None + if enabled_only and not self.check_api_enabled(entry, session_id): + return None + return entry + + candidates = list(self._by_full_name.get(normalized_full_name, [])) + filtered_entries = [ + entry + for entry in candidates + if not enabled_only or self.check_api_enabled(entry, session_id) + ] + if len(filtered_entries) != 1: + return None + return filtered_entries[0] + + def get_api( + self, + plugin_id: str, + name: str, + *, + version: str = "", + enabled_only: bool = True, + session_id: Optional[str] = None, + ) -> Optional[APIEntry]: + """按插件 ID、短名与版本查询单个 API。""" + + return self.get_api_by_full_name( + f"{plugin_id}.{name}", + version=version, + enabled_only=enabled_only, + session_id=session_id, + ) + + def get_apis( + self, + *, + plugin_id: Optional[str] = None, + name: str = "", + version: str = "", + enabled_only: bool = True, + session_id: Optional[str] = None, + ) -> List[APIEntry]: + """查询 API 列表。""" + + normalized_name = str(name or "").strip() + normalized_version = self._normalize_query_version(version) + + if plugin_id: + candidates = list(self._by_plugin.get(plugin_id, [])) + elif normalized_name: + candidates = list(self._by_name.get(normalized_name, [])) + else: + candidates = list(self._apis.values()) + + filtered_entries: List[APIEntry] = [] + for entry in candidates: + if plugin_id and entry.plugin_id != plugin_id: + continue + if normalized_name and entry.name != normalized_name: + continue + if normalized_version and entry.version != normalized_version: + continue + if enabled_only and not self.check_api_enabled(entry, session_id): + continue + filtered_entries.append(entry) + + filtered_entries.sort(key=lambda entry: (entry.plugin_id, entry.name, entry.version)) + return filtered_entries + + def toggle_api_status( + self, + full_name: str, + enabled: bool, + *, + version: str = "", + session_id: Optional[str] = None, + ) -> bool: + """设置指定 API 的启用状态。""" + + entry = self.get_api_by_full_name( + full_name, + version=version, + enabled_only=False, + session_id=session_id, + ) + if entry is None: + return False + if session_id: + if enabled: + entry.disabled_session.discard(session_id) + else: + entry.disabled_session.add(session_id) + else: + entry.enabled = enabled + if enabled: + entry.offline_reason = "" + entry.metadata.pop("offline_reason", None) + return True diff --git a/src/plugin_runtime/host/authorization.py b/src/plugin_runtime/host/authorization.py new file mode 100644 index 00000000..70593768 --- /dev/null +++ b/src/plugin_runtime/host/authorization.py @@ -0,0 +1,67 @@ +"""授权管理器 + +负责管理插件的能力授权以及校验 +每个插件在 manifest 中声明能力需求,Host 启动时签发能力令牌。 +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Set, Tuple + +_ALWAYS_ALLOWED_CAPABILITIES = frozenset({"api.replace_dynamic"}) + + +@dataclass +class CapabilityPermissionToken: + """能力令牌""" + + plugin_id: str + capabilities: Set[str] = field(default_factory=set) + + +class AuthorizationManager: + """授权管理器 + + 管理所有插件的能力令牌,提供授权校验。 + """ + + def __init__(self) -> None: + self._permission_tokens: Dict[str, CapabilityPermissionToken] = {} + + def register_plugin(self, plugin_id: str, capabilities: List[str]) -> CapabilityPermissionToken: + """为插件签发能力令牌""" + token = CapabilityPermissionToken(plugin_id=plugin_id, capabilities=set(capabilities)) + self._permission_tokens[plugin_id] = token + return token + + def revoke_permission_token(self, plugin_id: str): + """移除插件的能力令牌。""" + self._permission_tokens.pop(plugin_id, None) + + def clear(self) -> None: + """清空所有能力令牌。""" + self._permission_tokens.clear() + + def check_capability(self, plugin_id: str, capability: str) -> Tuple[bool, str]: + # sourcery skip: assign-if-exp, reintroduce-else, swap-if-else-branches, use-named-expression + """检查插件是否有权调用某项能力 + + Returns: + return (bool, str): (是否有此能力, 原因) + """ + if capability in _ALWAYS_ALLOWED_CAPABILITIES: + return True, "" + + token = self._permission_tokens.get(plugin_id) + if not token: + return False, f"插件 {plugin_id} 未注册能力令牌" + if capability not in token.capabilities: + return False, f"插件 {plugin_id} 未获授权能力: {capability}" + return True, "" + + def get_token(self, plugin_id: str) -> Optional[CapabilityPermissionToken]: + """获取插件的能力令牌""" + return self._permission_tokens.get(plugin_id) + + def list_plugins(self) -> List[str]: + """列出所有已注册的插件""" + return list(self._permission_tokens.keys()) diff --git a/src/plugin_runtime/host/capability_service.py b/src/plugin_runtime/host/capability_service.py new file mode 100644 index 00000000..0ff31fe1 --- /dev/null +++ b/src/plugin_runtime/host/capability_service.py @@ -0,0 +1,91 @@ +"""能力服务层 + +Host 端实现的能力服务,处理来自插件的 cap.* 请求。 +每个能力方法被注册到 RPC Server,接收 Runner 转发的请求并执行实际操作。 +""" + +from typing import Any, Callable, Dict, List, Coroutine, TYPE_CHECKING + +from src.common.logger import get_logger +from src.plugin_runtime.protocol.envelope import CapabilityRequestPayload, CapabilityResponsePayload, Envelope +from src.plugin_runtime.protocol.errors import ErrorCode, RPCError + +if TYPE_CHECKING: + from src.plugin_runtime.host.authorization import AuthorizationManager + +logger = get_logger("plugin_runtime.host.capability_service") + +# 能力实现函数类型: (plugin_id, capability, args) -> result +CapabilityImpl = Callable[[str, str, Dict[str, Any]], Coroutine[Any, Any, Any]] + + +class CapabilityService: + """能力服务 + + 负责: + 1. 注册能力实现 + 2. 接收插件的能力调用请求 + 3. 通过策略引擎校验权限和限流 + 4. 执行实际操作并返回结果 + """ + + def __init__(self, authorization: "AuthorizationManager") -> None: + """初始化能力服务。 + + Args: + authorization: 能力授权管理器。 + """ + self._authorization = authorization + # capability_name -> implementation + self._implementations: Dict[str, CapabilityImpl] = {} + + def register_capability(self, name: str, impl: CapabilityImpl) -> None: + """注册一个能力实现 + + Args: + name: 能力名称,如 "send.text", "db.query", "llm.generate" + impl: 实现函数 + """ + self._implementations[name] = impl + logger.debug(f"注册能力实现: {name}") + + async def handle_capability_request(self, envelope: Envelope) -> Envelope: + """处理能力调用请求(作为 RPC Server 的 method handler) + + 从 envelope 中提取 capability 名称和参数, + 校验权限后调用对应实现。 + """ + plugin_id = envelope.plugin_id + + try: + req = CapabilityRequestPayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, f"能力调用 payload 非法: {exc}") + + capability = req.capability + args = req.args + + # 1. 权限校验 + allowed, reason = self._authorization.check_capability(plugin_id, capability) + if not allowed: + return envelope.make_error_response(ErrorCode.E_CAPABILITY_DENIED.value, reason) + + # 2. 查找实现 + impl = self._implementations.get(capability) + if impl is None: + return envelope.make_error_response(ErrorCode.E_METHOD_NOT_ALLOWED.value, f"未注册的能力: {capability}") + + # 3. 执行 + try: + result = await impl(plugin_id, capability, args) + resp_payload = CapabilityResponsePayload(success=True, result=result) + return envelope.make_response(payload=resp_payload.model_dump()) + except RPCError as e: + return envelope.make_error_response(e.code.value, e.message, e.details) + except Exception as e: + logger.error(f"能力 {capability} 执行异常: {e}", exc_info=True) + return envelope.make_error_response(ErrorCode.E_CAPABILITY_FAILED.value, str(e)) + + def list_capabilities(self) -> List[str]: + """列出所有已注册的能力""" + return list(self._implementations.keys()) diff --git a/src/plugin_runtime/host/component_registry.py b/src/plugin_runtime/host/component_registry.py new file mode 100644 index 00000000..2b6e4c76 --- /dev/null +++ b/src/plugin_runtime/host/component_registry.py @@ -0,0 +1,1276 @@ +"""Host 侧组件注册表。 + +对齐旧系统 component_registry.py 的核心能力: +- 按类型注册组件(action / command / tool / event_handler / hook_handler / message_gateway) +- 命名空间 (plugin_id.component_name) +- 命令正则匹配 +- 组件启用/禁用 +- 多维度查询(按名称、类型、插件) +- 注册统计 +""" + +from enum import Enum +from typing import Any, Dict, List, Literal, Optional, Set, Tuple, TypedDict + +import contextlib +import re + +from src.common.logger import get_logger +from src.core.tooling import build_tool_detailed_description + +from .hook_spec_registry import HookSpecRegistry + +logger = get_logger("plugin_runtime.host.component_registry") + + +class ComponentRegistrationError(ValueError): + """组件注册失败异常。""" + + def __init__( + self, + message: str, + *, + component_name: str = "", + component_type: str = "", + plugin_id: str = "", + ) -> None: + """初始化组件注册失败异常。 + + Args: + message: 原始错误信息。 + component_name: 组件名称。 + component_type: 组件类型。 + plugin_id: 插件 ID。 + """ + + self.component_name = str(component_name or "").strip() + self.component_type = str(component_type or "").strip() + self.plugin_id = str(plugin_id or "").strip() + super().__init__(message) + + +class ComponentTypes(str, Enum): + ACTION = "ACTION" + COMMAND = "COMMAND" + TOOL = "TOOL" + EVENT_HANDLER = "EVENT_HANDLER" + HOOK_HANDLER = "HOOK_HANDLER" + MESSAGE_GATEWAY = "MESSAGE_GATEWAY" + + +ComponentChatScope = Literal["all", "group", "private"] + + +def _normalize_chat_scope(raw_value: Any) -> ComponentChatScope: + """规范化组件聊天类型适用范围。""" + + normalized_value = str(raw_value or "all").strip().lower() + if normalized_value == "group": + return "group" + if normalized_value == "private": + return "private" + return "all" + + +class StatusDict(TypedDict): + total: int + action: int + command: int + tool: int + event_handler: int + hook_handler: int + message_gateway: int + plugins: int + + +class ComponentEntry: + """组件条目""" + + __slots__ = ( + "name", + "full_name", + "component_type", + "plugin_id", + "metadata", + "enabled", + "compiled_pattern", + "disabled_session", + "chat_scope", + "allowed_session", + ) + + def __init__( + self, + name: str, + component_type: str, + plugin_id: str, + metadata: Dict[str, Any], + chat_scope: str = "all", + allowed_session: Optional[List[str]] = None, + ) -> None: + self.name: str = name + self.full_name: str = f"{plugin_id}.{name}" + self.component_type: ComponentTypes = ComponentTypes(component_type) + self.plugin_id: str = plugin_id + self.metadata: Dict[str, Any] = metadata + self.enabled: bool = metadata.get("enabled", True) + self.disabled_session: Set[str] = set() + self.chat_scope: ComponentChatScope = _normalize_chat_scope(chat_scope) + self.allowed_session: Set[str] = { + str(session_id).strip() + for session_id in (allowed_session or []) + if str(session_id).strip() + } + +class ActionEntry(ComponentEntry): + """Action 组件条目""" + + def __init__( + self, + name: str, + component_type: str, + plugin_id: str, + metadata: Dict[str, Any], + chat_scope: str = "all", + allowed_session: Optional[List[str]] = None, + ) -> None: + super().__init__(name, component_type, plugin_id, metadata, chat_scope, allowed_session) + + +class CommandEntry(ComponentEntry): + """Command 组件条目""" + + def __init__( + self, + name: str, + component_type: str, + plugin_id: str, + metadata: Dict[str, Any], + chat_scope: str = "all", + allowed_session: Optional[List[str]] = None, + ) -> None: + super().__init__(name, component_type, plugin_id, metadata, chat_scope, allowed_session) + self.aliases: List[str] = metadata.get("aliases", []) + self.compiled_pattern: Optional[re.Pattern] = None + if pattern := metadata.get("command_pattern", ""): + try: + self.compiled_pattern = re.compile(pattern) + except (re.error, TypeError) as e: + logger.warning(f"命令 {self.full_name} 正则编译失败: {e}") + + +class ToolEntry(ComponentEntry): + """Tool 组件条目""" + + def __init__( + self, + name: str, + component_type: str, + plugin_id: str, + metadata: Dict[str, Any], + chat_scope: str = "all", + allowed_session: Optional[List[str]] = None, + ) -> None: + self.description: str = str(metadata.get("description", "") or "").strip() + self.brief_description: str = str( + metadata.get("brief_description", self.description) or self.description or f"工具 {name}" + ).strip() + self.parameters: List[Dict[str, Any]] = metadata.get("parameters", []) + self.parameters_raw: Dict[str, Any] | List[Dict[str, Any]] = metadata.get("parameters_raw", {}) + detailed_description = str(metadata.get("detailed_description", "") or "").strip() + self.detailed_description: str = detailed_description + self.invoke_method: str = str(metadata.get("invoke_method", "plugin.invoke_tool") or "plugin.invoke_tool").strip() + self.legacy_component_type: str = str(metadata.get("legacy_component_type", "") or "").strip() + super().__init__(name, component_type, plugin_id, metadata, chat_scope, allowed_session) + + if not self.detailed_description: + parameters_schema = self._get_parameters_schema() + self.detailed_description = build_tool_detailed_description(parameters_schema) + + def _get_parameters_schema(self) -> Dict[str, Any] | None: + """获取当前工具条目的对象级参数 Schema。 + + Returns: + Dict[str, Any] | None: 归一化后的参数 Schema。 + """ + + if isinstance(self.parameters_raw, dict) and self.parameters_raw: + if self.parameters_raw.get("type") == "object" or "properties" in self.parameters_raw: + return dict(self.parameters_raw) + + required_names: List[str] = [] + normalized_properties: Dict[str, Any] = {} + for property_name, property_schema in self.parameters_raw.items(): + if not isinstance(property_schema, dict): + continue + property_schema_copy = dict(property_schema) + if bool(property_schema_copy.pop("required", False)): + required_names.append(str(property_name)) + normalized_properties[str(property_name)] = property_schema_copy + + schema: Dict[str, Any] = { + "type": "object", + "properties": normalized_properties, + } + if required_names: + schema["required"] = required_names + return schema + + if isinstance(self.parameters, list) and self.parameters: + properties: Dict[str, Any] = {} + required_names: List[str] = [] + for parameter in self.parameters: + if not isinstance(parameter, dict): + continue + parameter_name = str(parameter.get("name", "") or "").strip() + if not parameter_name: + continue + if bool(parameter.get("required", False)): + required_names.append(parameter_name) + properties[parameter_name] = { + key: value + for key, value in parameter.items() + if key not in {"name", "required", "param_type"} + } + properties[parameter_name]["type"] = str( + parameter.get("type", parameter.get("param_type", "string")) or "string" + ) + + schema = { + "type": "object", + "properties": properties, + } + if required_names: + schema["required"] = required_names + return schema + + return None + + +class EventHandlerEntry(ComponentEntry): + """EventHandler 组件条目""" + + def __init__( + self, + name: str, + component_type: str, + plugin_id: str, + metadata: Dict[str, Any], + chat_scope: str = "all", + allowed_session: Optional[List[str]] = None, + ) -> None: + self.event_type: str = metadata.get("event_type", "") + self.weight: int = metadata.get("weight", 0) + self.intercept_message: bool = metadata.get("intercept_message", False) + super().__init__(name, component_type, plugin_id, metadata, chat_scope, allowed_session) + + +class HookHandlerEntry(ComponentEntry): + """HookHandler 组件条目。""" + + def __init__( + self, + name: str, + component_type: str, + plugin_id: str, + metadata: Dict[str, Any], + chat_scope: str = "all", + allowed_session: Optional[List[str]] = None, + ) -> None: + self.hook: str = self._normalize_hook_name(metadata.get("hook", "")) + self.mode: str = self._normalize_mode(metadata.get("mode", "blocking")) + self.order: str = self._normalize_order(metadata.get("order", "normal")) + self.timeout_ms: int = self._normalize_timeout_ms(metadata.get("timeout_ms", 0)) + self.error_policy: str = self._normalize_error_policy(metadata.get("error_policy", "skip")) + super().__init__(name, component_type, plugin_id, metadata, chat_scope, allowed_session) + + @staticmethod + def _normalize_error_policy(raw_value: Any) -> str: + """规范化 Hook 异常处理策略。 + + Args: + raw_value: 原始异常处理策略值。 + + Returns: + str: 规范化后的异常处理策略。 + + Raises: + ValueError: 当异常处理策略不受支持时抛出。 + """ + + normalized_source = getattr(raw_value, "value", raw_value) + normalized_value = str(normalized_source or "").strip().lower() or "skip" + if normalized_value not in {"abort", "skip", "log"}: + raise ValueError(f"HookHandler 异常处理策略不合法: {raw_value}") + return normalized_value + + @staticmethod + def _normalize_hook_name(raw_value: Any) -> str: + """规范化命名 Hook 名称。 + + Args: + raw_value: 原始 Hook 名称。 + + Returns: + str: 去空白后的 Hook 名称。 + + Raises: + ValueError: 当 Hook 名称为空时抛出。 + """ + + normalized_source = getattr(raw_value, "value", raw_value) + if not (normalized_value := str(normalized_source or "").strip()): + raise ValueError("HookHandler 的 hook 名称不能为空") + return normalized_value + + @staticmethod + def _normalize_mode(raw_value: Any) -> str: + """规范化 Hook 处理模式。 + + Args: + raw_value: 原始模式值。 + + Returns: + str: 规范化后的模式。 + + Raises: + ValueError: 当模式不受支持时抛出。 + """ + + normalized_source = getattr(raw_value, "value", raw_value) + normalized_value = str(normalized_source or "").strip().lower() or "blocking" + if normalized_value not in {"blocking", "observe"}: + raise ValueError(f"HookHandler 模式不合法: {raw_value}") + return normalized_value + + @staticmethod + def _normalize_order(raw_value: Any) -> str: + """规范化 Hook 顺序槽位。 + + Args: + raw_value: 原始顺序值。 + + Returns: + str: 规范化后的顺序槽位。 + + Raises: + ValueError: 当顺序值不受支持时抛出。 + """ + + normalized_source = getattr(raw_value, "value", raw_value) + normalized_value = str(normalized_source or "").strip().lower() or "normal" + if normalized_value not in {"early", "normal", "late"}: + raise ValueError(f"HookHandler 顺序槽位不合法: {raw_value}") + return normalized_value + + @staticmethod + def _normalize_timeout_ms(raw_value: Any) -> int: + """规范化 Hook 超时配置。 + + Args: + raw_value: 原始超时值。 + + Returns: + int: 规范化后的超时毫秒数。 + + Raises: + ValueError: 当超时值为负数或无法转换为整数时抛出。 + """ + + try: + timeout_ms = int(raw_value or 0) + except (TypeError, ValueError) as exc: + raise ValueError(f"HookHandler 超时配置不合法: {raw_value}") from exc + if timeout_ms < 0: + raise ValueError(f"HookHandler 超时配置不能为负数: {raw_value}") + return timeout_ms + + @property + def is_blocking(self) -> bool: + """返回当前 Hook 是否为阻塞模式。""" + + return self.mode == "blocking" + + @property + def is_observe(self) -> bool: + """返回当前 Hook 是否为观察模式。""" + + return self.mode == "observe" + + +class MessageGatewayEntry(ComponentEntry): + """MessageGateway 组件条目""" + + def __init__( + self, + name: str, + component_type: str, + plugin_id: str, + metadata: Dict[str, Any], + chat_scope: str = "all", + allowed_session: Optional[List[str]] = None, + ) -> None: + self.route_type: str = self._normalize_route_type(metadata.get("route_type", "")) + self.platform: str = str(metadata.get("platform", "") or "").strip() + self.protocol: str = str(metadata.get("protocol", "") or "").strip() + self.account_id: str = str(metadata.get("account_id", "") or "").strip() + self.scope: str = str(metadata.get("scope", "") or "").strip() + super().__init__(name, component_type, plugin_id, metadata, chat_scope, allowed_session) + + @staticmethod + def _normalize_route_type(raw_value: Any) -> str: + """规范化消息网关路由类型。 + + Args: + raw_value: 原始路由类型值。 + + Returns: + str: 规范化后的路由类型。 + + Raises: + ValueError: 当路由类型不受支持时抛出。 + """ + + normalized_value = str(raw_value or "").strip().lower() + route_type_aliases = { + "send": "send", + "receive": "receive", + "recv": "receive", + "recive": "receive", + "duplex": "duplex", + } + route_type = route_type_aliases.get(normalized_value) + if route_type is None: + raise ValueError(f"MessageGateway 路由类型不合法: {raw_value}") + return route_type + + @property + def supports_send(self) -> bool: + """返回当前网关是否支持出站。""" + + return self.route_type in {"send", "duplex"} + + @property + def supports_receive(self) -> bool: + """返回当前网关是否支持入站。""" + + return self.route_type in {"receive", "duplex"} + + +class ComponentRegistry: + """Host 侧组件注册表。 + + 由 Supervisor 在收到 plugin.register_components 时调用。 + 供业务层查询可用组件、匹配命令、调度 action/event 等。 + """ + + def __init__(self, hook_spec_registry: Optional[HookSpecRegistry] = None) -> None: + """初始化组件注册表。 + + Args: + hook_spec_registry: 可选的 Hook 规格注册中心;提供后会在注册 + HookHandler 时执行规格校验。 + """ + + # 全量索引 + self._components: Dict[str, ComponentEntry] = {} # full_name -> comp + + # 按类型索引 + self._by_type: Dict[ComponentTypes, Dict[str, ComponentEntry]] = { + comp_type: {} for comp_type in ComponentTypes + } # component_type -> (full_name -> comp) + + # 按插件索引 + self._by_plugin: Dict[str, List[ComponentEntry]] = {} + self._hook_spec_registry = hook_spec_registry + + @staticmethod + def _convert_action_metadata_to_tool_metadata( + name: str, + metadata: Dict[str, Any], + ) -> Dict[str, Any]: + """将旧 Action 元数据转换为统一 Tool 元数据。 + + Args: + name: 组件名称。 + metadata: Action 原始元数据。 + + Returns: + Dict[str, Any]: 转换后的 Tool 元数据。 + """ + + action_parameters = metadata.get("action_parameters") + parameters_schema: Dict[str, Any] | None = None + if isinstance(action_parameters, dict) and action_parameters: + properties: Dict[str, Any] = {} + for parameter_name, parameter_description in action_parameters.items(): + normalized_name = str(parameter_name or "").strip() + if not normalized_name: + continue + properties[normalized_name] = { + "type": "string", + "description": str(parameter_description or "").strip() or "兼容旧 Action 参数", + } + if properties: + parameters_schema = { + "type": "object", + "properties": properties, + } + + detailed_parts: List[str] = [] + if parameters_schema is not None: + parameter_description = build_tool_detailed_description(parameters_schema) + if parameter_description: + detailed_parts.append(parameter_description) + + action_require = [ + str(item).strip() + for item in (metadata.get("action_require") or []) + if str(item).strip() + ] + if action_require: + detailed_parts.append("使用建议:\n" + "\n".join(f"- {item}" for item in action_require)) + + associated_types = [ + str(item).strip() + for item in (metadata.get("associated_types") or []) + if str(item).strip() + ] + if associated_types: + detailed_parts.append(f"适用消息类型:{'、'.join(associated_types)}。") + + activation_type = str(metadata.get("activation_type", "always") or "always").strip() + activation_keywords = [ + str(item).strip() + for item in (metadata.get("activation_keywords") or []) + if str(item).strip() + ] + activation_lines = [f"兼容旧 Action 激活方式:{activation_type}。"] + if activation_keywords: + activation_lines.append(f"激活关键词:{'、'.join(activation_keywords)}。") + if str(metadata.get("action_prompt", "") or "").strip(): + activation_lines.append(f"原始 Action 提示语:{str(metadata['action_prompt']).strip()}。") + detailed_parts.append("\n".join(activation_lines)) + + brief_description = str(metadata.get("brief_description", metadata.get("description", "") or f"工具 {name}")).strip() + return { + **metadata, + "description": brief_description, + "brief_description": brief_description, + "detailed_description": "\n\n".join(part for part in detailed_parts if part).strip(), + "parameters_raw": parameters_schema or {}, + "invoke_method": "plugin.invoke_action", + "legacy_action": True, + "legacy_component_type": "ACTION", + } + + @staticmethod + def _normalize_component_type(component_type: str) -> ComponentTypes: + """规范化组件类型输入。 + + Args: + component_type: 原始组件类型字符串。 + + Returns: + ComponentTypes: 规范化后的组件类型枚举。 + + Raises: + ValueError: 当组件类型不受支持时抛出。 + """ + + normalized_value = str(component_type or "").strip().upper() + return ComponentTypes(normalized_value) + + def clear(self) -> None: + """清空全部组件注册状态。""" + self._components.clear() + for type_dict in self._by_type.values(): + type_dict.clear() + self._by_plugin.clear() + + @staticmethod + def _is_legacy_action_component(component: ComponentEntry) -> bool: + """判断组件是否为兼容旧 Action 的 Tool 条目。 + + Args: + component: 待判断的组件条目。 + + Returns: + bool: 是否为兼容旧 Action 组件。 + """ + + if not isinstance(component, ToolEntry): + return False + return str(component.metadata.get("legacy_component_type", "") or "").strip().upper() == "ACTION" + + def _validate_hook_handler_entry(self, component: HookHandlerEntry) -> None: + """校验 HookHandler 是否满足已注册的 Hook 规格。 + + Args: + component: 待校验的 HookHandler 条目。 + + Raises: + ComponentRegistrationError: HookHandler 声明不合法时抛出。 + """ + + if self._hook_spec_registry is None: + return + + hook_spec = self._hook_spec_registry.get_hook_spec(component.hook) + if hook_spec is None: + raise ComponentRegistrationError( + f"HookHandler {component.full_name} 声明了未注册的 Hook: {component.hook}", + component_name=component.name, + component_type=component.component_type.value, + plugin_id=component.plugin_id, + ) + + if component.is_blocking and not hook_spec.allow_blocking: + raise ComponentRegistrationError( + f"HookHandler {component.full_name} 不能注册为 blocking:Hook {component.hook} 不允许 blocking 处理器", + component_name=component.name, + component_type=component.component_type.value, + plugin_id=component.plugin_id, + ) + + if component.is_observe and not hook_spec.allow_observe: + raise ComponentRegistrationError( + f"HookHandler {component.full_name} 不能注册为 observe:Hook {component.hook} 不允许 observe 处理器", + component_name=component.name, + component_type=component.component_type.value, + plugin_id=component.plugin_id, + ) + + if component.error_policy == "abort" and not hook_spec.allow_abort: + raise ComponentRegistrationError( + f"HookHandler {component.full_name} 不能使用 error_policy=abort:Hook {component.hook} 不允许 abort", + component_name=component.name, + component_type=component.component_type.value, + plugin_id=component.plugin_id, + ) + + def _build_component_entry( + self, + name: str, + component_type: str, + plugin_id: str, + metadata: Dict[str, Any], + chat_scope: str = "all", + allowed_session: Optional[List[str]] = None, + ) -> ComponentEntry: + """根据声明构造组件条目。 + + Args: + name: 组件名称。 + component_type: 组件类型。 + plugin_id: 插件 ID。 + metadata: 组件元数据。 + + Returns: + ComponentEntry: 已构造并完成校验的组件条目。 + + Raises: + ComponentRegistrationError: 组件声明不合法时抛出。 + """ + + try: + normalized_type = self._normalize_component_type(component_type) + normalized_metadata = dict(metadata) + if normalized_type == ComponentTypes.ACTION: + normalized_metadata = self._convert_action_metadata_to_tool_metadata(name, normalized_metadata) + component = ToolEntry( + name, + ComponentTypes.TOOL.value, + plugin_id, + normalized_metadata, + chat_scope, + allowed_session, + ) + elif normalized_type == ComponentTypes.COMMAND: + component = CommandEntry( + name, + normalized_type.value, + plugin_id, + normalized_metadata, + chat_scope, + allowed_session, + ) + elif normalized_type == ComponentTypes.TOOL: + component = ToolEntry( + name, + normalized_type.value, + plugin_id, + normalized_metadata, + chat_scope, + allowed_session, + ) + elif normalized_type == ComponentTypes.EVENT_HANDLER: + component = EventHandlerEntry( + name, + normalized_type.value, + plugin_id, + normalized_metadata, + chat_scope, + allowed_session, + ) + elif normalized_type == ComponentTypes.HOOK_HANDLER: + component = HookHandlerEntry( + name, + normalized_type.value, + plugin_id, + normalized_metadata, + chat_scope, + allowed_session, + ) + self._validate_hook_handler_entry(component) + elif normalized_type == ComponentTypes.MESSAGE_GATEWAY: + component = MessageGatewayEntry( + name, + normalized_type.value, + plugin_id, + normalized_metadata, + chat_scope, + allowed_session, + ) + else: + raise ComponentRegistrationError( + f"组件类型 {component_type} 不存在", + component_name=name, + component_type=component_type, + plugin_id=plugin_id, + ) + except ComponentRegistrationError: + raise + except Exception as exc: + raise ComponentRegistrationError( + str(exc), + component_name=name, + component_type=component_type, + plugin_id=plugin_id, + ) from exc + + return component + + def _remove_existing_component_entry(self, component: ComponentEntry) -> None: + """移除同名旧组件条目。 + + Args: + component: 即将写入的新组件条目。 + """ + + if component.full_name not in self._components: + return + + logger.warning(f"组件 {component.full_name} 已存在,覆盖") + old_component = self._components[component.full_name] + old_list = self._by_plugin.get(old_component.plugin_id) + if old_list is not None: + with contextlib.suppress(ValueError): + old_list.remove(old_component) + if old_type_dict := self._by_type.get(old_component.component_type): + old_type_dict.pop(component.full_name, None) + + def _add_component_entry(self, component: ComponentEntry) -> None: + """写入单个组件条目到全部索引。 + + Args: + component: 待写入的组件条目。 + """ + + self._remove_existing_component_entry(component) + self._components[component.full_name] = component + self._by_type[component.component_type][component.full_name] = component + self._by_plugin.setdefault(component.plugin_id, []).append(component) + + # ====== 注册 / 注销 ====== + def register_component( + self, + name: str, + component_type: str, + plugin_id: str, + metadata: Dict[str, Any], + chat_scope: str = "all", + allowed_session: Optional[List[str]] = None, + ) -> bool: + """注册单个组件。 + + Args: + name: 组件名称(不含插件 ID 前缀)。 + component_type: 组件类型(如 ``ACTION``、``COMMAND`` 等)。 + plugin_id: 插件 ID。 + metadata: 组件元数据。 + + Returns: + bool: 注册成功时恒为 ``True``。 + + Raises: + ComponentRegistrationError: 组件声明不合法时抛出。 + """ + + component = self._build_component_entry( + name, + component_type, + plugin_id, + metadata, + chat_scope, + allowed_session, + ) + self._add_component_entry(component) + return True + + def register_plugin_components(self, plugin_id: str, components: List[Dict[str, Any]]) -> int: + """批量替换一个插件的组件集合。 + + 该方法会先完整校验所有组件声明,只有全部通过后才会替换旧组件, + 从而避免插件进入半注册状态。 + + Args: + plugin_id: 插件 ID。 + components: 组件声明字典列表。 + + Returns: + int: 实际注册的组件数量。 + + Raises: + ComponentRegistrationError: 任一组件声明不合法时抛出。 + """ + + prepared_components: List[ComponentEntry] = [] + for component_data in components: + raw_metadata = ( + dict(component_data.get("metadata", {})) + if isinstance(component_data.get("metadata"), dict) + else {} + ) + chat_scope = str(component_data.get("chat_scope", raw_metadata.pop("chat_scope", "all")) or "all") + raw_allowed_session = component_data.get("allowed_session", raw_metadata.pop("allowed_session", [])) + allowed_session = ( + [str(item).strip() for item in raw_allowed_session if str(item).strip()] + if isinstance(raw_allowed_session, list) + else [] + ) + prepared_components.append( + self._build_component_entry( + name=str(component_data.get("name", "") or ""), + component_type=str(component_data.get("component_type", "") or ""), + plugin_id=plugin_id, + metadata=raw_metadata, + chat_scope=chat_scope, + allowed_session=allowed_session, + ) + ) + + self.remove_components_by_plugin(plugin_id) + for component in prepared_components: + self._add_component_entry(component) + return len(prepared_components) + + def remove_components_by_plugin(self, plugin_id: str) -> int: + """移除某个插件的所有组件,返回移除数量。 + + Args: + plugin_id (str): 插件id + Returns: + count (int): 移除的组件数量 + """ + comps = self._by_plugin.pop(plugin_id, []) + for comp in comps: + self._components.pop(comp.full_name, None) + if type_dict := self._by_type.get(comp.component_type): + type_dict.pop(comp.full_name, None) + return len(comps) + + # ====== 启用 / 禁用 ====== + def check_component_enabled( + self, + component: ComponentEntry, + session_id: Optional[str] = None, + is_group_chat: Optional[bool] = None, + group_id: Optional[str] = None, + platform: Optional[str] = None, + ): + if session_id and session_id in component.disabled_session: + return False + if is_group_chat is not None: + if component.chat_scope == "group" and is_group_chat is not True: + return False + if component.chat_scope == "private" and is_group_chat is not False: + return False + if component.allowed_session: + allowed_candidates = {str(session_id or "").strip(), str(group_id or "").strip()} + if platform and group_id: + allowed_candidates.add(f"{platform}:{group_id}") + allowed_candidates.discard("") + if component.allowed_session.isdisjoint(allowed_candidates): + return False + return component.enabled + + def toggle_component_status(self, full_name: str, enabled: bool, session_id: Optional[str] = None) -> bool: + """启用或禁用指定组件。 + + Args: + full_name (str): 组件全名 + enabled (bool): 使能情况 + session_id (Optional[str]): 可选的会话ID,仅对该会话禁用(如果提供) + Returns: + success (bool): 是否成功设置(失败原因通常是组件不存在) + """ + comp = self._components.get(full_name) + if comp is None: + return False + if session_id: + if enabled: + comp.disabled_session.discard(session_id) + else: + comp.disabled_session.add(session_id) + else: + comp.enabled = enabled + return True + + def set_component_enabled(self, full_name: str, enabled: bool, session_id: Optional[str] = None) -> bool: + """设置指定组件的启用状态。 + + Args: + full_name: 组件全名。 + enabled: 目标启用状态。 + session_id: 可选的会话 ID,仅对该会话生效。 + + Returns: + bool: 是否设置成功。 + """ + + return self.toggle_component_status(full_name, enabled, session_id=session_id) + + def toggle_plugin_status(self, plugin_id: str, enabled: bool, session_id: Optional[str] = None) -> int: + """批量启用或禁用某插件的所有组件。 + + Args: + plugin_id (str): 插件id + enabled (bool): 使能情况 + session_id (Optional[str]): 可选的会话ID,仅对该会话禁用(如果提供) + Returns: + count (int): 成功设置的组件数量(失败原因通常是插件不存在) + """ + comps = self._by_plugin.get(plugin_id, []) + for comp in comps: + if session_id: + if enabled: + comp.disabled_session.discard(session_id) + else: + comp.disabled_session.add(session_id) + else: + comp.enabled = enabled + return len(comps) + + def get_component(self, full_name: str) -> Optional[ComponentEntry]: + """按全名查询。 + + Args: + full_name (str): 组件全名 + Returns: + component (Optional[ComponentEntry]): 组件条目,未找到时为 None + """ + return self._components.get(full_name) + + def get_components_by_type( + self, + component_type: str, + *, + enabled_only: bool = True, + session_id: Optional[str] = None, + is_group_chat: Optional[bool] = None, + group_id: Optional[str] = None, + platform: Optional[str] = None, + ) -> List[ComponentEntry]: + """按类型查询组件 + + Args: + component_type (str): 组件类型(如 `ACTION`、`COMMAND` 等) + enabled_only (bool): 是否仅返回启用的组件 + session_id (Optional[str]): 可选的会话ID,若提供则考虑会话禁用状态 + Returns: + components (List[ComponentEntry]): 组件条目列表 + """ + try: + comp_type = self._normalize_component_type(component_type) + except ValueError: + logger.error(f"组件类型 {component_type} 不存在") + raise + + if comp_type == ComponentTypes.ACTION: + action_components = [ + component + for component in self._by_type.get(ComponentTypes.TOOL, {}).values() + if self._is_legacy_action_component(component) + ] + if enabled_only: + return [ + component + for component in action_components + if self.check_component_enabled(component, session_id, is_group_chat, group_id, platform) + ] + return action_components + + type_dict = self._by_type.get(comp_type, {}) + if enabled_only: + return [ + c + for c in type_dict.values() + if self.check_component_enabled(c, session_id, is_group_chat, group_id, platform) + ] + return list(type_dict.values()) + + def get_components_by_plugin( + self, plugin_id: str, *, enabled_only: bool = True, session_id: Optional[str] = None + ) -> List[ComponentEntry]: + """按插件查询组件。 + + Args: + plugin_id (str): 插件ID + enabled_only (bool): 是否仅返回启用的组件 + session_id (Optional[str]): 可选的会话ID,若提供则考虑会话禁用状态 + Returns: + components (List[ComponentEntry]): 组件条目列表 + """ + comps = self._by_plugin.get(plugin_id, []) + return [c for c in comps if self.check_component_enabled(c, session_id)] if enabled_only else list(comps) + + def find_command_by_text( + self, text: str, session_id: Optional[str] = None + ) -> Optional[Tuple[ComponentEntry, Dict[str, Any]]]: + """通过文本匹配命令正则,返回 (组件, matched_groups) 元组。 + + matched_groups 为正则命名捕获组 dict,别名匹配时为空 dict。 + Args: + text (str): 待匹配文本 + session_id (Optional[str]): 可选的会话ID,若提供则考虑会话禁用状态 + Returns: + result (Optional[tuple[ComponentEntry, Dict[str, Any]]]): 匹配到的组件及正则捕获组,未找到时为 None + """ + for comp in self._by_type.get(ComponentTypes.COMMAND, {}).values(): + if not self.check_component_enabled(comp, session_id): + continue + if not isinstance(comp, CommandEntry): + continue + if comp.compiled_pattern: + if m := comp.compiled_pattern.search(text): + return comp, m.groupdict() + # 别名匹配 + for alias in comp.aliases: + if text.startswith(alias): + return comp, {} + return None + + def get_event_handlers( + self, event_type: str, *, enabled_only: bool = True, session_id: Optional[str] = None + ) -> List[EventHandlerEntry]: + """查询指定事件类型的事件处理器组件。 + + Args: + event_type (str): 事件类型 + enabled_only (bool): 是否仅返回启用的组件 + session_id (Optional[str]): 可选的会话ID,若提供则考虑会话禁用状态 + Returns: + handlers (List[EventHandlerEntry]): 符合条件的 EventHandler 组件列表,按 weight 降序排序 + """ + handlers: List[EventHandlerEntry] = [] + for comp in self._by_type.get(ComponentTypes.EVENT_HANDLER, {}).values(): + if enabled_only and not self.check_component_enabled(comp, session_id): + continue + if not isinstance(comp, EventHandlerEntry): + continue + if comp.event_type == event_type: + handlers.append(comp) + handlers.sort(key=lambda c: c.weight, reverse=True) + return handlers + + def get_hook_handlers( + self, hook_name: str, *, enabled_only: bool = True, session_id: Optional[str] = None + ) -> List[HookHandlerEntry]: + """获取订阅指定命名 Hook 的全部处理器。 + + Args: + hook_name: 目标 Hook 名称。 + enabled_only: 是否仅返回启用的组件。 + session_id: 可选的会话 ID,若提供则考虑会话禁用状态。 + + Returns: + List[HookHandlerEntry]: 符合条件的 HookHandler 组件列表。 + """ + handlers: List[HookHandlerEntry] = [] + for comp in self._by_type.get(ComponentTypes.HOOK_HANDLER, {}).values(): + if enabled_only and not self.check_component_enabled(comp, session_id): + continue + if not isinstance(comp, HookHandlerEntry): + continue + if comp.hook == hook_name: + handlers.append(comp) + handlers.sort(key=lambda comp: (self._get_hook_mode_rank(comp.mode), self._get_hook_order_rank(comp.order), comp.plugin_id, comp.name)) + return handlers + + @staticmethod + def _get_hook_mode_rank(mode: str) -> int: + """返回 Hook 模式的排序权重。 + + Args: + mode: Hook 模式字符串。 + + Returns: + int: 越小表示越靠前。 + """ + + return {"blocking": 0, "observe": 1}.get(mode, 99) + + @staticmethod + def _get_hook_order_rank(order: str) -> int: + """返回 Hook 顺序槽位的排序权重。 + + Args: + order: Hook 顺序槽位字符串。 + + Returns: + int: 越小表示越靠前。 + """ + + return {"early": 0, "normal": 1, "late": 2}.get(order, 99) + + def get_message_gateway( + self, + plugin_id: str, + name: str, + *, + enabled_only: bool = True, + session_id: Optional[str] = None, + ) -> Optional[MessageGatewayEntry]: + """按插件和组件名获取单个消息网关。 + + Args: + plugin_id: 插件 ID。 + name: 网关组件名称。 + enabled_only: 是否仅返回启用的组件。 + session_id: 可选的会话 ID。 + + Returns: + Optional[MessageGatewayEntry]: 若存在则返回消息网关条目。 + """ + + component = self._components.get(f"{plugin_id}.{name}") + if not isinstance(component, MessageGatewayEntry): + return None + if enabled_only and not self.check_component_enabled(component, session_id): + return None + return component + + def get_message_gateways( + self, + *, + plugin_id: Optional[str] = None, + platform: str = "", + route_type: str = "", + enabled_only: bool = True, + session_id: Optional[str] = None, + ) -> List[MessageGatewayEntry]: + """查询消息网关组件列表。 + + Args: + plugin_id: 可选的插件 ID 过滤条件。 + platform: 可选的平台过滤条件。 + route_type: 可选的路由类型过滤条件。 + enabled_only: 是否仅返回启用的组件。 + session_id: 可选的会话 ID。 + + Returns: + List[MessageGatewayEntry]: 符合条件的消息网关组件列表。 + """ + + normalized_platform = str(platform or "").strip() + normalized_route_type = str(route_type or "").strip().lower() + gateways: List[MessageGatewayEntry] = [] + for comp in self._by_type.get(ComponentTypes.MESSAGE_GATEWAY, {}).values(): + if not isinstance(comp, MessageGatewayEntry): + continue + if plugin_id and comp.plugin_id != plugin_id: + continue + if enabled_only and not self.check_component_enabled(comp, session_id): + continue + if normalized_platform and comp.platform != normalized_platform: + continue + if normalized_route_type and comp.route_type != normalized_route_type: + continue + gateways.append(comp) + return gateways + + def get_tools(self, *, enabled_only: bool = True, session_id: Optional[str] = None) -> List[ToolEntry]: + """查询所有工具组件。 + + Args: + enabled_only (bool): 是否仅返回启用的组件 + session_id (Optional[str]): 可选的会话ID,若提供则考虑会话禁用状态 + Returns: + tools (List[ToolEntry]): 符合条件的 Tool 组件列表 + """ + tools: List[ToolEntry] = [] + for comp in self._by_type.get(ComponentTypes.TOOL, {}).values(): + if enabled_only and not self.check_component_enabled(comp, session_id): + continue + if isinstance(comp, ToolEntry): + tools.append(comp) + return tools + + def get_tools_for_llm(self, *, enabled_only: bool = True, session_id: Optional[str] = None) -> List[Dict[str, Any]]: + """兼容旧接口,返回可供 LLM 使用的工具条目列表。 + + Args: + enabled_only: 是否仅返回启用的组件。 + session_id: 可选的会话 ID,若提供则考虑会话禁用状态。 + + Returns: + List[Dict[str, Any]]: 兼容旧结构的工具组件字典列表。 + """ + + return [ + { + "name": tool.full_name, + "description": tool.description, + "parameters": ( + dict(tool.parameters_raw) + if isinstance(tool.parameters_raw, dict) and tool.parameters_raw + else tool._get_parameters_schema() or {} + ), + "parameters_raw": tool.parameters_raw, + "enabled": tool.enabled, + "plugin_id": tool.plugin_id, + } + for tool in self.get_tools(enabled_only=enabled_only, session_id=session_id) + if not self._is_legacy_action_component(tool) + ] + + # ====== 统计信息 ====== + def get_stats(self) -> StatusDict: + """获取注册统计。 + + Returns: + stats (StatusDict): 组件统计信息,包括总数、各类型数量、插件数量等 + """ + return StatusDict( + total=len(self._components), + action=len( + [ + component + for component in self._by_type.get(ComponentTypes.TOOL, {}).values() + if self._is_legacy_action_component(component) + ] + ), + command=len(self._by_type[ComponentTypes.COMMAND]), + tool=len( + [ + component + for component in self._by_type.get(ComponentTypes.TOOL, {}).values() + if not self._is_legacy_action_component(component) + ] + ), + event_handler=len(self._by_type[ComponentTypes.EVENT_HANDLER]), + hook_handler=len(self._by_type[ComponentTypes.HOOK_HANDLER]), + message_gateway=len(self._by_type[ComponentTypes.MESSAGE_GATEWAY]), + plugins=len(self._by_plugin), + ) diff --git a/src/plugin_runtime/host/event_dispatcher.py b/src/plugin_runtime/host/event_dispatcher.py new file mode 100644 index 00000000..d252b6ee --- /dev/null +++ b/src/plugin_runtime/host/event_dispatcher.py @@ -0,0 +1,184 @@ +"""Host-side EventDispatcher + +负责: +1. 按事件类型查询已注册的 event_handler(通过 ComponentRegistry) +2. 按 weight 排序,依次通过 RPC 调用 Runner 中的处理器 +3. 支持阻塞(intercept_message)和非阻塞分发 +4. 事件结果历史记录(有上限) +""" + +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple, TYPE_CHECKING + +import asyncio + +from src.common.logger import get_logger + +from .message_utils import PluginMessageUtils, MessageDict + +if TYPE_CHECKING: + from .supervisor import PluginRunnerSupervisor + from .component_registry import ComponentRegistry, EventHandlerEntry + from src.chat.message_receive.message import SessionMessage + +logger = get_logger("plugin_runtime.host.event_dispatcher") + +# invoke_fn 类型: async (plugin_id, component_name, args) -> response_payload dict +InvokeFn = Callable[[str, str, Dict[str, Any]], Awaitable[Dict[str, Any]]] +# 每个事件类型的最大历史记录数量,防止内存无限增长 +_MAX_HISTORY_LENGTH = 100 + + +@dataclass +class EventResult: + """单个 EventHandler 的执行结果""" + + handler_name: str + success: bool = field(default=True) + continue_processing: bool = field(default=True) + modified_message: Optional[MessageDict] = field(default=None) + custom_result: Any = field(default=None) + + +class EventDispatcher: + """Host-side 事件分发器 + + 由业务层调用 dispatch_event(), + 内部通过 ComponentRegistry 查询 handler, + 再通过提供的 invoke_fn 回调 RPC 到 Runner 执行。 + """ + + def __init__(self, component_registry: "ComponentRegistry") -> None: + self._component_registry: "ComponentRegistry" = component_registry + self._result_history: Dict[str, List[EventResult]] = {} + self._history_enabled: Set[str] = set() + self._background_tasks: Set[asyncio.Task] = set() + + def enable_history(self, event_type: str) -> None: + self._history_enabled.add(event_type) + self._result_history.setdefault(event_type, []) + + def disable_history(self, event_type: str) -> None: + self._history_enabled.discard(event_type) + self._result_history.pop(event_type, None) + + def get_history(self, event_type: str) -> List[EventResult]: + return self._result_history.get(event_type, []) + + def clear_history(self, event_type: str) -> None: + if event_type in self._result_history: + self._result_history[event_type] = [] + + async def stop(self): + """停止 EventDispatcher,取消所有未完成的后台任务""" + for task in self._background_tasks: + task.cancel() + await asyncio.gather(*self._background_tasks, return_exceptions=True) + self._background_tasks.clear() + + async def dispatch_event( + self, + event_type: str, + supervisor: "PluginRunnerSupervisor", + message: Optional["SessionMessage"] = None, + extra_args: Optional[Dict[str, Any]] = None, + ) -> Tuple[bool, Optional["SessionMessage"]]: + """分发事件到所有对应 handler 的便捷方法。 + + 内置了通过 PluginSupervisor.invoke_plugin 调用 plugin.emit_event 的逻辑, + 无需调用方手动构造 invoke_fn 闭包。 + + Args: + event_type: 事件类型字符串 + supervisor: PluginSupervisor 实例,用于调用 invoke_plugin + message: MaiMessages 序列化后的 dict(可选) + extra_args: 额外参数 + + Returns: + (should_continue, modified_message_dict) (bool, SessionMessage | None): (是否继续后续执行, 可选的修改后的消息) + """ + handler_entries = self._component_registry.get_event_handlers(event_type) + if not handler_entries: + return True, None + + should_continue = True + modified_message: Optional[MessageDict] = ( + PluginMessageUtils._session_message_to_dict(message) if message else None + ) + intercept_handlers: List["EventHandlerEntry"] = [] + non_blocking_handlers: List["EventHandlerEntry"] = [] + + for entry in handler_entries: + if entry.intercept_message: + intercept_handlers.append(entry) + else: + non_blocking_handlers.append(entry) + + for entry in intercept_handlers: + args = { + "event_type": event_type, + "message": modified_message, + **(extra_args or {}), + } + result = await self._invoke_handler(supervisor, entry, args, event_type) + if result and not result.continue_processing: + should_continue = False + break + if result and result.modified_message: + modified_message = result.modified_message + + if should_continue: + final_message = modified_message + for entry in non_blocking_handlers: + async_message = final_message.copy() if final_message else final_message + args = { + "event_type": event_type, + "message": async_message, + **(extra_args or {}), + } + # 非阻塞:保持实例级强引用,防止 task 被 GC 回收 + task = asyncio.create_task(self._invoke_handler(supervisor, entry, args, event_type)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + try: + modified_message_obj = ( + PluginMessageUtils._build_session_message_from_dict(modified_message) if modified_message else None # type: ignore + ) + except Exception as e: + logger.error(f"构建修改后的 SessionMessage 失败: {e}") + modified_message_obj = None + return should_continue, modified_message_obj + + async def _invoke_handler( + self, + supervisor: "PluginRunnerSupervisor", + handler_entry: "EventHandlerEntry", + args: Dict[str, Any], + event_type: str, + ) -> Optional[EventResult]: + """调用单个 handler 并收集结果。""" + try: + resp_envelope = await supervisor.invoke_plugin( + "plugin.emit_event", handler_entry.plugin_id, handler_entry.name, args + ) + resp = resp_envelope.payload + result = EventResult( + handler_name=handler_entry.full_name, + success=resp.get("success", True), + continue_processing=resp.get("continue_processing", True), + modified_message=resp.get("modified_message"), + custom_result=resp.get("custom_result"), + ) + except Exception as e: + logger.error(f"EventHandler {handler_entry.full_name} 执行失败: {e}", exc_info=True) + result = EventResult(handler_name=handler_entry.full_name, success=False, continue_processing=True) + + if event_type in self._history_enabled: + history_list = self._result_history.setdefault(event_type, []) + history_list.append(result) + # 自动清理超出限制的旧记录,防止内存无限增长 + if len(history_list) > _MAX_HISTORY_LENGTH: + # 保留最新的 _MAX_HISTORY_LENGTH 条记录 + self._result_history[event_type] = history_list[-_MAX_HISTORY_LENGTH:] + + return result diff --git a/src/plugin_runtime/host/hook_dispatcher.py b/src/plugin_runtime/host/hook_dispatcher.py new file mode 100644 index 00000000..1891b1ed --- /dev/null +++ b/src/plugin_runtime/host/hook_dispatcher.py @@ -0,0 +1,665 @@ +"""命名 Hook 分发系统。 + +主程序可以在任意执行点触发一个命名 Hook,Host 会收集所有订阅该 Hook 的 +插件处理器,并按照固定的全局顺序调度执行。 + +排序规则如下: + +1. `blocking` 先于 `observe` +2. `early` 先于 `normal` 先于 `late` +3. 内置插件先于第三方插件 +4. `plugin_id` +5. `handler_name` + +其中: + +- `blocking` 处理器串行执行,可修改 `kwargs`,也可中止本次 Hook 调用。 +- `observe` 处理器后台并发执行,只允许旁路观察,不参与主流程控制。 +""" + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Set + +import asyncio +import contextlib + +from src.common.logger import get_logger +from src.config.config import global_config + +from .hook_spec_registry import HookSpec, HookSpecRegistry + +if TYPE_CHECKING: + from .component_registry import HookHandlerEntry + from .supervisor import PluginRunnerSupervisor + +logger = get_logger("plugin_runtime.host.hook_dispatcher") + + +@dataclass(slots=True) +class HookHandlerExecutionResult: + """单个 HookHandler 的执行结果。 + + Attributes: + handler_name: 完整处理器名称,格式通常为 `plugin_id.component_name`。 + plugin_id: 处理器所属插件 ID。 + success: 本次调用是否成功。 + action: 当前处理器要求的控制动作,仅支持 `continue` 或 `abort`。 + modified_kwargs: 处理器返回的修改后参数字典。 + custom_result: 处理器返回的附加结果。 + error_message: 失败时的错误描述。 + """ + + handler_name: str + plugin_id: str + success: bool = True + action: str = "continue" + modified_kwargs: Optional[Dict[str, Any]] = None + custom_result: Any = None + error_message: str = "" + + +@dataclass(slots=True) +class HookDispatchResult: + """一次命名 Hook 调用的聚合结果。 + + Attributes: + hook_name: 本次调用的 Hook 名称。 + kwargs: 经阻塞处理器串行处理后的最终参数字典。 + aborted: 是否被某个处理器中止。 + stopped_by: 若被中止,记录触发中止的完整处理器名称。 + custom_results: 阻塞处理器返回的附加结果列表。 + errors: 本次调用中记录到的错误信息列表。 + """ + + hook_name: str + kwargs: Dict[str, Any] = field(default_factory=dict) + aborted: bool = False + stopped_by: Optional[str] = None + custom_results: List[Any] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + + +@dataclass(slots=True) +class _HookInvocationTarget: + """内部使用的 Hook 调度目标。 + + Attributes: + supervisor: 负责该处理器的 Supervisor。 + entry: Hook 处理器条目。 + source_rank: 插件来源权重,内置插件为 `0`,第三方插件为 `1`。 + """ + + supervisor: "PluginRunnerSupervisor" + entry: "HookHandlerEntry" + source_rank: int + + +class HookDispatcher: + """命名 Hook 分发器。""" + + def __init__( + self, + supervisors_provider: Optional[Callable[[], Sequence["PluginRunnerSupervisor"]]] = None, + hook_spec_registry: Optional[HookSpecRegistry] = None, + ) -> None: + """初始化 Hook 分发器。 + + Args: + supervisors_provider: 可选的 Supervisor 提供器。若调用 `invoke_hook()` + 时未显式传入 `supervisors`,则使用该回调获取目标 Supervisor 列表。 + hook_spec_registry: 可选的 Hook 规格注册中心;留空时使用独立注册中心。 + """ + + self._background_tasks: Set[asyncio.Task[Any]] = set() + self._supervisors_provider = supervisors_provider + self._hook_spec_registry = hook_spec_registry or HookSpecRegistry() + + async def stop(self) -> None: + """停止分发器并取消所有未完成的观察任务。""" + + for task in self._background_tasks: + task.cancel() + await asyncio.gather(*self._background_tasks, return_exceptions=True) + self._background_tasks.clear() + + def register_hook_spec(self, spec: HookSpec) -> None: + """注册单个命名 Hook 规格。 + + Args: + spec: 需要注册的 Hook 规格。 + """ + + self._hook_spec_registry.register_hook_spec(spec) + + def register_hook_specs(self, specs: Sequence[HookSpec]) -> None: + """批量注册命名 Hook 规格。 + + Args: + specs: 需要注册的 Hook 规格序列。 + """ + + for spec in specs: + self.register_hook_spec(spec) + + def get_hook_spec(self, hook_name: str) -> HookSpec: + """获取指定 Hook 的规格定义。 + + Args: + hook_name: Hook 名称。 + + Returns: + HookSpec: 若未显式注册,则返回按系统默认值生成的运行时规格。 + """ + + normalized_name = self._normalize_hook_name(hook_name) + registered_spec = self._hook_spec_registry.get_hook_spec(normalized_name) + if registered_spec is not None: + return registered_spec + + return HookSpec( + name=normalized_name, + parameters_schema={}, + default_timeout_ms=self._get_default_timeout_ms(), + ) + + def unregister_hook_spec(self, hook_name: str) -> bool: + """注销指定命名 Hook 规格。 + + Args: + hook_name: 目标 Hook 名称。 + + Returns: + bool: 是否成功注销。 + """ + + return self._hook_spec_registry.unregister_hook_spec(hook_name) + + def list_hook_specs(self) -> List[HookSpec]: + """返回当前全部显式注册的 Hook 规格。 + + Returns: + List[HookSpec]: 已注册 Hook 规格列表。 + """ + + return self._hook_spec_registry.list_hook_specs() + + async def invoke_hook( + self, + hook_name: str, + supervisors: Optional[Sequence["PluginRunnerSupervisor"]] = None, + **kwargs: Any, + ) -> HookDispatchResult: + """触发一次命名 Hook 调用。 + + Args: + hook_name: 本次触发的 Hook 名称。 + supervisors: 当前运行时中所有可参与分发的 Supervisor;留空时使用绑定的提供器。 + **kwargs: 传递给 Hook 处理器的关键字参数。 + + Returns: + HookDispatchResult: 聚合后的 Hook 调用结果。 + """ + + resolved_supervisors = list(supervisors) if supervisors is not None else list(self._resolve_supervisors()) + normalized_hook_name = self._normalize_hook_name(hook_name) + hook_spec = self.get_hook_spec(normalized_hook_name) + current_kwargs: Dict[str, Any] = dict(kwargs) + dispatch_result = HookDispatchResult(hook_name=normalized_hook_name, kwargs=dict(current_kwargs)) + invocation_targets = self._collect_invocation_targets(normalized_hook_name, resolved_supervisors) + + if not invocation_targets: + return dispatch_result + + for target in invocation_targets: + if target.entry.is_observe: + self._schedule_observe_handler( + hook_name=normalized_hook_name, + hook_spec=hook_spec, + target=target, + kwargs=current_kwargs, + ) + continue + + if not hook_spec.allow_blocking: + error_message = ( + f"Hook {normalized_hook_name} 不允许 blocking 处理器," + f"已跳过 {target.entry.full_name}" + ) + logger.warning(error_message) + dispatch_result.errors.append(error_message) + continue + + execution_result = await self._invoke_handler( + hook_name=normalized_hook_name, + hook_spec=hook_spec, + target=target, + kwargs=current_kwargs, + ) + self._merge_blocking_result( + hook_spec=hook_spec, + target=target, + execution_result=execution_result, + dispatch_result=dispatch_result, + ) + + current_kwargs = dict(dispatch_result.kwargs) + if dispatch_result.aborted: + break + + return dispatch_result + + def _resolve_supervisors(self) -> Sequence["PluginRunnerSupervisor"]: + """解析当前调用应使用的 Supervisor 列表。 + + Returns: + Sequence[PluginRunnerSupervisor]: 可参与本次 Hook 调度的 Supervisor 序列。 + + Raises: + ValueError: 当未传入 `supervisors` 且分发器也未绑定提供器时抛出。 + """ + + if self._supervisors_provider is None: + raise ValueError("当前 HookDispatcher 未绑定 supervisors_provider,请显式传入 supervisors") + return self._supervisors_provider() + + def _collect_invocation_targets( + self, + hook_name: str, + supervisors: Sequence["PluginRunnerSupervisor"], + ) -> List[_HookInvocationTarget]: + """收集并排序本次 Hook 调用的全部处理器目标。 + + Args: + hook_name: 目标 Hook 名称。 + supervisors: 当前参与调度的 Supervisor 序列。 + + Returns: + List[_HookInvocationTarget]: 已完成全局排序的处理器目标列表。 + """ + + invocation_targets: List[_HookInvocationTarget] = [] + for supervisor in supervisors: + source_rank = self._get_supervisor_source_rank(supervisor) + for entry in supervisor.component_registry.get_hook_handlers(hook_name): + invocation_targets.append( + _HookInvocationTarget( + supervisor=supervisor, + entry=entry, + source_rank=source_rank, + ) + ) + + invocation_targets.sort(key=self._build_sort_key) + return invocation_targets + + @staticmethod + def _build_sort_key(target: _HookInvocationTarget) -> tuple[int, int, int, str, str]: + """构造 Hook 处理器的全局排序键。 + + Args: + target: 待排序的处理器目标。 + + Returns: + tuple[int, int, int, str, str]: 全局排序键。 + """ + + return ( + HookDispatcher._get_mode_rank(target.entry.mode), + HookDispatcher._get_order_rank(target.entry.order), + target.source_rank, + target.entry.plugin_id, + target.entry.name, + ) + + @staticmethod + def _get_default_timeout_ms() -> int: + """读取系统级默认 Hook 超时。 + + Returns: + int: 默认超时毫秒数。 + """ + + timeout_seconds = float(global_config.plugin_runtime.hook_blocking_timeout_sec or 30.0) + return max(int(timeout_seconds * 1000), 1) + + @staticmethod + def _get_mode_rank(mode: str) -> int: + """返回 Hook 模式的排序权重。 + + Args: + mode: Hook 模式。 + + Returns: + int: 越小表示越靠前。 + """ + + return {"blocking": 0, "observe": 1}.get(mode, 99) + + @staticmethod + def _get_order_rank(order: str) -> int: + """返回 Hook 顺序槽位的排序权重。 + + Args: + order: Hook 顺序槽位。 + + Returns: + int: 越小表示越靠前。 + """ + + return {"early": 0, "normal": 1, "late": 2}.get(order, 99) + + @staticmethod + def _get_supervisor_source_rank(supervisor: "PluginRunnerSupervisor") -> int: + """返回 Supervisor 的来源排序权重。 + + Args: + supervisor: 目标 Supervisor。 + + Returns: + int: 内置插件返回 `0`,第三方插件返回 `1`。 + """ + + return 0 if supervisor.group_name == "builtin" else 1 + + @staticmethod + def _normalize_hook_name(hook_name: str) -> str: + """规范化命名 Hook 名称。 + + Args: + hook_name: 原始 Hook 名称。 + + Returns: + str: 规范化后的 Hook 名称。 + + Raises: + ValueError: 当 Hook 名称为空时抛出。 + """ + + normalized_name = str(hook_name or "").strip() + if not normalized_name: + raise ValueError("Hook 名称不能为空") + return normalized_name + + def _resolve_timeout_ms(self, hook_spec: HookSpec, target: _HookInvocationTarget) -> int: + """计算单个处理器的实际超时。 + + Args: + hook_spec: 当前 Hook 的规格定义。 + target: 当前执行目标。 + + Returns: + int: 最终生效的超时毫秒数。 + """ + + if target.entry.timeout_ms > 0: + return target.entry.timeout_ms + if hook_spec.default_timeout_ms > 0: + return hook_spec.default_timeout_ms + return self._get_default_timeout_ms() + + async def _invoke_handler( + self, + hook_name: str, + hook_spec: HookSpec, + target: _HookInvocationTarget, + kwargs: Dict[str, Any], + ) -> HookHandlerExecutionResult: + """执行单个 Hook 处理器。 + + Args: + hook_name: 当前 Hook 名称。 + hook_spec: 当前 Hook 规格。 + target: 当前执行目标。 + kwargs: 当前参数字典。 + + Returns: + HookHandlerExecutionResult: 处理器执行结果。 + """ + + timeout_ms = self._resolve_timeout_ms(hook_spec, target) + request_args: Dict[str, Any] = {"hook_name": hook_name, **dict(kwargs)} + + try: + response_envelope = await asyncio.wait_for( + target.supervisor.invoke_plugin( + "plugin.invoke_hook", + target.entry.plugin_id, + target.entry.name, + request_args, + timeout_ms=timeout_ms, + ), + timeout=max(timeout_ms / 1000.0, 0.001), + ) + except asyncio.TimeoutError: + error_message = ( + f"HookHandler {target.entry.full_name} 执行超时,已超过 {timeout_ms}ms" + ) + logger.error(error_message) + return HookHandlerExecutionResult( + handler_name=target.entry.full_name, + plugin_id=target.entry.plugin_id, + success=False, + error_message=error_message, + ) + except Exception as exc: + error_message = f"HookHandler {target.entry.full_name} 执行失败: {exc}" + logger.error(error_message, exc_info=True) + return HookHandlerExecutionResult( + handler_name=target.entry.full_name, + plugin_id=target.entry.plugin_id, + success=False, + error_message=error_message, + ) + + response_payload = response_envelope.payload + if not isinstance(response_payload, dict): + return HookHandlerExecutionResult( + handler_name=target.entry.full_name, + plugin_id=target.entry.plugin_id, + custom_result=response_payload, + ) + + return HookHandlerExecutionResult( + handler_name=target.entry.full_name, + plugin_id=target.entry.plugin_id, + success=bool(response_payload.get("success", True)), + action=self._normalize_action(response_payload.get("action", "continue")), + modified_kwargs=self._extract_modified_kwargs(response_payload.get("modified_kwargs")), + custom_result=response_payload.get("custom_result"), + error_message=str(response_payload.get("error_message", "") or ""), + ) + + @staticmethod + def _extract_modified_kwargs(raw_value: Any) -> Optional[Dict[str, Any]]: + """提取并校验处理器返回的 `modified_kwargs`。 + + Args: + raw_value: 原始返回值。 + + Returns: + Optional[Dict[str, Any]]: 合法时返回字典,否则返回 `None`。 + """ + + if raw_value is None: + return None + if isinstance(raw_value, dict): + return dict(raw_value) + logger.warning("HookHandler 返回的 modified_kwargs 不是字典,已忽略") + return None + + @staticmethod + def _normalize_action(raw_value: Any) -> str: + """规范化处理器动作返回值。 + + Args: + raw_value: 原始动作值。 + + Returns: + str: 规范化后的动作值,仅支持 `continue` 或 `abort`。 + """ + + normalized_value = str(raw_value or "").strip().lower() or "continue" + if normalized_value not in {"continue", "abort"}: + logger.warning(f"未知的 Hook action: {raw_value},已按 continue 处理") + return "continue" + return normalized_value + + def _merge_blocking_result( + self, + hook_spec: HookSpec, + target: _HookInvocationTarget, + execution_result: HookHandlerExecutionResult, + dispatch_result: HookDispatchResult, + ) -> None: + """合并阻塞处理器结果到聚合结果。 + + Args: + hook_spec: 当前 Hook 规格。 + target: 当前执行目标。 + execution_result: 当前处理器执行结果。 + dispatch_result: 当前聚合结果对象。 + """ + + if execution_result.custom_result is not None: + dispatch_result.custom_results.append(execution_result.custom_result) + + if not execution_result.success: + error_message = execution_result.error_message or f"HookHandler {target.entry.full_name} 执行失败" + dispatch_result.errors.append(error_message) + self._apply_error_policy(target, hook_spec, dispatch_result, error_message) + return + + if execution_result.modified_kwargs is not None: + if hook_spec.allow_kwargs_mutation: + dispatch_result.kwargs = dict(execution_result.modified_kwargs) + else: + error_message = ( + f"Hook {dispatch_result.hook_name} 不允许修改 kwargs," + f"已忽略 {target.entry.full_name} 的 modified_kwargs" + ) + logger.warning(error_message) + dispatch_result.errors.append(error_message) + + if execution_result.action == "abort": + if hook_spec.allow_abort: + dispatch_result.aborted = True + dispatch_result.stopped_by = target.entry.full_name + logger.info(f"HookHandler {target.entry.full_name} 中止了 Hook {dispatch_result.hook_name}") + else: + error_message = ( + f"Hook {dispatch_result.hook_name} 不允许 abort," + f"已忽略 {target.entry.full_name} 的 abort 请求" + ) + logger.warning(error_message) + dispatch_result.errors.append(error_message) + + def _apply_error_policy( + self, + target: _HookInvocationTarget, + hook_spec: HookSpec, + dispatch_result: HookDispatchResult, + error_message: str, + ) -> None: + """根据错误策略处理阻塞处理器失败。 + + Args: + target: 触发错误的处理器目标。 + hook_spec: 当前 Hook 规格。 + dispatch_result: 当前聚合结果对象。 + error_message: 需要记录的错误描述。 + """ + + if target.entry.error_policy != "abort": + return + if not hook_spec.allow_abort: + logger.warning( + f"Hook {dispatch_result.hook_name} 禁止 abort," + f"已将 {target.entry.full_name} 的错误策略按 skip 处理" + ) + return + + dispatch_result.aborted = True + dispatch_result.stopped_by = target.entry.full_name + logger.warning( + f"HookHandler {target.entry.full_name} 因错误策略 abort " + f"中止了 Hook {dispatch_result.hook_name}: {error_message}" + ) + + def _schedule_observe_handler( + self, + hook_name: str, + hook_spec: HookSpec, + target: _HookInvocationTarget, + kwargs: Dict[str, Any], + ) -> None: + """后台调度观察型处理器。 + + Args: + hook_name: 当前 Hook 名称。 + hook_spec: 当前 Hook 规格。 + target: 当前观察型处理器目标。 + kwargs: 调用参数快照。 + """ + + if not hook_spec.allow_observe: + logger.warning(f"Hook {hook_name} 不允许 observe 处理器,已跳过 {target.entry.full_name}") + return + + task = asyncio.create_task( + self._run_observe_handler( + hook_name=hook_name, + hook_spec=hook_spec, + target=target, + kwargs=dict(kwargs), + ) + ) + self._background_tasks.add(task) + task.add_done_callback(self._handle_background_task_done) + + async def _run_observe_handler( + self, + hook_name: str, + hook_spec: HookSpec, + target: _HookInvocationTarget, + kwargs: Dict[str, Any], + ) -> None: + """执行观察型处理器并吞掉控制流副作用。 + + Args: + hook_name: 当前 Hook 名称。 + hook_spec: 当前 Hook 规格。 + target: 当前观察型处理器目标。 + kwargs: 调用参数快照。 + """ + + execution_result = await self._invoke_handler( + hook_name=hook_name, + hook_spec=hook_spec, + target=target, + kwargs=kwargs, + ) + + if not execution_result.success: + logger.warning( + f"观察型 HookHandler {target.entry.full_name} 执行失败: " + f"{execution_result.error_message or '未知错误'}" + ) + return + + if execution_result.modified_kwargs is not None: + logger.warning(f"观察型 HookHandler {target.entry.full_name} 返回了 modified_kwargs,已忽略") + if execution_result.action == "abort": + logger.warning(f"观察型 HookHandler {target.entry.full_name} 请求 abort,已忽略") + + def _handle_background_task_done(self, task: asyncio.Task[Any]) -> None: + """处理观察任务完成回调。 + + Args: + task: 已完成的后台任务。 + """ + + self._background_tasks.discard(task) + with contextlib.suppress(asyncio.CancelledError): + exception = task.exception() + if exception is not None: + logger.error(f"观察型 Hook 后台任务执行失败: {exception}") diff --git a/src/plugin_runtime/host/hook_spec_registry.py b/src/plugin_runtime/host/hook_spec_registry.py new file mode 100644 index 00000000..29ade396 --- /dev/null +++ b/src/plugin_runtime/host/hook_spec_registry.py @@ -0,0 +1,190 @@ +"""命名 Hook 规格注册中心。""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence + + +@dataclass(slots=True) +class HookSpec: + """命名 Hook 的静态规格定义。 + + Attributes: + name: Hook 的唯一名称。 + description: Hook 描述。 + parameters_schema: Hook 参数模型,使用对象级 JSON Schema 表示。 + default_timeout_ms: 默认超时毫秒数;为 ``0`` 时退回系统默认值。 + allow_blocking: 是否允许注册阻塞处理器。 + allow_observe: 是否允许注册观察处理器。 + allow_abort: 是否允许处理器中止当前 Hook 调用。 + allow_kwargs_mutation: 是否允许阻塞处理器修改 ``kwargs``。 + """ + + name: str + description: str = "" + parameters_schema: Dict[str, Any] = field(default_factory=dict) + default_timeout_ms: int = 0 + allow_blocking: bool = True + allow_observe: bool = True + allow_abort: bool = True + allow_kwargs_mutation: bool = True + + +class HookSpecRegistry: + """命名 Hook 规格注册中心。""" + + def __init__(self) -> None: + """初始化 Hook 规格注册中心。""" + + self._hook_specs: Dict[str, HookSpec] = {} + + @staticmethod + def _normalize_hook_name(hook_name: str) -> str: + """规范化 Hook 名称。 + + Args: + hook_name: 原始 Hook 名称。 + + Returns: + str: 规范化后的 Hook 名称。 + + Raises: + ValueError: Hook 名称为空时抛出。 + """ + + normalized_name = str(hook_name or "").strip() + if not normalized_name: + raise ValueError("Hook 名称不能为空") + return normalized_name + + @staticmethod + def _normalize_parameters_schema(raw_schema: Any) -> Dict[str, Any]: + """规范化 Hook 参数模型。 + + Args: + raw_schema: 原始参数模型。 + + Returns: + Dict[str, Any]: 规范化后的对象级 JSON Schema。 + + Raises: + ValueError: 参数模型不是合法对象级 Schema 时抛出。 + """ + + if raw_schema is None: + return {} + if not isinstance(raw_schema, dict): + raise ValueError("Hook 参数模型必须是字典") + if not raw_schema: + return {} + + normalized_schema = deepcopy(raw_schema) + schema_type = normalized_schema.get("type") + properties = normalized_schema.get("properties") + if schema_type not in {"", None, "object"} and properties is None: + raise ValueError("Hook 参数模型必须是 object 类型或属性映射") + if schema_type in {"", None} and properties is None: + normalized_schema = { + "type": "object", + "properties": normalized_schema, + } + elif schema_type in {"", None}: + normalized_schema["type"] = "object" + + if normalized_schema.get("type") != "object": + raise ValueError("Hook 参数模型必须是 object 类型") + return normalized_schema + + @classmethod + def _normalize_spec(cls, spec: HookSpec) -> HookSpec: + """规范化 Hook 规格对象。 + + Args: + spec: 原始 Hook 规格。 + + Returns: + HookSpec: 规范化后的 Hook 规格副本。 + """ + + return HookSpec( + name=cls._normalize_hook_name(spec.name), + description=str(spec.description or "").strip(), + parameters_schema=cls._normalize_parameters_schema(spec.parameters_schema), + default_timeout_ms=max(int(spec.default_timeout_ms), 0), + allow_blocking=bool(spec.allow_blocking), + allow_observe=bool(spec.allow_observe), + allow_abort=bool(spec.allow_abort), + allow_kwargs_mutation=bool(spec.allow_kwargs_mutation), + ) + + def clear(self) -> None: + """清空全部 Hook 规格。""" + + self._hook_specs.clear() + + def register_hook_spec(self, spec: HookSpec) -> HookSpec: + """注册单个 Hook 规格。 + + Args: + spec: 需要注册的 Hook 规格。 + + Returns: + HookSpec: 规范化后实际注册的 Hook 规格。 + """ + + normalized_spec = self._normalize_spec(spec) + self._hook_specs[normalized_spec.name] = normalized_spec + return normalized_spec + + def register_hook_specs(self, specs: Sequence[HookSpec]) -> List[HookSpec]: + """批量注册 Hook 规格。 + + Args: + specs: 需要注册的 Hook 规格列表。 + + Returns: + List[HookSpec]: 规范化后实际注册的 Hook 规格列表。 + """ + + return [self.register_hook_spec(spec) for spec in specs] + + def unregister_hook_spec(self, hook_name: str) -> bool: + """注销指定 Hook 规格。 + + Args: + hook_name: 目标 Hook 名称。 + + Returns: + bool: 是否成功删除。 + """ + + normalized_name = self._normalize_hook_name(hook_name) + return self._hook_specs.pop(normalized_name, None) is not None + + def get_hook_spec(self, hook_name: str) -> Optional[HookSpec]: + """获取指定 Hook 的显式规格。 + + Args: + hook_name: 目标 Hook 名称。 + + Returns: + Optional[HookSpec]: 已注册时返回规格副本,否则返回 ``None``。 + """ + + normalized_name = self._normalize_hook_name(hook_name) + spec = self._hook_specs.get(normalized_name) + return None if spec is None else self._normalize_spec(spec) + + def list_hook_specs(self) -> List[HookSpec]: + """返回当前全部 Hook 规格。 + + Returns: + List[HookSpec]: 按 Hook 名称升序排列的规格副本列表。 + """ + + return [ + self._normalize_spec(spec) + for _, spec in sorted(self._hook_specs.items(), key=lambda item: item[0]) + ] diff --git a/src/plugin_runtime/host/logger_bridge.py b/src/plugin_runtime/host/logger_bridge.py new file mode 100644 index 00000000..f2213dfe --- /dev/null +++ b/src/plugin_runtime/host/logger_bridge.py @@ -0,0 +1,45 @@ +import logging as stdlib_logging +from src.plugin_runtime.protocol.errors import ErrorCode +from src.plugin_runtime.protocol.envelope import Envelope, LogBatchPayload +class RunnerLogBridge: + """将 Runner 进程上报的批量日志重放到主进程的 Logger 中。 + + Runner 通过 ``runner.log_batch`` IPC 事件批量到达。 + 每条 LogEntry 被重建为一个真实的 :class:`logging.LogRecord` 并直接 + 调用 ``logging.getLogger(entry.logger_name).handle(record)``, + 从而接入主进程已配置好的 structlog Handler 链。 + """ + + async def handle_log_batch(self, envelope: Envelope) -> Envelope: + """IPC 事件处理器:解析批量日志并重放到主进程 Logger。 + + Args: + envelope: 方法名为 ``runner.log_batch`` 的 IPC 事件信封。 + + Returns: + 空响应信封(事件模式下将被忽略)。 + """ + try: + batch = LogBatchPayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + for entry in batch.entries: + # 重建一个与原始日志尽量相符的 LogRecord + record = stdlib_logging.LogRecord( + name=entry.logger_name, + level=entry.level, + pathname="", + lineno=0, + msg=entry.message, + args=(), + exc_info=None, + ) + record.created = entry.timestamp_ms / 1000.0 + record.msecs = entry.timestamp_ms % 1000 + if entry.exception_text: + record.exc_text = entry.exception_text + + stdlib_logging.getLogger(entry.logger_name).handle(record) + + return envelope.make_response(payload={"accepted": True, "count": len(batch.entries)}) \ No newline at end of file diff --git a/src/plugin_runtime/host/message_gateway.py b/src/plugin_runtime/host/message_gateway.py new file mode 100644 index 00000000..d1799648 --- /dev/null +++ b/src/plugin_runtime/host/message_gateway.py @@ -0,0 +1,114 @@ +"""Host 侧消息网关包装器。""" + +from typing import TYPE_CHECKING, Any, Dict + +from src.common.logger import get_logger +from src.platform_io import get_platform_io_manager + +from .message_utils import PluginMessageUtils + +if TYPE_CHECKING: + from src.chat.message_receive.message import SessionMessage + from .component_registry import ComponentRegistry + from .supervisor import PluginRunnerSupervisor + +logger = get_logger("plugin_runtime.host.message_gateway") + + +class MessageGateway: + """Host 侧消息网关包装器。""" + + def __init__(self, component_registry: "ComponentRegistry") -> None: + """初始化消息网关。 + + Args: + component_registry: 组件注册表。 + """ + self._component_registry = component_registry + + def build_session_message(self, external_message: Dict[str, Any]) -> "SessionMessage": + """将标准消息字典转换为 ``SessionMessage``。 + + Args: + external_message: 外部消息的字典格式数据。 + + Returns: + SessionMessage: 转换后的内部消息对象。 + + Raises: + ValueError: 消息字典不合法时抛出。 + """ + return PluginMessageUtils._build_session_message_from_dict(external_message) + + def build_message_dict(self, internal_message: "SessionMessage") -> Dict[str, Any]: + """将 ``SessionMessage`` 转换为标准消息字典。 + + Args: + internal_message: 内部消息对象。 + + Returns: + Dict[str, Any]: 供消息网关插件消费的标准消息字典。 + """ + return dict(PluginMessageUtils._session_message_to_dict(internal_message)) + + async def receive_external_message(self, external_message: Dict[str, Any]) -> None: + """接收外部消息并送入主消息链。 + + Args: + external_message: 外部消息的字典格式数据。 + """ + try: + session_message = self.build_session_message(external_message) + except Exception as e: + logger.error(f"转换外部消息失败: {e}") + return + + from src.chat.message_receive.bot import chat_bot + + await chat_bot.receive_message(session_message) + + async def send_message_to_external( + self, + internal_message: "SessionMessage", + supervisor: "PluginRunnerSupervisor", + *, + enabled_only: bool = True, + save_to_db: bool = True, + ) -> bool: + """将内部消息通过 Platform IO 发送到外部平台。 + + Args: + internal_message: 系统内部的 ``SessionMessage`` 对象。 + supervisor: 当前持有该消息网关的 Supervisor。 + enabled_only: 兼容旧签名的保留参数,当前未使用。 + save_to_db: 发送成功后是否写入数据库。 + + Returns: + bool: 是否发送成功。 + """ + del enabled_only + del supervisor + + platform_io_manager = get_platform_io_manager() + if not platform_io_manager.is_started: + logger.warning("Platform IO 尚未启动,无法通过适配器链路发送消息") + return False + + route_key = platform_io_manager.build_route_key_from_message(internal_message) + delivery_batch = await platform_io_manager.send_message(internal_message, route_key) + if not delivery_batch.has_success: + logger.warning("通过消息网关链路发送消息失败: 未命中任何成功回执") + return False + + first_successful_receipt = delivery_batch.sent_receipts[0] + external_message_id = str(first_successful_receipt.external_message_id or "").strip() + if external_message_id: + internal_message.message_id = external_message_id + if save_to_db: + try: + from src.common.utils.utils_message import MessageUtils + + MessageUtils.store_message_to_db(internal_message) + except Exception as e: + logger.error(f"保存消息到数据库失败: {e}") + return True diff --git a/src/plugin_runtime/host/message_utils.py b/src/plugin_runtime/host/message_utils.py new file mode 100644 index 00000000..a68af32d --- /dev/null +++ b/src/plugin_runtime/host/message_utils.py @@ -0,0 +1,548 @@ +from datetime import datetime +from typing import Any, Dict, List, Optional, TypedDict + +import base64 +import hashlib + +from src.common.logger import get_logger +from src.chat.message_receive.message import SessionMessage +from src.common.data_models.mai_message_data_model import UserInfo, GroupInfo, MessageInfo +from src.common.data_models.message_component_data_model import ( + AtComponent, + DictComponent, + EmojiComponent, + ForwardComponent, + ForwardNodeComponent, + ImageComponent, + MessageSequence, + ReplyComponent, + StandardMessageComponents, + TextComponent, + VoiceComponent, +) + +logger = get_logger("plugin_runtime.host.message_utils") + + +class UserInfoDict(TypedDict, total=False): + user_id: str + user_nickname: str + user_cardname: Optional[str] + + +class GroupInfoDict(TypedDict, total=False): + group_id: str + group_name: str + + +class MessageInfoDict(TypedDict, total=False): + user_info: UserInfoDict + group_info: Optional[GroupInfoDict] + additional_config: Dict[str, Any] + + +class MessageDict(TypedDict, total=False): + message_id: str + timestamp: str + platform: str + message_info: MessageInfoDict + raw_message: List[Dict[str, Any]] + is_mentioned: bool + is_at: bool + is_emoji: bool + is_picture: bool + is_command: bool + is_notify: bool + session_id: str + reply_to: Optional[str] + processed_plain_text: Optional[str] + + +class PluginMessageUtils: + @staticmethod + def _message_sequence_to_dict( + message_sequence: MessageSequence, + include_binary_data: bool = True, + ) -> List[Dict[str, Any]]: + """将消息组件序列转换为插件运行时使用的字典结构。 + + Args: + message_sequence: 待转换的消息组件序列。 + + Returns: + List[Dict[str, Any]]: 供插件运行时协议使用的消息段字典列表。 + """ + return [ + PluginMessageUtils._component_to_dict(component, include_binary_data=include_binary_data) + for component in message_sequence.components + ] + + @staticmethod + def _component_to_dict( + component: StandardMessageComponents, + include_binary_data: bool = True, + ) -> Dict[str, Any]: + """将单个消息组件转换为插件运行时字典结构。 + + Args: + component: 待转换的消息组件。 + + Returns: + Dict[str, Any]: 序列化后的消息组件字典。 + """ + if isinstance(component, TextComponent): + return {"type": "text", "data": component.text} + + if isinstance(component, ImageComponent): + serialized = { + "type": "image", + "data": component.content, + "hash": component.binary_hash, + } + if include_binary_data and ( + binary_data_base64 := PluginMessageUtils._binary_component_to_base64(component, "image") + ): + serialized["binary_data_base64"] = binary_data_base64 + return serialized + + if isinstance(component, EmojiComponent): + serialized = { + "type": "emoji", + "data": component.content, + "hash": component.binary_hash, + } + if include_binary_data and ( + binary_data_base64 := PluginMessageUtils._binary_component_to_base64(component, "emoji") + ): + serialized["binary_data_base64"] = binary_data_base64 + return serialized + + if isinstance(component, VoiceComponent): + serialized = { + "type": "voice", + "data": component.content, + "hash": component.binary_hash, + } + if include_binary_data and component.binary_data: + serialized["binary_data_base64"] = base64.b64encode(component.binary_data).decode("utf-8") + return serialized + + if isinstance(component, AtComponent): + return { + "type": "at", + "data": { + "target_user_id": component.target_user_id, + "target_user_nickname": component.target_user_nickname, + "target_user_cardname": component.target_user_cardname, + }, + } + + if isinstance(component, ReplyComponent): + return { + "type": "reply", + "data": { + "target_message_id": component.target_message_id, + "target_message_content": component.target_message_content, + "target_message_sender_id": component.target_message_sender_id, + "target_message_sender_nickname": component.target_message_sender_nickname, + "target_message_sender_cardname": component.target_message_sender_cardname, + }, + } + + if isinstance(component, ForwardNodeComponent): + return { + "type": "forward", + "data": [ + PluginMessageUtils._forward_component_to_dict(item, include_binary_data=include_binary_data) + for item in component.forward_components + ], + } + + return {"type": "dict", "data": component.data} + + @staticmethod + def _binary_component_to_base64(component: Any, image_type: str) -> str: + """将图片或表情组件转换为 Base64,必要时通过 hash 从图片库加载文件。""" + + if component.binary_data: + return base64.b64encode(component.binary_data).decode("utf-8") + + binary_hash = str(component.binary_hash or "").strip() + if not binary_hash: + return "" + + try: + from pathlib import Path + + from sqlmodel import select + + from src.common.database.database import get_db_session + from src.common.database.database_model import Images, ImageType + + target_image_type = ImageType.IMAGE if image_type == "image" else ImageType.EMOJI + with get_db_session(auto_commit=False) as db: + statement = select(Images).filter_by(image_hash=binary_hash, image_type=target_image_type).limit(1) + image_record = db.exec(statement).first() + if image_record is None or image_record.no_file_flag: + return "" + + image_path = Path(image_record.full_path) + if not image_path.is_file(): + return "" + return base64.b64encode(image_path.read_bytes()).decode("utf-8") + except Exception as exc: + logger.debug(f"通过 hash 加载历史媒体失败: type={image_type} hash={binary_hash} error={exc}") + return "" + + @staticmethod + def _forward_component_to_dict( + component: ForwardComponent, + include_binary_data: bool = True, + ) -> Dict[str, Any]: + """将单个转发节点组件转换为字典结构。 + + Args: + component: 待转换的转发节点组件。 + + Returns: + Dict[str, Any]: 序列化后的转发节点字典。 + """ + return { + "user_id": component.user_id, + "user_nickname": component.user_nickname, + "user_cardname": component.user_cardname, + "message_id": component.message_id, + "content": [ + PluginMessageUtils._component_to_dict(item, include_binary_data=include_binary_data) + for item in component.content + ], + } + + @staticmethod + def _message_sequence_from_dict(raw_message_data: List[Dict[str, Any]]) -> MessageSequence: + """从插件运行时字典结构恢复消息组件序列。 + + Args: + raw_message_data: 插件运行时消息段字典列表。 + + Returns: + MessageSequence: 恢复后的消息组件序列。 + """ + components = [PluginMessageUtils._component_from_dict(item) for item in raw_message_data] + return MessageSequence(components=components) + + @staticmethod + def _component_from_dict(item: Dict[str, Any]) -> StandardMessageComponents: + """从插件运行时字典结构恢复单个消息组件。 + + Args: + item: 单个消息组件的字典表示。 + + Returns: + StandardMessageComponents: 恢复后的内部消息组件对象。 + """ + item_type = str(item.get("type") or "").strip() + if item_type == "text": + return TextComponent(text=str(item.get("data") or "")) + + if item_type == "image": + return PluginMessageUtils._build_binary_component(ImageComponent, item) + + if item_type == "emoji": + return PluginMessageUtils._build_binary_component(EmojiComponent, item) + + if item_type == "voice": + return PluginMessageUtils._build_binary_component(VoiceComponent, item) + + if item_type == "at": + item_data = item.get("data", {}) + if not isinstance(item_data, dict): + item_data = {} + return AtComponent( + target_user_id=str(item_data.get("target_user_id") or ""), + target_user_nickname=PluginMessageUtils._normalize_optional_string(item_data.get("target_user_nickname")), + target_user_cardname=PluginMessageUtils._normalize_optional_string(item_data.get("target_user_cardname")), + ) + + if item_type == "reply": + reply_data = item.get("data") + if isinstance(reply_data, dict): + return ReplyComponent( + target_message_id=str(reply_data.get("target_message_id") or ""), + target_message_content=PluginMessageUtils._normalize_optional_string( + reply_data.get("target_message_content") + ), + target_message_sender_id=PluginMessageUtils._normalize_optional_string( + reply_data.get("target_message_sender_id") + ), + target_message_sender_nickname=PluginMessageUtils._normalize_optional_string( + reply_data.get("target_message_sender_nickname") + ), + target_message_sender_cardname=PluginMessageUtils._normalize_optional_string( + reply_data.get("target_message_sender_cardname") + ), + ) + return ReplyComponent(target_message_id=str(reply_data or "")) + + if item_type == "forward": + forward_nodes: List[ForwardComponent] = [] + raw_forward_nodes = item.get("data", []) + if isinstance(raw_forward_nodes, list): + for node in raw_forward_nodes: + if not isinstance(node, dict): + logger.info(f"解析转发节点时跳过非字典节点: {node!r}") + continue + raw_content = node.get("content", []) + node_components: List[StandardMessageComponents] = [] + if isinstance(raw_content, list): + node_components = [ + PluginMessageUtils._component_from_dict(content) + for content in raw_content + if isinstance(content, dict) + ] + if not node_components: + logger.warning( + "转发节点内容为空,使用占位文本回退: " + f"message_id={node.get('message_id')!r} raw_content={raw_content!r}" + ) + node_components = [TextComponent(text="[empty forward node]")] + forward_nodes.append( + ForwardComponent( + user_nickname=str(node.get("user_nickname") or "未知用户"), + user_id=PluginMessageUtils._normalize_optional_string(node.get("user_id")), + user_cardname=PluginMessageUtils._normalize_optional_string(node.get("user_cardname")), + message_id=str(node.get("message_id") or ""), + content=node_components, + ) + ) + if not forward_nodes: + return DictComponent(data={"type": "forward", "data": item.get("data", [])}) + return ForwardNodeComponent(forward_components=forward_nodes) + + component_data = item.get("data") + if isinstance(component_data, dict): + return DictComponent(data=component_data) + return DictComponent(data=item) + + @staticmethod + def _build_binary_component(component_cls: Any, item: Dict[str, Any]) -> StandardMessageComponents: + """从字典构造带二进制负载的消息组件。 + + Args: + component_cls: 目标组件类型。 + item: 消息组件字典。 + + Returns: + StandardMessageComponents: 构造后的组件对象。 + """ + content = str(item.get("data") or "") + binary_hash = str(item.get("hash") or "") + raw_binary_base64 = item.get("binary_data_base64") + binary_data = b"" + if isinstance(raw_binary_base64, str) and raw_binary_base64: + try: + binary_data = base64.b64decode(raw_binary_base64) + except Exception: + binary_data = b"" + + if not binary_hash and binary_data: + binary_hash = hashlib.sha256(binary_data).hexdigest() + + return component_cls(binary_hash=binary_hash, content=content, binary_data=binary_data) + + @staticmethod + def _normalize_optional_string(value: Any) -> Optional[str]: + """将任意值规范化为可选字符串。 + + Args: + value: 待规范化的值。 + + Returns: + Optional[str]: 规范化后的字符串;若值为空则返回 ``None``。 + """ + if value is None: + return None + normalized_value = str(value) + return normalized_value if normalized_value else None + + @staticmethod + def _message_info_to_dict(message_info: MessageInfo) -> MessageInfoDict: + """ + 将 MessageInfo 对象转换为字典格式 + + Args: + message_info: MessageInfo 对象 + + Returns: + 字典格式的消息信息 + """ + user_info_dict = UserInfoDict( + user_id=message_info.user_info.user_id, + user_nickname=message_info.user_info.user_nickname, + user_cardname=message_info.user_info.user_cardname, + ) + + group_info_dict: Optional[GroupInfoDict] = None + if message_info.group_info: + group_info_dict = GroupInfoDict( + group_id=message_info.group_info.group_id, + group_name=message_info.group_info.group_name, + ) + + return MessageInfoDict( + user_info=user_info_dict, + group_info=group_info_dict, + additional_config=message_info.additional_config, + ) + + @staticmethod + def _session_message_to_dict( + session_message: SessionMessage, + include_binary_data: bool = True, + ) -> MessageDict: + """ + 将 SessionMessage 对象转换为字典格式(复用 MessageSequence.to_dict 方法) + + Args: + session_message: SessionMessage 对象 + + Returns: + 字典格式的消息 + """ + # 转换基本信息 + message_dict = MessageDict( + message_id=session_message.message_id, + timestamp=str(session_message.timestamp.timestamp()), # 转换为时间戳字符串 + platform=session_message.platform, + message_info=PluginMessageUtils._message_info_to_dict(session_message.message_info), + raw_message=PluginMessageUtils._message_sequence_to_dict( + session_message.raw_message, + include_binary_data=include_binary_data, + ), + is_mentioned=session_message.is_mentioned, + is_at=session_message.is_at, + is_emoji=session_message.is_emoji, + is_picture=session_message.is_picture, + is_command=session_message.is_command, + is_notify=session_message.is_notify, + session_id=session_message.session_id, + ) + + # 添加可选字段 + if session_message.reply_to is not None: + message_dict["reply_to"] = session_message.reply_to + if session_message.processed_plain_text is not None: + message_dict["processed_plain_text"] = session_message.processed_plain_text + + return message_dict + + @staticmethod + def _build_message_info_from_dict(message_info_dict: Dict[str, Any]) -> MessageInfo: + """ + 从字典构建 MessageInfo 对象 + + Args: + message_info_dict: 包含消息信息的字典 + + Returns: + MessageInfo 对象 + """ + # 构建用户信息 + user_info_dict = message_info_dict.get("user_info") + if not user_info_dict or not isinstance(user_info_dict, dict): + raise ValueError("消息字典中 'user_info' 字段无效") + user_id = user_info_dict.get("user_id") + user_nickname = user_info_dict.get("user_nickname") + user_cardname = user_info_dict.get("user_cardname") + if not isinstance(user_id, str) or not isinstance(user_nickname, str) or not user_id or not user_nickname: + raise ValueError("消息字典中 'user_info' 字段缺少有效的 'user_id' 或 'user_nickname'") + user_cardname = str(user_cardname) if user_cardname is not None else None + user_info = UserInfo(user_id=user_id, user_nickname=user_nickname, user_cardname=user_cardname) + + # 构建群信息 + if group_info_dict := message_info_dict.get("group_info"): + group_id = group_info_dict.get("group_id") + group_name = group_info_dict.get("group_name") + if not isinstance(group_id, str) or not isinstance(group_name, str) or not group_id or not group_name: + raise ValueError("消息字典中 'group_info' 字段缺少有效的 'group_id' 或 'group_name'") + group_info = GroupInfo(group_id=group_id, group_name=group_name) + else: + group_info = None + + # 获取额外配置 + additional_config: Dict[str, Any] = message_info_dict.get("additional_config", {}) + + return MessageInfo(user_info=user_info, group_info=group_info, additional_config=additional_config) + + @staticmethod + def _build_session_message_from_dict(message_dict: Dict[str, Any]) -> SessionMessage: + """ + 从字典构建 SessionMessage 对象(递归处理消息组件) + + Args: + message_dict: 包含消息完整信息的字典 + + Returns: + SessionMessage 对象 + """ + # 提取基本信息 + message_id = message_dict["message_id"] + timestamp_str: str = message_dict.get("timestamp", "") + platform = message_dict["platform"] + if not isinstance(message_id, str) or not message_id: + raise ValueError("消息字典中缺少有效的 'message_id' 字段") + if not isinstance(platform, str) or not platform: + raise ValueError("消息字典中缺少有效的 'platform' 字段") + + # 解析时间戳 + try: + timestamp_float = float(timestamp_str) + timestamp = datetime.fromtimestamp(timestamp_float) + except (ValueError, TypeError): + timestamp = datetime.now() # 如果解析失败,使用当前时间 + + # 创建 SessionMessage 实例 + session_message = SessionMessage(message_id=message_id, timestamp=timestamp, platform=platform) + + # 构建消息信息 + session_message.message_info = PluginMessageUtils._build_message_info_from_dict(message_dict["message_info"]) + + # 构建原始消息组件序列(复用 MessageSequence.from_dict 方法) + raw_message_data = message_dict["raw_message"] + if isinstance(raw_message_data, list): + session_message.raw_message = PluginMessageUtils._message_sequence_from_dict(raw_message_data) + else: + raise ValueError("消息字典中 'raw_message' 字段必须是一个列表") + + # 设置其他可选属性 + session_message.is_mentioned = message_dict.get("is_mentioned", False) + if not isinstance(session_message.is_mentioned, bool): + session_message.is_mentioned = False + session_message.is_at = message_dict.get("is_at", False) + if not isinstance(session_message.is_at, bool): + session_message.is_at = False + session_message.is_emoji = message_dict.get("is_emoji", False) + if not isinstance(session_message.is_emoji, bool): + session_message.is_emoji = False + session_message.is_picture = message_dict.get("is_picture", False) + if not isinstance(session_message.is_picture, bool): + session_message.is_picture = False + session_message.is_command = message_dict.get("is_command", False) + if not isinstance(session_message.is_command, bool): + session_message.is_command = False + session_message.is_notify = message_dict.get("is_notify", False) + if not isinstance(session_message.is_notify, bool): + session_message.is_notify = False + session_message.session_id = message_dict.get("session_id", "") + if not isinstance(session_message.session_id, str): + session_message.session_id = "" + session_message.reply_to = message_dict.get("reply_to") + if session_message.reply_to is not None and not isinstance(session_message.reply_to, str): + session_message.reply_to = None + session_message.processed_plain_text = message_dict.get("processed_plain_text") + if session_message.processed_plain_text is not None and not isinstance( + session_message.processed_plain_text, str + ): + session_message.processed_plain_text = None + + return session_message diff --git a/src/plugin_runtime/host/rpc_server.py b/src/plugin_runtime/host/rpc_server.py new file mode 100644 index 00000000..eb6768c2 --- /dev/null +++ b/src/plugin_runtime/host/rpc_server.py @@ -0,0 +1,448 @@ +"""Host 端 RPC Server + +负责: +1. 监听 Runner 连接 +2. 处理握手(runner.hello) +3. 分发调用请求给 Runner / 处理 Runner 的能力调用 +4. 请求-响应关联与超时管理 +""" + +from typing import Any, Callable, Dict, List, Optional, Tuple, Coroutine + +import asyncio +import contextlib +import re +import secrets + +from src.common.logger import get_logger +from src.plugin_runtime.protocol.codec import Codec, MsgPackCodec +from src.plugin_runtime.protocol.envelope import ( + PROTOCOL_VERSION, + MIN_SDK_VERSION, + MAX_SDK_VERSION, + Envelope, + HelloPayload, + HelloResponsePayload, + MessageType, + RequestIdGenerator, +) +from src.plugin_runtime.protocol.errors import ErrorCode, RPCError +from src.plugin_runtime.transport.base import Connection, TransportServer + +logger = get_logger("plugin_runtime.host.rpc_server") + +# RPC 方法处理器类型 +MethodHandler = Callable[[Envelope], Coroutine[Any, Any, Envelope]] + + +class RPCServer: + """Host 端 RPC 服务器 + + 管理与 Runner 的 IPC 连接,处理双向 RPC 调用。 + """ + + def __init__( + self, + transport: TransportServer, + session_token: Optional[str] = None, + codec: Optional[Codec] = None, + send_queue_size: int = 128, + ): + self._transport = transport + self._session_token = session_token or secrets.token_hex(32) + self._codec = codec or MsgPackCodec() + self._send_queue_size = send_queue_size + + self._id_gen = RequestIdGenerator() + self._connection: Optional[Connection] = None # 当前活跃的 Runner 连接 + + # 方法处理器注册表 + self._method_handlers: Dict[str, MethodHandler] = {} + + # 等待响应的 pending 请求: request_id -> Future + self._pending_requests: Dict[int, asyncio.Future[Envelope]] = {} + + # 发送队列(背压控制) + self._send_queue: Optional[asyncio.Queue[Tuple[Connection, bytes, asyncio.Future[None]]]] = None + self._send_worker_task: Optional[asyncio.Task[None]] = None + + # 运行状态 + self._running: bool = False + self._tasks: List[asyncio.Task[None]] = [] + self._last_handshake_rejection_reason: str = "" + self._connection_lock: asyncio.Lock = asyncio.Lock() + + @property + def session_token(self) -> str: + return self._session_token + + @property + def is_connected(self) -> bool: + return self._connection is not None and not self._connection.is_closed + + @property + def last_handshake_rejection_reason(self) -> str: + """返回最近一次握手被拒绝的原因。""" + return self._last_handshake_rejection_reason + + def clear_handshake_state(self) -> None: + """清空最近一次握手拒绝状态。""" + self._last_handshake_rejection_reason = "" + + def register_method(self, method: str, handler: MethodHandler) -> None: + """注册 RPC 方法处理器""" + self._method_handlers[method] = handler + + async def start(self) -> None: + """启动 RPC 服务器""" + self._running = True + self.clear_handshake_state() + self._send_queue = asyncio.Queue(maxsize=self._send_queue_size) + self._send_worker_task = asyncio.create_task(self._send_loop()) + await self._transport.start(self._handle_connection) + logger.info(f"RPC Server 已启动,监听地址: {self._transport.get_address()}") + + async def stop(self) -> None: + """停止 RPC 服务器""" + self._running = False + self.clear_handshake_state() + self._fail_pending_requests(ErrorCode.E_SHUTTING_DOWN, "服务器正在关闭") + self._fail_queued_sends(ErrorCode.E_SHUTTING_DOWN, "服务器正在关闭") + + if self._send_worker_task: + self._send_worker_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._send_worker_task + self._send_worker_task = None + + # 取消后台任务 + for task in self._tasks: + task.cancel() + self._tasks.clear() + + # 关闭连接 + if self._connection: + await self._connection.close() + self._connection = None + + await self._transport.stop() + logger.info("RPC Server 已停止") + + async def send_request( + self, + method: str, + plugin_id: str = "", + payload: Optional[Dict[str, Any]] = None, + timeout_ms: int = 30000, + ) -> Envelope: + """向 Runner 发送 RPC 请求并等待响应 + + Args: + method: RPC 方法名 + plugin_id: 目标插件 ID + payload: 请求数据 + timeout_ms: 超时时间(ms) + + Returns: + 响应 Envelope + + Raises: + RPCError: 调用失败 + """ + if not self._connection or self._connection.is_closed: + raise RPCError(ErrorCode.E_PLUGIN_CRASHED, "Runner 未连接") + request_id = await self._id_gen.next() + envelope = Envelope( + request_id=request_id, + message_type=MessageType.REQUEST, + method=method, + plugin_id=plugin_id, + timeout_ms=timeout_ms, + payload=payload or {}, + ) + + # 注册 pending future + loop = asyncio.get_running_loop() + future: asyncio.Future[Envelope] = loop.create_future() + self._pending_requests[request_id] = future + + try: + # 发送请求 + data = self._codec.encode_envelope(envelope) + await self._enqueue_send(self._connection, data) + + # 等待响应 + timeout_sec = timeout_ms / 1000.0 + return await asyncio.wait_for(future, timeout=timeout_sec) + except asyncio.TimeoutError: + self._pending_requests.pop(request_id, None) + raise RPCError(ErrorCode.E_TIMEOUT, f"请求 {method} 超时 ({timeout_ms}ms)") from None + except Exception as e: + self._pending_requests.pop(request_id, None) + if isinstance(e, RPCError): + raise + raise RPCError(ErrorCode.E_UNKNOWN, str(e)) from e + + # ============ 内部方法 ============ + # ========= 发送循环 ========= + async def _send_loop(self) -> None: + """后台发送循环:串行消费发送队列,统一执行连接写入。""" + if self._send_queue is None: + raise RuntimeError("没有消息队列") + + while True: + try: + conn, data, send_future = await self._send_queue.get() + except asyncio.CancelledError: + break + + try: + if conn.is_closed: + raise RPCError(ErrorCode.E_PLUGIN_CRASHED, "Runner 未连接") + await conn.send_frame(data) + if not send_future.done(): + send_future.set_result(None) + except asyncio.CancelledError: + if not send_future.done(): + send_future.set_exception(RPCError(ErrorCode.E_TIMEOUT, "服务器关闭")) + raise + except Exception as e: + send_error = RPCError.from_exception(e, {ConnectionError: ErrorCode.E_PLUGIN_CRASHED}) + if not send_future.done(): + send_future.set_exception(send_error) + finally: + self._send_queue.task_done() + + # ====== 发送循环方法 ====== + async def _handle_connection(self, conn: Connection) -> None: + """处理新的 Runner 连接""" + logger.info("收到 Runner 连接") + try: + async with self._connection_lock: + self.clear_handshake_state() + success = await self._handle_handshake(conn) + if not success: + await conn.close() + return + logger.info("Runner staged 握手成功") + self._connection = conn + except Exception as e: + logger.error(f"握手失败: {e}") + await conn.close() + return + + # 启动消息接收循环 + try: + await self._recv_loop(conn) + except Exception as e: + logger.error(f"连接异常断开: {e}") + finally: + should_fail_pending_requests = False + async with self._connection_lock: + if self._connection is conn: + self._connection = None + should_fail_pending_requests = True + if should_fail_pending_requests: + self._fail_pending_requests(ErrorCode.E_PLUGIN_CRASHED, "Runner 连接已断开") + + async def _handle_handshake(self, conn: Connection) -> bool: + """处理 runner.hello 握手""" + # 接收握手请求 + data = await asyncio.wait_for(conn.recv_frame(), timeout=10.0) + envelope = self._codec.decode_envelope(data) + if envelope.method != "runner.hello": + logger.error(f"期望 runner.hello,收到 {envelope.method}") + self._last_handshake_rejection_reason = "首条消息必须为 runner.hello" + error_resp = envelope.make_error_response( + ErrorCode.E_PROTOCOL_MISMATCH.value, + "首条消息必须为 runner.hello", + ) + await conn.send_frame(self._codec.encode_envelope(error_resp)) + return False + + # 解析握手 payload + hello = HelloPayload.model_validate(envelope.payload) + # 校验会话令牌 + if hello.session_token != self._session_token: + logger.error("会话令牌不匹配") + self._last_handshake_rejection_reason = "会话令牌无效" + resp_payload = HelloResponsePayload(accepted=False, reason=self._last_handshake_rejection_reason) + resp = envelope.make_response(payload=resp_payload.model_dump()) + await conn.send_frame(self._codec.encode_envelope(resp)) + return False + + # 若已有活跃连接,直接拒绝新的握手,避免后来的连接抢占当前通道。 + if self.is_connected: + logger.warning("拒绝新的 Runner 连接:已有活跃连接") + self._last_handshake_rejection_reason = "已有活跃 Runner 连接,拒绝新的握手" + resp_payload = HelloResponsePayload(accepted=False, reason=self._last_handshake_rejection_reason) + resp = envelope.make_response(payload=resp_payload.model_dump()) + await conn.send_frame(self._codec.encode_envelope(resp)) + return False + + # 校验 SDK 版本 + if not self._check_sdk_version(hello.sdk_version): + logger.error(f"SDK 版本不兼容: {hello.sdk_version}") + self._last_handshake_rejection_reason = ( + f"SDK 版本 {hello.sdk_version} 不在支持范围 [{MIN_SDK_VERSION}, {MAX_SDK_VERSION}]" + ) + resp_payload = HelloResponsePayload( + accepted=False, + reason=self._last_handshake_rejection_reason, + ) + resp = envelope.make_response(payload=resp_payload.model_dump()) + await conn.send_frame(self._codec.encode_envelope(resp)) + return False + + # 发送响应 + self.clear_handshake_state() + resp_payload = HelloResponsePayload(accepted=True, host_version=PROTOCOL_VERSION) + resp = envelope.make_response(payload=resp_payload.model_dump()) + await conn.send_frame(self._codec.encode_envelope(resp)) + return True + + def _check_sdk_version(self, sdk_version: str) -> bool: + """检查 SDK 版本是否在支持范围内""" + try: + sdk_parts = _parse_version_tuple(sdk_version) + min_parts = _parse_version_tuple(MIN_SDK_VERSION) + max_parts = _parse_version_tuple(MAX_SDK_VERSION) + return min_parts <= sdk_parts <= max_parts + except (ValueError, AttributeError): + return False + + # ========= 接收循环 ========= + async def _recv_loop(self, conn: Connection) -> None: + """消息接收主循环""" + while self._running and not conn.is_closed: + try: + data = await conn.recv_frame() + except (asyncio.IncompleteReadError, ConnectionError): + logger.info("Runner 连接已断开") + break + except Exception as e: + logger.error(f"接收帧失败: {e}") + break + + try: + envelope = self._codec.decode_envelope(data) + except Exception as e: + logger.error(f"解码消息失败: {e}") + continue + + # 分发消息 + if envelope.is_response(): + self._handle_response(envelope) + elif envelope.is_request(): + # 异步处理请求(Runner 发来的能力调用) + task = asyncio.create_task(self._handle_request(envelope, conn)) + self._tasks.append(task) + task.add_done_callback(lambda t: self._tasks.remove(t) if t in self._tasks else None) + elif envelope.is_broadcast(): + task = asyncio.create_task(self._handle_broadcast(envelope)) + self._tasks.append(task) + task.add_done_callback(lambda t: self._tasks.remove(t) if t in self._tasks else None) + else: + logger.warning(f"未知的消息类型: {envelope.message_type}") + continue + + # ====== 接收循环内部方法 ====== + def _handle_response(self, envelope: Envelope) -> None: + """处理来自 Runner 的响应""" + pending_future = self._pending_requests.pop(envelope.request_id, None) + if pending_future is None: + return + if not pending_future.done(): + if envelope.error: + pending_future.set_exception(RPCError.from_dict(envelope.error)) + else: + pending_future.set_result(envelope) + + async def _handle_request(self, envelope: Envelope, conn: Connection) -> None: + """处理来自 Runner 的请求(通常是能力调用 cap.*)""" + target_method = envelope.method + handler = self._method_handlers.get(target_method) + if not handler: + error_response = envelope.make_error_response( + ErrorCode.E_METHOD_NOT_ALLOWED.value, + f"未注册的方法: {envelope.method}", + ) + await conn.send_frame(self._codec.encode_envelope(error_response)) + return + + try: + response = await handler(envelope) + await conn.send_frame(self._codec.encode_envelope(response)) + except RPCError as e: + error_resp = envelope.make_error_response(e.code.value, e.message, e.details) + await conn.send_frame(self._codec.encode_envelope(error_resp)) + except Exception as e: + logger.error(f"处理请求 {envelope.method} 异常: {e}", exc_info=True) + error_resp = envelope.make_error_response(ErrorCode.E_UNKNOWN.value, str(e)) + await conn.send_frame(self._codec.encode_envelope(error_resp)) + + async def _handle_broadcast(self, envelope: Envelope) -> None: + if handler := self._method_handlers.get(envelope.method): + try: + result = await handler(envelope) + # 检查 handler 返回的信封是否包含错误信息 + if result.error: + logger.warning(f"事件 {envelope.method} handler 返回错误: {result.error.get('message', '')}") + except Exception as e: + logger.error(f"处理事件 {envelope.method} 异常: {e}", exc_info=True) + + def _fail_pending_requests(self, error_code: ErrorCode, message: str) -> int: + """失败所有等待中的请求(如连接断开时)""" + aborted_request_count = 0 + for future in self._pending_requests.values(): + if not future.done(): + future.set_exception(RPCError(error_code, message)) + aborted_request_count += 1 + self._pending_requests.clear() + return aborted_request_count + + def _fail_queued_sends(self, error_code: ErrorCode, message: str) -> int: + if self._send_queue is None: + return 0 + + failed_count = 0 + while True: + try: + _conn, _data, send_future = self._send_queue.get_nowait() + except asyncio.QueueEmpty: + break + + if not send_future.done(): + send_future.set_exception(RPCError(error_code, message)) + failed_count += 1 + self._send_queue.task_done() + + return failed_count + + async def _enqueue_send(self, conn: Connection, data: bytes) -> None: + """通过发送队列串行发送消息,提供真实背压。""" + if conn.is_closed: + raise RPCError(ErrorCode.E_PLUGIN_CRASHED, "Runner 未连接") + + if self._send_queue is None: + await conn.send_frame(data) + return + + loop = asyncio.get_running_loop() + send_future: asyncio.Future[None] = loop.create_future() + + try: + self._send_queue.put_nowait((conn, data, send_future)) + except asyncio.QueueFull: + raise RPCError(ErrorCode.E_BACK_PRESSURE, "发送队列已满") from None + + await send_future + + +def _parse_version_tuple(version: str) -> Tuple[int, int, int]: + base_version = re.split(r"[-.](?:snapshot|dev|alpha|beta|rc)", version or "", flags=re.IGNORECASE)[0] + base_version = base_version.split("+", 1)[0] + parts = [part for part in base_version.split(".") if part != ""] + while len(parts) < 3: + parts.append("0") + return (int(parts[0]), int(parts[1]), int(parts[2])) diff --git a/src/plugin_runtime/host/supervisor.py b/src/plugin_runtime/host/supervisor.py new file mode 100644 index 00000000..71af06b1 --- /dev/null +++ b/src/plugin_runtime/host/supervisor.py @@ -0,0 +1,1620 @@ +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple + +import asyncio +import contextlib +import json +import os +import sys + +from src.common.logger import get_logger +from src.config.config import global_config +from src.llm_models.model_client.base_client import ClientProviderRegistration, client_registry +from src.llm_models.model_client.plugin_client import PluginLLMClient +from src.platform_io import DriverKind, InboundMessageEnvelope, RouteBinding, RouteKey, get_platform_io_manager +from src.platform_io.drivers import PluginPlatformDriver +from src.platform_io.route_key_factory import RouteKeyFactory +from src.plugin_runtime import ( + ENV_BLOCKED_PLUGIN_REASONS, + ENV_EXTERNAL_PLUGIN_IDS, + ENV_HOST_VERSION, + ENV_IPC_ADDRESS, + ENV_PLUGIN_DIRS, + ENV_SESSION_TOKEN, +) +from src.plugin_runtime.protocol.envelope import ( + BootstrapPluginPayload, + ConfigReloadScope, + ConfigUpdatedPayload, + Envelope, + HealthPayload, + InspectPluginConfigPayload, + InspectPluginConfigResultPayload, + LLMProviderInvokePayload, + MessageGatewayStateUpdatePayload, + MessageGatewayStateUpdateResultPayload, + PROTOCOL_VERSION, + ReceiveExternalMessageResultPayload, + RegisterPluginPayload, + ReloadPluginResultPayload, + ReloadPluginsPayload, + ReloadPluginsResultPayload, + RouteMessagePayload, + RunnerReadyPayload, + ShutdownPayload, + UnregisterPluginPayload, + ValidatePluginConfigPayload, + ValidatePluginConfigResultPayload, +) +from src.plugin_runtime.protocol.codec import MsgPackCodec +from src.plugin_runtime.protocol.errors import ErrorCode, RPCError +from src.plugin_runtime.transport.factory import create_transport_server + +from .authorization import AuthorizationManager +from .api_registry import APIRegistry +from .capability_service import CapabilityService +from .component_registry import ComponentRegistry +from .event_dispatcher import EventDispatcher +from .hook_dispatcher import HookDispatchResult, HookDispatcher +from .hook_spec_registry import HookSpecRegistry +from .logger_bridge import RunnerLogBridge +from .message_gateway import MessageGateway +from .rpc_server import RPCServer + +if TYPE_CHECKING: + from src.chat.message_receive.message import SessionMessage + +logger = get_logger("plugin_runtime.host.runner_manager") + + +@dataclass(slots=True) +class _MessageGatewayRuntimeState: + """保存消息网关当前的运行时连接状态。""" + + ready: bool = False + platform: Optional[str] = None + account_id: Optional[str] = None + scope: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + +class PluginRunnerSupervisor: + """插件 Runner 监督器。 + + 负责 Host 侧与单个 Runner 子进程之间的生命周期、内部 RPC、 + 健康检查和插件级重载协调。 + """ + + def __init__( + self, + plugin_dirs: Optional[List[Path]] = None, + group_name: str = "third_party", + hook_spec_registry: Optional[HookSpecRegistry] = None, + socket_path: Optional[str] = None, + health_check_interval_sec: Optional[float] = None, + max_restart_attempts: Optional[int] = None, + runner_spawn_timeout_sec: Optional[float] = None, + ) -> None: + """初始化 Supervisor。 + + Args: + plugin_dirs: 由当前 Runner 负责加载的插件目录列表。 + group_name: 当前 Supervisor 所属运行时分组名称。 + hook_spec_registry: 可选的共享 Hook 规格注册中心。 + socket_path: 自定义 IPC 地址;留空时由传输层自动生成。 + health_check_interval_sec: 健康检查间隔,单位秒。 + max_restart_attempts: 自动重启 Runner 的最大次数。 + runner_spawn_timeout_sec: 等待 Runner 建连并就绪的超时时间,单位秒。 + """ + runtime_config = global_config.plugin_runtime + self._group_name: str = str(group_name or "third_party").strip() or "third_party" + self._plugin_dirs: List[Path] = plugin_dirs or [] + self._health_interval: float = health_check_interval_sec or runtime_config.health_check_interval_sec or 30.0 + self._runner_spawn_timeout: float = runner_spawn_timeout_sec or runtime_config.runner_spawn_timeout_sec or 30.0 + self._max_restart_attempts: int = max_restart_attempts or runtime_config.max_restart_attempts or 3 + + self._transport = create_transport_server(socket_path=socket_path) + self._authorization = AuthorizationManager() + self._capability_service = CapabilityService(self._authorization) + self._api_registry = APIRegistry() + self._component_registry = ComponentRegistry(hook_spec_registry=hook_spec_registry) + self._event_dispatcher = EventDispatcher(self._component_registry) + self._hook_dispatcher = HookDispatcher( + lambda: [self], + hook_spec_registry=hook_spec_registry, + ) + self._message_gateway = MessageGateway(self._component_registry) + self._log_bridge = RunnerLogBridge() + + codec = MsgPackCodec() + self._rpc_server = RPCServer(transport=self._transport, codec=codec) + + self._runner_process: Optional[asyncio.subprocess.Process] = None + self._registered_plugins: Dict[str, RegisterPluginPayload] = {} + self._message_gateway_states: Dict[str, Dict[str, _MessageGatewayRuntimeState]] = {} + self._external_available_plugins: Dict[str, str] = {} + self._blocked_plugin_reasons: Dict[str, str] = {} + self._runner_ready_events: asyncio.Event = asyncio.Event() + self._runner_ready_payloads: RunnerReadyPayload = RunnerReadyPayload() + self._health_task: Optional[asyncio.Task[None]] = None + self._stderr_drain_task: Optional[asyncio.Task[None]] = None + self._restart_count: int = 0 + self._running: bool = False + + self._register_internal_methods() + + @property + def authorization_manager(self) -> AuthorizationManager: + """返回授权管理器。""" + return self._authorization + + @property + def group_name(self) -> str: + """返回当前 Supervisor 的运行时分组名称。""" + + return self._group_name + + @property + def capability_service(self) -> CapabilityService: + """返回能力服务。""" + return self._capability_service + + @property + def api_registry(self) -> APIRegistry: + """返回 API 专用注册表。""" + return self._api_registry + + @property + def component_registry(self) -> ComponentRegistry: + """返回组件注册表。""" + return self._component_registry + + @property + def event_dispatcher(self) -> EventDispatcher: + """返回事件分发器。""" + return self._event_dispatcher + + @property + def hook_dispatcher(self) -> HookDispatcher: + """返回 Hook 分发器。""" + return self._hook_dispatcher + + @property + def message_gateway(self) -> MessageGateway: + """返回消息网关。""" + return self._message_gateway + + @property + def rpc_server(self) -> RPCServer: + """返回底层 RPC 服务端。""" + return self._rpc_server + + def set_external_available_plugins(self, plugin_versions: Dict[str, str]) -> None: + """设置当前 Runner 启动/重载时可视为已满足的外部依赖版本映射。 + + Args: + plugin_versions: 外部插件版本映射,键为插件 ID,值为插件版本。 + """ + self._external_available_plugins = { + str(plugin_id or "").strip(): str(plugin_version or "").strip() + for plugin_id, plugin_version in plugin_versions.items() + if str(plugin_id or "").strip() and str(plugin_version or "").strip() + } + + def get_loaded_plugin_ids(self) -> List[str]: + """返回当前 Supervisor 已注册的插件 ID 列表。""" + + return sorted(self._registered_plugins.keys()) + + def get_loaded_plugin_versions(self) -> Dict[str, str]: + """返回当前 Supervisor 已注册插件的版本映射。 + + Returns: + Dict[str, str]: 已注册插件版本映射,键为插件 ID,值为插件版本。 + """ + return {plugin_id: registration.plugin_version for plugin_id, registration in self._registered_plugins.items()} + + def get_plugin_load_statuses(self) -> Dict[str, str]: + """返回 Runner 最近一次上报的插件加载状态。""" + + statuses: Dict[str, str] = {} + for plugin_id in self._runner_ready_payloads.loaded_plugins: + statuses[plugin_id] = "success" + for plugin_id in self._runner_ready_payloads.failed_plugins: + statuses[plugin_id] = "failed" + for plugin_id in self._runner_ready_payloads.inactive_plugins: + statuses.setdefault(plugin_id, "inactive") + for plugin_id in self._registered_plugins: + statuses[plugin_id] = "success" + return statuses + + def set_blocked_plugin_reasons(self, blocked_plugin_reasons: Dict[str, str]) -> None: + """设置当前 Runner 启动时应拒绝加载的插件列表。 + + Args: + blocked_plugin_reasons: 需要拒绝加载的插件及原因映射。 + """ + + self._blocked_plugin_reasons = { + str(plugin_id or "").strip(): str(reason or "").strip() + for plugin_id, reason in blocked_plugin_reasons.items() + if str(plugin_id or "").strip() and str(reason or "").strip() + } + + @staticmethod + def _normalize_reload_plugin_ids(plugin_ids: Optional[List[str] | str]) -> List[str]: + """规范化批量重载入参。 + + Args: + plugin_ids: 原始插件 ID 列表或单个插件 ID。 + + Returns: + List[str]: 去重且去空白后的插件 ID 列表。 + """ + + raw_plugin_ids: List[str] + if plugin_ids is None: + raw_plugin_ids = [] + elif isinstance(plugin_ids, str): + raw_plugin_ids = [plugin_ids] + else: + raw_plugin_ids = list(plugin_ids) + + normalized_plugin_ids: List[str] = [] + seen_plugin_ids: set[str] = set() + for plugin_id in raw_plugin_ids: + normalized_plugin_id = str(plugin_id or "").strip() + if not normalized_plugin_id or normalized_plugin_id in seen_plugin_ids: + continue + seen_plugin_ids.add(normalized_plugin_id) + normalized_plugin_ids.append(normalized_plugin_id) + return normalized_plugin_ids + + async def dispatch_event( + self, + event_type: str, + message: Optional["SessionMessage"] = None, + extra_args: Optional[Dict[str, Any]] = None, + ) -> Tuple[bool, Optional["SessionMessage"]]: + """分发事件到已注册的事件处理器。 + + Args: + event_type: 事件类型。 + message: 可选的消息对象。 + extra_args: 附加参数。 + + Returns: + Tuple[bool, Optional[SessionMessage]]: 是否继续处理,以及插件可能修改后的消息。 + """ + return await self._event_dispatcher.dispatch_event(event_type, self, message, extra_args) + + async def invoke_hook(self, hook_name: str, **kwargs: Any) -> HookDispatchResult: + """在当前 Supervisor 内触发一次命名 Hook 调用。 + + Args: + hook_name: 本次触发的 Hook 名称。 + **kwargs: 传递给 Hook 处理器的关键字参数。 + + Returns: + HookDispatchResult: 聚合后的 Hook 调用结果。 + """ + + return await self._hook_dispatcher.invoke_hook(hook_name, **kwargs) + + async def send_message_to_external( + self, + internal_message: "SessionMessage", + *, + enabled_only: bool = True, + save_to_db: bool = True, + ) -> bool: + """通过插件消息网关发送外部消息。 + + Args: + internal_message: 系统内部消息对象。 + enabled_only: 是否仅使用启用的网关组件。 + save_to_db: 发送成功后是否写入数据库。 + + Returns: + bool: 是否发送成功。 + """ + return await self._message_gateway.send_message_to_external( + internal_message, + self, + enabled_only=enabled_only, + save_to_db=save_to_db, + ) + + async def start(self) -> None: + """启动 Supervisor。""" + if self._running: + logger.warning("PluginRunnerSupervisor 已在运行,跳过重复启动") + return + + self._running = True + self._restart_count = 0 + self._clear_runner_state() + + try: + await self._rpc_server.start() + await self._spawn_runner() + + try: + await self._wait_for_runner_connection(timeout_sec=self._runner_spawn_timeout) + await self._wait_for_runner_ready(timeout_sec=self._runner_spawn_timeout) + except TimeoutError: + if not self._rpc_server.is_connected: + logger.warning("Runner 未在限定时间内完成连接,后续操作可能失败") + else: + logger.warning("Runner 未在限定时间内完成初始化,后续操作可能失败") + except Exception: + await self._shutdown_runner(reason="startup_failed") + await self._rpc_server.stop() + self._clear_runner_state() + self._running = False + raise + + self._health_task = asyncio.create_task(self._health_check_loop(), name="PluginRunnerSupervisor.health") + logger.info("PluginRunnerSupervisor 已启动") + + async def stop(self) -> None: + """停止 Supervisor。""" + if not self._running: + return + + self._running = False + + if self._health_task is not None: + self._health_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._health_task + self._health_task = None + + await self._event_dispatcher.stop() + await self._hook_dispatcher.stop() + await self._shutdown_runner(reason="host_stop") + await self._rpc_server.stop() + self._clear_runner_state() + + logger.info("PluginRunnerSupervisor 已停止") + + async def invoke_plugin( + self, + method: str, + plugin_id: str, + component_name: str, + args: Optional[Dict[str, Any]] = None, + timeout_ms: int = 30000, + ) -> Envelope: + """调用 Runner 内的插件组件。 + + Args: + method: RPC 方法名。 + plugin_id: 目标插件 ID。 + component_name: 组件名。 + args: 调用参数。 + timeout_ms: RPC 超时时间,单位毫秒。 + + Returns: + Envelope: RPC 响应信封。 + """ + return await self._rpc_server.send_request( + method, + plugin_id, + {"component_name": component_name, "args": args or {}}, + timeout_ms, + ) + + async def invoke_message_gateway( + self, + plugin_id: str, + component_name: str, + args: Optional[Dict[str, Any]] = None, + timeout_ms: int = 30000, + ) -> Envelope: + """调用插件声明的消息网关方法。 + + Args: + plugin_id: 目标插件 ID。 + component_name: 消息网关组件名称。 + args: 传递给网关方法的关键字参数。 + timeout_ms: RPC 超时时间,单位毫秒。 + + Returns: + Envelope: Runner 返回的响应信封。 + """ + + return await self.invoke_plugin( + method="plugin.invoke_message_gateway", + plugin_id=plugin_id, + component_name=component_name, + args=args, + timeout_ms=timeout_ms, + ) + + async def invoke_llm_provider( + self, + plugin_id: str, + client_type: str, + operation: str, + request: Optional[Dict[str, Any]] = None, + timeout_ms: int = 30000, + ) -> Envelope: + """调用插件声明的 LLM Provider。 + + Args: + plugin_id: 目标插件 ID。 + client_type: 目标客户端类型。 + operation: 请求操作类型。 + request: 已序列化的 LLM 请求。 + timeout_ms: RPC 超时时间,单位毫秒。 + + Returns: + Envelope: Runner 返回的响应信封。 + """ + payload = LLMProviderInvokePayload( + client_type=client_type, + operation=operation, + request=request or {}, + ) + return await self._rpc_server.send_request( + "plugin.invoke_llm_provider", + plugin_id=plugin_id, + payload=payload.model_dump(), + timeout_ms=timeout_ms, + ) + + async def invoke_api( + self, + plugin_id: str, + component_name: str, + args: Optional[Dict[str, Any]] = None, + timeout_ms: int = 30000, + ) -> Envelope: + """调用插件声明的 API 方法。 + + Args: + plugin_id: 目标插件 ID。 + component_name: API 组件名称。 + args: 传递给 API 方法的关键字参数。 + timeout_ms: RPC 超时时间,单位毫秒。 + + Returns: + Envelope: Runner 返回的响应信封。 + """ + + return await self.invoke_plugin( + method="plugin.invoke_api", + plugin_id=plugin_id, + component_name=component_name, + args=args, + timeout_ms=timeout_ms, + ) + + async def reload_plugin( + self, + plugin_id: str, + reason: str = "manual", + external_available_plugins: Optional[Dict[str, str]] = None, + ) -> bool: + """按插件 ID 触发精确重载。 + + Args: + plugin_id: 目标插件 ID。 + reason: 重载原因。 + external_available_plugins: 视为已满足的外部依赖插件版本映射。 + + Returns: + bool: 是否重载成功。 + """ + try: + response = await self._rpc_server.send_request( + "plugin.reload", + plugin_id=plugin_id, + payload={ + "plugin_id": plugin_id, + "reason": reason, + "external_available_plugins": external_available_plugins or self._external_available_plugins, + }, + timeout_ms=max(int(self._runner_spawn_timeout * 1000), 10000), + ) + except Exception as exc: + logger.error(f"插件 {plugin_id} 重载请求失败: {exc}") + return False + + result = ReloadPluginResultPayload.model_validate(response.payload) + if not result.success: + logger.warning(f"插件 {plugin_id} 重载失败: {result.failed_plugins}") + return result.success + + async def reload_plugins( + self, + plugin_ids: Optional[List[str] | str] = None, + reason: str = "manual", + external_available_plugins: Optional[Dict[str, str]] = None, + ) -> bool: + """批量重载插件。 + + Args: + plugin_ids: 目标插件 ID 列表;为空时重载当前已注册的全部插件。 + reason: 重载原因。 + external_available_plugins: 视为已满足的外部依赖插件版本映射。 + + Returns: + bool: 是否全部重载成功。 + """ + ordered_plugin_ids = self._normalize_reload_plugin_ids(plugin_ids) + if not ordered_plugin_ids: + ordered_plugin_ids = list(self._registered_plugins.keys()) + if not ordered_plugin_ids: + return True + + if len(ordered_plugin_ids) == 1: + return await self.reload_plugin( + plugin_id=ordered_plugin_ids[0], + reason=reason, + external_available_plugins=external_available_plugins, + ) + + try: + response = await self._rpc_server.send_request( + "plugin.reload_batch", + payload=ReloadPluginsPayload( + plugin_ids=ordered_plugin_ids, + reason=reason, + external_available_plugins=external_available_plugins or self._external_available_plugins, + ).model_dump(), + timeout_ms=max(int(self._runner_spawn_timeout * 1000), 10000), + ) + except Exception as exc: + logger.error(f"插件批量重载请求失败: {exc}") + return False + + result = ReloadPluginsResultPayload.model_validate(response.payload) + if not result.success: + logger.warning(f"插件批量重载失败: {result.failed_plugins}") + return result.success + + async def notify_plugin_config_updated( + self, + plugin_id: str, + config_data: Optional[Dict[str, Any]] = None, + config_version: str = "", + config_scope: str | ConfigReloadScope = "self", + ) -> bool: + """向 Runner 推送插件配置更新。 + + Args: + plugin_id: 目标插件 ID。 + config_data: 配置内容。 + config_version: 配置版本号。 + config_scope: 配置变更范围。 + + Returns: + bool: 请求是否成功送达并被 Runner 接受。 + """ + try: + normalized_scope = ConfigReloadScope(config_scope) + except ValueError: + logger.warning(f"插件 {plugin_id} 配置更新通知失败: 非法的 config_scope={config_scope}") + return False + + payload = ConfigUpdatedPayload( + plugin_id=plugin_id, + config_scope=normalized_scope, + config_version=config_version, + config_data=config_data or {}, + ) + try: + response = await self._rpc_server.send_request( + "plugin.config_updated", + plugin_id=plugin_id, + payload=payload.model_dump(), + timeout_ms=10000, + ) + except Exception as exc: + logger.warning(f"插件 {plugin_id} 配置更新通知失败: {exc}") + return False + + return bool(response.payload.get("acknowledged", False)) + + async def validate_plugin_config(self, plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any]: + """请求 Runner 使用插件自身配置模型校验配置。 + + Args: + plugin_id: 目标插件 ID。 + config_data: 待校验的配置内容。 + + Returns: + Dict[str, Any]: 插件模型归一化后的配置字典。 + + Raises: + ValueError: 插件拒绝该配置或校验失败时抛出。 + """ + + payload = ValidatePluginConfigPayload(config_data=config_data) + try: + response = await self._rpc_server.send_request( + "plugin.validate_config", + plugin_id=plugin_id, + payload=payload.model_dump(), + timeout_ms=10000, + ) + except Exception as exc: + raise ValueError(f"插件配置校验请求失败: {exc}") from exc + + if response.error: + raise ValueError(str(response.error.get("message", "插件配置校验失败"))) + + result = ValidatePluginConfigResultPayload.model_validate(response.payload) + if not result.success: + raise ValueError("插件配置校验失败") + return dict(result.normalized_config) + + async def inspect_plugin_config( + self, + plugin_id: str, + config_data: Optional[Dict[str, Any]] = None, + *, + use_provided_config: bool = False, + ) -> InspectPluginConfigResultPayload: + """请求 Runner 解析插件配置元数据。 + + Args: + plugin_id: 目标插件 ID。 + config_data: 可选的配置内容。 + use_provided_config: 是否优先使用传入配置而不是磁盘配置。 + + Returns: + InspectPluginConfigResultPayload: 插件配置解析结果。 + + Raises: + ValueError: Runner 无法解析插件或返回了错误响应时抛出。 + """ + + payload = InspectPluginConfigPayload( + config_data=config_data or {}, + use_provided_config=use_provided_config, + ) + try: + response = await self._rpc_server.send_request( + "plugin.inspect_config", + plugin_id=plugin_id, + payload=payload.model_dump(), + timeout_ms=10000, + ) + except Exception as exc: + raise ValueError(f"插件配置解析请求失败: {exc}") from exc + + if response.error: + raise ValueError(str(response.error.get("message", "插件配置解析失败"))) + + result = InspectPluginConfigResultPayload.model_validate(response.payload) + if not result.success: + raise ValueError("插件配置解析失败") + return result + + def get_config_reload_subscribers(self, scope: str) -> List[str]: + """返回订阅指定全局配置广播的插件列表。 + + Args: + scope: 配置变更范围,仅支持 ``bot`` 或 ``model``。 + + Returns: + List[str]: 已声明订阅该范围的插件 ID 列表。 + """ + + return [ + plugin_id + for plugin_id, registration in self._registered_plugins.items() + if scope in registration.config_reload_subscriptions + ] + + async def _wait_for_runner_connection(self, timeout_sec: float) -> None: + """等待 Runner 建立 RPC 连接。 + + Args: + timeout_sec: 超时时间,单位秒。 + + Raises: + TimeoutError: 在超时时间内 Runner 未完成连接。 + """ + + async def wait_for_connection() -> None: + """轮询等待 RPC 连接建立。""" + while True: + if self._rpc_server.is_connected: + return + + if not self._running: + raise RuntimeError("Supervisor 已停止,等待 Runner 连接已取消") + + if failure_reason := self._get_runner_startup_failure_reason(): + raise RuntimeError(f"等待 Runner 连接失败: {failure_reason}") + + await asyncio.sleep(0.1) + + try: + await asyncio.wait_for(wait_for_connection(), timeout=timeout_sec) + logger.info("Runner 已连接到 RPC Server") + except asyncio.TimeoutError as exc: + raise TimeoutError(f"等待 Runner 连接超时({timeout_sec}s)") from exc + + async def _wait_for_runner_ready(self, timeout_sec: float = 30.0) -> RunnerReadyPayload: + """等待 Runner 完成启动初始化。 + + Args: + timeout_sec: 超时时间,单位秒。 + + Returns: + RunnerReadyPayload: Runner 上报的就绪信息。 + + Raises: + TimeoutError: 在超时时间内 Runner 未完成初始化。 + """ + + async def wait_for_ready() -> RunnerReadyPayload: + """轮询等待 Runner 上报就绪。""" + while True: + if self._runner_ready_events.is_set(): + return self._runner_ready_payloads + + if not self._running: + raise RuntimeError("Supervisor 已停止,等待 Runner 就绪已取消") + + if failure_reason := self._get_runner_startup_failure_reason(): + raise RuntimeError(f"等待 Runner 就绪失败: {failure_reason}") + + if not self._rpc_server.is_connected: + raise RuntimeError("等待 Runner 就绪失败: Runner RPC 连接已断开") + + await asyncio.sleep(0.1) + + try: + payload = await asyncio.wait_for(wait_for_ready(), timeout=timeout_sec) + logger.info("Runner 已完成初始化并上报就绪") + return payload + except asyncio.TimeoutError as exc: + raise TimeoutError(f"等待 Runner 就绪超时({timeout_sec}s)") from exc + + def _register_internal_methods(self) -> None: + """注册 Host 侧内部 RPC 方法。""" + self._rpc_server.register_method("cap.call", self._capability_service.handle_capability_request) + self._rpc_server.register_method("host.route_message", self._handle_route_message) + self._rpc_server.register_method("host.update_message_gateway_state", self._handle_update_message_gateway_state) + self._rpc_server.register_method("plugin.bootstrap", self._handle_bootstrap_plugin) + self._rpc_server.register_method("plugin.register_components", self._handle_register_plugin) + self._rpc_server.register_method("plugin.register_plugin", self._handle_register_plugin) + self._rpc_server.register_method("plugin.unregister", self._handle_unregister_plugin) + self._rpc_server.register_method("runner.log_batch", self._log_bridge.handle_log_batch) + self._rpc_server.register_method("runner.ready", self._handle_runner_ready) + + async def _handle_bootstrap_plugin(self, envelope: Envelope) -> Envelope: + """处理插件 bootstrap 请求。 + + Args: + envelope: RPC 请求信封。 + + Returns: + Envelope: RPC 响应信封。 + """ + try: + payload = BootstrapPluginPayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + if payload.capabilities_required: + self._authorization.register_plugin(payload.plugin_id, payload.capabilities_required) + else: + self._authorization.revoke_permission_token(payload.plugin_id) + + return envelope.make_response(payload={"accepted": True, "plugin_id": payload.plugin_id}) + + async def _handle_register_plugin(self, envelope: Envelope) -> Envelope: + """处理插件组件注册请求。 + + Args: + envelope: RPC 请求信封。 + + Returns: + Envelope: RPC 响应信封。 + """ + try: + payload = RegisterPluginPayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + component_declarations = [component.model_dump() for component in payload.components] + runtime_components, api_components = self._split_component_declarations(component_declarations) + try: + client_registry.validate_plugin_provider_replacement( + payload.plugin_id, + [provider.client_type for provider in payload.llm_providers], + ) + except Exception as exc: + logger.error(f"插件 {payload.plugin_id} LLM Provider 注册校验失败: {exc}") + return envelope.make_error_response( + ErrorCode.E_BAD_PAYLOAD.value, + str(exc), + details={ + "plugin_id": payload.plugin_id, + "llm_provider_count": len(payload.llm_providers), + }, + ) + + try: + registered_count = self._component_registry.register_plugin_components( + payload.plugin_id, + runtime_components, + ) + except Exception as exc: + logger.error(f"插件 {payload.plugin_id} 组件注册失败: {exc}") + return envelope.make_error_response( + ErrorCode.E_BAD_PAYLOAD.value, + str(exc), + details={ + "plugin_id": payload.plugin_id, + "component_count": len(runtime_components), + }, + ) + + self._api_registry.remove_apis_by_plugin(payload.plugin_id) + registered_api_count = self._api_registry.register_plugin_apis(payload.plugin_id, api_components) + await self._unregister_all_message_gateway_drivers_for_plugin(payload.plugin_id) + client_registry.replace_plugin_providers( + payload.plugin_id, + [ + ClientProviderRegistration( + client_type=provider.client_type, + factory=lambda api_provider, provider_client_type=provider.client_type: PluginLLMClient( + api_provider=api_provider, + supervisor=self, + plugin_id=payload.plugin_id, + client_type=provider_client_type, + ), + owner_plugin_id=payload.plugin_id, + version=provider.version, + description=provider.description or provider.name, + ) + for provider in payload.llm_providers + ], + ) + self._registered_plugins[payload.plugin_id] = payload + self._message_gateway_states[payload.plugin_id] = {} + + return envelope.make_response( + payload={ + "accepted": True, + "plugin_id": payload.plugin_id, + "registered_components": registered_count, + "registered_apis": registered_api_count, + "message_gateways": len( + self._component_registry.get_message_gateways(plugin_id=payload.plugin_id, enabled_only=False) + ), + "llm_providers": len(payload.llm_providers), + } + ) + + async def _handle_unregister_plugin(self, envelope: Envelope) -> Envelope: + """处理插件注销请求。 + + Args: + envelope: RPC 请求信封。 + + Returns: + Envelope: RPC 响应信封。 + """ + try: + payload = UnregisterPluginPayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + removed_components = self._component_registry.remove_components_by_plugin(payload.plugin_id) + removed_apis = self._api_registry.remove_apis_by_plugin(payload.plugin_id) + removed_llm_providers = client_registry.unregister_plugin_providers(payload.plugin_id) + self._authorization.revoke_permission_token(payload.plugin_id) + removed_registration = self._registered_plugins.pop(payload.plugin_id, None) is not None + await self._unregister_all_message_gateway_drivers_for_plugin(payload.plugin_id) + self._message_gateway_states.pop(payload.plugin_id, None) + + return envelope.make_response( + payload={ + "accepted": True, + "plugin_id": payload.plugin_id, + "reason": payload.reason, + "removed_components": removed_components, + "removed_apis": removed_apis, + "removed_llm_providers": removed_llm_providers, + "removed_registration": removed_registration, + } + ) + + @staticmethod + def _is_api_component(component: Dict[str, Any]) -> bool: + """判断组件声明是否属于 API。 + + Args: + component: 原始组件声明字典。 + + Returns: + bool: 是否为 API 组件。 + """ + + return str(component.get("component_type", "") or "").strip().upper() == "API" + + def _split_component_declarations( + self, + components: List[Dict[str, Any]], + ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """拆分通用组件声明和 API 声明。 + + Args: + components: Runner 上报的原始组件声明列表。 + + Returns: + Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + 第一个列表为需要进入通用组件表的声明, + 第二个列表为需要进入 API 专用表的声明。 + """ + + runtime_components: List[Dict[str, Any]] = [] + api_components: List[Dict[str, Any]] = [] + for component in components: + if self._is_api_component(component): + api_components.append(component) + else: + runtime_components.append(component) + return runtime_components, api_components + + @staticmethod + def _build_message_gateway_driver_id(plugin_id: str, gateway_name: str) -> str: + """构造消息网关驱动 ID。 + + Args: + plugin_id: 插件 ID。 + gateway_name: 网关组件名称。 + + Returns: + str: 对应 Platform IO 中的驱动 ID。 + """ + + return f"gateway:{plugin_id}:{gateway_name}" + + @staticmethod + def _normalize_runtime_route_value(value: str) -> Optional[str]: + """规范化运行时路由字段。 + + Args: + value: 待规范化的原始字符串。 + + Returns: + Optional[str]: 规范化后非空则返回字符串,否则返回 ``None``。 + """ + + normalized_value = str(value or "").strip() + return normalized_value or None + + def _resolve_message_gateway_entry( + self, + plugin_id: str, + gateway_name: str, + ) -> Optional[Any]: + """解析指定插件的消息网关组件。 + + Args: + plugin_id: 插件 ID。 + gateway_name: 网关组件名称;为空时按兼容规则推断。 + + Returns: + Optional[Any]: 匹配到的消息网关组件条目。 + """ + + if gateway_name: + return self._component_registry.get_message_gateway( + plugin_id=plugin_id, + name=gateway_name, + enabled_only=False, + ) + + gateways = self._component_registry.get_message_gateways(plugin_id=plugin_id, enabled_only=False) + return gateways[0] if len(gateways) == 1 else None + + async def _register_message_gateway_driver( + self, + plugin_id: str, + gateway_entry: Any, + route_key: RouteKey, + ) -> None: + """为消息网关注册驱动并绑定发送/接收路由。 + + Args: + plugin_id: 插件 ID。 + gateway_entry: 消息网关组件条目。 + route_key: 当前链路对应的路由键。 + """ + + await self._unregister_message_gateway_driver(plugin_id, gateway_entry.name) + + platform_io_manager = get_platform_io_manager() + driver = PluginPlatformDriver( + driver_id=self._build_message_gateway_driver_id(plugin_id, gateway_entry.name), + platform=route_key.platform, + account_id=route_key.account_id, + scope=route_key.scope, + plugin_id=plugin_id, + component_name=gateway_entry.name, + supports_send=bool(gateway_entry.supports_send), + supervisor=self, + metadata={ + "protocol": gateway_entry.protocol, + "route_type": gateway_entry.route_type, + **gateway_entry.metadata, + }, + ) + + try: + if platform_io_manager.is_started: + await platform_io_manager.add_driver(driver) + else: + platform_io_manager.register_driver(driver) + except Exception: + with contextlib.suppress(Exception): + if platform_io_manager.is_started: + await platform_io_manager.remove_driver(driver.driver_id) + else: + platform_io_manager.unregister_driver(driver.driver_id) + raise + + binding_metadata = { + "plugin_id": plugin_id, + "gateway_name": gateway_entry.name, + "protocol": gateway_entry.protocol, + "route_type": gateway_entry.route_type, + **gateway_entry.metadata, + } + binding = RouteBinding( + route_key=route_key, + driver_id=driver.driver_id, + driver_kind=DriverKind.PLUGIN, + metadata=binding_metadata, + ) + if gateway_entry.supports_send: + platform_io_manager.bind_send_route(binding) + if gateway_entry.supports_receive: + platform_io_manager.bind_receive_route(binding) + + async def _unregister_message_gateway_driver(self, plugin_id: str, gateway_name: str) -> None: + """从 Platform IO 注销单个消息网关驱动。 + + Args: + plugin_id: 插件 ID。 + gateway_name: 网关组件名称。 + """ + + platform_io_manager = get_platform_io_manager() + driver_id = self._build_message_gateway_driver_id(plugin_id, gateway_name) + platform_io_manager.send_route_table.remove_bindings_by_driver(driver_id) + platform_io_manager.receive_route_table.remove_bindings_by_driver(driver_id) + + with contextlib.suppress(Exception): + if platform_io_manager.is_started: + await platform_io_manager.remove_driver(driver_id) + else: + platform_io_manager.unregister_driver(driver_id) + + async def _unregister_all_message_gateway_drivers_for_plugin(self, plugin_id: str) -> None: + """注销指定插件的全部消息网关驱动。 + + Args: + plugin_id: 插件 ID。 + """ + + gateway_names = list(self._message_gateway_states.get(plugin_id, {}).keys()) + for gateway_name in gateway_names: + await self._unregister_message_gateway_driver(plugin_id, gateway_name) + + def _build_message_gateway_route_key( + self, + gateway_entry: Any, + payload: MessageGatewayStateUpdatePayload, + ) -> RouteKey: + """根据消息网关运行时状态构造路由键。 + + Args: + gateway_entry: 消息网关组件条目。 + payload: 网关上报的运行时状态。 + + Returns: + RouteKey: 当前链路对应的路由键。 + + Raises: + ValueError: 当平台信息缺失时抛出。 + """ + + if not (platform := str(payload.platform or gateway_entry.platform or "").strip()): + raise ValueError(f"消息网关 {gateway_entry.full_name} 未提供有效的平台名称") + + return RouteKey( + platform=platform, + account_id=self._normalize_runtime_route_value(payload.account_id) or gateway_entry.account_id or None, + scope=self._normalize_runtime_route_value(payload.scope) or gateway_entry.scope or None, + ) + + def _apply_message_gateway_state( + self, + plugin_id: str, + gateway_entry: Any, + payload: MessageGatewayStateUpdatePayload, + ) -> Tuple[_MessageGatewayRuntimeState, Dict[str, Any]]: + """应用消息网关运行时状态,并同步 Platform IO 路由。 + + Args: + plugin_id: 插件 ID。 + gateway_entry: 消息网关组件条目。 + payload: 网关上报的运行时状态。 + + Returns: + Tuple[_MessageGatewayRuntimeState, Dict[str, Any]]: 更新后的状态与路由键字典。 + """ + + plugin_states = self._message_gateway_states.setdefault(plugin_id, {}) + if not payload.ready: + runtime_state = _MessageGatewayRuntimeState( + ready=False, + platform=self._normalize_runtime_route_value(payload.platform) or gateway_entry.platform or None, + account_id=self._normalize_runtime_route_value(payload.account_id) or gateway_entry.account_id or None, + scope=self._normalize_runtime_route_value(payload.scope) or gateway_entry.scope or None, + metadata=dict(payload.metadata), + ) + plugin_states[gateway_entry.name] = runtime_state + return runtime_state, {} + + route_key = self._build_message_gateway_route_key(gateway_entry, payload) + runtime_state = _MessageGatewayRuntimeState( + ready=True, + platform=route_key.platform, + account_id=route_key.account_id, + scope=route_key.scope, + metadata=dict(payload.metadata), + ) + plugin_states[gateway_entry.name] = runtime_state + return runtime_state, { + "platform": route_key.platform, + "account_id": route_key.account_id, + "scope": route_key.scope, + } + + @staticmethod + def _attach_inbound_route_metadata( + session_message: "SessionMessage", + route_key: RouteKey, + route_metadata: Dict[str, Any], + ) -> None: + """将入站路由信息写回消息的 ``additional_config``。 + + Args: + session_message: 已构造好的内部消息对象。 + route_key: Host 为该消息解析出的标准路由键。 + route_metadata: 插件通过 RPC 补充的原始路由辅助元数据。 + """ + + additional_config = session_message.message_info.additional_config + if not isinstance(additional_config, dict): + additional_config = {} + session_message.message_info.additional_config = additional_config + + for key, value in route_metadata.items(): + if value is None: + continue + normalized_value = str(value).strip() + if normalized_value: + additional_config[key] = value + + if route_key.account_id: + additional_config.setdefault("platform_io_account_id", route_key.account_id) + if route_key.scope: + additional_config.setdefault("platform_io_scope", route_key.scope) + + def _build_inbound_route_key( + self, + gateway_entry: Any, + runtime_state: _MessageGatewayRuntimeState, + message: Dict[str, Any], + route_metadata: Dict[str, Any], + ) -> RouteKey: + """为入站消息构造归一路由键。 + + Args: + gateway_entry: 接收消息的网关组件条目。 + runtime_state: 当前网关的运行时状态。 + message: 标准消息字典。 + route_metadata: 插件补充的路由辅助元数据。 + + Returns: + RouteKey: 供 Platform IO 使用的规范化路由键。 + """ + + platform = str( + message.get("platform") + or route_metadata.get("platform") + or runtime_state.platform + or gateway_entry.platform + or "" + ).strip() + if not platform: + raise ValueError(f"消息网关 {gateway_entry.full_name} 的入站消息缺少平台信息") + + try: + route_key = RouteKeyFactory.from_message_dict(message) + except Exception: + route_key = RouteKey(platform=platform) + + route_account_id, route_scope = RouteKeyFactory.extract_components(route_metadata) + account_id = ( + route_key.account_id or route_account_id or runtime_state.account_id or gateway_entry.account_id or None + ) + scope = route_key.scope or route_scope or runtime_state.scope or gateway_entry.scope or None + return RouteKey( + platform=platform, + account_id=account_id, + scope=scope, + ) + + async def _handle_update_message_gateway_state(self, envelope: Envelope) -> Envelope: + """处理消息网关上报的运行时状态更新。 + + Args: + envelope: RPC 请求信封。 + + Returns: + Envelope: 状态更新处理结果。 + """ + + try: + payload = MessageGatewayStateUpdatePayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + gateway_entry = self._resolve_message_gateway_entry(envelope.plugin_id, payload.gateway_name) + if gateway_entry is None: + return envelope.make_error_response( + ErrorCode.E_METHOD_NOT_ALLOWED.value, + f"插件 {envelope.plugin_id} 未声明消息网关 {payload.gateway_name or ''}", + ) + + try: + if payload.ready: + route_key = self._build_message_gateway_route_key(gateway_entry, payload) + await self._register_message_gateway_driver(envelope.plugin_id, gateway_entry, route_key) + else: + await self._unregister_message_gateway_driver(envelope.plugin_id, gateway_entry.name) + runtime_state, route_key_dict = self._apply_message_gateway_state( + plugin_id=envelope.plugin_id, + gateway_entry=gateway_entry, + payload=payload, + ) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + response = MessageGatewayStateUpdateResultPayload( + accepted=True, + ready=runtime_state.ready, + route_key=route_key_dict, + ) + return envelope.make_response(payload=response.model_dump()) + + async def _handle_route_message(self, envelope: Envelope) -> Envelope: + """处理消息网关上报的外部入站消息。 + + Args: + envelope: RPC 请求信封。 + + Returns: + Envelope: 注入结果响应。 + """ + + try: + payload = RouteMessagePayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + gateway_entry = self._resolve_message_gateway_entry(envelope.plugin_id, payload.gateway_name) + if gateway_entry is None or not bool(gateway_entry.supports_receive): + return envelope.make_error_response( + ErrorCode.E_METHOD_NOT_ALLOWED.value, + f"插件 {envelope.plugin_id} 未声明可接收的消息网关 {payload.gateway_name}", + ) + + runtime_state = self._message_gateway_states.get(envelope.plugin_id, {}).get( + gateway_entry.name, + _MessageGatewayRuntimeState(), + ) + if not runtime_state.ready: + return envelope.make_error_response( + ErrorCode.E_METHOD_NOT_ALLOWED.value, + f"消息网关 {gateway_entry.full_name} 尚未就绪,不能注入外部消息", + ) + + try: + route_key = self._build_inbound_route_key( + gateway_entry=gateway_entry, + runtime_state=runtime_state, + message=payload.message, + route_metadata=payload.route_metadata, + ) + session_message = self._message_gateway.build_session_message(payload.message) + self._attach_inbound_route_metadata(session_message, route_key, payload.route_metadata) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + platform_io_manager = get_platform_io_manager() + accepted = await platform_io_manager.accept_inbound( + InboundMessageEnvelope( + route_key=route_key, + driver_id=self._build_message_gateway_driver_id(envelope.plugin_id, gateway_entry.name), + driver_kind=DriverKind.PLUGIN, + external_message_id=payload.external_message_id or str(payload.message.get("message_id") or "") or None, + dedupe_key=payload.dedupe_key or None, + session_message=session_message, + payload=payload.message, + metadata={ + "plugin_id": envelope.plugin_id, + "gateway_name": gateway_entry.name, + "protocol": gateway_entry.protocol, + **payload.route_metadata, + }, + ) + ) + response = ReceiveExternalMessageResultPayload( + accepted=accepted, + route_key={ + "platform": route_key.platform, + "account_id": route_key.account_id, + "scope": route_key.scope, + }, + ) + return envelope.make_response(payload=response.model_dump()) + + async def _handle_runner_ready(self, envelope: Envelope) -> Envelope: + """处理 Runner 就绪通知。 + + Args: + envelope: RPC 请求信封。 + + Returns: + Envelope: RPC 响应信封。 + """ + try: + payload = RunnerReadyPayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + self._runner_ready_payloads = payload + if payload.failed_plugins: + logger.error(f"插件注册失败: {', '.join(payload.failed_plugins)}") + if payload.inactive_plugins: + logger.warning(f"插件未激活: {', '.join(payload.inactive_plugins)}") + logger.info( + "Runner 插件初始化完成: " + f"loaded={len(payload.loaded_plugins)} failed={len(payload.failed_plugins)} inactive={len(payload.inactive_plugins)}" + ) + self._runner_ready_events.set() + return envelope.make_response(payload={"accepted": True}) + + def _build_runner_environment(self) -> Dict[str, str]: + """构建拉起 Runner 所需的环境变量。 + + Returns: + Dict[str, str]: 传递给 Runner 进程的环境变量映射。 + """ + return { + ENV_BLOCKED_PLUGIN_REASONS: json.dumps(self._blocked_plugin_reasons, ensure_ascii=False), + ENV_EXTERNAL_PLUGIN_IDS: json.dumps(self._external_available_plugins, ensure_ascii=False), + ENV_HOST_VERSION: PROTOCOL_VERSION, + ENV_IPC_ADDRESS: self._transport.get_address(), + ENV_PLUGIN_DIRS: os.pathsep.join(str(path) for path in self._plugin_dirs), + ENV_SESSION_TOKEN: self._rpc_server.session_token, + } + + async def _spawn_runner(self) -> None: + """拉起 Runner 子进程。""" + if self._runner_process is not None and self._runner_process.returncode is None: + logger.warning("Runner 已在运行,跳过重复拉起") + return + + self._clear_runner_state() + + env = os.environ.copy() + env.update(self._build_runner_environment()) + + self._runner_process = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "src.plugin_runtime.runner.runner_main", + env=env, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + + if self._runner_process.stderr is not None: + self._stderr_drain_task = asyncio.create_task( + self._drain_runner_stderr(self._runner_process.stderr), + name="PluginRunnerSupervisor.stderr", + ) + + logger.info(f"Runner 已拉起,pid={self._runner_process.pid}") + + async def _drain_runner_stderr(self, stream: asyncio.StreamReader) -> None: + """持续排空 Runner 的 stderr。 + + Args: + stream: Runner 的 stderr 流。 + """ + try: + while True: + line = await stream.readline() + if not line: + return + if message := line.decode("utf-8", errors="replace").rstrip(): + logger.warning(f"[runner-stderr] {message}") + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning(f"排空 Runner stderr 失败: {exc}") + + async def _shutdown_runner(self, reason: str = "normal") -> None: + """优雅关闭 Runner 子进程。 + + Args: + reason: 关停原因。 + """ + process = self._runner_process + if process is None: + return + + payload = ShutdownPayload(reason=reason) + + if process.returncode is None and self._rpc_server.is_connected: + with contextlib.suppress(Exception): + await self._rpc_server.send_request( + "plugin.prepare_shutdown", + payload=payload.model_dump(), + timeout_ms=payload.drain_timeout_ms, + ) + with contextlib.suppress(Exception): + await self._rpc_server.send_request( + "plugin.shutdown", + payload=payload.model_dump(), + timeout_ms=payload.drain_timeout_ms, + ) + + if process.returncode is None: + try: + await asyncio.wait_for(process.wait(), timeout=max(payload.drain_timeout_ms / 1000.0, 1.0)) + except asyncio.TimeoutError: + logger.warning("Runner 优雅退出超时,尝试 terminate") + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=5.0) + except asyncio.TimeoutError: + logger.warning("Runner terminate 超时,尝试 kill") + process.kill() + with contextlib.suppress(Exception): + await asyncio.wait_for(process.wait(), timeout=5.0) + + self._runner_process = None + + if self._stderr_drain_task is not None: + self._stderr_drain_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._stderr_drain_task + self._stderr_drain_task = None + + for plugin_id in list(self._message_gateway_states.keys()): + await self._unregister_all_message_gateway_drivers_for_plugin(plugin_id) + self._clear_runner_state() + + async def _health_check_loop(self) -> None: + """周期性检查 Runner 健康状态,并在必要时重启。""" + timeout_ms = max(int(self._health_interval * 1000), 1000) + + while self._running: + try: + await asyncio.sleep(self._health_interval) + except asyncio.CancelledError: + return + + if not self._running: + return + + process = self._runner_process + if process is None or process.returncode is not None: + reason = "runner_process_exited" if process is not None else "runner_process_missing" + restarted = await self._restart_runner(reason=reason) + if not restarted: + return + continue + + try: + response = await self._rpc_server.send_request("plugin.health", timeout_ms=timeout_ms) + health = HealthPayload.model_validate(response.payload) + if not health.healthy: + restarted = await self._restart_runner(reason="health_check_unhealthy") + if not restarted: + return + except asyncio.CancelledError: + return + except (RPCError, Exception) as exc: + logger.warning(f"Runner 健康检查失败: {exc}") + restarted = await self._restart_runner(reason="health_check_failed") + if not restarted: + return + + async def _restart_runner(self, reason: str) -> bool: + """在 Runner 异常时执行整进程级重启。 + + Args: + reason: 触发重启的原因。 + + Returns: + bool: 是否重启成功。 + """ + if not self._running: + return False + + if self._restart_count >= self._max_restart_attempts: + logger.error(f"Runner 自动重启次数已达上限,停止重启。reason={reason}") + return False + + self._restart_count += 1 + logger.warning(f"准备重启 Runner,第 {self._restart_count} 次,reason={reason}") + + await self._shutdown_runner(reason=reason) + + try: + await self._spawn_runner() + await self._wait_for_runner_connection(timeout_sec=self._runner_spawn_timeout) + await self._wait_for_runner_ready(timeout_sec=self._runner_spawn_timeout) + except Exception as exc: + await self._shutdown_runner(reason="restart_failed") + logger.error(f"Runner 重启失败: {exc}", exc_info=True) + return False + + self._restart_count = 0 + logger.info("Runner 已成功重启") + return True + + def _clear_runner_state(self) -> None: + """清理当前 Runner 对应的 Host 侧注册状态。""" + for plugin_id in list(self._registered_plugins): + client_registry.unregister_plugin_providers(plugin_id) + self._authorization.clear() + self._api_registry.clear() + self._component_registry.clear() + self._registered_plugins.clear() + self._message_gateway_states.clear() + self._runner_ready_events = asyncio.Event() + self._runner_ready_payloads = RunnerReadyPayload() + self._rpc_server.clear_handshake_state() + + def _get_runner_startup_failure_reason(self) -> Optional[str]: + """获取 Runner 在启动阶段已经暴露出的失败原因。 + + Returns: + Optional[str]: 若已检测到失败则返回失败原因,否则返回 ``None``。 + """ + if handshake_reason := self._rpc_server.last_handshake_rejection_reason: + return f"握手被拒绝: {handshake_reason}" + + process = self._runner_process + if process is None: + return "Runner 进程不存在" + + if process.returncode is not None: + return f"Runner 进程已退出,退出码 {process.returncode}" + + return None + + +PluginSupervisor = PluginRunnerSupervisor diff --git a/src/plugin_runtime/integration.py b/src/plugin_runtime/integration.py new file mode 100644 index 00000000..8ea86924 --- /dev/null +++ b/src/plugin_runtime/integration.py @@ -0,0 +1,1522 @@ +"""插件运行时与主程序的集成层 + +提供 PluginRuntimeManager 单例,负责: +1. 管理双 PluginSupervisor 的生命周期(内置插件 / 第三方插件各一个子进程) +2. 将 EventType 桥接到运行时的 event dispatch +3. 触发跨 Supervisor 的命名 Hook 调用 +4. 在运行时的 ComponentRegistry 中查找命令 +5. 提供统一的能力实现注册接口,使插件可以调用主程序功能 +""" + +from dataclasses import dataclass +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Coroutine, + Dict, + Iterable, + List, + Optional, + Sequence, + Set, + Tuple, +) + +import asyncio +import inspect + +import tomlkit + +from src.common.logger import get_logger +from src.config.config import config_manager +from src.config.file_watcher import FileChange, FileWatcher +from src.platform_io import DeliveryBatch, InboundMessageEnvelope, get_platform_io_manager +from src.plugin_runtime.capabilities import ( + RuntimeComponentCapabilityMixin, + RuntimeCoreCapabilityMixin, + RuntimeDataCapabilityMixin, + RuntimeRenderCapabilityMixin, +) +from src.plugin_runtime.capabilities.registry import register_capability_impls +from src.plugin_runtime.dependency_pipeline import PluginDependencyPipeline +from src.plugin_runtime.hook_catalog import register_builtin_hook_specs +from src.plugin_runtime.host.hook_dispatcher import HookDispatchResult, HookDispatcher +from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry +from src.plugin_runtime.host.message_utils import MessageDict, PluginMessageUtils +from src.plugin_runtime.protocol.envelope import InspectPluginConfigResultPayload +from src.plugin_runtime.runner.manifest_validator import ManifestValidator + +if TYPE_CHECKING: + from src.chat.message_receive.message import SessionMessage + from src.plugin_runtime.host.supervisor import PluginSupervisor + +logger = get_logger("plugin_runtime.integration") + +# 旧系统 EventType -> 新系统 event_type 字符串映射 +_EVENT_TYPE_MAP: Dict[str, str] = { + "on_start": "on_start", + "on_stop": "on_stop", + "on_message_pre_process": "on_message_pre_process", + "on_message": "on_message", + "on_plan": "on_plan", + "post_llm": "post_llm", + "after_llm": "after_llm", + "post_send_pre_process": "post_send_pre_process", + "post_send": "post_send", + "after_send": "after_send", +} + + +@dataclass(frozen=True) +class DependencySyncState: + """表示一次插件依赖同步后的状态。""" + + blocked_changed_plugin_ids: Set[str] + environment_changed: bool + + +class PluginRuntimeManager( + RuntimeCoreCapabilityMixin, + RuntimeDataCapabilityMixin, + RuntimeComponentCapabilityMixin, + RuntimeRenderCapabilityMixin, +): + """插件运行时管理器(单例) + + 内置插件与第三方插件分别运行在各自的 Supervisor / Runner 子进程中。 + """ + + def __init__(self) -> None: + """初始化插件运行时管理器。""" + from src.plugin_runtime.host.supervisor import PluginSupervisor + + self._builtin_supervisor: Optional[PluginSupervisor] = None + self._third_party_supervisor: Optional[PluginSupervisor] = None + self._started: bool = False + self._plugin_file_watcher: Optional[FileWatcher] = None + self._plugin_source_watcher_subscription_id: Optional[str] = None + self._plugin_config_watcher_subscriptions: Dict[str, Tuple[Path, str]] = {} + self._plugin_path_cache: Dict[str, Path] = {} + self._manifest_validator: ManifestValidator = ManifestValidator(validate_python_package_dependencies=False) + self._plugin_dependency_pipeline: PluginDependencyPipeline = PluginDependencyPipeline() + self._blocked_plugin_reasons: Dict[str, str] = {} + self._config_reload_callback: Callable[[Sequence[str]], Awaitable[None]] = self._handle_main_config_reload + self._config_reload_callback_registered: bool = False + self._hook_spec_registry: HookSpecRegistry = HookSpecRegistry() + self._builtin_hook_specs_registered: bool = False + self._hook_dispatcher: HookDispatcher = HookDispatcher( + lambda: self.supervisors, + hook_spec_registry=self._hook_spec_registry, + ) + + async def _dispatch_platform_inbound(self, envelope: InboundMessageEnvelope) -> None: + """接收 Platform IO 审核后的入站消息并送入主消息链。 + + Args: + envelope: Platform IO 产出的入站封装。 + """ + session_message = envelope.session_message + if session_message is None and envelope.payload is not None: + session_message = PluginMessageUtils._build_session_message_from_dict(dict(envelope.payload)) + if session_message is None: + raise ValueError("Platform IO 入站封装缺少可用的 SessionMessage 或 payload") + + from src.chat.message_receive.bot import chat_bot + + await chat_bot.receive_message(session_message) + + # ─── 插件目录 ───────────────────────────────────────────── + + @staticmethod + def _get_builtin_plugin_dirs() -> List[Path]: + """内置插件目录:src/plugins/built_in/""" + candidate = Path("src", "plugins", "built_in").resolve() + return [candidate] if candidate.is_dir() else [] + + @staticmethod + def _get_third_party_plugin_dirs() -> List[Path]: + """第三方插件目录:plugins/""" + candidate = Path("plugins").resolve() + return [candidate] if candidate.is_dir() else [] + + @classmethod + def _discover_plugin_dependency_map(cls, plugin_dirs: Iterable[Path]) -> Dict[str, List[str]]: + """扫描指定插件目录集合,返回 ``plugin_id -> dependencies`` 映射。""" + validator = ManifestValidator(validate_python_package_dependencies=False) + return validator.build_plugin_dependency_map(plugin_dirs) + + @classmethod + def _discover_llm_provider_conflicts(cls, plugin_dirs: Iterable[Path]) -> Dict[str, str]: + """扫描插件 Manifest,发现 LLM Provider client_type 冲突。 + + Args: + plugin_dirs: 需要扫描的插件根目录集合。 + + Returns: + Dict[str, str]: 需要阻止加载的插件 ID 与原因映射。 + """ + validator = ManifestValidator(validate_python_package_dependencies=False) + provider_owners: Dict[str, List[str]] = {} + for _plugin_path, manifest in validator.iter_plugin_manifests(plugin_dirs, require_entrypoint=True): + for client_type in manifest.llm_provider_client_types: + provider_owners.setdefault(client_type, []).append(manifest.id) + + blocked_reasons: Dict[str, str] = {} + for client_type, plugin_ids in provider_owners.items(): + unique_plugin_ids = sorted(set(plugin_ids)) + if len(unique_plugin_ids) <= 1: + continue + reason = ( + f"LLM Provider client_type 冲突: {client_type} 被以下插件重复声明: " + f"{', '.join(unique_plugin_ids)}" + ) + for plugin_id in unique_plugin_ids: + blocked_reasons[plugin_id] = reason + return blocked_reasons + + @classmethod + def _build_group_start_order( + cls, + builtin_dirs: Sequence[Path], + third_party_dirs: Sequence[Path], + ) -> List[str]: + """根据跨 Supervisor 依赖关系决定 Runner 启动顺序。""" + + builtin_dependencies = cls._discover_plugin_dependency_map(builtin_dirs) + third_party_dependencies = cls._discover_plugin_dependency_map(third_party_dirs) + builtin_plugin_ids = set(builtin_dependencies) + third_party_plugin_ids = set(third_party_dependencies) + + builtin_needs_third_party = any( + dependency in third_party_plugin_ids + for dependencies in builtin_dependencies.values() + for dependency in dependencies + ) + third_party_needs_builtin = any( + dependency in builtin_plugin_ids + for dependencies in third_party_dependencies.values() + for dependency in dependencies + ) + + if builtin_needs_third_party and third_party_needs_builtin: + raise RuntimeError("检测到跨 Supervisor 循环依赖,当前无法安全启动独立 Runner") + if builtin_needs_third_party: + return ["third_party", "builtin"] + return ["builtin", "third_party"] + + @staticmethod + def _instantiate_supervisor(supervisor_cls: Any, **kwargs: Any) -> Any: + """兼容不同构造签名地实例化 Supervisor。 + + Args: + supervisor_cls: 目标 Supervisor 类。 + **kwargs: 期望传入的构造参数。 + + Returns: + Any: 实例化后的 Supervisor。 + """ + + signature = inspect.signature(supervisor_cls) + accepts_var_keyword = any( + parameter.kind == inspect.Parameter.VAR_KEYWORD + for parameter in signature.parameters.values() + ) + if accepts_var_keyword: + return supervisor_cls(**kwargs) + + supported_kwargs = { + key: value + for key, value in kwargs.items() + if key in signature.parameters + } + return supervisor_cls(**supported_kwargs) + + def _resolve_runtime_plugin_dirs(self) -> Tuple[List[Path], List[Path]]: + """解析当前运行时应管理的插件根目录。 + + Returns: + Tuple[List[Path], List[Path]]: 内置插件目录列表与第三方插件目录列表。 + """ + + return self._get_builtin_plugin_dirs(), self._get_third_party_plugin_dirs() + + @staticmethod + def _resolve_supervisor_socket_paths() -> Tuple[Optional[str], Optional[str]]: + """解析内置与第三方 Supervisor 的 IPC 地址。 + + Returns: + Tuple[Optional[str], Optional[str]]: 内置 Runner 与第三方 Runner 的 socket 地址。 + """ + + runtime_config = config_manager.get_global_config().plugin_runtime + socket_path_base = runtime_config.ipc_socket_path or None + builtin_socket = f"{socket_path_base}-builtin" if socket_path_base else None + third_party_socket = f"{socket_path_base}-third_party" if socket_path_base else None + return builtin_socket, third_party_socket + + def _apply_blocked_plugin_reasons_to_supervisors(self) -> None: + """将当前阻止加载插件列表同步到全部 Supervisor。""" + + for supervisor in self.supervisors: + set_blocked_plugin_reasons = getattr(supervisor, "set_blocked_plugin_reasons", None) + if callable(set_blocked_plugin_reasons): + set_blocked_plugin_reasons(self._blocked_plugin_reasons) + + def _set_blocked_plugin_reasons(self, blocked_plugin_reasons: Dict[str, str]) -> Set[str]: + """更新 Host 侧维护的阻止加载插件列表。 + + Args: + blocked_plugin_reasons: 最新的阻止加载插件及原因映射。 + + Returns: + Set[str]: 本次发生状态变化的插件 ID 集合。 + """ + + normalized_reasons = { + str(plugin_id or "").strip(): str(reason or "").strip() + for plugin_id, reason in blocked_plugin_reasons.items() + if str(plugin_id or "").strip() and str(reason or "").strip() + } + changed_plugin_ids = { + plugin_id + for plugin_id in set(self._blocked_plugin_reasons) | set(normalized_reasons) + if self._blocked_plugin_reasons.get(plugin_id) != normalized_reasons.get(plugin_id) + } + self._blocked_plugin_reasons = normalized_reasons + self._apply_blocked_plugin_reasons_to_supervisors() + return changed_plugin_ids + + async def _sync_plugin_dependencies(self, plugin_dirs: Sequence[Path]) -> DependencySyncState: + """执行插件依赖同步,并刷新阻止加载插件列表。 + + Args: + plugin_dirs: 当前需要参与分析的插件根目录列表。 + + Returns: + DependencySyncState: 同步后的环境变更状态与阻止列表变化集合。 + """ + + result = await self._plugin_dependency_pipeline.execute(plugin_dirs) + blocked_plugin_reasons = { + **result.blocked_plugin_reasons, + **self._discover_llm_provider_conflicts(plugin_dirs), + } + changed_plugin_ids = self._set_blocked_plugin_reasons(blocked_plugin_reasons) + return DependencySyncState( + blocked_changed_plugin_ids=changed_plugin_ids, + environment_changed=result.environment_changed, + ) + + def _build_supervisors(self, builtin_dirs: Sequence[Path], third_party_dirs: Sequence[Path]) -> None: + """根据目录列表创建当前运行时所需的 Supervisor。 + + Args: + builtin_dirs: 内置插件目录列表。 + third_party_dirs: 第三方插件目录列表。 + """ + + from src.plugin_runtime.host.supervisor import PluginSupervisor + + builtin_socket, third_party_socket = self._resolve_supervisor_socket_paths() + self._builtin_supervisor = None + self._third_party_supervisor = None + + if builtin_dirs: + builtin_supervisor = self._instantiate_supervisor( + PluginSupervisor, + plugin_dirs=list(builtin_dirs), + group_name="builtin", + hook_spec_registry=self._hook_spec_registry, + socket_path=builtin_socket, + ) + self._builtin_supervisor = builtin_supervisor + self._register_capability_impls(builtin_supervisor) + + if third_party_dirs: + third_party_supervisor = self._instantiate_supervisor( + PluginSupervisor, + plugin_dirs=list(third_party_dirs), + group_name="third_party", + hook_spec_registry=self._hook_spec_registry, + socket_path=third_party_socket, + ) + self._third_party_supervisor = third_party_supervisor + self._register_capability_impls(third_party_supervisor) + + self._apply_blocked_plugin_reasons_to_supervisors() + + async def _start_supervisors( + self, + builtin_dirs: Sequence[Path], + third_party_dirs: Sequence[Path], + ) -> List["PluginSupervisor"]: + """按依赖顺序启动当前已创建的 Supervisor。 + + Args: + builtin_dirs: 内置插件目录列表。 + third_party_dirs: 第三方插件目录列表。 + + Returns: + List[PluginSupervisor]: 成功启动的 Supervisor 列表。 + """ + + started_supervisors: List["PluginSupervisor"] = [] + supervisor_groups: Dict[str, Optional["PluginSupervisor"]] = { + "builtin": self._builtin_supervisor, + "third_party": self._third_party_supervisor, + } + start_order = self._build_group_start_order(builtin_dirs, third_party_dirs) + + try: + for group_name in start_order: + supervisor = supervisor_groups.get(group_name) + if supervisor is None: + continue + + external_plugin_versions = { + plugin_id: plugin_version + for started_supervisor in started_supervisors + for plugin_id, plugin_version in started_supervisor.get_loaded_plugin_versions().items() + } + supervisor.set_external_available_plugins(external_plugin_versions) + set_blocked_plugin_reasons = getattr(supervisor, "set_blocked_plugin_reasons", None) + if callable(set_blocked_plugin_reasons): + set_blocked_plugin_reasons(self._blocked_plugin_reasons) + await supervisor.start() + started_supervisors.append(supervisor) + except Exception: + await asyncio.gather(*(supervisor.stop() for supervisor in started_supervisors), return_exceptions=True) + raise + + return started_supervisors + + async def _stop_supervisors(self) -> None: + """停止当前全部 Supervisor。""" + + supervisors = self.supervisors + if not supervisors: + return + + await asyncio.gather(*(supervisor.stop() for supervisor in supervisors), return_exceptions=True) + self._builtin_supervisor = None + self._third_party_supervisor = None + + async def _restart_supervisors(self, reason: str) -> bool: + """重启当前全部 Supervisor。 + + Args: + reason: 本次重启的原因。 + + Returns: + bool: 是否重启成功。 + """ + + builtin_dirs, third_party_dirs = self._resolve_runtime_plugin_dirs() + if duplicate_plugin_ids := self._find_duplicate_plugin_ids(builtin_dirs + third_party_dirs): + details = "; ".join( + f"{plugin_id}: {', '.join(str(path) for path in paths)}" + for plugin_id, paths in sorted(duplicate_plugin_ids.items()) + ) + logger.error(f"检测到重复插件 ID,拒绝执行 Supervisor 重启: {details}") + return False + + logger.info(f"开始重启插件运行时 Supervisor: {reason}") + await self._stop_supervisors() + self._build_supervisors(builtin_dirs, third_party_dirs) + + try: + await self._start_supervisors(builtin_dirs, third_party_dirs) + except Exception as exc: + logger.error(f"重启插件运行时 Supervisor 失败: {exc}", exc_info=True) + await self._stop_supervisors() + return False + + self._refresh_plugin_config_watch_subscriptions() + logger.info(f"插件运行时 Supervisor 已重启完成: {reason}") + return True + + # ─── 生命周期 ───────────────────────────────────────────── + + async def start(self) -> None: + """启动双子进程插件运行时""" + if self._started: + logger.warning("PluginRuntimeManager 已在运行中,跳过重复启动") + return + + _cfg = config_manager.get_global_config().plugin_runtime + if not _cfg.enabled: + logger.info("插件运行时已在配置中禁用,跳过启动") + return + + builtin_dirs, third_party_dirs = self._resolve_runtime_plugin_dirs() + + if duplicate_plugin_ids := self._find_duplicate_plugin_ids(builtin_dirs + third_party_dirs): + details = "; ".join( + f"{plugin_id}: {', '.join(str(p) for p in paths)}" + for plugin_id, paths in sorted(duplicate_plugin_ids.items()) + ) + logger.error(f"检测到重复插件 ID,拒绝启动插件运行时: {details}") + return + + if not builtin_dirs and not third_party_dirs: + logger.info("未找到任何插件目录,跳过插件运行时启动") + return + + dependency_sync_state = await self._sync_plugin_dependencies(builtin_dirs + third_party_dirs) + if dependency_sync_state.environment_changed: + logger.info("插件依赖流水线已更新当前 Python 环境,启动时将直接加载最新环境") + + self.ensure_builtin_hook_specs_registered() + platform_io_manager = get_platform_io_manager() + self._build_supervisors(builtin_dirs, third_party_dirs) + + started_supervisors: List["PluginSupervisor"] = [] + try: + platform_io_manager.set_inbound_dispatcher(self._dispatch_platform_inbound) + await platform_io_manager.ensure_send_pipeline_ready() + started_supervisors = await self._start_supervisors(builtin_dirs, third_party_dirs) + + await self._start_plugin_file_watcher() + config_manager.register_reload_callback(self._config_reload_callback) + self._config_reload_callback_registered = True + self._started = True + logger.info(f"插件运行时已启动 — 内置: {builtin_dirs or '无'}, 第三方: {third_party_dirs or '无'}") + except Exception as e: + logger.error(f"插件运行时启动失败: {e}", exc_info=True) + await self._stop_plugin_file_watcher() + if self._config_reload_callback_registered: + config_manager.unregister_reload_callback(self._config_reload_callback) + self._config_reload_callback_registered = False + await asyncio.gather(*(sv.stop() for sv in started_supervisors), return_exceptions=True) + platform_io_manager.clear_inbound_dispatcher() + try: + await platform_io_manager.stop() + except Exception as platform_io_exc: + logger.warning(f"Platform IO 停止失败: {platform_io_exc}") + await self._hook_dispatcher.stop() + self._started = False + self._builtin_supervisor = None + self._third_party_supervisor = None + + async def stop(self) -> None: + """停止所有插件运行时""" + if not self._started: + return + + platform_io_manager = get_platform_io_manager() + await self._stop_plugin_file_watcher() + if self._config_reload_callback_registered: + config_manager.unregister_reload_callback(self._config_reload_callback) + self._config_reload_callback_registered = False + + coroutines: List[Coroutine[Any, Any, None]] = [] + if self._builtin_supervisor: + coroutines.append(self._builtin_supervisor.stop()) + if self._third_party_supervisor: + coroutines.append(self._third_party_supervisor.stop()) + + stop_errors: List[str] = [] + try: + results = await asyncio.gather(*coroutines, return_exceptions=True) + for result in results: + if isinstance(result, Exception): + stop_errors.append(str(result)) + + platform_io_manager.clear_inbound_dispatcher() + try: + await platform_io_manager.stop() + except Exception as exc: + stop_errors.append(f"Platform IO: {exc}") + + if stop_errors: + logger.error(f"插件运行时停止过程中存在错误: {'; '.join(stop_errors)}") + else: + logger.info("插件运行时已停止") + finally: + await self._hook_dispatcher.stop() + self._started = False + self._builtin_supervisor = None + self._third_party_supervisor = None + self._plugin_path_cache.clear() + + @property + def is_running(self) -> bool: + """返回插件运行时是否处于启动状态。""" + return self._started + + @property + def hook_dispatcher(self) -> HookDispatcher: + """返回跨 Supervisor 的命名 Hook 分发器。""" + + return self._hook_dispatcher + + @property + def invoke_dispatcher(self) -> HookDispatcher: + """返回命名 Hook 分发器的兼容别名。""" + + return self._hook_dispatcher + + @property + def supervisors(self) -> List["PluginSupervisor"]: + """获取所有活跃的 Supervisor""" + return [s for s in (self._builtin_supervisor, self._third_party_supervisor) if s is not None] + + def register_hook_spec(self, spec: HookSpec) -> None: + """注册单个命名 Hook 规格。 + + Args: + spec: 需要注册的 Hook 规格。 + """ + + self.ensure_builtin_hook_specs_registered() + self._hook_dispatcher.register_hook_spec(spec) + + def register_hook_specs(self, specs: Sequence[HookSpec]) -> None: + """批量注册命名 Hook 规格。 + + Args: + specs: 需要注册的 Hook 规格序列。 + """ + + self.ensure_builtin_hook_specs_registered() + self._hook_dispatcher.register_hook_specs(specs) + + def unregister_hook_spec(self, hook_name: str) -> bool: + """注销指定命名 Hook 规格。 + + Args: + hook_name: 目标 Hook 名称。 + + Returns: + bool: 是否成功注销。 + """ + + self.ensure_builtin_hook_specs_registered() + return self._hook_dispatcher.unregister_hook_spec(hook_name) + + def list_hook_specs(self) -> List[HookSpec]: + """返回当前全部命名 Hook 规格。 + + Returns: + List[HookSpec]: 当前已注册的 Hook 规格列表。 + """ + + self.ensure_builtin_hook_specs_registered() + return self._hook_dispatcher.list_hook_specs() + + def ensure_builtin_hook_specs_registered(self) -> None: + """确保内置 Hook 规格已经注册到共享中心表。""" + + if self._builtin_hook_specs_registered: + return + + register_builtin_hook_specs(self._hook_spec_registry) + self._builtin_hook_specs_registered = True + + def _build_registered_dependency_map(self) -> Dict[str, Set[str]]: + """根据当前已注册插件构建全局依赖图。""" + + dependency_map: Dict[str, Set[str]] = {} + for supervisor in self.supervisors: + for plugin_id, registration in getattr(supervisor, "_registered_plugins", {}).items(): + dependency_map[plugin_id] = { + str(dependency or "").strip() + for dependency in getattr(registration, "dependencies", []) + if str(dependency or "").strip() + } + return dependency_map + + @staticmethod + def _collect_reverse_dependents( + plugin_ids: Set[str], + dependency_map: Dict[str, Set[str]], + ) -> Set[str]: + """根据依赖图收集反向依赖闭包。""" + + impacted_plugins: Set[str] = set(plugin_ids) + changed = True + + while changed: + changed = False + for registered_plugin_id, dependencies in dependency_map.items(): + if registered_plugin_id in impacted_plugins: + continue + if dependencies & impacted_plugins: + impacted_plugins.add(registered_plugin_id) + changed = True + + return impacted_plugins + + def _build_registered_supervisor_map(self) -> Dict[str, "PluginSupervisor"]: + """构建当前已注册插件到所属 Supervisor 的映射。""" + + return { + plugin_id: supervisor for supervisor in self.supervisors for plugin_id in supervisor.get_loaded_plugin_ids() + } + + def get_plugin_load_statuses(self) -> Dict[str, str]: + """汇总所有 Supervisor 上报的插件加载状态。""" + + statuses: Dict[str, str] = {} + for supervisor in self.supervisors: + statuses.update(supervisor.get_plugin_load_statuses()) + return statuses + + def _build_external_available_plugins_for_supervisor(self, target_supervisor: "PluginSupervisor") -> Dict[str, str]: + """收集某个 Supervisor 可用的外部插件版本映射。""" + + external_plugin_versions: Dict[str, str] = {} + for supervisor in self.supervisors: + if supervisor is target_supervisor: + continue + external_plugin_versions.update(supervisor.get_loaded_plugin_versions()) + return external_plugin_versions + + def _find_supervisor_by_plugin_directory(self, plugin_id: str) -> Optional["PluginSupervisor"]: + """根据插件目录推断应负责该插件重载的 Supervisor。""" + + for supervisor in self.supervisors: + if self._get_plugin_path_for_supervisor(supervisor, plugin_id) is not None: + return supervisor + return None + + def _warn_skipped_cross_supervisor_reload( + self, + requested_loaded_plugin_ids: Set[str], + dependency_map: Dict[str, Set[str]], + supervisor_by_plugin: Dict[str, "PluginSupervisor"], + ) -> None: + """记录因跨 Supervisor 边界而未参与联动重载的插件。""" + + if not requested_loaded_plugin_ids: + return + + handled_plugin_ids: Set[str] = set() + for supervisor in self.supervisors: + local_requested_plugin_ids = { + plugin_id + for plugin_id in requested_loaded_plugin_ids + if supervisor_by_plugin.get(plugin_id) is supervisor + } + if not local_requested_plugin_ids: + continue + + local_plugin_ids = set(supervisor.get_loaded_plugin_ids()) + local_dependency_map = { + plugin_id: { + dependency for dependency in dependency_map.get(plugin_id, set()) if dependency in local_plugin_ids + } + for plugin_id in local_plugin_ids + } + handled_plugin_ids.update( + self._collect_reverse_dependents(local_requested_plugin_ids, local_dependency_map) + ) + + impacted_plugin_ids = self._collect_reverse_dependents(requested_loaded_plugin_ids, dependency_map) + skipped_plugin_ids = sorted(impacted_plugin_ids - handled_plugin_ids) + if not skipped_plugin_ids: + return + + logger.warning( + f"插件 {', '.join(sorted(requested_loaded_plugin_ids))} 存在跨 Supervisor 依赖方未联动重载: " + f"{', '.join(skipped_plugin_ids)}。当前仅在单个 Supervisor 内执行联动重载;" + "跨 Supervisor API 调用仍然可用。如需联动重载,请将相关插件放在同一个 Supervisor 内。" + ) + + async def reload_plugins_globally(self, plugin_ids: Sequence[str], reason: str = "manual") -> bool: + """按 Supervisor 分组执行精确重载。 + + 仅在单个 Supervisor 内执行依赖联动;跨 Supervisor 依赖方仅记录告警, + 不再自动参与本次热重载。 + """ + + normalized_plugin_ids = [ + normalized_plugin_id for plugin_id in plugin_ids if (normalized_plugin_id := str(plugin_id or "").strip()) + ] + if not normalized_plugin_ids: + return True + + blocked_plugin_ids = [plugin_id for plugin_id in normalized_plugin_ids if plugin_id in self._blocked_plugin_reasons] + if blocked_plugin_ids: + logger.warning( + "以下插件当前被依赖流水线阻止加载,已拒绝重载请求: " + + ", ".join( + f"{plugin_id} ({self._blocked_plugin_reasons[plugin_id]})" + for plugin_id in sorted(blocked_plugin_ids) + ) + ) + normalized_plugin_ids = [ + plugin_id for plugin_id in normalized_plugin_ids if plugin_id not in self._blocked_plugin_reasons + ] + if not normalized_plugin_ids: + return False + + dependency_map = self._build_registered_dependency_map() + supervisor_by_plugin = self._build_registered_supervisor_map() + supervisor_roots: Dict["PluginSupervisor", List[str]] = {} + requested_loaded_plugin_ids: Set[str] = set() + missing_plugin_ids: List[str] = [] + + for plugin_id in normalized_plugin_ids: + supervisor = supervisor_by_plugin.get(plugin_id) + if supervisor is not None: + requested_loaded_plugin_ids.add(plugin_id) + else: + supervisor = self._find_supervisor_by_plugin_directory(plugin_id) + + if supervisor is None: + missing_plugin_ids.append(plugin_id) + continue + + if plugin_id not in supervisor_roots.setdefault(supervisor, []): + supervisor_roots[supervisor].append(plugin_id) + + if missing_plugin_ids: + logger.warning(f"以下插件未找到可重载的 Supervisor,已跳过: {', '.join(sorted(missing_plugin_ids))}") + + self._warn_skipped_cross_supervisor_reload( + requested_loaded_plugin_ids=requested_loaded_plugin_ids, + dependency_map=dependency_map, + supervisor_by_plugin=supervisor_by_plugin, + ) + + success = True + for supervisor, root_plugin_ids in supervisor_roots.items(): + if not root_plugin_ids: + continue + + reloaded = await supervisor.reload_plugins( + plugin_ids=root_plugin_ids, + reason=reason, + external_available_plugins=self._build_external_available_plugins_for_supervisor(supervisor), + ) + success = success and reloaded + + return success and not missing_plugin_ids + + async def notify_plugin_config_updated( + self, + plugin_id: str, + config_data: Optional[Dict[str, Any]] = None, + config_version: str = "", + config_scope: str = "self", + ) -> bool: + """向拥有该插件的 Supervisor 推送配置更新事件。 + + Args: + plugin_id: 插件 ID + config_data: 可选的配置数据(如果为 None 则由 Supervisor 从磁盘加载) + config_version: 可选的配置版本字符串,供 Supervisor 进行版本控制 + config_scope: 配置变更范围。 + """ + if not self._started: + return False + + try: + sv = self._get_supervisor_for_plugin(plugin_id) + except RuntimeError as exc: + logger.error(f"推送插件配置更新失败: {exc}") + return False + + if sv is None: + return False + + config_payload = ( + config_data if config_data is not None else self._load_plugin_config_for_supervisor(sv, plugin_id) + ) + return await sv.notify_plugin_config_updated( + plugin_id=plugin_id, + config_data=config_payload, + config_version=config_version, + config_scope=config_scope, + ) + + async def validate_plugin_config(self, plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None: + """请求运行时按插件自身配置模型校验配置。 + + Args: + plugin_id: 目标插件 ID。 + config_data: 待校验的配置内容。 + + Returns: + Dict[str, Any] | None: 校验成功时返回规范化后的配置;若插件不存在、 + 当前不可路由或运行时不可用,则返回 ``None`` 以便调用方回退到弱推断方案。 + + Raises: + ValueError: 插件已加载,但配置校验失败时抛出。 + """ + + if not self._started: + return None + + try: + supervisor = self._get_supervisor_for_plugin(plugin_id) + except RuntimeError as exc: + logger.warning(f"插件 {plugin_id} 配置校验路由失败,将回退到静态 Schema: {exc}") + return None + + if supervisor is None: + supervisor = self._find_supervisor_by_plugin_directory(plugin_id) + if supervisor is None: + return None + + try: + return await supervisor.validate_plugin_config(plugin_id, config_data) + except ValueError: + raise + except Exception as exc: + logger.warning(f"插件 {plugin_id} 运行时配置校验不可用,将回退到静态 Schema: {exc}") + return None + + async def inspect_plugin_config( + self, + plugin_id: str, + config_data: Optional[Dict[str, Any]] = None, + *, + use_provided_config: bool = False, + ) -> InspectPluginConfigResultPayload | None: + """请求运行时解析插件配置元数据。 + + Args: + plugin_id: 目标插件 ID。 + config_data: 可选的配置内容。 + use_provided_config: 是否优先使用传入的配置内容而不是磁盘配置。 + + Returns: + InspectPluginConfigResultPayload | None: 解析成功时返回结构化结果;若插件 + 当前不可路由或运行时不可用,则返回 ``None``。 + + Raises: + ValueError: 插件存在,但运行时明确拒绝解析请求时抛出。 + """ + + if not self._started: + return None + + try: + supervisor = self._get_supervisor_for_plugin(plugin_id) + except RuntimeError as exc: + logger.warning(f"插件 {plugin_id} 配置解析路由失败: {exc}") + return None + + if supervisor is None: + supervisor = self._find_supervisor_by_plugin_directory(plugin_id) + if supervisor is None: + return None + + try: + return await supervisor.inspect_plugin_config( + plugin_id=plugin_id, + config_data=config_data, + use_provided_config=use_provided_config, + ) + except ValueError: + raise + except Exception as exc: + logger.warning(f"插件 {plugin_id} 配置解析不可用: {exc}") + return None + + @staticmethod + def _normalize_config_reload_scopes(changed_scopes: Sequence[str]) -> tuple[str, ...]: + """规范化配置热重载范围列表。 + + Args: + changed_scopes: 原始配置热重载范围列表。 + + Returns: + tuple[str, ...]: 去重后的有效配置范围元组。 + """ + + normalized_scopes: list[str] = [] + for scope in changed_scopes: + normalized_scope = str(scope or "").strip().lower() + if normalized_scope not in {"bot", "model"}: + continue + if normalized_scope not in normalized_scopes: + normalized_scopes.append(normalized_scope) + return tuple(normalized_scopes) + + async def _broadcast_config_reload(self, scope: str, config_data: Dict[str, Any]) -> None: + """向订阅指定范围的插件广播配置热重载。 + + Args: + scope: 配置变更范围,仅支持 ``bot`` 或 ``model``。 + config_data: 最新配置数据。 + """ + + for supervisor in self.supervisors: + for plugin_id in supervisor.get_config_reload_subscribers(scope): + delivered = await supervisor.notify_plugin_config_updated( + plugin_id=plugin_id, + config_data=config_data, + config_version="", + config_scope=scope, + ) + if not delivered: + logger.warning(f"向插件 {plugin_id} 广播 {scope} 配置热重载失败") + + async def _handle_main_config_reload(self, changed_scopes: Sequence[str]) -> None: + """处理 bot/model 主配置热重载广播。 + + Args: + changed_scopes: 本次热重载命中的配置范围列表。 + """ + + if not self._started: + return + + normalized_scopes = self._normalize_config_reload_scopes(changed_scopes) + if "bot" in normalized_scopes: + await self._broadcast_config_reload("bot", config_manager.get_global_config().model_dump(mode="json")) + if "model" in normalized_scopes: + await self._broadcast_config_reload("model", config_manager.get_model_config().model_dump(mode="json")) + + # ─── 事件桥接 ────────────────────────────────────────────── + + async def bridge_event( + self, + event_type_value: str, + message_dict: Optional[MessageDict] = None, + extra_args: Optional[Dict[str, Any]] = None, + ) -> Tuple[bool, Optional[MessageDict]]: + """将事件分发到所有 Supervisor + + Returns: + (continue_flag, modified_message_dict) + """ + if not self._started: + return True, None + + new_event_type: str = _EVENT_TYPE_MAP.get(event_type_value, event_type_value) + modified: Optional[MessageDict] = None + current_message: Optional["SessionMessage"] = ( + PluginMessageUtils._build_session_message_from_dict(dict(message_dict)) + if message_dict is not None + else None + ) + + for sv in self.supervisors: + try: + cont, mod = await sv.dispatch_event( + event_type=new_event_type, + message=current_message, + extra_args=extra_args, + ) + if mod is not None: + current_message = mod + modified = PluginMessageUtils._session_message_to_dict(mod) + if not cont: + return False, modified + except Exception as e: + logger.error(f"事件 {new_event_type} 分发失败: {e}", exc_info=True) + + return True, modified + + async def invoke_hook(self, hook_name: str, **kwargs: Any) -> HookDispatchResult: + """触发一次跨 Supervisor 的命名 Hook 调用。 + + Args: + hook_name: 本次触发的 Hook 名称。 + **kwargs: 传递给 Hook 处理器的关键字参数。 + + Returns: + HookDispatchResult: 聚合后的 Hook 调用结果。 + """ + + return await self._hook_dispatcher.invoke_hook(hook_name, **kwargs) + + # ─── 命令查找 ────────────────────────────────────────────── + + def find_command_by_text(self, text: str) -> Optional[Dict[str, Any]]: + """在所有 Supervisor 的 ComponentRegistry 中查找命令""" + if not self._started: + return None + + for sv in self.supervisors: + match_result = sv.component_registry.find_command_by_text(text) + if match_result is not None: + comp, matched_groups = match_result + return { + "name": comp.name, + "full_name": comp.full_name, + "component_type": comp.component_type, + "plugin_id": comp.plugin_id, + "metadata": comp.metadata, + "enabled": comp.enabled, + "matched_groups": matched_groups, + } + return None + + async def invoke_plugin( + self, + method: str, + plugin_id: str, + component_name: str, + args: Optional[Dict[str, Any]] = None, + timeout_ms: int = 30000, + ) -> Any: + """将插件调用路由到拥有该插件的 Supervisor""" + sv = self._get_supervisor_for_plugin(plugin_id) + if sv is None: + raise RuntimeError(f"插件 {plugin_id} 未在任何 Supervisor 中注册") + return await sv.invoke_plugin( + method=method, + plugin_id=plugin_id, + component_name=component_name, + args=args, + timeout_ms=timeout_ms, + ) + + async def try_send_message_via_platform_io( + self, + message: "SessionMessage", + ) -> Optional[DeliveryBatch]: + """尝试通过 Platform IO 中间层发送消息。 + + Args: + message: 待发送的内部会话消息。 + + Returns: + Optional[DeliveryBatch]: 若当前消息命中了至少一条发送路由,则返回 + 实际发送结果;若没有可用路由或 Platform IO 尚未启动,则返回 ``None``。 + """ + if not self._started: + return None + + platform_io_manager = get_platform_io_manager() + if not platform_io_manager.is_started: + return None + + try: + route_key = platform_io_manager.build_route_key_from_message(message) + except Exception as exc: + logger.warning(f"根据消息构造 Platform IO 路由键失败: {exc}") + return None + + if not platform_io_manager.resolve_drivers(route_key): + return None + + return await platform_io_manager.send_message(message, route_key) + + def _get_supervisors_for_plugin(self, plugin_id: str) -> List["PluginSupervisor"]: + """返回当前持有指定插件的所有 Supervisor。 + + 该辅助函数主要用于检测插件是否被重复注册到多个运行时分组, + 供后续单路由选择和冲突检查使用。 + """ + return [supervisor for supervisor in self.supervisors if plugin_id in supervisor._registered_plugins] + + def _get_supervisor_for_plugin(self, plugin_id: str) -> Optional["PluginSupervisor"]: + """返回负责指定插件的唯一 Supervisor。 + + 如果同一个插件同时出现在多个 Supervisor 中,说明运行时状态异常, + 此时直接抛出错误,避免把请求路由到错误的子进程。 + """ + matches = self._get_supervisors_for_plugin(plugin_id) + if len(matches) > 1: + raise RuntimeError(f"插件 {plugin_id} 同时存在于多个 Supervisor 中,无法安全路由") + return matches[0] if matches else None + + async def load_plugin_globally(self, plugin_id: str, reason: str = "manual") -> bool: + """加载或重载单个插件,并为其补齐跨 Supervisor 外部依赖。 + + Args: + plugin_id: 目标插件 ID。 + reason: 加载或重载原因。 + + Returns: + bool: 插件最终是否处于已加载状态。 + """ + + normalized_plugin_id = str(plugin_id or "").strip() + if not normalized_plugin_id: + return False + if normalized_plugin_id in self._blocked_plugin_reasons: + logger.warning( + f"插件 {normalized_plugin_id} 当前被依赖流水线阻止加载: " + f"{self._blocked_plugin_reasons[normalized_plugin_id]}" + ) + return False + + try: + registered_supervisor = self._get_supervisor_for_plugin(normalized_plugin_id) + except RuntimeError: + return False + + if registered_supervisor is not None: + return await self.reload_plugins_globally([normalized_plugin_id], reason=reason) + + supervisor = self._find_supervisor_by_plugin_directory(normalized_plugin_id) + if supervisor is None: + return False + + reloaded = await supervisor.reload_plugins( + plugin_ids=[normalized_plugin_id], + reason=reason, + external_available_plugins=self._build_external_available_plugins_for_supervisor(supervisor), + ) + return reloaded and normalized_plugin_id in supervisor.get_loaded_plugin_ids() + + @classmethod + def _find_duplicate_plugin_ids(cls, plugin_dirs: List[Path]) -> Dict[str, List[Path]]: + """扫描插件目录,找出被多个目录重复声明的插件 ID。""" + plugin_locations: Dict[str, List[Path]] = {} + validator = ManifestValidator(validate_python_package_dependencies=False) + for plugin_path, manifest in validator.iter_plugin_manifests(plugin_dirs): + plugin_locations.setdefault(manifest.id, []).append(plugin_path) + + return { + plugin_id: sorted(dict.fromkeys(paths), key=lambda p: str(p)) + for plugin_id, paths in plugin_locations.items() + if len(set(paths)) > 1 + } + + async def _start_plugin_file_watcher(self) -> None: + """启动插件文件监视器,并建立源码与配置两类订阅。""" + if self._plugin_file_watcher is not None and self._plugin_file_watcher.running: + return + + watch_paths = [path.resolve() for path in self._iter_plugin_dirs() if path.is_dir()] + if not watch_paths: + return + + watcher = FileWatcher( + paths=watch_paths, + debounce_ms=600, + callback_timeout_s=15.0, + callback_failure_threshold=3, + callback_cooldown_s=30.0, + ) + subscription_id = watcher.subscribe(self._handle_plugin_source_changes, paths=watch_paths) + await watcher.start() + self._plugin_file_watcher = watcher + self._plugin_source_watcher_subscription_id = subscription_id + self._refresh_plugin_config_watch_subscriptions() + + async def _stop_plugin_file_watcher(self) -> None: + """停止插件文件监视器,并清理所有已注册订阅。""" + if self._plugin_file_watcher is None: + self._plugin_path_cache.clear() + return + for _plugin_id, (_config_path, subscription_id) in list(self._plugin_config_watcher_subscriptions.items()): + self._plugin_file_watcher.unsubscribe(subscription_id) + self._plugin_config_watcher_subscriptions.clear() + if self._plugin_source_watcher_subscription_id is not None: + self._plugin_file_watcher.unsubscribe(self._plugin_source_watcher_subscription_id) + self._plugin_source_watcher_subscription_id = None + await self._plugin_file_watcher.stop() + self._plugin_file_watcher = None + self._plugin_path_cache.clear() + + def _iter_plugin_dirs(self) -> Iterable[Path]: + """迭代所有 Supervisor 当前管理的插件根目录。""" + for supervisor in self.supervisors: + yield from getattr(supervisor, "_plugin_dirs", []) + + @staticmethod + def _iter_candidate_plugin_paths(plugin_dirs: Iterable[Path]) -> Iterable[Path]: + """迭代所有可能的插件目录路径。 + + Args: + plugin_dirs: 一个或多个插件根目录。 + + Yields: + Path: 单个插件目录路径。 + """ + for plugin_dir in plugin_dirs: + plugin_root = Path(plugin_dir).resolve() + if not plugin_root.is_dir(): + continue + for entry in plugin_root.iterdir(): + if entry.is_dir(): + yield entry.resolve() + + def _read_plugin_id_from_plugin_path(self, plugin_path: Path) -> Optional[str]: + """从单个插件目录中读取 manifest 声明的插件 ID。 + + Args: + plugin_path: 单个插件目录路径。 + + Returns: + Optional[str]: 解析成功时返回插件 ID,否则返回 ``None``。 + """ + return self._manifest_validator.read_plugin_id_from_plugin_path(plugin_path) + + def _iter_discovered_plugin_paths(self, plugin_dirs: Iterable[Path]) -> Iterable[Tuple[str, Path]]: + """迭代目录中可解析到的插件 ID 与实际目录路径。 + + Args: + plugin_dirs: 一个或多个插件根目录。 + + Yields: + Tuple[str, Path]: ``(plugin_id, plugin_path)`` 二元组。 + """ + for plugin_path in self._iter_candidate_plugin_paths(plugin_dirs): + if plugin_id := self._read_plugin_id_from_plugin_path(plugin_path): + yield plugin_id, plugin_path + + def _get_plugin_path_for_supervisor(self, supervisor: Any, plugin_id: str) -> Optional[Path]: + """为指定 Supervisor 定位某个插件的实际目录。 + + Args: + supervisor: 目标 Supervisor。 + plugin_id: 插件 ID。 + + Returns: + Optional[Path]: 插件目录路径;未找到时返回 ``None``。 + """ + cached_path = self._plugin_path_cache.get(plugin_id) + if cached_path is not None: + for plugin_dir in getattr(supervisor, "_plugin_dirs", []): + if self._plugin_dir_matches(cached_path, Path(plugin_dir)): + return cached_path + + for candidate_plugin_id, plugin_path in self._iter_discovered_plugin_paths( + getattr(supervisor, "_plugin_dirs", []) + ): + if candidate_plugin_id != plugin_id: + continue + self._plugin_path_cache[plugin_id] = plugin_path + return plugin_path + + return None + + def _refresh_plugin_config_watch_subscriptions(self) -> None: + """按当前可识别插件集合刷新 config.toml 的单插件订阅。 + + 当插件热重载后,插件集合或目录位置可能发生变化,因此需要重新对齐 + watcher 的订阅,确保每个插件配置变更只触发对应 plugin_id。 + 这里不仅覆盖当前已注册插件,也覆盖已存在但暂未激活的合法插件。 + """ + if self._plugin_file_watcher is None: + return + + desired_plugin_paths = dict(self._iter_watchable_plugin_paths()) + self._plugin_path_cache = desired_plugin_paths.copy() + desired_config_paths = { + plugin_id: self._resolve_plugin_config_path(plugin_id, plugin_path) + for plugin_id, plugin_path in desired_plugin_paths.items() + } + + for plugin_id, (_old_path, subscription_id) in list(self._plugin_config_watcher_subscriptions.items()): + if desired_config_paths.get(plugin_id) == self._plugin_config_watcher_subscriptions[plugin_id][0]: + continue + self._plugin_file_watcher.unsubscribe(subscription_id) + del self._plugin_config_watcher_subscriptions[plugin_id] + + for plugin_id, config_path in desired_config_paths.items(): + existing_subscription = self._plugin_config_watcher_subscriptions.get(plugin_id) + if existing_subscription is not None and existing_subscription[0] == config_path: + continue + subscription_id = self._plugin_file_watcher.subscribe( + self._build_plugin_config_change_callback(plugin_id), + paths=[config_path], + ) + self._plugin_config_watcher_subscriptions[plugin_id] = (config_path, subscription_id) + + def _build_plugin_config_change_callback(self, plugin_id: str) -> Callable[[Sequence[FileChange]], Awaitable[None]]: + """为指定插件生成配置文件变更回调。""" + + async def _callback(changes: Sequence[FileChange]) -> None: + """将 watcher 事件转发到指定插件的配置处理逻辑。 + + Args: + changes: 当前批次收集到的文件变更列表。 + """ + await self._handle_plugin_config_changes(plugin_id, changes) + + return _callback + + def _iter_registered_plugin_paths(self) -> Iterable[Tuple[str, Path]]: + """迭代当前所有已注册插件的实际目录路径。""" + for supervisor in self.supervisors: + for plugin_id in getattr(supervisor, "_registered_plugins", {}).keys(): + if plugin_path := self._get_plugin_path_for_supervisor(supervisor, plugin_id): + yield plugin_id, plugin_path + + def _iter_watchable_plugin_paths(self) -> Iterable[Tuple[str, Path]]: + """迭代应被配置监听器追踪的插件目录。 + + Returns: + Iterable[Tuple[str, Path]]: ``(plugin_id, plugin_path)`` 迭代器。 + """ + + watchable_plugin_paths = dict(self._iter_discovered_plugin_paths(self._iter_plugin_dirs())) + for plugin_id, plugin_path in self._iter_registered_plugin_paths(): + watchable_plugin_paths.setdefault(plugin_id, plugin_path) + yield from watchable_plugin_paths.items() + + def _get_plugin_config_path_for_supervisor(self, supervisor: Any, plugin_id: str) -> Optional[Path]: + """从指定 Supervisor 的插件目录中定位某个插件的 config.toml。""" + plugin_path = self._get_plugin_path_for_supervisor(supervisor, plugin_id) + return None if plugin_path is None else self._resolve_plugin_config_path(plugin_id, plugin_path) + + @staticmethod + def _resolve_plugin_config_path(plugin_id: str, plugin_path: Path) -> Path: + return plugin_path / "config.toml" + + async def _handle_plugin_config_changes(self, plugin_id: str, changes: Sequence[FileChange]) -> None: + """处理单个插件配置文件变化,并定向派发自配置热更新。 + + Args: + plugin_id: 发生配置变更的插件 ID。 + changes: 当前批次收集到的配置文件变更列表。 + + """ + if not self._started or not changes: + return + + try: + supervisor = self._get_supervisor_for_plugin(plugin_id) + except RuntimeError as exc: + logger.warning(f"插件 {plugin_id} 配置监听匹配失败: {exc}") + return + + if supervisor is None: + supervisor = self._find_supervisor_by_plugin_directory(plugin_id) + if supervisor is None: + return + + plugin_is_loaded = plugin_id in getattr(supervisor, "_registered_plugins", {}) + + try: + snapshot = await supervisor.inspect_plugin_config(plugin_id) + except Exception as exc: + logger.warning(f"插件 {plugin_id} 配置文件变更解析失败: {exc}") + return + + try: + if plugin_is_loaded and snapshot.enabled: + delivered = await supervisor.notify_plugin_config_updated( + plugin_id=plugin_id, + config_data=dict(snapshot.normalized_config), + config_version="", + config_scope="self", + ) + if not delivered: + logger.warning(f"插件 {plugin_id} 配置文件变更后通知失败") + return + + if plugin_is_loaded and not snapshot.enabled: + reloaded = await self.reload_plugins_globally([plugin_id], reason="config_disabled") + if not reloaded: + logger.warning(f"插件 {plugin_id} 禁用配置已写入,但运行时卸载失败") + return + + if not snapshot.enabled: + logger.info(f"插件 {plugin_id} 当前处于禁用状态,跳过自动加载") + return + + loaded = await self.load_plugin_globally(plugin_id, reason="config_enabled") + if not loaded: + logger.warning(f"插件 {plugin_id} 配置文件变更后自动加载失败") + except Exception as exc: + logger.warning(f"插件 {plugin_id} 配置文件变更处理失败: {exc}") + + async def _handle_plugin_source_changes(self, changes: Sequence[FileChange]) -> None: + """处理插件源码相关变化。 + + 这里仅负责源码、清单等会影响插件装载状态的文件;配置文件的变化会由 + 单独的 per-plugin watcher 处理,并定向派发给目标插件的 + ``on_config_update()``,避免放大成不必要的跨插件 reload。 + """ + if not self._started or not changes: + return + + plugin_dirs = list(self._iter_plugin_dirs()) + if duplicate_plugin_ids := self._find_duplicate_plugin_ids(plugin_dirs): + details = "; ".join( + f"{plugin_id}: {', '.join(str(path) for path in paths)}" + for plugin_id, paths in sorted(duplicate_plugin_ids.items()) + ) + logger.error(f"检测到重复插件 ID,跳过本次插件热重载: {details}") + return + + relevant_source_changes = [ + change.path.resolve() + for change in changes + if change.path.name in {"plugin.py", "_manifest.json"} or change.path.suffix == ".py" + ] + if not relevant_source_changes: + return + + dependency_sync_state = await self._sync_plugin_dependencies(plugin_dirs) + restart_reason = "file_watcher" + if dependency_sync_state.environment_changed: + restart_reason = "file_watcher_dependency_install" + elif dependency_sync_state.blocked_changed_plugin_ids: + restart_reason = "file_watcher_blocklist_changed" + + restarted = await self._restart_supervisors(restart_reason) + if not restarted: + logger.warning(f"插件源码变更后重启 Supervisor 失败: {restart_reason}") + + @staticmethod + def _plugin_dir_matches(path: Path, plugin_dir: Path) -> bool: + """判断某个文件路径是否落在指定插件根目录内。""" + plugin_root = plugin_dir.resolve() + return path == plugin_root or path.is_relative_to(plugin_root) + + def _match_plugin_id_for_supervisor(self, supervisor: Any, path: Path) -> Optional[str]: + """根据变更路径为指定 Supervisor 推断受影响的插件 ID。""" + resolved_path = path.resolve() + + for plugin_id in getattr(supervisor, "_registered_plugins", {}).keys(): + plugin_path = self._get_plugin_path_for_supervisor(supervisor, plugin_id) + if plugin_path is not None and (resolved_path == plugin_path or resolved_path.is_relative_to(plugin_path)): + return plugin_id + + for plugin_id, plugin_path in self._plugin_path_cache.items(): + if not any( + self._plugin_dir_matches(plugin_path, Path(plugin_dir)) + for plugin_dir in getattr(supervisor, "_plugin_dirs", []) + ): + continue + if resolved_path == plugin_path or resolved_path.is_relative_to(plugin_path): + return plugin_id + + for plugin_id, plugin_path in self._iter_discovered_plugin_paths(getattr(supervisor, "_plugin_dirs", [])): + if resolved_path == plugin_path or resolved_path.is_relative_to(plugin_path): + self._plugin_path_cache[plugin_id] = plugin_path + return plugin_id + + return None + + def _load_plugin_config_for_supervisor(self, supervisor: Any, plugin_id: str) -> Dict[str, Any]: + """从给定插件目录集合中读取目标插件的配置内容。""" + plugin_path = self._get_plugin_path_for_supervisor(supervisor, plugin_id) + if plugin_path is None: + return {} + + config_path = self._resolve_plugin_config_path(plugin_id, plugin_path) + if not config_path.exists(): + return {} + + with open(config_path, "r", encoding="utf-8") as handle: + return tomlkit.load(handle).unwrap() + + # ─── 能力实现注册 ────────────────────────────────────────── + + def _register_capability_impls(self, supervisor: "PluginSupervisor") -> None: + """向指定 Supervisor 注册主程序能力实现。 + + Args: + supervisor: 需要注册能力实现的目标 Supervisor。 + """ + register_capability_impls(self, supervisor) + + +# ─── 单例 ────────────────────────────────────────────────── + +_manager: Optional[PluginRuntimeManager] = None + + +def get_plugin_runtime_manager() -> PluginRuntimeManager: + """获取 PluginRuntimeManager 全局单例""" + global _manager + if _manager is None: + _manager = PluginRuntimeManager() + return _manager diff --git a/src/plugin_runtime/protocol/__init__.py b/src/plugin_runtime/protocol/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/plugin_runtime/protocol/__init__.py @@ -0,0 +1 @@ + diff --git a/src/plugin_runtime/protocol/codec.py b/src/plugin_runtime/protocol/codec.py new file mode 100644 index 00000000..87816456 --- /dev/null +++ b/src/plugin_runtime/protocol/codec.py @@ -0,0 +1,47 @@ +"""MsgPack 编解码器""" + +from abc import ABC, abstractmethod +from typing import Any, Dict + +import msgpack + +from .envelope import Envelope + + +class Codec(ABC): + """消息编解码器基类""" + + @abstractmethod + def encode_envelope(self, envelope: Envelope) -> bytes: ... + + @abstractmethod + def decode_envelope(self, data: bytes) -> Envelope: ... + + @abstractmethod + def encode(self, obj: Dict[str, Any]) -> bytes: ... + + @abstractmethod + def decode(self, data: bytes) -> Dict[str, Any]: ... + + +class MsgPackCodec(Codec): + """MsgPack 编解码器""" + + def encode(self, obj: Dict[str, Any]) -> bytes: + result = msgpack.packb(obj, use_bin_type=True) + if result is None: + raise ValueError("msgpack.packb returned None, expected bytes") + return result + + def decode(self, data: bytes) -> Dict[str, Any]: + result = msgpack.unpackb(data, raw=False) + if not isinstance(result, dict): + raise ValueError(f"期望解码为 dict,实际为 {type(result)}") + return result + + def encode_envelope(self, envelope: Envelope) -> bytes: + return self.encode(envelope.model_dump()) + + def decode_envelope(self, data: bytes) -> Envelope: + raw = self.decode(data) + return Envelope.model_validate(raw) diff --git a/src/plugin_runtime/protocol/envelope.py b/src/plugin_runtime/protocol/envelope.py new file mode 100644 index 00000000..58d6d73b --- /dev/null +++ b/src/plugin_runtime/protocol/envelope.py @@ -0,0 +1,552 @@ +"""RPC Envelope 消息模型。 + +定义 Host 与 Runner 之间所有 RPC 消息的统一信封格式。 +使用 Pydantic 进行 Schema 定义与校验。 +""" + +from enum import Enum +from typing import Any, Dict, List, Optional + +import logging as stdlib_logging +import time + +from pydantic import BaseModel, Field + + +# ====== 协议常量 ====== +PROTOCOL_VERSION = "1.0.0" +# 支持的 SDK 版本范围(Host 在握手时校验) +MIN_SDK_VERSION = "1.0.0" +MAX_SDK_VERSION = "2.99.99" + + +# ====== 消息类型 ====== +class MessageType(str, Enum): + """RPC 消息类型""" + + REQUEST = "request" + RESPONSE = "response" + BROADCAST = "broadcast" + + +class ConfigReloadScope(str, Enum): + """配置热重载范围。""" + + SELF = "self" + BOT = "bot" + MODEL = "model" + + +# ====== 请求 ID 生成器 ====== +class RequestIdGenerator: + """单调递增 int64 请求 ID 生成器。""" + + def __init__(self, start: int = 1) -> None: + """初始化请求 ID 生成器。 + + Args: + start: 起始请求 ID。 + """ + self._counter = start + + async def next(self) -> int: + """返回下一个请求 ID。 + + Returns: + int: 下一个可用的请求 ID。 + """ + + current = self._counter + self._counter += 1 + return current + + +# ====== Envelope 模型 ====== +class Envelope(BaseModel): + """RPC 统一消息封装。 + + 所有 Host <-> Runner 消息均封装为此格式。 + 序列化流程:Envelope -> .model_dump() -> MsgPack encode + 反序列化流程:MsgPack decode -> Envelope.model_validate(data) + """ + + protocol_version: str = Field(default=PROTOCOL_VERSION, description="协议版本") + """协议版本""" + request_id: int = Field(description="单调递增请求 ID") + """单调递增请求 ID""" + message_type: MessageType = Field(description="消息类型") + """消息类型""" + method: str = Field(default="", description="RPC 方法名") + """RPC 方法名""" + plugin_id: str = Field(default="", description="目标插件 ID") + """目标插件 ID""" + timestamp_ms: int = Field(default_factory=lambda: int(time.time() * 1000), description="发送时间戳 (ms)") + """发送时间戳 (ms)""" + timeout_ms: int = Field(default=30000, description="相对超时 (ms)") + """相对超时 (ms)""" + payload: Dict[str, Any] = Field(default_factory=dict, description="业务数据") + """业务数据""" + error: Optional[Dict[str, Any]] = Field(default=None, description="错误信息 (仅 response)") + """错误信息 (仅 response)""" + + def is_request(self) -> bool: + """判断当前信封是否为请求消息。 + + Returns: + bool: 当前消息类型是否为 ``REQUEST``。 + """ + + return self.message_type == MessageType.REQUEST + + def is_response(self) -> bool: + """判断当前信封是否为响应消息。 + + Returns: + bool: 当前消息类型是否为 ``RESPONSE``。 + """ + + return self.message_type == MessageType.RESPONSE + + def is_broadcast(self) -> bool: + """判断当前信封是否为广播消息。 + + Returns: + bool: 当前消息类型是否为 ``BROADCAST``。 + """ + + return self.message_type == MessageType.BROADCAST + + def make_response( + self, payload: Optional[Dict[str, Any]] = None, error: Optional[Dict[str, Any]] = None + ) -> "Envelope": + """基于当前请求创建对应的响应信封。 + + Args: + payload: 响应业务载荷。 + error: 响应错误信息。 + + Returns: + Envelope: 对应的响应信封。 + """ + return Envelope( + protocol_version=self.protocol_version, + request_id=self.request_id, + message_type=MessageType.RESPONSE, + method=self.method, + plugin_id=self.plugin_id, + payload=payload or {}, + error=error, + ) + + def make_error_response(self, code: str, message: str = "", details: Optional[Dict[str, Any]] = None) -> "Envelope": + """基于当前请求创建错误响应。 + + Args: + code: 错误码。 + message: 错误描述。 + details: 详细错误信息。 + + Returns: + Envelope: 错误响应信封。 + """ + return self.make_response( + error={ + "code": code, + "message": message, + "details": details or {}, + } + ) + + +# ====== 握手请求与响应 ====== +class HelloPayload(BaseModel): + """runner.hello 握手请求 payload""" + + runner_id: str = Field(description="Runner 进程唯一标识") + """Runner 进程唯一标识""" + sdk_version: str = Field(description="SDK 版本号") + """SDK 版本号""" + session_token: str = Field(description="一次性会话令牌") + """一次性会话令牌""" + + +class HelloResponsePayload(BaseModel): + """runner.hello 握手响应 payload""" + + accepted: bool = Field(description="是否接受连接") + """是否接受连接""" + host_version: str = Field(default="", description="Host 版本号") + """Host 版本号""" + reason: str = Field(default="", description="拒绝原因 (若 accepted=False)") + """拒绝原因 (若 `accepted`=`False`)""" + + +# ====== 组件注册消息 ====== +class ComponentDeclaration(BaseModel): + """单个组件声明""" + + name: str = Field(description="组件名称") + """组件名称""" + component_type: str = Field(description="组件类型:action/command/tool/event_handler/hook_handler/message_gateway") + """组件类型:`action`/`command`/`tool`/`event_handler`/`hook_handler`/`message_gateway`""" + plugin_id: str = Field(description="所属插件 ID") + """所属插件 ID""" + chat_scope: str = Field(default="all", description="组件适用聊天类型:all/group/private") + """组件适用聊天类型。""" + allowed_session: List[str] = Field(default_factory=list, description="允许暴露该组件的会话 ID 或平台作用域 ID") + """允许暴露该组件的具体会话。空列表表示不限制。""" + metadata: Dict[str, Any] = Field(default_factory=dict, description="组件元数据") + """组件元数据""" + + +class LLMProviderDeclaration(BaseModel): + """单个 LLM Provider 声明。""" + + client_type: str = Field(description="客户端类型标识,对应模型配置中的 api_providers[].client_type") + """客户端类型标识。""" + name: str = Field(default="", description="Provider 展示名称") + """Provider 展示名称。""" + description: str = Field(default="", description="Provider 描述") + """Provider 描述。""" + version: str = Field(default="1.0.0", description="Provider 实现版本") + """Provider 实现版本。""" + metadata: Dict[str, Any] = Field(default_factory=dict, description="Provider 元数据") + """Provider 元数据。""" + + +class RegisterPluginPayload(BaseModel): + """插件组件注册请求载荷。 + + 该模型同时用于 ``plugin.register_components`` 与兼容旧命名的 + ``plugin.register_plugin`` 请求。 + """ + + plugin_id: str = Field(description="插件 ID") + """插件 ID""" + plugin_version: str = Field(default="1.0.0", description="插件版本") + """插件版本""" + components: List[ComponentDeclaration] = Field(default_factory=list, description="组件列表") + """组件列表""" + llm_providers: List[LLMProviderDeclaration] = Field(default_factory=list, description="LLM Provider 声明列表") + """LLM Provider 声明列表。""" + capabilities_required: List[str] = Field(default_factory=list, description="所需能力列表") + """所需能力列表""" + dependencies: List[str] = Field(default_factory=list, description="插件级依赖插件 ID 列表") + """插件级依赖插件 ID 列表""" + config_reload_subscriptions: List[str] = Field(default_factory=list, description="订阅的全局配置热重载范围") + """订阅的全局配置热重载范围""" + default_config: Dict[str, Any] = Field(default_factory=dict, description="插件默认配置") + """插件默认配置""" + config_schema: Dict[str, Any] = Field(default_factory=dict, description="插件配置 Schema") + """插件配置 Schema""" + + +class BootstrapPluginPayload(BaseModel): + """plugin.bootstrap 请求 payload""" + + plugin_id: str = Field(description="插件 ID") + """插件 ID""" + plugin_version: str = Field(default="1.0.0", description="插件版本") + """插件版本""" + capabilities_required: List[str] = Field(default_factory=list, description="所需能力列表") + """所需能力列表""" + + +# ====== 插件调用请求和响应 ====== +class InvokePayload(BaseModel): + """plugin.invoke.* 请求 payload""" + + component_name: str = Field(description="要调用的组件名称") + """要调用的组件名称""" + args: Dict[str, Any] = Field(default_factory=dict, description="调用参数") + """调用参数""" + + +class InvokeResultPayload(BaseModel): + """plugin.invoke.* 响应 payload""" + + success: bool = Field(description="是否成功") + """是否成功""" + result: Any = Field(default=None, description="返回值") + """返回值""" + + +class LLMProviderInvokePayload(BaseModel): + """plugin.invoke_llm_provider 请求 payload。""" + + client_type: str = Field(description="目标 LLM Provider 客户端类型") + """目标 LLM Provider 客户端类型。""" + operation: str = Field(description="请求操作类型") + """请求操作类型,如 response、embedding、audio_transcription。""" + request: Dict[str, Any] = Field(default_factory=dict, description="已序列化的 LLM 请求") + """已序列化的 LLM 请求。""" + + +# ====== 能力调用消息 ====== +class CapabilityRequestPayload(BaseModel): + """cap.* 请求 payload(插件 -> Host 能力调用)""" + + capability: str = Field(description="能力名称,如 send.text, db.query") + """能力名称,如 send.text, db.query""" + args: Dict[str, Any] = Field(default_factory=dict, description="调用参数") + """调用参数""" + + +class CapabilityResponsePayload(BaseModel): + """cap.* 响应 payload""" + + success: bool = Field(description="是否成功") + """是否成功""" + result: Any = Field(default=None, description="返回值") + """返回值""" + + +# ====== 健康检查 ====== +class HealthPayload(BaseModel): + """plugin.health 响应 payload""" + + healthy: bool = Field(description="是否健康") + """是否健康""" + loaded_plugins: List[str] = Field(default_factory=list, description="已加载的插件列表") + """已加载的插件列表""" + uptime_ms: int = Field(default=0, description="运行时长 (ms)") + """运行时长 (ms)""" + + +class RunnerReadyPayload(BaseModel): + """runner.ready 请求 payload""" + + loaded_plugins: List[str] = Field(default_factory=list, description="已完成初始化的插件列表") + """已完成初始化的插件列表""" + failed_plugins: List[str] = Field(default_factory=list, description="初始化失败的插件列表") + """初始化失败的插件列表""" + inactive_plugins: List[str] = Field(default_factory=list, description="当前因禁用或依赖不可用而未激活的插件列表") + """当前因禁用或依赖不可用而未激活的插件列表""" + + +# ====== 配置更新 ====== +class ConfigUpdatedPayload(BaseModel): + """plugin.config_updated 事件 payload""" + + plugin_id: str = Field(description="插件 ID") + """插件 ID""" + config_scope: ConfigReloadScope = Field(description="配置变更范围") + """配置变更范围""" + config_version: str = Field(description="新配置版本") + """新配置版本""" + config_data: Dict[str, Any] = Field(default_factory=dict, description="配置内容") + """配置内容""" + + +class ValidatePluginConfigPayload(BaseModel): + """plugin.validate_config 请求 payload。""" + + config_data: Dict[str, Any] = Field(default_factory=dict, description="待校验的配置内容") + """待校验的配置内容""" + + +class InspectPluginConfigPayload(BaseModel): + """plugin.inspect_config 请求 payload。""" + + config_data: Dict[str, Any] = Field(default_factory=dict, description="可选的配置内容") + """可选的配置内容""" + use_provided_config: bool = Field(default=False, description="是否优先使用请求中携带的配置内容") + """是否优先使用请求中携带的配置内容""" + + +class InspectPluginConfigResultPayload(BaseModel): + """plugin.inspect_config 响应 payload。""" + + success: bool = Field(description="是否解析成功") + """是否解析成功""" + default_config: Dict[str, Any] = Field(default_factory=dict, description="插件默认配置") + """插件默认配置""" + config_schema: Dict[str, Any] = Field(default_factory=dict, description="插件配置 Schema") + """插件配置 Schema""" + normalized_config: Dict[str, Any] = Field(default_factory=dict, description="归一化后的配置内容") + """归一化后的配置内容""" + changed: bool = Field(default=False, description="是否在归一化过程中自动补齐或修正了配置") + """是否在归一化过程中自动补齐或修正了配置""" + enabled: bool = Field(default=True, description="插件在当前配置下是否应被视为启用") + """插件在当前配置下是否应被视为启用""" + + +class ValidatePluginConfigResultPayload(BaseModel): + """plugin.validate_config 响应 payload。""" + + success: bool = Field(description="是否校验成功") + """是否校验成功""" + normalized_config: Dict[str, Any] = Field(default_factory=dict, description="校验后的规范化配置") + """校验后的规范化配置""" + changed: bool = Field(default=False, description="是否在校验过程中自动补齐或归一化") + """是否在校验过程中自动补齐或归一化""" + + +# ====== 关停 ====== +class ShutdownPayload(BaseModel): + """plugin.shutdown / plugin.prepare_shutdown payload""" + + reason: str = Field(default="normal", description="关停原因") + """关停原因""" + drain_timeout_ms: int = Field(default=5000, description="排空超时 (ms)") + """排空超时 (ms)""" + + +class UnregisterPluginPayload(BaseModel): + """插件注销请求载荷。""" + + plugin_id: str = Field(description="插件 ID") + """插件 ID""" + reason: str = Field(default="manual", description="注销原因") + """注销原因""" + + +class ReloadPluginPayload(BaseModel): + """插件重载请求载荷。""" + + plugin_id: str = Field(description="目标插件 ID") + """目标插件 ID""" + reason: str = Field(default="manual", description="重载原因") + """重载原因""" + external_available_plugins: Dict[str, str] = Field( + default_factory=dict, + description="可视为已满足的外部依赖插件版本映射", + ) + """可视为已满足的外部依赖插件版本映射""" + + +class ReloadPluginsPayload(BaseModel): + """批量插件重载请求载荷。""" + + plugin_ids: List[str] = Field(default_factory=list, description="目标插件 ID 列表") + """目标插件 ID 列表""" + reason: str = Field(default="manual", description="重载原因") + """重载原因""" + external_available_plugins: Dict[str, str] = Field( + default_factory=dict, + description="可视为已满足的外部依赖插件版本映射", + ) + """可视为已满足的外部依赖插件版本映射""" + + +class ReloadPluginResultPayload(BaseModel): + """插件重载结果载荷。""" + + success: bool = Field(description="是否重载成功") + """是否重载成功""" + requested_plugin_id: str = Field(description="请求重载的插件 ID") + """请求重载的插件 ID""" + reloaded_plugins: List[str] = Field(default_factory=list, description="成功完成重载的插件列表") + """成功完成重载的插件列表""" + unloaded_plugins: List[str] = Field(default_factory=list, description="本次已卸载的插件列表") + """本次已卸载的插件列表""" + inactive_plugins: List[str] = Field(default_factory=list, description="本次处于未激活状态的插件列表") + """本次处于未激活状态的插件列表""" + failed_plugins: Dict[str, str] = Field(default_factory=dict, description="重载失败的插件及原因") + """重载失败的插件及原因""" + + +class ReloadPluginsResultPayload(BaseModel): + """批量插件重载结果载荷。""" + + success: bool = Field(description="是否重载成功") + """是否重载成功""" + requested_plugin_ids: List[str] = Field(default_factory=list, description="请求重载的插件 ID 列表") + """请求重载的插件 ID 列表""" + reloaded_plugins: List[str] = Field(default_factory=list, description="成功完成重载的插件列表") + """成功完成重载的插件列表""" + unloaded_plugins: List[str] = Field(default_factory=list, description="本次已卸载的插件列表") + """本次已卸载的插件列表""" + inactive_plugins: List[str] = Field(default_factory=list, description="本次处于未激活状态的插件列表") + """本次处于未激活状态的插件列表""" + failed_plugins: Dict[str, str] = Field(default_factory=dict, description="重载失败的插件及原因") + """重载失败的插件及原因""" + + +class MessageGatewayStateUpdatePayload(BaseModel): + """消息网关运行时状态更新载荷。""" + + gateway_name: str = Field(description="消息网关组件名称") + """消息网关组件名称""" + ready: bool = Field(description="当前链路是否已经就绪") + """当前链路是否已经就绪""" + platform: str = Field(default="", description="当前链路负责的平台名称") + """当前链路负责的平台名称""" + account_id: str = Field(default="", description="当前链路对应的账号 ID 或 self_id") + """当前链路对应的账号 ID 或 self_id""" + scope: str = Field(default="", description="当前链路对应的可选路由作用域") + """当前链路对应的可选路由作用域""" + metadata: Dict[str, Any] = Field(default_factory=dict, description="可选的运行时状态元数据") + """可选的运行时状态元数据""" + + +class MessageGatewayStateUpdateResultPayload(BaseModel): + """消息网关运行时状态更新结果载荷。""" + + accepted: bool = Field(description="Host 是否接受了本次状态更新") + """Host 是否接受了本次状态更新""" + ready: bool = Field(description="Host 记录的当前就绪状态") + """Host 记录的当前就绪状态""" + route_key: Dict[str, Any] = Field(default_factory=dict, description="当前生效的路由键") + """当前生效的路由键""" + + +class RouteMessagePayload(BaseModel): + """消息网关向 Host 路由外部消息的请求载荷。""" + + gateway_name: str = Field(description="接收消息的网关组件名称") + """接收消息的网关组件名称""" + message: Dict[str, Any] = Field(description="符合 MessageDict 结构的标准消息字典") + """符合 MessageDict 结构的标准消息字典""" + route_metadata: Dict[str, Any] = Field(default_factory=dict, description="可选的路由辅助元数据") + """可选的路由辅助元数据""" + external_message_id: str = Field(default="", description="可选的外部平台消息 ID") + """可选的外部平台消息 ID""" + dedupe_key: str = Field(default="", description="可选的显式去重键") + """可选的显式去重键""" + + +class ReceiveExternalMessageResultPayload(BaseModel): + """外部消息注入结果载荷。""" + + accepted: bool = Field(description="Host 是否接受了本次消息注入") + """Host 是否接受了本次消息注入""" + route_key: Dict[str, Any] = Field(default_factory=dict, description="本次消息使用的归一路由键") + """本次消息使用的归一路由键""" + + +RegisterPluginPayload.model_rebuild() + + +# ====== 日志传输 ====== + + +class LogEntry(BaseModel): + """单条日志记录(Runner → Host 传输格式)""" + + timestamp_ms: int = Field(description="日志时间戳,Unix epoch 毫秒") + """日志时间戳,Unix epoch 毫秒""" + level: int = Field(description="stdlib logging 整数级别:10=DEBUG, 20=INFO, 30=WARNING, 40=ERROR, 50=CRITICAL") + """stdlib logging 整数级别:10=DEBUG, 20=INFO, 30=WARNING, 40=ERROR, 50=CRITICAL""" + logger_name: str = Field(description="Logger 名称,如 plugin.my_plugin.submodule") + """Logger 名称,如 plugin.my_plugin.submodule""" + message: str = Field(description="经 Formatter 格式化后的完整日志消息(含 exc_info 文本)") + """经 Formatter 格式化后的完整日志消息(含 exc_info 文本)""" + exception_text: str = Field( + default="", + description="原始异常摘要(exc_text),供结构化消费;已嵌入 message 中", + ) + """原始异常摘要(exc_text),供结构化消费;已嵌入 message 中""" + log_color_in_hex: Optional[str] = Field(default=None, description="日志颜色的十六进制字符串(如 #RRGGBB)") + + @property + def levelname(self) -> str: + """返回对应的 stdlib logging 级别名称(如 'INFO')。""" + return stdlib_logging.getLevelName(self.level) + + +class LogBatchPayload(BaseModel): + """runner.log_batch 事件 payload:Runner 端向 Host 批量推送日志记录""" + + entries: List[LogEntry] = Field(description="本批次日志记录列表,按时间升序排列") + """本批次日志记录列表,按时间升序排列""" diff --git a/src/plugin_runtime/protocol/errors.py b/src/plugin_runtime/protocol/errors.py new file mode 100644 index 00000000..d2b9228b --- /dev/null +++ b/src/plugin_runtime/protocol/errors.py @@ -0,0 +1,77 @@ +"""RPC 错误码定义 + +所有 Host 与 Runner 之间的 RPC 通信使用统一的错误码体系。 +""" + +from enum import Enum +from typing import Any, Dict, Optional + + +class ErrorCode(str, Enum): + """RPC 错误码枚举""" + + # 通用 + OK = "OK" + E_UNKNOWN = "E_UNKNOWN" + + # 协议层 + E_TIMEOUT = "E_TIMEOUT" + E_BAD_PAYLOAD = "E_BAD_PAYLOAD" + E_PROTOCOL_MISMATCH = "E_PROTOCOL_MISMATCH" + E_SHUTTING_DOWN = "E_SHUTTING_DOWN" + + # 权限与策略 + E_UNAUTHORIZED = "E_UNAUTHORIZED" + E_METHOD_NOT_ALLOWED = "E_METHOD_NOT_ALLOWED" + E_BACK_PRESSURE = "E_BACK_PRESSURE" + E_HOST_OVERLOADED = "E_HOST_OVERLOADED" + + # 插件生命周期 + E_PLUGIN_CRASHED = "E_PLUGIN_CRASHED" + E_PLUGIN_NOT_FOUND = "E_PLUGIN_NOT_FOUND" + E_RELOAD_IN_PROGRESS = "E_RELOAD_IN_PROGRESS" + + # 能力调用 + E_CAPABILITY_DENIED = "E_CAPABILITY_DENIED" + E_CAPABILITY_FAILED = "E_CAPABILITY_FAILED" + + +class RPCError(Exception): + """RPC 调用异常""" + + def __init__( + self, + code: ErrorCode, + message: str = "", + details: Optional[Dict[str, Any]] = None, + ) -> None: + self.code = code + self.message = message or code.value + self.details = details or {} + super().__init__(f"[{code.value}] {self.message}") + + def to_dict(self) -> Dict[str, Any]: + return { + "code": self.code.value, + "message": self.message, + "details": self.details, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "RPCError": + code = ErrorCode(data.get("code", "E_UNKNOWN")) + return cls( + code=code, + message=data.get("message", ""), + details=data.get("details", {}), + ) + + @classmethod + def from_exception(cls, exception: Exception, code_mapping: Optional[Dict[type[Exception], ErrorCode]] = None): + if isinstance(exception, cls): + return exception + if code_mapping: + for exception_type, code in code_mapping.items(): + if isinstance(exception, exception_type): + return cls(code=code, message=str(exception)) + return cls(ErrorCode.E_UNKNOWN, str(exception)) diff --git a/src/plugin_runtime/runner/__init__.py b/src/plugin_runtime/runner/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/plugin_runtime/runner/__init__.py @@ -0,0 +1 @@ + diff --git a/src/plugin_runtime/runner/log_handler.py b/src/plugin_runtime/runner/log_handler.py new file mode 100644 index 00000000..03f2db4d --- /dev/null +++ b/src/plugin_runtime/runner/log_handler.py @@ -0,0 +1,239 @@ +"""Runner 端 IPC 日志 Handler + +将 Runner 进程内所有 stdlib logging 日志通过 IPC 批量发送到 Host, +Host 端将其重放到主进程的 Logger(以 plugin. 为名)中,从而 +统一在主进程的结构化日志体系中显示插件日志。 + +架构: + Runner 进程 + └── logging.root ← RunnerIPCLogHandler(本文件) + └── emit() 非阻塞入缓冲 → 后台刷新协程批量发送 + └── rpc_client.send_event("runner.log_batch", ...) + └── IPC socket → Host + └── RunnerLogBridge.handle_log_batch() + └── logging.getLogger("plugin.").handle(record) + +设计原则: +- emit() 必须是非阻塞的,不得在热路径上 await 任何 IPC 调用 +- 使用 collections.deque(maxlen=QUEUE_MAX) 作为有界环形缓冲: + 满时最旧条目自动被覆盖(不区分级别,为实现简单接受此折损) +- CPython 的 deque.append / deque.popleft 在 GIL 保护下是线程安全的, + 适合单消费后台协程 + 多生产线程的使用场景 +- 后台刷新协程每 FLUSH_INTERVAL_SEC 秒或 FLUSH_BATCH_SIZE 条后批量发送 +- IPC 发送失败时静默忽略;stderr fallback 由 supervisor 的 drain task 覆盖 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Optional + +import asyncio +import collections +import contextlib +import json +import logging + +from src.plugin_runtime.protocol.envelope import LogBatchPayload, LogEntry + +if TYPE_CHECKING: + from src.plugin_runtime.runner.rpc_client import RPCClient + + +class RunnerIPCLogHandler(logging.Handler): + """将 Runner 进程内所有日志通过 IPC 批量转发到 Host 主进程。 + + 典型用法:: + + handler = RunnerIPCLogHandler() + handler.start(rpc_client, asyncio.get_running_loop()) + logging.root.addHandler(handler) + # ... 进程运行 ... + logging.root.removeHandler(handler) + await handler.stop() + """ + + #: 日志缓冲最大条数;超出后最旧的条目将被静默丢弃(deque(maxlen) 行为) + QUEUE_MAX: int = 200 + + #: 后台刷新循环的休眠间隔(秒) + FLUSH_INTERVAL_SEC: float = 0.1 + + #: 每次 send_event 携带的最大日志条数 + FLUSH_BATCH_SIZE: int = 20 + + #: 仅转发 logger name 以这些前缀开头的日志,第三方库日志将被忽略 + #: 包含 "_maibot_plugin_" 前缀以覆盖插件模块中 logging.getLogger(__name__) 的场景 + ALLOWED_LOGGER_PREFIXES: tuple[str, ...] = ("plugin.", "plugin_runtime.", "_maibot_plugin_") + + def __init__(self) -> None: + """初始化 Runner 端日志转发处理器。 + + 创建有界日志缓冲区,并准备与 RPC 客户端绑定的后台刷新任务。 + 此时不会启动任何异步任务;真正开始转发要等到 :meth:`start` + 被调用后才会发生。 + """ + super().__init__() + # deque(maxlen=N): append/popleft 在 CPython GIL 保护下线程安全 + self._buffer: collections.deque[LogEntry] = collections.deque(maxlen=self.QUEUE_MAX) + self._rpc_client: Optional[RPCClient] = None + self._flush_task: Optional[asyncio.Task[None]] = None + + # ─── 公开 API ────────────────────────────────────────────────── + + def start(self, rpc_client: RPCClient, loop: asyncio.AbstractEventLoop) -> None: + """握手完成后、在事件循环内调用,启动后台刷新任务。 + + Args: + rpc_client: 已完成握手的 RPCClient 实例。 + loop: 当前运行的 asyncio 事件循环。 + """ + self._rpc_client = rpc_client + self._flush_task = loop.create_task( + self._flush_loop(), + name="RunnerIPCLogHandler._flush_loop", + ) + + async def stop(self) -> None: + """停止刷新任务并将缓冲中剩余日志全部发送出去。 + + 应在 ``logging.root.removeHandler(handler)`` 之后调用, + 确保 emit() 不会在 stop() 执行期间向已消耗的缓冲写入新条目。 + """ + if self._flush_task is not None: + self._flush_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._flush_task + self._flush_task = None + + # 关闭前全量刷新,分多批次直到缓冲清空 + await self._flush_remaining() + + # ─── logging.Handler 接口 ────────────────────────────────────── + + def emit(self, record: logging.LogRecord) -> None: + """将一条 LogRecord 序列化后放入缓冲(同步,永不阻塞)。 + + 仅转发 logger name 匹配 ``ALLOWED_LOGGER_PREFIXES`` 的日志, + 第三方库日志被静默忽略,避免噪声淹没插件日志。 + 缓冲已满时,deque 自动从左侧丢弃最旧条目(FIFO 溢出)。 + 异常通过 ``self.handleError(record)`` 写到 stderr,不引发。 + """ + try: + # 过滤:仅允许插件相关的 logger,跳过第三方库日志 + if not any(record.name.startswith(p) for p in self.ALLOWED_LOGGER_PREFIXES): + return + + # structlog 透传到 stdlib logging 时,record.msg 往往是 event_dict。 + # 这里先提取可读的 event 文本,避免 Host 侧收到一整段 dict 字符串。 + msg = self._serialize_message(record) + entry = LogEntry( + timestamp_ms=int(record.created * 1000), + level=record.levelno, + logger_name=record.name, + message=msg, + exception_text=record.exc_text or "", + ) + self._buffer.append(entry) + except Exception: + self.handleError(record) + + def _serialize_message(self, record: logging.LogRecord) -> str: + """将 LogRecord 序列化为适合 Host 重放的纯文本消息。""" + if isinstance(record.msg, dict): + event_dict = record.msg + event_text = self._stringify_value(event_dict.get("event", "")) + extras = [] + ignored_keys = { + "event", + "logger", + "logger_name", + "level", + "timestamp", + "module", + "lineno", + "pathname", + "_from_structlog", + "_record", + } + for key, value in event_dict.items(): + if key in ignored_keys: + continue + extras.append(f"{key}={self._stringify_value(value)}") + + if extras: + return f"{event_text} {' '.join(extras)}".strip() + return event_text + + # format() 会处理占位参数替换和 exc_info 文本拼接。 + return self.format(record) + + @staticmethod + def _stringify_value(value: object) -> str: + """将结构化字段转换为紧凑字符串。""" + if isinstance(value, (dict, list)): + try: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + except (TypeError, ValueError): + return str(value) + return str(value) + + # ─── 内部方法 ────────────────────────────────────────────────── + + async def _flush_loop(self) -> None: + """后台批量刷新循环——每 FLUSH_INTERVAL_SEC 秒醒来一次。""" + while True: + try: + await asyncio.sleep(self.FLUSH_INTERVAL_SEC) + await self._flush_batch(self.FLUSH_BATCH_SIZE) + except asyncio.CancelledError: + break + except Exception: + # 任何发送侧错误都静默忽略,避免向 logging 写入导致嵌套循环 + pass + + async def _flush_batch(self, max_count: int) -> None: + """从缓冲中取出最多 max_count 条日志并通过 IPC 发送一个批次。 + + Args: + max_count: 本次最多发送的条目数。 + """ + if not self._buffer or self._rpc_client is None: + return + + entries: List[LogEntry] = [] + while self._buffer and len(entries) < max_count: + entries.append(self._buffer.popleft()) + + if not entries: + return + + # IPC 连接断开时回退到 stderr,避免日志静默丢失 + if not self._rpc_client.is_connected: + import sys + + for entry in entries: + print( + f"[LOG-FALLBACK] [{entry.logger_name}] {entry.message}", + file=sys.stderr, + ) + return + + # IPC 发送失败时回退到 stderr + try: + await self._rpc_client.send_event( + "runner.log_batch", + payload=LogBatchPayload(entries=entries).model_dump(), + ) + except Exception: + import sys + + for entry in entries: + print( + f"[LOG-FALLBACK] [{entry.logger_name}] {entry.message}", + file=sys.stderr, + ) + + async def _flush_remaining(self) -> None: + """将缓冲中剩余的所有条目分批全部发送。""" + while self._buffer: + await self._flush_batch(self.FLUSH_BATCH_SIZE) diff --git a/src/plugin_runtime/runner/manifest_validator.py b/src/plugin_runtime/runner/manifest_validator.py new file mode 100644 index 00000000..7bf2c040 --- /dev/null +++ b/src/plugin_runtime/runner/manifest_validator.py @@ -0,0 +1,1294 @@ +"""Manifest 校验与解析。 + +集中负责插件 ``_manifest.json`` 的读取、结构校验、运行时兼容性判断, +以及插件依赖/Python 包依赖的解析逻辑。 +""" + +from functools import lru_cache +from importlib import metadata as importlib_metadata +from pathlib import Path +from typing import Annotated, Any, Dict, Iterable, List, Literal, Optional, Set, Tuple, Union + +import json +import re +import tomllib + +from packaging.requirements import InvalidRequirement, Requirement +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator + +from src.common.logger import get_logger + +logger = get_logger("plugin_runtime.runner.manifest_validator") + +_SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") +_PLUGIN_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+(?:[.-][A-Za-z0-9_]+)+$") +_PACKAGE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +_HTTP_URL_PATTERN = re.compile(r"^https?://.+$") + + +class VersionComparator: + """语义化版本号比较器。""" + + @staticmethod + def normalize_version(version: str) -> str: + """将版本号规范化为三段式语义版本字符串。 + + Args: + version: 原始版本号字符串。 + + Returns: + str: 规范化后的 ``major.minor.patch`` 形式版本号。 + 当输入为空或格式非法时返回 ``0.0.0``。 + """ + if not version: + return "0.0.0" + + normalized = re.sub(r"-snapshot\.\d+", "", str(version).strip()) + try: + parsed_version = Version(normalized) + parts = [str(part) for part in parsed_version.release[:3]] + while len(parts) < 3: + parts.append("0") + return ".".join(parts) + except InvalidVersion: + pass + + if not re.match(r"^\d+(\.\d+){0,2}$", normalized): + return "0.0.0" + + parts = normalized.split(".") + while len(parts) < 3: + parts.append("0") + return ".".join(parts[:3]) + + @staticmethod + def parse_version(version: str) -> Tuple[int, int, int]: + """将版本字符串解析为可比较的整数元组。 + + Args: + version: 原始版本号字符串。 + + Returns: + Tuple[int, int, int]: 三段式版本号对应的整数元组。 + 当解析失败时返回 ``(0, 0, 0)``。 + """ + normalized = VersionComparator.normalize_version(version) + try: + parts = normalized.split(".") + return (int(parts[0]), int(parts[1]), int(parts[2])) + except (ValueError, IndexError): + return (0, 0, 0) + + @staticmethod + def compare(v1: str, v2: str) -> int: + """比较两个版本号的大小关系。 + + Args: + v1: 第一个版本号。 + v2: 第二个版本号。 + + Returns: + int: ``-1`` 表示 ``v1 < v2``,``1`` 表示 ``v1 > v2``, + ``0`` 表示两者相等。 + """ + t1 = VersionComparator.parse_version(v1) + t2 = VersionComparator.parse_version(v2) + if t1 < t2: + return -1 + if t1 > t2: + return 1 + return 0 + + @staticmethod + def is_in_range(version: str, min_version: str = "", max_version: str = "") -> Tuple[bool, str]: + """判断版本号是否落在给定闭区间内。 + + Args: + version: 待检查的版本号。 + min_version: 允许的最小版本号,留空表示不限制下界。 + max_version: 允许的最大版本号,留空表示不限制上界。 + + Returns: + Tuple[bool, str]: 第一项表示是否满足要求,第二项为失败原因; + 当校验通过时第二项为空字符串。 + """ + if not min_version and not max_version: + return True, "" + + normalized_version = VersionComparator.normalize_version(version) + if min_version: + normalized_min_version = VersionComparator.normalize_version(min_version) + if VersionComparator.compare(normalized_version, normalized_min_version) < 0: + return False, f"版本 {normalized_version} 低于最小要求 {normalized_min_version}" + if max_version: + normalized_max_version = VersionComparator.normalize_version(max_version) + if VersionComparator.compare(normalized_version, normalized_max_version) > 0: + return False, f"版本 {normalized_version} 高于最大支持 {normalized_max_version}" + return True, "" + + @staticmethod + def is_valid_semver(version: str) -> bool: + """判断字符串是否为严格三段式语义版本号。 + + Args: + version: 待检查的版本号字符串。 + + Returns: + bool: 是否满足 ``X.Y.Z`` 格式。 + """ + return bool(_SEMVER_PATTERN.fullmatch(str(version or "").strip())) + + @staticmethod + def is_valid_project_version(version: str) -> bool: + """判断主程序或 SDK 的项目版本号是否可被解析。 + + ``pyproject.toml`` 遵循 Python 包版本规范,允许 ``1.0.0rc16`` 或 + ``1.0.0-pre.16`` 这类预发布版本;兼容性比较时只取其 release 部分。 + """ + + normalized = VersionComparator.normalize_version(version) + return normalized != "0.0.0" or str(version or "").strip() == "0.0.0" + + +class _StrictManifestModel(BaseModel): + """Manifest 解析使用的严格基类模型。""" + + model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) + + +class ManifestAuthor(_StrictManifestModel): + """插件作者信息。""" + + name: str = Field(description="作者名称") + url: str = Field(description="作者主页地址") + + @field_validator("name") + @classmethod + def _validate_name(cls, value: str) -> str: + """校验作者名称。 + + Args: + value: 原始作者名称。 + + Returns: + str: 规范化后的作者名称。 + + Raises: + ValueError: 当字段为空时抛出。 + """ + if not value: + raise ValueError("不能为空") + return value + + @field_validator("url") + @classmethod + def _validate_url(cls, value: str) -> str: + """校验作者主页地址。 + + Args: + value: 原始主页地址。 + + Returns: + str: 规范化后的主页地址。 + + Raises: + ValueError: 当字段为空或不是 HTTP/HTTPS URL 时抛出。 + """ + if not value: + raise ValueError("不能为空") + if not _HTTP_URL_PATTERN.fullmatch(value): + raise ValueError("必须为 http:// 或 https:// 开头的 URL") + return value + + +class ManifestUrls(_StrictManifestModel): + """插件相关链接集合。""" + + repository: str = Field(description="插件仓库地址") + homepage: Optional[str] = Field(default=None, description="插件主页地址") + documentation: Optional[str] = Field(default=None, description="插件文档地址") + issues: Optional[str] = Field(default=None, description="插件问题反馈地址") + + @field_validator("repository") + @classmethod + def _validate_repository(cls, value: str) -> str: + """校验仓库地址。 + + Args: + value: 原始仓库地址。 + + Returns: + str: 规范化后的仓库地址。 + + Raises: + ValueError: 当字段为空或不是 HTTP/HTTPS URL 时抛出。 + """ + if not value: + raise ValueError("不能为空") + if not _HTTP_URL_PATTERN.fullmatch(value): + raise ValueError("必须为 http:// 或 https:// 开头的 URL") + return value + + @field_validator("homepage", "documentation", "issues") + @classmethod + def _validate_optional_url(cls, value: Optional[str]) -> Optional[str]: + """校验可选链接字段。 + + Args: + value: 原始链接值。 + + Returns: + Optional[str]: 合法的链接值。 + + Raises: + ValueError: 当提供的值不是 HTTP/HTTPS URL 时抛出。 + """ + if value is None: + return None + if not value: + raise ValueError("不能为空字符串") + if not _HTTP_URL_PATTERN.fullmatch(value): + raise ValueError("必须为 http:// 或 https:// 开头的 URL") + return value + + +class ManifestVersionRange(_StrictManifestModel): + """版本闭区间声明。""" + + min_version: str = Field(description="最小版本,闭区间") + max_version: str = Field(description="最大版本,闭区间") + + @field_validator("min_version", "max_version") + @classmethod + def _validate_version(cls, value: str) -> str: + """校验版本号格式。 + + Args: + value: 原始版本号。 + + Returns: + str: 合法的版本号。 + + Raises: + ValueError: 当版本号不是严格三段式语义版本时抛出。 + """ + if not VersionComparator.is_valid_semver(value): + raise ValueError("必须为严格三段式版本号,例如 1.0.0") + return value + + @model_validator(mode="after") + def _validate_range(self) -> "ManifestVersionRange": + """校验版本区间上下界关系。 + + Returns: + ManifestVersionRange: 当前对象本身。 + + Raises: + ValueError: 当最小版本大于最大版本时抛出。 + """ + if VersionComparator.compare(self.min_version, self.max_version) > 0: + raise ValueError("min_version 不能大于 max_version") + return self + + +class ManifestI18n(_StrictManifestModel): + """国际化配置。""" + + default_locale: str = Field(description="默认语言") + locales_path: Optional[str] = Field(default=None, description="语言资源目录") + supported_locales: List[str] = Field(default_factory=list, description="支持的语言列表") + + @field_validator("default_locale") + @classmethod + def _validate_default_locale(cls, value: str) -> str: + """校验默认语言。 + + Args: + value: 原始默认语言。 + + Returns: + str: 规范化后的默认语言。 + + Raises: + ValueError: 当字段为空时抛出。 + """ + if not value: + raise ValueError("不能为空") + return value + + @field_validator("locales_path") + @classmethod + def _validate_locales_path(cls, value: Optional[str]) -> Optional[str]: + """校验语言资源目录。 + + Args: + value: 原始语言资源目录。 + + Returns: + Optional[str]: 合法的目录值。 + + Raises: + ValueError: 当值为空字符串时抛出。 + """ + if value is None: + return None + if not value: + raise ValueError("不能为空字符串") + return value + + @field_validator("supported_locales") + @classmethod + def _validate_supported_locales(cls, value: List[str]) -> List[str]: + """校验支持语言列表。 + + Args: + value: 原始语言列表。 + + Returns: + List[str]: 去重后的语言列表。 + + Raises: + ValueError: 当列表项为空时抛出。 + """ + normalized_locales: List[str] = [] + for locale in value: + normalized_locale = str(locale or "").strip() + if not normalized_locale: + raise ValueError("语言列表中存在空值") + if normalized_locale not in normalized_locales: + normalized_locales.append(normalized_locale) + return normalized_locales + + @model_validator(mode="after") + def _validate_default_locale_membership(self) -> "ManifestI18n": + """校验默认语言是否位于支持列表中。 + + Returns: + ManifestI18n: 当前对象本身。 + + Raises: + ValueError: 当 ``supported_locales`` 非空但未包含 ``default_locale`` 时抛出。 + """ + if self.supported_locales and self.default_locale not in self.supported_locales: + raise ValueError("default_locale 必须包含在 supported_locales 中") + return self + + +class PluginDependencyDefinition(_StrictManifestModel): + """插件级依赖声明。""" + + type: Literal["plugin"] = Field(description="依赖类型") + id: str = Field(description="依赖插件 ID") + version_spec: str = Field(description="版本约束表达式") + + @field_validator("id") + @classmethod + def _validate_id(cls, value: str) -> str: + """校验依赖插件 ID。 + + Args: + value: 原始依赖插件 ID。 + + Returns: + str: 合法的依赖插件 ID。 + + Raises: + ValueError: 当 ID 不符合规则时抛出。 + """ + if not _PLUGIN_ID_PATTERN.fullmatch(value): + raise ValueError("必须使用字母/数字/下划线,并以点号或横线分隔,例如 github.author.plugin") + return value + + @field_validator("version_spec") + @classmethod + def _validate_version_spec(cls, value: str) -> str: + """校验插件依赖版本约束。 + + Args: + value: 原始版本约束表达式。 + + Returns: + str: 合法的版本约束表达式。 + + Raises: + ValueError: 当表达式无效时抛出。 + """ + if not value: + raise ValueError("不能为空") + try: + SpecifierSet(value) + except InvalidSpecifier as exc: + raise ValueError(f"无效的版本约束: {exc}") from exc + return value + + +class PythonPackageDependencyDefinition(_StrictManifestModel): + """Python 包依赖声明。""" + + type: Literal["python_package"] = Field(description="依赖类型") + name: str = Field(description="Python 包名") + version_spec: str = Field(description="版本约束表达式") + + @field_validator("name") + @classmethod + def _validate_name(cls, value: str) -> str: + """校验 Python 包名。 + + Args: + value: 原始包名。 + + Returns: + str: 合法的包名。 + + Raises: + ValueError: 当包名不合法时抛出。 + """ + if not _PACKAGE_NAME_PATTERN.fullmatch(value): + raise ValueError("包名只能包含字母、数字、点号、下划线和横线") + return value + + @field_validator("version_spec") + @classmethod + def _validate_version_spec(cls, value: str) -> str: + """校验 Python 包版本约束。 + + Args: + value: 原始版本约束表达式。 + + Returns: + str: 合法的版本约束表达式。 + + Raises: + ValueError: 当表达式无效时抛出。 + """ + if not value: + raise ValueError("不能为空") + try: + Requirement(f"placeholder{value}") + except InvalidRequirement as exc: + raise ValueError(f"无效的版本约束: {exc}") from exc + return value + + +class LLMProviderManifestDeclaration(_StrictManifestModel): + """插件 Manifest 中声明的 LLM Provider。""" + + client_type: str = Field(description="客户端类型标识,对应模型配置中的 api_providers[].client_type") + """客户端类型标识。""" + name: str = Field(default="", description="Provider 展示名称") + """Provider 展示名称。""" + description: str = Field(default="", description="Provider 描述") + """Provider 描述。""" + version: str = Field(default="1.0.0", description="Provider 实现版本") + """Provider 实现版本。""" + + @field_validator("client_type") + @classmethod + def _validate_client_type(cls, value: str) -> str: + """校验客户端类型标识。 + + Args: + value: 原始客户端类型标识。 + + Returns: + str: 合法的客户端类型标识。 + + Raises: + ValueError: 当客户端类型为空时抛出。 + """ + normalized_value = str(value or "").strip() + if not normalized_value: + raise ValueError("client_type 不能为空") + return normalized_value + + +ManifestDependencyDefinition = Annotated[ + Union[PluginDependencyDefinition, PythonPackageDependencyDefinition], + Field(discriminator="type"), +] + + +class PluginManifest(_StrictManifestModel): + """插件 Manifest v2 强类型模型。""" + + manifest_version: Literal[2] = Field(description="Manifest 协议版本") + version: str = Field(description="插件版本") + name: str = Field(description="插件展示名称") + description: str = Field(description="插件描述") + author: ManifestAuthor = Field(description="插件作者信息") + license: str = Field(description="插件协议") + urls: ManifestUrls = Field(description="插件相关链接") + host_application: ManifestVersionRange = Field(description="Host 兼容区间") + sdk: ManifestVersionRange = Field(description="SDK 兼容区间") + dependencies: List[ManifestDependencyDefinition] = Field(default_factory=list, description="依赖声明") + llm_providers: List[LLMProviderManifestDeclaration] = Field( + default_factory=list, + description="插件静态声明的 LLM Provider 列表", + ) + capabilities: List[str] = Field(description="插件声明的能力请求") + i18n: ManifestI18n = Field(description="国际化配置") + id: str = Field(description="稳定插件 ID") + + @field_validator("version") + @classmethod + def _validate_version(cls, value: str) -> str: + """校验插件版本号格式。 + + Args: + value: 原始插件版本号。 + + Returns: + str: 合法的插件版本号。 + + Raises: + ValueError: 当版本号不是严格三段式语义版本时抛出。 + """ + if not VersionComparator.is_valid_semver(value): + raise ValueError("必须为严格三段式版本号,例如 1.0.0") + return value + + @field_validator("name", "description", "license", "id") + @classmethod + def _validate_required_string(cls, value: str, info: Any) -> str: + """校验必填字符串字段。 + + Args: + value: 原始字段值。 + info: Pydantic 字段上下文。 + + Returns: + str: 合法的字段值。 + + Raises: + ValueError: 当字段为空或格式不合法时抛出。 + """ + if not value: + raise ValueError("不能为空") + if info.field_name == "id" and not _PLUGIN_ID_PATTERN.fullmatch(value): + raise ValueError("必须使用字母/数字/下划线,并以点号或横线分隔,例如 github.author.plugin") + return value + + @field_validator("capabilities") + @classmethod + def _validate_capabilities(cls, value: List[str]) -> List[str]: + """校验能力声明列表。 + + Args: + value: 原始能力声明列表。 + + Returns: + List[str]: 去重后的能力列表。 + + Raises: + ValueError: 当列表为空项或能力名为空时抛出。 + """ + normalized_capabilities: List[str] = [] + for capability in value: + normalized_capability = str(capability or "").strip() + if not normalized_capability: + raise ValueError("capabilities 中存在空能力名") + if normalized_capability not in normalized_capabilities: + normalized_capabilities.append(normalized_capability) + return normalized_capabilities + + @model_validator(mode="after") + def _validate_dependencies(self) -> "PluginManifest": + """校验依赖声明集合。 + + Returns: + PluginManifest: 当前对象本身。 + + Raises: + ValueError: 当依赖项重复或插件依赖自身时抛出。 + """ + plugin_dependency_ids: set[str] = set() + python_package_names: set[str] = set() + + for dependency in self.dependencies: + if isinstance(dependency, PluginDependencyDefinition): + if dependency.id == self.id: + raise ValueError("dependencies 中的插件依赖不能依赖自身") + if dependency.id in plugin_dependency_ids: + raise ValueError(f"存在重复的插件依赖声明: {dependency.id}") + plugin_dependency_ids.add(dependency.id) + continue + + normalized_package_name = canonicalize_name(dependency.name) + if normalized_package_name in python_package_names: + raise ValueError(f"存在重复的 Python 包依赖声明: {dependency.name}") + python_package_names.add(normalized_package_name) + + return self + + @model_validator(mode="after") + def _validate_llm_providers(self) -> "PluginManifest": + """校验 LLM Provider 静态声明集合。 + + Returns: + PluginManifest: 当前对象本身。 + + Raises: + ValueError: 当同一 Manifest 内重复声明 client_type 时抛出。 + """ + client_types: Set[str] = set() + for provider in self.llm_providers: + if provider.client_type in client_types: + raise ValueError(f"存在重复的 LLM Provider 声明: {provider.client_type}") + client_types.add(provider.client_type) + return self + + @property + def plugin_dependencies(self) -> List[PluginDependencyDefinition]: + """返回插件级依赖列表。 + + Returns: + List[PluginDependencyDefinition]: 所有 ``type=plugin`` 的依赖项。 + """ + return [dependency for dependency in self.dependencies if isinstance(dependency, PluginDependencyDefinition)] + + @property + def python_package_dependencies(self) -> List[PythonPackageDependencyDefinition]: + """返回 Python 包依赖列表。 + + Returns: + List[PythonPackageDependencyDefinition]: 所有 ``type=python_package`` 的依赖项。 + """ + return [ + dependency + for dependency in self.dependencies + if isinstance(dependency, PythonPackageDependencyDefinition) + ] + + @property + def plugin_dependency_ids(self) -> List[str]: + """返回插件级依赖的插件 ID 列表。 + + Returns: + List[str]: 所有插件级依赖的插件 ID。 + """ + return [dependency.id for dependency in self.plugin_dependencies] + + @property + def llm_provider_client_types(self) -> List[str]: + """返回 Manifest 静态声明的 LLM Provider client_type 列表。 + + Returns: + List[str]: 当前插件声明的 LLM Provider client_type。 + """ + return [provider.client_type for provider in self.llm_providers] + + +class ManifestValidator: + """严格的插件 Manifest v2 校验器。""" + + SUPPORTED_MANIFEST_VERSIONS = [2] + + def __init__( + self, + host_version: str = "", + sdk_version: str = "", + project_root: Optional[Path] = None, + validate_python_package_dependencies: bool = True, + ) -> None: + """初始化 Manifest 校验器。 + + Args: + host_version: 当前 Host 版本号;留空时自动从主程序 ``pyproject.toml`` 读取。 + sdk_version: 当前 SDK 版本号;留空时自动从运行环境中探测。 + project_root: 项目根目录;留空时自动推断。 + validate_python_package_dependencies: 是否校验 Python 包依赖与当前环境的关系。 + """ + self._project_root: Path = project_root or self._resolve_project_root() + self._host_version: str = host_version or self._detect_default_host_version(self._project_root) + self._sdk_version: str = sdk_version or self._detect_default_sdk_version(self._project_root) + self._validate_python_package_dependencies: bool = validate_python_package_dependencies + self.errors: List[str] = [] + self.warnings: List[str] = [] + + def validate(self, manifest: Dict[str, Any]) -> bool: + """校验 manifest 数据,返回是否通过。 + + Args: + manifest: 待校验的 Manifest 原始字典。 + + Returns: + bool: 校验是否通过。 + """ + return self.parse_manifest(manifest) is not None + + def parse_manifest(self, manifest: Dict[str, Any]) -> Optional[PluginManifest]: + """解析并校验 manifest 字典。 + + Args: + manifest: 待解析的 Manifest 原始字典。 + + Returns: + Optional[PluginManifest]: 解析成功时返回强类型 Manifest;失败时返回 ``None``。 + """ + self.errors.clear() + self.warnings.clear() + + try: + parsed_manifest = PluginManifest.model_validate(manifest) + except ValidationError as exc: + self.errors.extend(self._format_validation_errors(exc)) + self._log_errors() + return None + + self._validate_runtime_compatibility(parsed_manifest) + if self.errors: + self._log_errors() + return None + + return parsed_manifest + + def load_from_plugin_path(self, plugin_path: Path, require_entrypoint: bool = True) -> Optional[PluginManifest]: + """从插件目录读取并解析 manifest。 + + Args: + plugin_path: 单个插件目录路径。 + require_entrypoint: 是否要求目录内存在 ``plugin.py`` 入口文件。 + + Returns: + Optional[PluginManifest]: 解析成功时返回强类型 Manifest;失败时返回 ``None``。 + """ + self.errors.clear() + self.warnings.clear() + + manifest_path = plugin_path / "_manifest.json" + entrypoint_path = plugin_path / "plugin.py" + + if not manifest_path.is_file(): + self.errors.append("缺少 _manifest.json") + return None + if require_entrypoint and not entrypoint_path.is_file(): + self.errors.append("缺少 plugin.py") + return None + + try: + with manifest_path.open("r", encoding="utf-8") as manifest_file: + manifest_data = json.load(manifest_file) + except Exception as exc: + self.errors.append(f"manifest 解析失败: {exc}") + self._log_errors() + return None + + if not isinstance(manifest_data, dict): + self.errors.append("manifest 顶层必须为 JSON 对象") + self._log_errors() + return None + + return self.parse_manifest(manifest_data) + + def iter_plugin_manifests( + self, + plugin_dirs: Iterable[Path], + require_entrypoint: bool = True, + ) -> Iterable[Tuple[Path, PluginManifest]]: + """扫描插件根目录并迭代所有可成功解析的 Manifest。 + + Args: + plugin_dirs: 一个或多个插件根目录。 + require_entrypoint: 是否要求每个插件目录内存在 ``plugin.py``。 + + Yields: + Tuple[Path, PluginManifest]: ``(插件目录路径, 解析结果)`` 二元组。 + """ + for plugin_root in plugin_dirs: + normalized_root = Path(plugin_root).resolve() + if not normalized_root.is_dir(): + continue + + for candidate_path in sorted(entry.resolve() for entry in normalized_root.iterdir() if entry.is_dir()): + parsed_manifest = self.load_from_plugin_path(candidate_path, require_entrypoint=require_entrypoint) + if parsed_manifest is None: + continue + yield candidate_path, parsed_manifest + + def build_plugin_dependency_map( + self, + plugin_dirs: Iterable[Path], + require_entrypoint: bool = True, + ) -> Dict[str, List[str]]: + """扫描目录并构建 ``plugin_id -> 依赖插件 ID 列表`` 映射。 + + Args: + plugin_dirs: 一个或多个插件根目录。 + require_entrypoint: 是否要求每个插件目录内存在 ``plugin.py``。 + + Returns: + Dict[str, List[str]]: 所有成功解析到的插件依赖映射。 + """ + dependency_map: Dict[str, List[str]] = {} + for _plugin_path, manifest in self.iter_plugin_manifests(plugin_dirs, require_entrypoint=require_entrypoint): + dependency_map[manifest.id] = manifest.plugin_dependency_ids + return dependency_map + + def read_plugin_id_from_plugin_path(self, plugin_path: Path, require_entrypoint: bool = True) -> Optional[str]: + """从单个插件目录中读取规范化后的插件 ID。 + + Args: + plugin_path: 单个插件目录路径。 + require_entrypoint: 是否要求目录内存在 ``plugin.py``。 + + Returns: + Optional[str]: 解析成功时返回插件 ID,否则返回 ``None``。 + """ + manifest = self.load_from_plugin_path(plugin_path, require_entrypoint=require_entrypoint) + if manifest is None: + return None + return manifest.id + + def get_unsatisfied_plugin_dependencies( + self, + manifest: PluginManifest, + available_plugin_versions: Dict[str, str], + ) -> List[str]: + """返回当前 Manifest 尚未满足的插件依赖项。 + + Args: + manifest: 目标插件的强类型 Manifest。 + available_plugin_versions: 当前可用插件版本映射,键为插件 ID,值为插件版本。 + + Returns: + List[str]: 未满足依赖的错误描述列表。 + """ + unsatisfied_dependencies: List[str] = [] + for dependency in manifest.plugin_dependencies: + dependency_version = available_plugin_versions.get(dependency.id) + if not dependency_version: + unsatisfied_dependencies.append(f"{dependency.id} (未找到依赖插件)") + continue + + if not self._version_matches_specifier(dependency_version, dependency.version_spec): + unsatisfied_dependencies.append( + f"{dependency.id} (需要 {dependency.version_spec},当前 {dependency_version})" + ) + + return unsatisfied_dependencies + + def is_plugin_dependency_satisfied( + self, + dependency: PluginDependencyDefinition, + plugin_version: str, + ) -> bool: + """判断单个插件依赖是否被指定版本满足。 + + Args: + dependency: 插件级依赖声明。 + plugin_version: 当前可用的插件版本号。 + + Returns: + bool: 是否满足版本约束。 + """ + return self._version_matches_specifier(plugin_version, dependency.version_spec) + + def _validate_runtime_compatibility(self, manifest: PluginManifest) -> None: + """校验运行时版本兼容性与 Python 包依赖。 + + Args: + manifest: 已通过结构校验的强类型 Manifest。 + """ + host_ok, host_message = VersionComparator.is_in_range( + self._host_version, + manifest.host_application.min_version, + manifest.host_application.max_version, + ) + if not host_ok: + self.errors.append(f"Host 版本不兼容: {host_message} (当前 Host: {self._host_version})") + + sdk_ok, sdk_message = VersionComparator.is_in_range( + self._sdk_version, + manifest.sdk.min_version, + manifest.sdk.max_version, + ) + if not sdk_ok: + self.errors.append(f"SDK 版本不兼容: {sdk_message} (当前 SDK: {self._sdk_version})") + + if self._validate_python_package_dependencies: + self._validate_python_package_dependencies_against_runtime(manifest) + + def _validate_python_package_dependencies_against_runtime(self, manifest: PluginManifest) -> None: + """校验 Python 包依赖与主程序运行环境是否冲突。 + + Args: + manifest: 已通过结构校验的强类型 Manifest。 + """ + host_requirements = self._load_host_dependency_requirements(self._project_root) + + for dependency in manifest.python_package_dependencies: + normalized_package_name = canonicalize_name(dependency.name) + package_specifier = self._build_specifier_set(dependency.version_spec) + if package_specifier is None: + self.errors.append( + f"Python 包依赖 {dependency.name} 的版本约束无效: {dependency.version_spec}" + ) + continue + + installed_version = self._get_installed_package_version(dependency.name) + host_requirement = host_requirements.get(normalized_package_name) + + if installed_version is not None and not self._version_matches_specifier( + installed_version, + dependency.version_spec, + ): + self.errors.append( + f"Python 包依赖冲突: {dependency.name} 需要 {dependency.version_spec}," + f"当前运行环境为 {installed_version}" + ) + continue + + if host_requirement is None: + continue + + if not self._requirements_may_overlap(host_requirement.specifier, package_specifier): + host_specifier = str(host_requirement.specifier or "") + self.errors.append( + f"Python 包依赖冲突: {dependency.name} 需要 {dependency.version_spec}," + f"主程序依赖约束为 {host_specifier or '任意版本'}" + ) + + def load_host_dependency_requirements(self) -> Dict[str, Requirement]: + """读取主程序在 ``pyproject.toml`` 中声明的依赖约束。 + + Returns: + Dict[str, Requirement]: 以规范化包名为键的依赖约束映射。 + """ + + return self._load_host_dependency_requirements(self._project_root) + + def get_installed_package_version(self, package_name: str) -> Optional[str]: + """查询当前运行环境中指定包的安装版本。 + + Args: + package_name: 需要查询的包名。 + + Returns: + Optional[str]: 已安装版本号;未安装时返回 ``None``。 + """ + + return self._get_installed_package_version(package_name) + + @staticmethod + def build_specifier_set(version_spec: str) -> Optional[SpecifierSet]: + """将版本约束文本转换为 ``SpecifierSet``。 + + Args: + version_spec: 原始版本约束文本。 + + Returns: + Optional[SpecifierSet]: 转换成功时返回约束对象,否则返回 ``None``。 + """ + + return ManifestValidator._build_specifier_set(version_spec) + + @staticmethod + def version_matches_specifier(version: str, version_spec: str) -> bool: + """判断版本号是否满足给定约束。 + + Args: + version: 待判断的版本号。 + version_spec: 版本约束表达式。 + + Returns: + bool: 是否满足约束。 + """ + + return ManifestValidator._version_matches_specifier(version, version_spec) + + @classmethod + def requirements_may_overlap(cls, left: SpecifierSet, right: SpecifierSet) -> bool: + """判断两个版本约束是否可能存在交集。 + + Args: + left: 左侧版本约束。 + right: 右侧版本约束。 + + Returns: + bool: 若两者可能同时满足则返回 ``True``。 + """ + + return cls._requirements_may_overlap(left, right) + + def _log_errors(self) -> None: + """输出当前累计的 Manifest 校验错误。""" + for error_message in self.errors: + logger.error(f"Manifest 校验失败: {error_message}") + + @classmethod + def _resolve_project_root(cls) -> Path: + """推断当前项目根目录。 + + Returns: + Path: 项目根目录路径。 + """ + return Path(__file__).resolve().parents[3] + + @classmethod + @lru_cache(maxsize=None) + def _detect_default_host_version(cls, project_root: Path) -> str: + """从主程序 ``pyproject.toml`` 探测 Host 版本号。 + + Args: + project_root: 项目根目录。 + + Returns: + str: 探测到的 Host 版本号;失败时返回空字符串。 + """ + pyproject_path = project_root / "pyproject.toml" + try: + with pyproject_path.open("rb") as pyproject_file: + pyproject_data = tomllib.load(pyproject_file) + except Exception: + return "" + + project_data = pyproject_data.get("project", {}) + if not isinstance(project_data, dict): + return "" + + raw_version = str(project_data.get("version", "") or "").strip() + if VersionComparator.is_valid_project_version(raw_version): + return raw_version + return "" + + @classmethod + @lru_cache(maxsize=None) + def _detect_default_sdk_version(cls, project_root: Path) -> str: + """探测当前运行环境中的 SDK 版本号。 + + Args: + project_root: 项目根目录。 + + Returns: + str: 探测到的 SDK 版本号;失败时返回空字符串。 + """ + try: + raw_version = importlib_metadata.version("maibot-plugin-sdk") + if VersionComparator.is_valid_project_version(raw_version): + return raw_version + except importlib_metadata.PackageNotFoundError: + pass + + sdk_pyproject_path = project_root / "packages" / "maibot-plugin-sdk" / "pyproject.toml" + try: + with sdk_pyproject_path.open("rb") as pyproject_file: + pyproject_data = tomllib.load(pyproject_file) + except Exception: + return "" + + project_data = pyproject_data.get("project", {}) + if not isinstance(project_data, dict): + return "" + + raw_version = str(project_data.get("version", "") or "").strip() + if VersionComparator.is_valid_project_version(raw_version): + return raw_version + return "" + + @classmethod + @lru_cache(maxsize=None) + def _load_host_dependency_requirements(cls, project_root: Path) -> Dict[str, Requirement]: + """加载主程序 ``pyproject.toml`` 中声明的依赖约束。 + + Args: + project_root: 项目根目录。 + + Returns: + Dict[str, Requirement]: 以规范化包名为键的 Requirement 映射。 + """ + pyproject_path = project_root / "pyproject.toml" + try: + with pyproject_path.open("rb") as pyproject_file: + pyproject_data = tomllib.load(pyproject_file) + except Exception: + return {} + + project_data = pyproject_data.get("project", {}) + if not isinstance(project_data, dict): + return {} + + raw_dependencies = project_data.get("dependencies", []) + if not isinstance(raw_dependencies, list): + return {} + + requirements: Dict[str, Requirement] = {} + for raw_dependency in raw_dependencies: + dependency_text = str(raw_dependency or "").strip() + if not dependency_text: + continue + + try: + requirement = Requirement(dependency_text) + except InvalidRequirement: + continue + + requirements[canonicalize_name(requirement.name)] = requirement + + return requirements + + @staticmethod + def _get_installed_package_version(package_name: str) -> Optional[str]: + """获取当前运行环境中指定 Python 包的安装版本。 + + Args: + package_name: 待查询的包名。 + + Returns: + Optional[str]: 已安装版本号;未安装时返回 ``None``。 + """ + try: + return importlib_metadata.version(package_name) + except importlib_metadata.PackageNotFoundError: + return None + + @staticmethod + def _build_specifier_set(version_spec: str) -> Optional[SpecifierSet]: + """构造版本约束对象。 + + Args: + version_spec: 版本约束字符串。 + + Returns: + Optional[SpecifierSet]: 构造成功时返回约束对象,否则返回 ``None``。 + """ + try: + return SpecifierSet(version_spec) + except InvalidSpecifier: + return None + + @staticmethod + def _version_matches_specifier(version: str, version_spec: str) -> bool: + """判断版本是否满足给定约束。 + + Args: + version: 待判断的版本号。 + version_spec: 版本约束表达式。 + + Returns: + bool: 是否满足约束。 + """ + try: + normalized_version = Version(version) + specifier_set = SpecifierSet(version_spec) + except (InvalidVersion, InvalidSpecifier): + return False + return specifier_set.contains(normalized_version, prereleases=True) + + @classmethod + def _requirements_may_overlap(cls, left: SpecifierSet, right: SpecifierSet) -> bool: + """粗略判断两个版本约束是否存在交集。 + + Args: + left: 左侧版本约束。 + right: 右侧版本约束。 + + Returns: + bool: 若可能存在交集则返回 ``True``,否则返回 ``False``。 + """ + candidate_versions = cls._build_candidate_versions(left, right) + for candidate_version in candidate_versions: + if left.contains(candidate_version, prereleases=True) and right.contains(candidate_version, prereleases=True): + return True + return False + + @classmethod + def _build_candidate_versions(cls, left: SpecifierSet, right: SpecifierSet) -> List[Version]: + """为两个版本约束构造一组用于交集探测的候选版本。 + + Args: + left: 左侧版本约束。 + right: 右侧版本约束。 + + Returns: + List[Version]: 去重后的候选版本列表。 + """ + candidate_versions: List[Version] = [Version("0.0.0")] + for specifier in tuple(left) + tuple(right): + for candidate_version in cls._expand_candidate_versions(specifier.version): + if candidate_version not in candidate_versions: + candidate_versions.append(candidate_version) + return candidate_versions + + @staticmethod + def _expand_candidate_versions(raw_version: str) -> List[Version]: + """根据边界版本扩展出一组邻近候选版本。 + + Args: + raw_version: 约束中出现的边界版本字符串。 + + Returns: + List[Version]: 可用于交集探测的候选版本列表。 + """ + normalized_text = raw_version.replace("*", "0") + try: + boundary_version = Version(normalized_text) + except InvalidVersion: + return [] + + release_parts = list(boundary_version.release[:3]) + while len(release_parts) < 3: + release_parts.append(0) + major, minor, patch = release_parts[:3] + + candidates = { + Version(f"{major}.{minor}.{patch}"), + Version(f"{major}.{minor}.{patch + 1}"), + } + if patch > 0: + candidates.add(Version(f"{major}.{minor}.{patch - 1}")) + elif minor > 0: + candidates.add(Version(f"{major}.{minor - 1}.999")) + elif major > 0: + candidates.add(Version(f"{major - 1}.999.999")) + + return sorted(candidates) + + @classmethod + def _format_validation_errors(cls, exc: ValidationError) -> List[str]: + """将 Pydantic 校验错误转换为中文错误列表。 + + Args: + exc: Pydantic 抛出的校验异常。 + + Returns: + List[str]: 中文错误描述列表。 + """ + error_messages: List[str] = [] + for error in exc.errors(): + location = cls._format_error_location(error.get("loc", ())) + error_type = str(error.get("type", "")) + error_input = error.get("input") + error_context = error.get("ctx", {}) or {} + + if error_type == "missing": + error_messages.append(f"缺少必需字段: {location}") + elif error_type == "extra_forbidden": + error_messages.append(f"存在未声明字段: {location}") + elif error_type == "literal_error": + expected_values = error_context.get("expected") + error_messages.append(f"字段 {location} 的值不合法,必须为 {expected_values}") + elif error_type == "model_type": + error_messages.append(f"字段 {location} 必须为对象") + elif error_type.endswith("_type"): + error_messages.append(f"字段 {location} 的类型不正确") + elif error_type == "value_error": + error_messages.append(f"字段 {location} 校验失败: {error_context.get('error')}") + else: + error_messages.append(f"字段 {location} 校验失败: {error.get('msg', error_input)}") + + return error_messages + + @staticmethod + def _format_error_location(location: Tuple[Any, ...]) -> str: + """格式化校验错误字段路径。 + + Args: + location: Pydantic 提供的字段路径元组。 + + Returns: + str: 点号连接后的字段路径。 + """ + return ".".join(str(item) for item in location) if location else "" diff --git a/src/plugin_runtime/runner/plugin_loader.py b/src/plugin_runtime/runner/plugin_loader.py new file mode 100644 index 00000000..95e6b814 --- /dev/null +++ b/src/plugin_runtime/runner/plugin_loader.py @@ -0,0 +1,602 @@ +"""插件加载器 + +在 Runner 进程中负责发现和加载插件。 +插件通过 SDK 编写,不再 import src.*。 +支持:manifest 校验、依赖解析(拓扑排序)、生命周期钩子。 +兼容旧版 src.plugin_system 插件(通过导入钩子 + LegacyPluginAdapter)。 +""" + +from collections import deque +from pathlib import Path +from typing import Any, Dict, Iterator, List, Optional, Set, Tuple + +import contextlib +import importlib +import importlib.util +import os +import re +import sys + +from src.common.logger import get_logger +from src.plugin_runtime.runner.manifest_validator import ManifestValidator, PluginManifest + +logger = get_logger("plugin_runtime.runner.plugin_loader") + +PluginCandidate = Tuple[Path, PluginManifest, Path] + + +class PluginMeta: + """加载后的插件元数据""" + + def __init__( + self, + plugin_id: str, + plugin_dir: str, + module_name: str, + plugin_instance: Any, + manifest: PluginManifest, + ) -> None: + """初始化插件元数据。 + + Args: + plugin_id: 插件 ID。 + plugin_dir: 插件目录绝对路径。 + module_name: 插件入口模块名。 + plugin_instance: 插件实例对象。 + manifest: 解析后的强类型 Manifest。 + """ + self.plugin_id = plugin_id + self.plugin_dir = plugin_dir + self.module_name = module_name + self.instance = plugin_instance + self.manifest = manifest + self.version = manifest.version + self.capabilities_required = list(manifest.capabilities) + self.dependencies: List[str] = list(manifest.plugin_dependency_ids) + self.component_handlers: Dict[str, str] = {} + self.llm_provider_handlers: Dict[str, str] = {} + + +class PluginLoader: + """插件加载器 + + 扫描插件目录,加载符合 SDK 规范的插件。 + 每个插件目录须包含: + - _manifest.json: 插件元数据 + - plugin.py: 插件入口模块(导出 create_plugin 工厂函数) + """ + + def __init__(self, host_version: str = "") -> None: + """初始化插件加载器。 + + Args: + host_version: Host 版本号,用于 manifest 兼容性校验。 + """ + self._loaded_plugins: Dict[str, PluginMeta] = {} + self._failed_plugins: Dict[str, str] = {} + self._manifest_validator = ManifestValidator(host_version=host_version) + self._compat_hook_installed = False + self._blocked_plugin_reasons: Dict[str, str] = {} + + def set_blocked_plugin_reasons(self, blocked_plugin_reasons: Optional[Dict[str, str]] = None) -> None: + """更新当前加载器持有的拒绝加载插件列表。 + + Args: + blocked_plugin_reasons: 需要拒绝加载的插件及原因映射。 + """ + + self._blocked_plugin_reasons = { + str(plugin_id or "").strip(): str(reason or "").strip() + for plugin_id, reason in (blocked_plugin_reasons or {}).items() + if str(plugin_id or "").strip() and str(reason or "").strip() + } + + def get_blocked_plugin_reason(self, plugin_id: str) -> Optional[str]: + """返回指定插件当前的拒绝加载原因。 + + Args: + plugin_id: 目标插件 ID。 + + Returns: + Optional[str]: 若插件被阻止加载则返回原因,否则返回 ``None``。 + """ + + normalized_plugin_id = str(plugin_id or "").strip() + if not normalized_plugin_id: + return None + return self._blocked_plugin_reasons.get(normalized_plugin_id) + + def discover_and_load( + self, + plugin_dirs: List[str], + extra_available: Optional[Dict[str, str]] = None, + ) -> List[PluginMeta]: + """扫描多个目录并加载所有插件。 + + Args: + plugin_dirs: 插件目录列表。 + extra_available: 额外视为已满足的外部依赖插件版本映射。 + + Returns: + List[PluginMeta]: 成功加载的插件元数据列表,按依赖顺序排列。 + """ + candidates, duplicate_candidates = self._discover_candidates(plugin_dirs) + self._record_duplicate_candidates(duplicate_candidates) + + # 第二阶段:依赖解析(拓扑排序) + load_order, failed_deps = self._resolve_dependencies(candidates, extra_available=extra_available) + self._record_failed_dependencies(failed_deps) + + # 第三阶段:按依赖顺序加载 + return self._load_plugins_in_order(load_order, candidates) + + def discover_candidates(self, plugin_dirs: List[str]) -> Tuple[Dict[str, PluginCandidate], Dict[str, List[Path]]]: + """扫描插件目录并返回候选插件。 + + Args: + plugin_dirs: 需要扫描的插件根目录列表。 + + Returns: + Tuple[Dict[str, PluginCandidate], Dict[str, List[Path]]]: + 候选插件映射和重复插件 ID 冲突映射。 + """ + return self._discover_candidates(plugin_dirs) + + def _discover_candidates(self, plugin_dirs: List[str]) -> Tuple[Dict[str, PluginCandidate], Dict[str, List[Path]]]: + """扫描插件目录并收集候选插件。""" + candidates: Dict[str, PluginCandidate] = {} + duplicate_candidates: Dict[str, List[Path]] = {} + + for base_dir_str in plugin_dirs: + base_dir = Path(base_dir_str) + if not base_dir.is_dir(): + logger.warning(f"插件目录不存在: {base_dir}") + continue + + for plugin_dir in sorted(entry for entry in base_dir.iterdir() if entry.is_dir()): + discovered = self._discover_single_candidate(plugin_dir) + if discovered is None: + continue + + plugin_id, candidate = discovered + if plugin_id in duplicate_candidates: + duplicate_candidates[plugin_id].append(candidate[0]) + continue + + previous = candidates.get(plugin_id) + if previous is not None: + duplicate_candidates[plugin_id] = [previous[0], candidate[0]] + candidates.pop(plugin_id, None) + continue + + candidates[plugin_id] = candidate + + return candidates, duplicate_candidates + + def _discover_single_candidate(self, plugin_dir: Path) -> Optional[Tuple[str, PluginCandidate]]: + """发现并校验单个插件目录。""" + plugin_path = plugin_dir / "plugin.py" + if not plugin_path.exists(): + return None + + manifest = self._manifest_validator.load_from_plugin_path(plugin_dir) + if manifest is None: + errors = "; ".join(self._manifest_validator.errors) + self._failed_plugins[plugin_dir.name] = f"manifest 校验失败: {errors}" + return None + + plugin_id = manifest.id + if blocked_reason := self.get_blocked_plugin_reason(plugin_id): + self._failed_plugins[plugin_id] = blocked_reason + logger.warning(f"插件 {plugin_id} 已被 Host 依赖流水线阻止加载: {blocked_reason}") + return None + + return plugin_id, (plugin_dir, manifest, plugin_path) + + def _record_duplicate_candidates(self, duplicate_candidates: Dict[str, List[Path]]) -> None: + """记录重复插件 ID 错误。""" + for plugin_id, conflict_dirs in duplicate_candidates.items(): + unique_dirs = sorted({str(path) for path in conflict_dirs}) + reason = f"检测到重复插件 ID: {plugin_id} -> {', '.join(unique_dirs)}" + self._failed_plugins[plugin_id] = reason + logger.error(reason) + + def _record_failed_dependencies(self, failed_deps: Dict[str, str]) -> None: + """记录依赖解析失败信息。""" + for plugin_id, reason in failed_deps.items(): + self._failed_plugins[plugin_id] = reason + logger.error(f"插件 {plugin_id} 依赖解析失败: {reason}") + + def _load_plugins_in_order( + self, + load_order: List[str], + candidates: Dict[str, PluginCandidate], + ) -> List[PluginMeta]: + """按依赖顺序加载插件。""" + results: List[PluginMeta] = [] + for plugin_id in load_order: + plugin_dir, manifest, plugin_path = candidates[plugin_id] + try: + if meta := self._load_single_plugin(plugin_id, plugin_dir, manifest, plugin_path): + results.append(meta) + except Exception as e: + self._failed_plugins[plugin_id] = str(e) + logger.error(f"加载插件失败 [{plugin_id}]: {e}", exc_info=True) + + return results + + def get_plugin(self, plugin_id: str) -> Optional[PluginMeta]: + """获取已加载的插件""" + return self._loaded_plugins.get(plugin_id) + + def set_loaded_plugin(self, meta: PluginMeta) -> None: + """登记一个已经完成初始化的插件。 + + Args: + meta: 待登记的插件元数据。 + """ + self._loaded_plugins[meta.plugin_id] = meta + + def remove_loaded_plugin(self, plugin_id: str) -> Optional[PluginMeta]: + """移除一个已加载插件的元数据。 + + Args: + plugin_id: 待移除的插件 ID。 + + Returns: + Optional[PluginMeta]: 被移除的插件元数据;不存在时返回 ``None``。 + """ + return self._loaded_plugins.pop(plugin_id, None) + + def purge_plugin_modules(self, plugin_id: str, plugin_dir: str) -> List[str]: + """清理指定插件目录下的模块缓存。 + + Args: + plugin_id: 插件 ID。 + plugin_dir: 插件目录绝对路径。 + + Returns: + List[str]: 已从 ``sys.modules`` 中移除的模块名列表。 + """ + removed_modules: List[str] = [] + plugin_path = Path(plugin_dir).resolve() + synthetic_module_name = self._build_safe_module_name(plugin_id) + + for module_name, module in list(sys.modules.items()): + if module_name == synthetic_module_name: + removed_modules.append(module_name) + sys.modules.pop(module_name, None) + continue + + module_file = getattr(module, "__file__", None) + if module_file is None: + continue + + try: + module_path = Path(module_file).resolve() + except Exception: + continue + + if module_path.is_relative_to(plugin_path): + removed_modules.append(module_name) + sys.modules.pop(module_name, None) + + importlib.invalidate_caches() + return removed_modules + + @staticmethod + def _build_safe_module_name(plugin_id: str) -> str: + """将插件 ID 转换为可用于动态导入的安全模块名。 + + Args: + plugin_id: 原始插件 ID。 + + Returns: + str: 仅包含字母、数字和下划线的合成模块名。 + """ + normalized_plugin_id = re.sub(r"[^0-9A-Za-z_]", "_", str(plugin_id or "").strip()) + if normalized_plugin_id and normalized_plugin_id[0].isdigit(): + normalized_plugin_id = f"_{normalized_plugin_id}" + return f"_maibot_plugin_{normalized_plugin_id or 'plugin'}" + + def list_plugins(self) -> List[str]: + """列出所有已加载的插件 ID""" + return list(self._loaded_plugins.keys()) + + @property + def failed_plugins(self) -> Dict[str, str]: + """返回当前记录的失败插件原因映射。""" + return dict(self._failed_plugins) + + @property + def manifest_validator(self) -> ManifestValidator: + """返回当前加载器持有的 Manifest 校验器。 + + Returns: + ManifestValidator: 当前使用的 Manifest 校验器实例。 + """ + return self._manifest_validator + + # ──── 依赖解析 ──────────────────────────────────────────── + + def resolve_dependencies( + self, + candidates: Dict[str, PluginCandidate], + extra_available: Optional[Dict[str, str]] = None, + ) -> Tuple[List[str], Dict[str, str]]: + """解析候选插件的依赖顺序。 + + Args: + candidates: 待加载的候选插件集合。 + extra_available: 视为已满足的外部依赖插件版本映射。 + + Returns: + Tuple[List[str], Dict[str, str]]: 可加载顺序和失败原因映射。 + """ + return self._resolve_dependencies(candidates, extra_available=extra_available) + + def load_candidate(self, plugin_id: str, candidate: PluginCandidate) -> Optional[PluginMeta]: + """加载单个候选插件模块。 + + Args: + plugin_id: 插件 ID。 + candidate: 候选插件三元组。 + + Returns: + Optional[PluginMeta]: 加载成功的插件元数据;失败时返回 ``None``。 + """ + plugin_dir, manifest, plugin_path = candidate + return self._load_single_plugin(plugin_id, plugin_dir, manifest, plugin_path) + + def _resolve_dependencies( + self, + candidates: Dict[str, PluginCandidate], + extra_available: Optional[Dict[str, str]] = None, + ) -> Tuple[List[str], Dict[str, str]]: + """拓扑排序解析加载顺序,返回 (有序列表, 失败项 {id: reason})。""" + available = set(candidates.keys()) + satisfied_dependencies = { + str(plugin_id or "").strip(): str(plugin_version or "").strip() + for plugin_id, plugin_version in (extra_available or {}).items() + if str(plugin_id or "").strip() and str(plugin_version or "").strip() + } + dep_graph: Dict[str, Set[str]] = {} + failed: Dict[str, str] = {} + + for pid, (_, manifest, _) in candidates.items(): + resolved: Set[str] = set() + missing_or_incompatible: List[str] = [] + + for dependency in manifest.plugin_dependencies: + dependency_id = dependency.id + if dependency_id in available: + dependency_manifest = candidates[dependency_id][1] + if not self._manifest_validator.is_plugin_dependency_satisfied( + dependency, + dependency_manifest.version, + ): + missing_or_incompatible.append( + f"{dependency_id} (需要 {dependency.version_spec},当前 {dependency_manifest.version})" + ) + continue + resolved.add(dependency_id) + continue + + external_dependency_version = satisfied_dependencies.get(dependency_id) + if external_dependency_version is None: + missing_or_incompatible.append(f"{dependency_id} (未找到依赖插件)") + continue + + if not self._manifest_validator.is_plugin_dependency_satisfied( + dependency, + external_dependency_version, + ): + missing_or_incompatible.append( + f"{dependency_id} (需要 {dependency.version_spec},当前 {external_dependency_version})" + ) + + if missing_or_incompatible: + failed[pid] = f"依赖未满足: {', '.join(missing_or_incompatible)}" + dep_graph[pid] = resolved + + # 迭代传播“依赖自身加载失败”到上游依赖方,避免误报为循环依赖 + changed = True + while changed: + changed = False + failed_plugin_ids = set(failed) + for pid, dependencies in list(dep_graph.items()): + if pid in failed: + dep_graph.pop(pid, None) + continue + + failed_dependencies = sorted(dependency for dependency in dependencies if dependency in failed_plugin_ids) + if not failed_dependencies: + continue + + failed[pid] = f"依赖未满足: {', '.join(f'{dependency} (依赖插件加载失败)' for dependency in failed_dependencies)}" + dep_graph.pop(pid, None) + changed = True + + # Kahn 拓扑排序 + indegree = {pid: len(deps) for pid, deps in dep_graph.items()} + reverse: Dict[str, Set[str]] = {pid: set() for pid in dep_graph} + for pid, deps in dep_graph.items(): + for d in deps: + if d in reverse: + reverse[d].add(pid) + + queue = deque(sorted(pid for pid, deg in indegree.items() if deg == 0)) + sorted_order: List[str] = [] + + while queue: + current = queue.popleft() + sorted_order.append(current) + for dependent in sorted(reverse.get(current, [])): + indegree[dependent] -= 1 + if indegree[dependent] == 0: + queue.append(dependent) + + cycle_plugins = {pid for pid, deg in indegree.items() if deg > 0} + for pid in cycle_plugins: + failed[pid] = "检测到循环依赖" + + return sorted_order, failed + + # ──── 单个插件加载 ──────────────────────────────────────── + + def _load_single_plugin( + self, + plugin_id: str, + plugin_dir: Path, + manifest: PluginManifest, + plugin_path: Path, + ) -> Optional[PluginMeta]: + """加载单个插件""" + # 确保兼容层导入钩子已安装(旧版插件可能 import src.plugin_system) + self._ensure_compat_hook() + + # 动态导入插件模块 + module_name = self._build_safe_module_name(plugin_id) + spec = importlib.util.spec_from_file_location( + module_name, + str(plugin_path), + submodule_search_locations=[str(plugin_dir)], + ) + if spec is None or spec.loader is None: + logger.error(f"无法创建模块 spec: {plugin_path}") + return None + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + + plugin_parent_dir = plugin_dir.parent + src_root = Path("src").resolve() + try: + with self._temporary_sys_path_entry(src_root): + with self._temporary_sys_path_entry(plugin_parent_dir): + spec.loader.exec_module(module) + + # 优先使用新版 create_plugin 工厂函数 + create_plugin = getattr(module, "create_plugin", None) + if create_plugin is not None: + instance = create_plugin() + self._validate_sdk_plugin_contract(plugin_id, instance) + logger.info(f"插件 {plugin_id} v{manifest.version} 加载成功") + return PluginMeta( + plugin_id=plugin_id, + plugin_dir=str(plugin_dir), + module_name=module_name, + plugin_instance=instance, + manifest=manifest, + ) + + # 回退:检测旧版 @register_plugin 标记的 BasePlugin 子类 + instance = self._try_load_legacy_plugin(module, plugin_id) + if instance is not None: + logger.info( + f"插件 {plugin_id} v{manifest.version} 通过旧版兼容层加载成功(请尽快迁移到 maibot_sdk)" + ) + return PluginMeta( + plugin_id=plugin_id, + plugin_dir=str(plugin_dir), + module_name=module_name, + plugin_instance=instance, + manifest=manifest, + ) + except Exception: + sys.modules.pop(module_name, None) + raise + + logger.error(f"插件 {plugin_id} 缺少 create_plugin 工厂函数且未检测到旧版 BasePlugin") + return None + + @staticmethod + def _validate_sdk_plugin_contract(plugin_id: str, instance: Any) -> None: + """校验 SDK 插件的基础契约。 + + Args: + plugin_id: 当前插件 ID。 + instance: ``create_plugin()`` 返回的插件实例。 + + Raises: + TypeError: 当插件未覆盖必需生命周期方法或订阅声明不合法时抛出。 + """ + + try: + from maibot_sdk.plugin import MaiBotPlugin + except ImportError: + return + + if not isinstance(instance, MaiBotPlugin): + return + + if type(instance).on_load is MaiBotPlugin.on_load: + raise TypeError(f"插件 {plugin_id} 必须实现 on_load()") + if type(instance).on_unload is MaiBotPlugin.on_unload: + raise TypeError(f"插件 {plugin_id} 必须实现 on_unload()") + if type(instance).on_config_update is MaiBotPlugin.on_config_update: + raise TypeError(f"插件 {plugin_id} 必须实现 on_config_update()") + + instance.get_config_reload_subscriptions() + + @staticmethod + @contextlib.contextmanager + def _temporary_sys_path_entry(path: Path) -> Iterator[None]: + """临时将路径放入 sys.path 头部,并在离开作用域后恢复。""" + normalized_path = os.path.normpath(str(path)) + existing_paths = {os.path.normpath(entry) for entry in sys.path} + inserted = normalized_path not in existing_paths + if inserted: + sys.path.insert(0, normalized_path) + + try: + yield + finally: + if inserted: + with contextlib.suppress(ValueError): + sys.path.remove(normalized_path) + + # ──── 旧版插件兼容 ──────────────────────────────────────── + + def _ensure_compat_hook(self) -> None: + """安装旧版 src.plugin_system 导入钩子(幂等)""" + if self._compat_hook_installed: + return + try: + from maibot_sdk.compat._import_hook import install_hook + + install_hook() + self._compat_hook_installed = True + except ImportError: + logger.debug("maibot_sdk.compat 不可用,跳过导入钩子安装") + + @staticmethod + def _try_load_legacy_plugin(module: Any, plugin_id: str) -> Optional[Any]: + """尝试从模块中发现旧版 BasePlugin 子类并包装为 LegacyPluginAdapter""" + # 方式 1: @register_plugin 装饰器设置的标记 + legacy_cls = getattr(module, "_legacy_plugin_class", None) + + # 方式 2: 扫描模块中所有 BasePlugin 子类 + if legacy_cls is None: + try: + from maibot_sdk.compat.base.base_plugin import BasePlugin as LegacyBasePlugin + except ImportError: + return None + + for attr_name in dir(module): + obj = getattr(module, attr_name, None) + if isinstance(obj, type) and issubclass(obj, LegacyBasePlugin) and obj is not LegacyBasePlugin: + legacy_cls = obj + break + + if legacy_cls is None: + return None + + try: + from maibot_sdk.compat.legacy_adapter import LegacyPluginAdapter + + legacy_instance = legacy_cls() + return LegacyPluginAdapter(legacy_instance) + except Exception as e: + logger.error(f"旧版插件 {plugin_id} 适配失败: {e}", exc_info=True) + return None diff --git a/src/plugin_runtime/runner/rpc_client.py b/src/plugin_runtime/runner/rpc_client.py new file mode 100644 index 00000000..dc917cc8 --- /dev/null +++ b/src/plugin_runtime/runner/rpc_client.py @@ -0,0 +1,335 @@ +"""Runner 端 RPC 客户端。""" + +from typing import Any, Awaitable, Callable, Dict, Optional, Set, cast + +import asyncio +import contextlib +import uuid + +from src.common.logger import get_logger +from src.plugin_runtime.protocol.codec import Codec, MsgPackCodec +from src.plugin_runtime.protocol.envelope import ( + Envelope, + HelloPayload, + HelloResponsePayload, + MessageType, + RequestIdGenerator, +) +from src.plugin_runtime.protocol.errors import ErrorCode, RPCError +from src.plugin_runtime.transport.base import Connection +from src.plugin_runtime.transport.factory import create_transport_client + +logger = get_logger("plugin_runtime.runner.rpc_client") + +MethodHandler = Callable[[Envelope], Awaitable[Envelope]] + + +def _get_sdk_version() -> str: + """读取 SDK 版本号。 + + Returns: + str: 已安装的 SDK 版本;读取失败时回退到 ``1.0.0``。 + """ + try: + from importlib.metadata import version + + return version("maibot-plugin-sdk") + except Exception: + return "1.0.0" + + +SDK_VERSION = _get_sdk_version() + + +class RPCClient: + """Runner 端 RPC 客户端。""" + + def __init__( + self, + host_address: str, + session_token: str, + codec: Optional[Codec] = None, + ) -> None: + """初始化 RPC 客户端。 + + Args: + host_address: Host 的 IPC 地址。 + session_token: 握手用会话令牌。 + codec: 可选的编解码器实现。 + """ + self._host_address: str = host_address + self._session_token: str = session_token + self._codec: Codec = codec or MsgPackCodec() + + self._id_gen = RequestIdGenerator() + self._connection: Optional[Connection] = None + self._runner_id: str = str(uuid.uuid4()) + self._method_handlers: Dict[str, MethodHandler] = {} + self._pending_requests: Dict[int, asyncio.Future[Envelope]] = {} + self._running: bool = False + self._recv_task: Optional[asyncio.Task[None]] = None + self._background_tasks: Set[asyncio.Task[Any]] = set() + + @property + def is_connected(self) -> bool: + """返回当前连接是否可用。""" + return self._connection is not None and not self._connection.is_closed + + def register_method(self, method: str, handler: MethodHandler) -> None: + """注册 Host -> Runner 的 RPC 处理器。 + + Args: + method: RPC 方法名。 + handler: 方法处理函数。 + """ + self._method_handlers[method] = handler + + def _require_connection(self) -> Connection: + """返回当前可用连接。 + + Returns: + Connection: 当前连接对象。 + + Raises: + RPCError: 当前未连接到 Host。 + """ + connection = self._connection + if connection is None or connection.is_closed: + raise RPCError(ErrorCode.E_UNKNOWN, "未连接到 Host") + return cast(Connection, connection) + + async def connect_and_handshake(self) -> bool: + """连接 Host 并完成握手。 + + Returns: + bool: 是否握手成功。 + """ + client = create_transport_client(self._host_address) + self._connection = await client.connect() + connection = self._require_connection() + + hello = HelloPayload( + runner_id=self._runner_id, + sdk_version=SDK_VERSION, + session_token=self._session_token, + ) + request_id = await self._id_gen.next() + envelope = Envelope( + request_id=request_id, + message_type=MessageType.REQUEST, + method="runner.hello", + payload=hello.model_dump(), + ) + + await connection.send_frame(self._codec.encode_envelope(envelope)) + + resp_data = await asyncio.wait_for(connection.recv_frame(), timeout=10.0) + response = self._codec.decode_envelope(resp_data) + resp_payload = HelloResponsePayload.model_validate(response.payload) + + if not resp_payload.accepted: + logger.error(f"握手被拒绝: {resp_payload.reason}") + await self.disconnect() + return False + + logger.info(f"握手成功: host_version={resp_payload.host_version}") + self._running = True + self._recv_task = asyncio.create_task(self._recv_loop(), name="RPCClient.recv") + return True + + async def disconnect(self) -> None: + """断开与 Host 的连接并清理状态。""" + self._running = False + + if self._recv_task is not None: + self._recv_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._recv_task + self._recv_task = None + + for task in list(self._background_tasks): + task.cancel() + if self._background_tasks: + with contextlib.suppress(Exception): + await asyncio.gather(*self._background_tasks, return_exceptions=True) + self._background_tasks.clear() + + for future in self._pending_requests.values(): + if not future.done(): + future.set_exception(RPCError(ErrorCode.E_TIMEOUT, "连接关闭")) + self._pending_requests.clear() + + if self._connection is not None: + await self._connection.close() + self._connection = None + + async def send_request( + self, + method: str, + plugin_id: str = "", + payload: Optional[Dict[str, Any]] = None, + timeout_ms: int = 30000, + ) -> Envelope: + """向 Host 发送 RPC 请求并等待响应。 + + Args: + method: RPC 方法名。 + plugin_id: 目标插件 ID。 + payload: 请求载荷。 + timeout_ms: 超时时间,单位毫秒。 + + Returns: + Envelope: Host 返回的响应信封。 + + Raises: + RPCError: 发送失败、超时或连接异常。 + """ + connection = self._require_connection() + request_id = await self._id_gen.next() + envelope = Envelope( + request_id=request_id, + message_type=MessageType.REQUEST, + method=method, + plugin_id=plugin_id, + timeout_ms=timeout_ms, + payload=payload or {}, + ) + + loop = asyncio.get_running_loop() + future: asyncio.Future[Envelope] = loop.create_future() + self._pending_requests[request_id] = future + + try: + await connection.send_frame(self._codec.encode_envelope(envelope)) + return await asyncio.wait_for(future, timeout=timeout_ms / 1000.0) + except asyncio.TimeoutError: + self._pending_requests.pop(request_id, None) + raise RPCError(ErrorCode.E_TIMEOUT, f"请求 {method} 超时 ({timeout_ms}ms)") from None + except Exception as exc: + self._pending_requests.pop(request_id, None) + if isinstance(exc, RPCError): + raise + raise RPCError(ErrorCode.E_UNKNOWN, str(exc)) from exc + + async def send_event( + self, + method: str, + plugin_id: str = "", + payload: Optional[Dict[str, Any]] = None, + ) -> None: + """向 Host 发送单向广播消息。 + + Args: + method: RPC 方法名。 + plugin_id: 目标插件 ID。 + payload: 广播载荷。 + """ + if not self.is_connected: + return + + connection = self._require_connection() + request_id = await self._id_gen.next() + envelope = Envelope( + request_id=request_id, + message_type=MessageType.BROADCAST, + method=method, + plugin_id=plugin_id, + payload=payload or {}, + ) + await connection.send_frame(self._codec.encode_envelope(envelope)) + + async def _recv_loop(self) -> None: + """持续接收 Host 发来的消息并分发。""" + while self._running and self._connection is not None and not self._connection.is_closed: + try: + data = await self._connection.recv_frame() + except (asyncio.IncompleteReadError, ConnectionError): + logger.info("Host 连接已断开") + break + except asyncio.CancelledError: + break + except Exception as exc: + logger.error(f"接收帧失败: {exc}") + break + + try: + envelope = self._codec.decode_envelope(data) + except Exception as exc: + logger.error(f"解码消息失败: {exc}") + continue + + if envelope.is_response(): + self._handle_response(envelope) + elif envelope.is_request(): + self._track_background_task(asyncio.create_task(self._handle_request(envelope))) + elif envelope.is_broadcast(): + self._track_background_task(asyncio.create_task(self._handle_broadcast(envelope))) + + def _handle_response(self, envelope: Envelope) -> None: + """处理 Host 返回的响应。 + + Args: + envelope: 响应信封。 + """ + future = self._pending_requests.pop(envelope.request_id, None) + if future is None or future.done(): + return + if envelope.error: + future.set_exception(RPCError.from_dict(envelope.error)) + else: + future.set_result(envelope) + + async def _handle_request(self, envelope: Envelope) -> None: + """处理 Host 发来的请求。 + + Args: + envelope: 请求信封。 + """ + connection = self._connection + if connection is None or connection.is_closed: + logger.warning(f"处理请求 {envelope.method} 时连接已关闭,跳过响应") + return + + handler = self._method_handlers.get(envelope.method) + if handler is None: + error_resp = envelope.make_error_response( + ErrorCode.E_METHOD_NOT_ALLOWED.value, + f"未注册的方法: {envelope.method}", + ) + await connection.send_frame(self._codec.encode_envelope(error_resp)) + return + + try: + response = await handler(envelope) + await connection.send_frame(self._codec.encode_envelope(response)) + except RPCError as exc: + error_resp = envelope.make_error_response(exc.code.value, exc.message, exc.details) + await connection.send_frame(self._codec.encode_envelope(error_resp)) + except Exception as exc: + logger.error(f"处理请求 {envelope.method} 异常: {exc}", exc_info=True) + error_resp = envelope.make_error_response(ErrorCode.E_UNKNOWN.value, str(exc)) + await connection.send_frame(self._codec.encode_envelope(error_resp)) + + async def _handle_broadcast(self, envelope: Envelope) -> None: + """处理 Host 发来的广播事件。 + + Args: + envelope: 广播信封。 + """ + handler = self._method_handlers.get(envelope.method) + if handler is None: + return + + try: + await handler(envelope) + except Exception as exc: + logger.error(f"处理广播 {envelope.method} 异常: {exc}", exc_info=True) + + def _track_background_task(self, task: asyncio.Task[Any]) -> None: + """持有后台任务强引用直到其结束。 + + Args: + task: 后台任务。 + """ + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) diff --git a/src/plugin_runtime/runner/runner_main.py b/src/plugin_runtime/runner/runner_main.py new file mode 100644 index 00000000..bc6f92be --- /dev/null +++ b/src/plugin_runtime/runner/runner_main.py @@ -0,0 +1,2092 @@ +"""Runner 主循环 + +作为独立子进程运行,负责: +1. 从环境变量读取 IPC 地址和会话令牌 +2. 连接 Host 并完成握手 +3. 加载所有插件 +4. 注册组件到 Host +5. 处理 Host 的调用请求 +6. 转发插件的能力调用到 Host +""" + +from collections.abc import Mapping +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, cast + +import asyncio +import contextlib +import inspect +import json +import logging as stdlib_logging +import os +import signal +import sys +import time +import tomllib + +import tomlkit + +from src.common.logger import get_console_handler, get_logger, initialize_logging +from src.config.config_utils import compare_versions +from src.plugin_runtime import ( + ENV_BLOCKED_PLUGIN_REASONS, + ENV_EXTERNAL_PLUGIN_IDS, + ENV_HOST_VERSION, + ENV_IPC_ADDRESS, + ENV_PLUGIN_DIRS, + ENV_SESSION_TOKEN, +) +from src.plugin_runtime.protocol.envelope import ( + BootstrapPluginPayload, + ComponentDeclaration, + ConfigUpdatedPayload, + Envelope, + HealthPayload, + InspectPluginConfigPayload, + InspectPluginConfigResultPayload, + InvokePayload, + InvokeResultPayload, + LLMProviderDeclaration, + LLMProviderInvokePayload, + RegisterPluginPayload, + ReloadPluginPayload, + ReloadPluginResultPayload, + ReloadPluginsPayload, + ReloadPluginsResultPayload, + RunnerReadyPayload, + UnregisterPluginPayload, + ValidatePluginConfigPayload, + ValidatePluginConfigResultPayload, +) +from src.plugin_runtime.protocol.errors import ErrorCode +from src.plugin_runtime.runner.log_handler import RunnerIPCLogHandler +from src.plugin_runtime.runner.plugin_loader import PluginCandidate, PluginLoader, PluginMeta +from src.plugin_runtime.runner.rpc_client import RPCClient + +logger = get_logger("plugin_runtime.runner.main") + +_PLUGIN_ALLOWED_RAW_HOST_METHODS = frozenset( + { + "cap.call", + "host.route_message", + "host.update_message_gateway_state", + } +) + + +class _ContextAwarePlugin(Protocol): + """支持注入运行时上下文的插件协议。 + + 该协议用于描述 Runner 在激活插件时依赖的最小接口。 + 只要插件实例实现了 ``_set_context`` 方法,就可以被 Runner + 注入 ``PluginContext`` 或兼容层上下文对象。 + """ + + def _set_context(self, context: Any) -> None: + """为插件实例注入运行时上下文。 + + Args: + context: 由 Runner 构造的上下文对象。 + """ + + +class _ConfigAwarePlugin(Protocol): + """支持声明式插件配置能力的插件协议。""" + + def normalize_plugin_config(self, config_data: Optional[Mapping[str, Any]]) -> Tuple[Dict[str, Any], bool]: + """对插件配置进行归一化与补齐。 + + Args: + config_data: 原始配置数据。 + + Returns: + Tuple[Dict[str, Any], bool]: 归一化后的配置,以及是否发生自动变更。 + """ + + ... + + def set_plugin_config(self, config: Dict[str, Any]) -> None: + """注入插件当前配置。 + + Args: + config: 当前最新插件配置。 + """ + + ... + + def get_default_config(self) -> Dict[str, Any]: + """返回插件默认配置。 + + Returns: + Dict[str, Any]: 默认配置字典。 + """ + + ... + + def get_webui_config_schema( + self, + *, + plugin_id: str = "", + plugin_name: str = "", + plugin_version: str = "", + plugin_description: str = "", + plugin_author: str = "", + ) -> Dict[str, Any]: + """返回插件配置 Schema。 + + Args: + plugin_id: 插件 ID。 + plugin_name: 插件名称。 + plugin_version: 插件版本。 + plugin_description: 插件描述。 + plugin_author: 插件作者。 + + Returns: + Dict[str, Any]: WebUI 配置 Schema。 + """ + + ... + + +class PluginActivationStatus(str, Enum): + """描述插件激活结果。""" + + LOADED = "loaded" + INACTIVE = "inactive" + FAILED = "failed" + + +@dataclass(frozen=True) +class PluginConfigNormalizationResult: + """描述插件配置归一化结果。""" + + normalized_config: Dict[str, Any] + changed: bool + should_persist: bool + + +class PluginConfigVersionError(ValueError): + """插件配置版本不合法时抛出的异常。""" + + +def _deep_copy_plugin_config_value(value: Any) -> Any: + """递归复制插件配置值。 + + Args: + value: 待复制的任意配置值。 + + Returns: + Any: 深复制后的配置值。 + """ + + if isinstance(value, Mapping): + return _deep_copy_plugin_config_mapping(value) + if isinstance(value, list): + return [_deep_copy_plugin_config_value(item) for item in value] + return value + + +def _deep_copy_plugin_config_mapping(value: Mapping[str, Any]) -> Dict[str, Any]: + """递归复制插件配置字典。 + + Args: + value: 待复制的插件配置映射。 + + Returns: + Dict[str, Any]: 深复制后的插件配置字典。 + """ + + return {str(key): _deep_copy_plugin_config_value(item) for key, item in value.items()} + + +def _overlay_plugin_config_fields(target: Dict[str, Any], source: Mapping[str, Any]) -> None: + """将旧配置中的已有字段覆盖到新配置骨架中。 + + Args: + target: 以最新默认配置构造出的目标配置字典。 + source: 旧版本配置字典。 + """ + + for key, source_value in source.items(): + if key not in target: + continue + if key == "config_version": + continue + + target_value = target[key] + if isinstance(target_value, dict) and isinstance(source_value, Mapping): + _overlay_plugin_config_fields(target_value, source_value) + continue + + target[key] = _deep_copy_plugin_config_value(source_value) + + +def extract_plugin_config_version(config_data: Mapping[str, Any]) -> str: + """提取插件配置中的版本号。 + + Args: + config_data: 插件配置字典。 + + Returns: + str: ``plugin.config_version`` 的规范化字符串值。 + + Raises: + PluginConfigVersionError: 当缺少 ``[plugin]`` 配置节或 ``config_version`` + 字段为空时抛出。 + """ + + plugin_section = config_data.get("plugin") + if not isinstance(plugin_section, Mapping): + raise PluginConfigVersionError( + "插件配置文件缺少 [plugin] 配置节,且必须提供 plugin.config_version 版本号" + ) + + version_value = plugin_section.get("config_version") + normalized_version = str(version_value or "").strip() + if not normalized_version: + raise PluginConfigVersionError( + "插件配置文件缺少 plugin.config_version 版本号,当前版本策略不再兼容无版本配置" + ) + return normalized_version + + +def rebuild_plugin_config_data( + default_config: Mapping[str, Any], + current_config: Mapping[str, Any], +) -> Dict[str, Any]: + """基于默认结构重建插件配置。 + + 该方法用于版本升级场景:以最新默认配置为骨架,仅迁移仍然存在的旧字段值, + 从而达到“补齐新增字段、移除废弃字段、保留用户已有值”的效果。 + + Args: + default_config: 最新默认配置内容。 + current_config: 旧版本配置内容。 + + Returns: + Dict[str, Any]: 按最新结构重建后的配置字典。 + """ + + rebuilt_config = _deep_copy_plugin_config_mapping(default_config) + _overlay_plugin_config_fields(rebuilt_config, current_config) + return rebuilt_config + + +def _install_shutdown_signal_handlers( + mark_runner_shutting_down: Callable[[], None], + loop: Optional[asyncio.AbstractEventLoop] = None, +) -> None: + """为 Runner 注册关停信号处理器。 + + Windows 默认事件循环不支持 add_signal_handler,且当前 Runner 在 Windows + 下由 Host 直接 terminate/kill,不依赖进程内信号回调进行优雅收尾。 + """ + if sys.platform == "win32": + return + + target_loop = loop or asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + try: + target_loop.add_signal_handler(sig, mark_runner_shutting_down) + except Exception as exc: + if not isinstance(exc, (NotImplementedError, RuntimeError)): + raise + logger.debug(f"当前事件循环不支持注册 Runner 信号处理器: {exc}") + return + + +def _disable_runner_console_logging() -> None: + """关闭 Runner 的控制台日志输出,避免被 Host 从 stderr 二次包装。""" + root_logger = stdlib_logging.getLogger() + console_handler = get_console_handler() + if console_handler in root_logger.handlers: + root_logger.removeHandler(console_handler) + + +class PluginRunner: + """插件 Runner + + 运行在独立子进程中,管理所有插件的执行。 + """ + + def __init__( + self, + host_address: str, + session_token: str, + plugin_dirs: List[str], + external_available_plugins: Optional[Dict[str, str]] = None, + blocked_plugin_reasons: Optional[Dict[str, str]] = None, + ) -> None: + """初始化 Runner。 + + Args: + host_address: Host 的 IPC 地址。 + session_token: 握手用会话令牌。 + plugin_dirs: 当前 Runner 负责扫描的插件目录列表。 + external_available_plugins: 视为已满足的外部依赖插件版本映射。 + blocked_plugin_reasons: 需要拒绝加载的插件及原因映射。 + """ + self._host_address: str = host_address + self._session_token: str = session_token + self._plugin_dirs: List[str] = plugin_dirs + self._external_available_plugins: Dict[str, str] = { + str(plugin_id or "").strip(): str(plugin_version or "").strip() + for plugin_id, plugin_version in (external_available_plugins or {}).items() + if str(plugin_id or "").strip() and str(plugin_version or "").strip() + } + self._blocked_plugin_reasons: Dict[str, str] = { + str(plugin_id or "").strip(): str(reason or "").strip() + for plugin_id, reason in (blocked_plugin_reasons or {}).items() + if str(plugin_id or "").strip() and str(reason or "").strip() + } + + self._rpc_client: RPCClient = RPCClient(host_address, session_token) + self._loader: PluginLoader = PluginLoader(host_version=os.getenv(ENV_HOST_VERSION, "")) + self._loader.set_blocked_plugin_reasons(self._blocked_plugin_reasons) + self._start_time: float = time.monotonic() + self._shutting_down: bool = False + self._reload_lock: asyncio.Lock = asyncio.Lock() + + # IPC 日志 Handler:握手成功后安装,将所有 stdlib logging 转发到 Host + self._log_handler: Optional[RunnerIPCLogHandler] = None + self._suspended_console_handlers: List[stdlib_logging.Handler] = [] + + async def run(self) -> None: + """运行 Runner 主循环。""" + # 1. 连接 Host + logger.info(f"Runner 启动,连接 Host: {self._host_address}") + ok = await self._rpc_client.connect_and_handshake() + if not ok: + logger.error("握手失败,退出") + return + + # 2. 握手成功后立即安装 IPC 日志 Handler,接管所有 Runner 端日志 + self._install_log_handler() + + # 3. 注册方法处理器 + self._register_handlers() + + # 3. 加载插件 + plugins = self._loader.discover_and_load( + self._plugin_dirs, + extra_available=self._external_available_plugins, + ) + logger.info(f"已加载 {len(plugins)} 个插件") + + # 4. 注入 PluginContext + 调用 on_load 生命周期钩子 + failed_plugins: Set[str] = set(self._loader.failed_plugins.keys()) + inactive_plugins: Set[str] = set() + available_plugin_versions: Dict[str, str] = dict(self._external_available_plugins) + for meta in plugins: + unsatisfied_dependencies = [ + dependency.id + for dependency in meta.manifest.plugin_dependencies + if dependency.id not in available_plugin_versions + or not self._loader.manifest_validator.is_plugin_dependency_satisfied( + dependency, + available_plugin_versions[dependency.id], + ) + ] + if unsatisfied_dependencies: + if any(dependency_id in inactive_plugins for dependency_id in unsatisfied_dependencies): + logger.info( + f"插件 {meta.plugin_id} 依赖的插件当前未激活,跳过本次启动: {', '.join(unsatisfied_dependencies)}" + ) + inactive_plugins.add(meta.plugin_id) + continue + failed_plugins.add(meta.plugin_id) + continue + + activation_status = await self._activate_plugin(meta) + if activation_status == PluginActivationStatus.LOADED: + available_plugin_versions[meta.plugin_id] = meta.version + continue + if activation_status == PluginActivationStatus.INACTIVE: + inactive_plugins.add(meta.plugin_id) + continue + failed_plugins.add(meta.plugin_id) + + successful_plugins = [ + meta.plugin_id + for meta in plugins + if meta.plugin_id not in failed_plugins and meta.plugin_id not in inactive_plugins + ] + await self._notify_ready(successful_plugins, sorted(failed_plugins), sorted(inactive_plugins)) + + # 5. 等待直到收到关停信号 + with contextlib.suppress(asyncio.CancelledError): + while not self._shutting_down: + await asyncio.sleep(1.0) + + # 6. 卸载 IPC 日志 Handler 并刷空剩余缓冲,然后断开连接 + logger.info("Runner 开始关停") + await self._uninstall_log_handler() + await self._rpc_client.disconnect() + logger.info("Runner 已退出") + + def _install_log_handler(self) -> None: + """握手完成后将 RunnerIPCLogHandler 安装到 logging.root。 + + 安装后,Runner 进程内所有 stdlib logging 调用(含 structlog 透传的) + 均会通过 IPC 转发到 Host,由 Host 的 RunnerLogBridge 重放到主进程 Logger。 + """ + loop = asyncio.get_running_loop() + handler = RunnerIPCLogHandler() + handler.start(self._rpc_client, loop) + self._suspend_console_handlers() + stdlib_logging.root.addHandler(handler) + self._log_handler = handler + logger.debug( + "RunnerIPCLogHandler \u5df2\u5b89\u88c3\uff0c\u63d2\u4ef6\u65e5\u5fd7\u5c06\u901a\u8fc7 IPC \u8f6c\u53d1\u5230\u4e3b\u8fdb\u7a0b" + ) + + async def _uninstall_log_handler(self) -> None: + """关停前从 logging.root 移除 Handler 并刷空缓冲。 + + 必须在 disconnect() 之前调用,确保最后一批日志能正常发送。 + """ + if self._log_handler is None: + return + stdlib_logging.root.removeHandler(self._log_handler) + await self._log_handler.stop() + self._log_handler = None + self._restore_console_handlers() + logger.debug("RunnerIPCLogHandler \u5df2\u5378\u8f7d") + + def _suspend_console_handlers(self) -> None: + """暂停 Runner 的控制台输出,避免与 IPC 转发重复。""" + if self._suspended_console_handlers: + return + + for handler in list(stdlib_logging.root.handlers): + if isinstance(handler, stdlib_logging.StreamHandler): + stdlib_logging.root.removeHandler(handler) + self._suspended_console_handlers.append(handler) + + def _restore_console_handlers(self) -> None: + """恢复此前暂停的控制台输出。""" + if not self._suspended_console_handlers: + return + + for handler in self._suspended_console_handlers: + if handler not in stdlib_logging.root.handlers: + stdlib_logging.root.addHandler(handler) + self._suspended_console_handlers.clear() + + def _inject_context(self, plugin_id: str, instance: object) -> None: + """为插件实例创建并注入 PluginContext。 + + 对新版 MaiBotPlugin(具有 _set_context 方法):创建 PluginContext 并注入。 + 对旧版 LegacyPluginAdapter(具有 _set_context 方法,由兼容代理封装):同上。 + """ + if not hasattr(instance, "_set_context"): + return + + try: + from maibot_sdk.context import PluginContext + except ImportError: + logger.warning(f"maibot_sdk 不可用,无法为插件 {plugin_id} 创建 PluginContext") + return + + rpc_client = self._rpc_client + bound_plugin_id = plugin_id + + async def _rpc_call( + method: str, + plugin_id: str = "", + payload: Optional[Dict[str, Any]] = None, + ) -> Any: + """桥接 PluginContext 的原始 RPC 调用到 Host。 + + 无论调用方传入何种 plugin_id,实际发往 Host 的 plugin_id + 始终绑定为当前插件实例,避免伪造其他插件身份申请能力。 + """ + if plugin_id and plugin_id != bound_plugin_id: + logger.warning(f"插件 {bound_plugin_id} 尝试以 {plugin_id} 身份发起 RPC,已强制绑定回自身身份") + normalized_method = str(method or "").strip() + if normalized_method not in _PLUGIN_ALLOWED_RAW_HOST_METHODS: + raise PermissionError( + f"插件 {bound_plugin_id} 不允许直接调用 Host 原始 RPC 方法: {normalized_method or ''}" + ) + resp = await rpc_client.send_request( + method=normalized_method, + plugin_id=bound_plugin_id, + payload=payload or {}, + ) + if resp.error: + raise RuntimeError(resp.error.get("message", "能力调用失败")) + if normalized_method == "cap.call" and isinstance(resp.payload, dict) and "result" in resp.payload: + return resp.payload.get("result") + return resp.payload + + ctx = PluginContext(plugin_id=plugin_id, rpc_call=_rpc_call) + cast(_ContextAwarePlugin, instance)._set_context(ctx) + logger.debug(f"已为插件 {plugin_id} 注入 PluginContext") + + def _apply_plugin_config(self, meta: PluginMeta, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """在 Runner 侧为插件实例注入当前插件配置。 + + Args: + meta: 插件元数据。 + config_data: 可选的配置数据;留空时自动从插件目录读取。 + + Returns: + Dict[str, Any]: 归一化后的当前插件配置。 + """ + instance = meta.instance + default_config = self._get_plugin_default_config(instance) + raw_config = config_data if config_data is not None else self._load_plugin_config(meta.plugin_dir, meta.plugin_id) + normalization_result = self._normalize_plugin_config( + instance, + raw_config, + default_config=default_config, + suppress_errors=False, + enforce_version=True, + ) + plugin_config = normalization_result.normalized_config + config_path = Path(meta.plugin_dir) / "config.toml" + should_initialize_file = not config_path.exists() and bool(default_config) + if normalization_result.should_persist or should_initialize_file: + self._save_plugin_config(meta.plugin_dir, plugin_config) + if hasattr(instance, "set_plugin_config"): + try: + cast(_ConfigAwarePlugin, instance).set_plugin_config(plugin_config) + except Exception as exc: + logger.warning(f"插件 {meta.plugin_id} 配置注入失败: {exc}") + return plugin_config + + def _normalize_plugin_config( + self, + instance: object, + config_data: Optional[Dict[str, Any]], + *, + default_config: Optional[Dict[str, Any]] = None, + suppress_errors: bool = True, + enforce_version: bool = True, + ) -> PluginConfigNormalizationResult: + """对插件配置做统一归一化处理。 + + Args: + instance: 插件实例。 + config_data: 原始配置数据。 + default_config: 插件声明的默认配置。 + suppress_errors: 是否在归一化失败时吞掉异常并回退原始配置。 + enforce_version: 是否强制执行 ``plugin.config_version`` 版本检查。 + + Returns: + PluginConfigNormalizationResult: 归一化结果、是否发生变更以及是否应写回文件。 + """ + + raw_config = dict(config_data or {}) + latest_default_config = default_config if default_config is not None else self._get_plugin_default_config(instance) + config_for_normalize = rebuild_plugin_config_data(raw_config, {}) + should_persist = False + + try: + if latest_default_config: + if enforce_version: + config_for_normalize, should_persist = self._prepare_plugin_config_for_version_update( + raw_config=raw_config, + default_config=latest_default_config, + ) + elif not raw_config: + config_for_normalize = rebuild_plugin_config_data(latest_default_config, {}) + except Exception as exc: + if not suppress_errors: + raise + logger.warning(f"插件配置版本检查失败,将回退为原始配置: {exc}") + return PluginConfigNormalizationResult( + normalized_config=raw_config, + changed=False, + should_persist=False, + ) + + if not hasattr(instance, "normalize_plugin_config"): + return PluginConfigNormalizationResult( + normalized_config=config_for_normalize, + changed=config_for_normalize != raw_config, + should_persist=should_persist, + ) + + try: + normalized_config, normalized_changed = cast(_ConfigAwarePlugin, instance).normalize_plugin_config( + config_for_normalize + ) + except Exception as exc: + if not suppress_errors: + raise + logger.warning(f"插件配置归一化失败,将回退为原始配置: {exc}") + return PluginConfigNormalizationResult( + normalized_config=raw_config, + changed=False, + should_persist=False, + ) + + return PluginConfigNormalizationResult( + normalized_config=normalized_config, + changed=normalized_changed or normalized_config != raw_config, + should_persist=should_persist, + ) + + @staticmethod + def _prepare_plugin_config_for_version_update( + raw_config: Mapping[str, Any], + default_config: Mapping[str, Any], + ) -> Tuple[Dict[str, Any], bool]: + """基于配置版本决定是否需要重建插件配置。 + + Args: + raw_config: 当前磁盘上的插件配置。 + default_config: 插件最新默认配置。 + + Returns: + Tuple[Dict[str, Any], bool]: 用于后续归一化的配置副本,以及是否需要写回文件。 + + Raises: + PluginConfigVersionError: 当默认配置或当前配置缺少版本号时抛出。 + """ + + if not default_config: + return rebuild_plugin_config_data(raw_config, {}), False + + latest_version = extract_plugin_config_version(default_config) + if not raw_config: + return rebuild_plugin_config_data(default_config, {}), False + + current_version = extract_plugin_config_version(raw_config) + if compare_versions(current_version, latest_version): + logger.info(f"检测到插件配置版本升级: {current_version} -> {latest_version}") + return rebuild_plugin_config_data(default_config, raw_config), True + + return rebuild_plugin_config_data(raw_config, {}), False + + @staticmethod + def _merge_plugin_config_document(target: Any, source: Any) -> None: + """递归更新现有 TOML 文档,尽量保留原注释与格式。 + + 这里采用“更新已有键、补充缺失键”的策略,而不是直接整体重写, + 这样插件启动时因补齐默认配置触发落盘时,可以尽量保留用户手写的注释。 + + Args: + target: 现有的 TOML 文档或表对象。 + source: 最新的配置字典。 + """ + + if isinstance(source, list) or not isinstance(source, dict) or not isinstance(target, dict): + return + + for key, value in source.items(): + if key in target: + target_value = target[key] + if isinstance(value, dict) and isinstance(target_value, dict): + PluginRunner._merge_plugin_config_document(target_value, value) + else: + try: + target[key] = tomlkit.item(value) + except (TypeError, ValueError): + target[key] = value + else: + try: + target[key] = tomlkit.item(value) + except (TypeError, ValueError): + target[key] = value + + @staticmethod + def _has_extra_config_keys(existing_config: Any, latest_config: Any) -> bool: + """判断现有配置中是否包含新配置不存在的键。 + + 如果插件归一化后的结果删除了某些旧键,就需要回退到完整重写, + 否则仅做增量合并会把旧键残留在文件里。 + + Args: + existing_config: 现有配置字典。 + latest_config: 最新配置字典。 + + Returns: + bool: 是否存在需要通过整文件重写才能删除的旧键。 + """ + + if not isinstance(existing_config, dict) or not isinstance(latest_config, dict): + return False + + for key, existing_value in existing_config.items(): + if key not in latest_config: + return True + if PluginRunner._has_extra_config_keys(existing_value, latest_config[key]): + return True + return False + + @staticmethod + def _is_plugin_enabled(config_data: Optional[Mapping[str, Any]]) -> bool: + """根据配置内容判断插件是否应被视为启用。 + + Args: + config_data: 当前插件配置。 + + Returns: + bool: 插件是否启用。 + """ + + if not isinstance(config_data, Mapping): + return True + + plugin_section = config_data.get("plugin") + if not isinstance(plugin_section, Mapping): + return True + + enabled_value = plugin_section.get("enabled", True) + if isinstance(enabled_value, str): + normalized_value = enabled_value.strip().lower() + if normalized_value in {"0", "false", "no", "off"}: + return False + if normalized_value in {"1", "true", "yes", "on"}: + return True + return bool(enabled_value) + + @staticmethod + def _save_plugin_config(plugin_dir: str, config_data: Dict[str, Any]) -> None: + """将插件配置写回到 ``config.toml``。 + + Args: + plugin_dir: 插件目录。 + config_data: 需要写回的配置字典。 + """ + + config_path = Path(plugin_dir) / "config.toml" + config_path.parent.mkdir(parents=True, exist_ok=True) + if config_path.exists(): + try: + with config_path.open("r", encoding="utf-8") as handle: + existing_document = tomlkit.load(handle) + existing_config = existing_document.unwrap() + if not PluginRunner._has_extra_config_keys(existing_config, config_data): + PluginRunner._merge_plugin_config_document(existing_document, config_data) + with config_path.open("w", encoding="utf-8") as handle: + handle.write(tomlkit.dumps(existing_document)) + return + except Exception as exc: + logger.warning(f"保留插件配置注释失败,将回退为整文件重写: {config_path}: {exc}") + + with config_path.open("w", encoding="utf-8") as handle: + handle.write(tomlkit.dumps(config_data)) + + @staticmethod + def _load_plugin_config(plugin_dir: str, plugin_id: str = "") -> Dict[str, Any]: + """从插件目录读取 config.toml。""" + _ = plugin_id + config_path = Path(plugin_dir) / "config.toml" + if not config_path.exists(): + return {} + + try: + with config_path.open("rb") as handle: + loaded = tomllib.load(handle) + except Exception as exc: + logger.warning(f"读取插件配置失败 {config_path}: {exc}") + return {} + + return loaded if isinstance(loaded, dict) else {} + + def _resolve_plugin_candidate(self, plugin_id: str) -> Tuple[Optional[PluginCandidate], Optional[str]]: + """解析指定插件的候选目录。 + + Args: + plugin_id: 目标插件 ID。 + + Returns: + Tuple[Optional[PluginCandidate], Optional[str]]: 候选插件与错误信息。 + """ + + candidates, duplicate_candidates = self._loader.discover_candidates(self._plugin_dirs) + if plugin_id in duplicate_candidates: + conflict_paths = ", ".join(str(path) for path in duplicate_candidates[plugin_id]) + return None, f"检测到重复插件 ID: {conflict_paths}" + + candidate = candidates.get(plugin_id) + if candidate is None: + return None, f"未找到插件: {plugin_id}" + return candidate, None + + def _resolve_plugin_meta_for_config_request( + self, + plugin_id: str, + ) -> Tuple[Optional[PluginMeta], bool, Optional[str]]: + """为配置相关请求解析插件元数据。 + + Args: + plugin_id: 目标插件 ID。 + + Returns: + Tuple[Optional[PluginMeta], bool, Optional[str]]: 依次为插件元数据、 + 是否为临时冷加载实例、以及错误信息。 + """ + + loaded_meta = self._loader.get_plugin(plugin_id) + if loaded_meta is not None: + return loaded_meta, False, None + + candidate, error_message = self._resolve_plugin_candidate(plugin_id) + if candidate is None: + return None, False, error_message + + try: + meta = self._loader.load_candidate(plugin_id, candidate) + except Exception as exc: + return None, False, str(exc) + if meta is None: + return None, False, "插件模块加载失败" + return meta, True, None + + def _inspect_plugin_config( + self, + meta: PluginMeta, + *, + config_data: Optional[Dict[str, Any]] = None, + use_provided_config: bool = False, + suppress_errors: bool = True, + enforce_version: bool = True, + ) -> InspectPluginConfigResultPayload: + """解析插件代码定义的配置元数据。 + + Args: + meta: 插件元数据。 + config_data: 可选的配置内容。 + use_provided_config: 是否优先使用传入的配置内容。 + suppress_errors: 是否在归一化失败时回退原始配置。 + enforce_version: 是否强制校验 ``plugin.config_version``。 + + Returns: + InspectPluginConfigResultPayload: 结构化解析结果。 + """ + + raw_config = config_data if use_provided_config else self._load_plugin_config(meta.plugin_dir) + if use_provided_config and config_data is None: + raw_config = {} + + default_config = self._get_plugin_default_config(meta.instance) + normalization_result = self._normalize_plugin_config( + meta.instance, + raw_config, + default_config=default_config, + suppress_errors=suppress_errors, + enforce_version=enforce_version, + ) + normalized_config = normalization_result.normalized_config + changed = normalization_result.changed + if not normalized_config and not raw_config and default_config: + normalized_config = rebuild_plugin_config_data(default_config, {}) + changed = True + + return InspectPluginConfigResultPayload( + success=True, + default_config=default_config, + config_schema=self._get_plugin_config_schema(meta), + normalized_config=normalized_config, + changed=changed, + enabled=self._is_plugin_enabled(normalized_config), + ) + + def _register_handlers(self) -> None: + """注册 Host -> Runner 的方法处理器。""" + self._rpc_client.register_method("plugin.invoke_command", self._handle_invoke) + self._rpc_client.register_method("plugin.invoke_action", self._handle_invoke) + self._rpc_client.register_method("plugin.invoke_api", self._handle_invoke) + self._rpc_client.register_method("plugin.invoke_tool", self._handle_invoke) + self._rpc_client.register_method("plugin.invoke_message_gateway", self._handle_invoke) + self._rpc_client.register_method("plugin.invoke_llm_provider", self._handle_llm_provider_invoke) + self._rpc_client.register_method("plugin.emit_event", self._handle_event_invoke) + self._rpc_client.register_method("plugin.invoke_hook", self._handle_hook_invoke) + self._rpc_client.register_method("plugin.health", self._handle_health) + self._rpc_client.register_method("plugin.prepare_shutdown", self._handle_prepare_shutdown) + self._rpc_client.register_method("plugin.shutdown", self._handle_shutdown) + self._rpc_client.register_method("plugin.config_updated", self._handle_config_updated) + self._rpc_client.register_method("plugin.inspect_config", self._handle_inspect_plugin_config) + self._rpc_client.register_method("plugin.validate_config", self._handle_validate_plugin_config) + self._rpc_client.register_method("plugin.reload", self._handle_reload_plugin) + self._rpc_client.register_method("plugin.reload_batch", self._handle_reload_plugins) + + @staticmethod + def _resolve_component_handler_name(meta: PluginMeta, component_name: str) -> str: + """解析组件名对应的真实处理函数名。 + + Args: + meta: 已加载插件的元数据。 + component_name: Host 侧请求中的组件声明名。 + + Returns: + str: 实际应在插件实例上查找的方法名。 + """ + return str(meta.component_handlers.get(component_name, component_name) or component_name) + + def _resolve_component_handler(self, meta: PluginMeta, component_name: str) -> Any: + """根据组件声明名解析插件实例上的可调用处理函数。 + + Args: + meta: 已加载插件的元数据。 + component_name: Host 侧请求中的组件声明名。 + + Returns: + Any: 解析到的可调用对象;未找到时返回 ``None``。 + """ + instance = meta.instance + handler_name = self._resolve_component_handler_name(meta, component_name) + handler_method = getattr(instance, handler_name, None) + if handler_method is not None: + return handler_method + + if handler_name != component_name: + legacy_style_handler = getattr(instance, f"handle_{component_name}", None) + if legacy_style_handler is not None: + return legacy_style_handler + + prefixed_handler = getattr(instance, f"handle_{component_name}", None) + if prefixed_handler is not None: + return prefixed_handler + return getattr(instance, component_name, None) + + async def _bootstrap_plugin(self, meta: PluginMeta, capabilities_required: Optional[List[str]] = None) -> bool: + """向 Host 同步插件 bootstrap 能力令牌。""" + payload = BootstrapPluginPayload( + plugin_id=meta.plugin_id, + plugin_version=meta.version, + capabilities_required=capabilities_required + if capabilities_required is not None + else list(meta.capabilities_required or []), + ) + + try: + response = await self._rpc_client.send_request( + "plugin.bootstrap", + plugin_id=meta.plugin_id, + payload=payload.model_dump(), + timeout_ms=10000, + ) + if response.error: + raise RuntimeError(response.error.get("message", "插件 bootstrap 失败")) + return True + except Exception as e: + logger.error(f"插件 {meta.plugin_id} bootstrap 失败: {e}") + return False + + async def _deactivate_plugin(self, meta: PluginMeta) -> None: + """撤销 bootstrap 期间为插件签发的能力令牌。""" + await self._bootstrap_plugin(meta, capabilities_required=[]) + + async def _register_plugin(self, meta: PluginMeta) -> bool: + """向 Host 注册单个插件。 + + Args: + meta: 待注册的插件元数据。 + + Returns: + bool: 是否注册成功。 + """ + # 收集插件组件声明 + components: List[ComponentDeclaration] = [] + llm_providers: List[LLMProviderDeclaration] = [] + config_reload_subscriptions: List[str] = [] + instance = meta.instance + + # 从插件实例获取组件声明(SDK 插件须实现 get_components 方法) + if hasattr(instance, "get_components"): + meta.component_handlers.clear() + for comp_info in instance.get_components(): + if not isinstance(comp_info, dict): + continue + + component_name = str(comp_info.get("name", "") or "").strip() + raw_metadata = comp_info.get("metadata", {}) + component_metadata = raw_metadata if isinstance(raw_metadata, dict) else {} + + if component_name: + handler_name = str(component_metadata.get("handler_name", component_name) or component_name).strip() + meta.component_handlers[component_name] = handler_name or component_name + + components.append( + ComponentDeclaration( + name=component_name, + component_type=str(comp_info.get("type", "") or "").strip(), + plugin_id=meta.plugin_id, + chat_scope=str(comp_info.get("chat_scope", "all") or "all").strip(), + allowed_session=[ + str(item).strip() + for item in comp_info.get("allowed_session", []) + if str(item).strip() + ] + if isinstance(comp_info.get("allowed_session"), list) + else [], + metadata=component_metadata, + ) + ) + if hasattr(instance, "get_config_reload_subscriptions"): + config_reload_subscriptions = list(instance.get_config_reload_subscriptions()) + if hasattr(instance, "get_llm_providers"): + meta.llm_provider_handlers.clear() + for provider_info in instance.get_llm_providers(): + if not isinstance(provider_info, dict): + continue + + client_type = str(provider_info.get("client_type", "") or "").strip() + raw_metadata = provider_info.get("metadata", {}) + provider_metadata = raw_metadata if isinstance(raw_metadata, dict) else {} + if client_type: + handler_name = str(provider_metadata.get("handler_name", client_type) or client_type).strip() + meta.llm_provider_handlers[client_type] = handler_name or client_type + + llm_providers.append( + LLMProviderDeclaration( + client_type=client_type, + name=str(provider_info.get("name", "") or "").strip(), + description=str(provider_info.get("description", "") or "").strip(), + version=str(provider_info.get("version", "1.0.0") or "1.0.0").strip() or "1.0.0", + metadata=provider_metadata, + ) + ) + + declared_client_types = sorted(meta.manifest.llm_provider_client_types) + registered_client_types = sorted(provider.client_type for provider in llm_providers) + if declared_client_types != registered_client_types: + logger.error( + f"插件 {meta.plugin_id} LLM Provider 声明不一致: " + f"manifest={declared_client_types}, code={registered_client_types}" + ) + return False + + reg_payload = RegisterPluginPayload( + plugin_id=meta.plugin_id, + plugin_version=meta.version, + components=components, + llm_providers=llm_providers, + capabilities_required=meta.capabilities_required, + dependencies=meta.dependencies, + config_reload_subscriptions=config_reload_subscriptions, + default_config=self._get_plugin_default_config(instance), + config_schema=self._get_plugin_config_schema(meta), + ) + + try: + response = await self._rpc_client.send_request( + "plugin.register_components", + plugin_id=meta.plugin_id, + payload=reg_payload.model_dump(), + timeout_ms=10000, + ) + if response.error: + raise RuntimeError(response.error.get("message", "插件注册失败")) + response_payload = response.payload if isinstance(response.payload, dict) else {} + if not bool(response_payload.get("accepted", True)): + raise RuntimeError(str(response_payload.get("reason", "插件注册失败"))) + logger.info(f"插件 {meta.plugin_id} 注册完成") + return True + except Exception as e: + logger.error(f"插件 {meta.plugin_id} 注册失败: {e}") + return False + + @staticmethod + def _get_plugin_default_config(instance: object) -> Dict[str, Any]: + """获取插件默认配置。 + + Args: + instance: 插件实例。 + + Returns: + Dict[str, Any]: 默认配置;插件未声明时返回空字典。 + """ + + if not hasattr(instance, "get_default_config"): + return {} + try: + default_config = cast(_ConfigAwarePlugin, instance).get_default_config() + except Exception as exc: + logger.warning(f"读取插件默认配置失败: {exc}") + return {} + return default_config if isinstance(default_config, dict) else {} + + @staticmethod + def _get_plugin_config_schema(meta: PluginMeta) -> Dict[str, Any]: + """获取插件 WebUI 配置 Schema。 + + Args: + meta: 插件元数据。 + + Returns: + Dict[str, Any]: 插件配置 Schema;插件未声明时返回空字典。 + """ + + instance = meta.instance + if not hasattr(instance, "get_webui_config_schema"): + return {} + try: + schema = cast(_ConfigAwarePlugin, instance).get_webui_config_schema( + plugin_id=meta.plugin_id, + plugin_name=meta.manifest.name, + plugin_version=meta.version, + plugin_description=meta.manifest.description, + plugin_author=meta.manifest.author.name, + ) + except Exception as exc: + logger.warning(f"构造插件配置 Schema 失败: {exc}") + return {} + return schema if isinstance(schema, dict) else {} + + async def _unregister_plugin(self, plugin_id: str, reason: str) -> None: + """通知 Host 注销指定插件。 + + Args: + plugin_id: 目标插件 ID。 + reason: 注销原因。 + """ + payload = UnregisterPluginPayload(plugin_id=plugin_id, reason=reason) + try: + await self._rpc_client.send_request( + "plugin.unregister", + plugin_id=plugin_id, + payload=payload.model_dump(), + timeout_ms=10000, + ) + except Exception as exc: + logger.warning(f"插件 {plugin_id} 注销通知失败: {exc}") + + async def _invoke_plugin_on_load(self, meta: PluginMeta) -> bool: + """执行插件的 ``on_load`` 生命周期。 + + Args: + meta: 待初始化的插件元数据。 + + Returns: + bool: 生命周期是否执行成功。 + """ + instance = meta.instance + if not hasattr(instance, "on_load"): + return True + + try: + result = instance.on_load() + if asyncio.iscoroutine(result): + await result + return True + except Exception as exc: + logger.error(f"插件 {meta.plugin_id} on_load 失败: {exc}", exc_info=True) + return False + + async def _invoke_plugin_on_unload(self, meta: PluginMeta) -> None: + """执行插件的 ``on_unload`` 生命周期。 + + Args: + meta: 待卸载的插件元数据。 + """ + instance = meta.instance + if not hasattr(instance, "on_unload"): + return + + try: + result = instance.on_unload() + if asyncio.iscoroutine(result): + await result + except Exception as exc: + logger.error(f"插件 {meta.plugin_id} on_unload 失败: {exc}", exc_info=True) + + async def _activate_plugin(self, meta: PluginMeta) -> PluginActivationStatus: + """完成插件注入、授权、生命周期和组件注册。 + + Args: + meta: 待激活的插件元数据。 + + Returns: + PluginActivationStatus: 插件激活结果。 + """ + self._inject_context(meta.plugin_id, meta.instance) + try: + plugin_config = self._apply_plugin_config(meta) + except PluginConfigVersionError as exc: + logger.error(f"插件 {meta.plugin_id} 配置版本非法: {exc}") + self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) + return PluginActivationStatus.FAILED + except Exception as exc: + logger.error(f"插件 {meta.plugin_id} 配置加载失败: {exc}", exc_info=True) + self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) + return PluginActivationStatus.FAILED + if not self._is_plugin_enabled(plugin_config): + logger.info(f"插件 {meta.plugin_id} 已在配置中禁用,跳过激活") + self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) + return PluginActivationStatus.INACTIVE + + if not await self._bootstrap_plugin(meta): + self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) + return PluginActivationStatus.FAILED + + if not await self._register_plugin(meta): + await self._invoke_plugin_on_unload(meta) + await self._deactivate_plugin(meta) + self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) + return PluginActivationStatus.FAILED + + if not await self._invoke_plugin_on_load(meta): + await self._unregister_plugin(meta.plugin_id, reason="on_load_failed") + await self._deactivate_plugin(meta) + self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) + return PluginActivationStatus.FAILED + + self._loader.set_loaded_plugin(meta) + return PluginActivationStatus.LOADED + + async def _unload_plugin(self, meta: PluginMeta, reason: str, *, purge_modules: bool = True) -> None: + """卸载单个插件并清理 Host/Runner 两侧状态。 + + Args: + meta: 待卸载的插件元数据。 + reason: 卸载原因。 + purge_modules: 是否在卸载完成后清理插件模块缓存。 + """ + await self._invoke_plugin_on_unload(meta) + await self._unregister_plugin(meta.plugin_id, reason) + await self._deactivate_plugin(meta) + self._loader.remove_loaded_plugin(meta.plugin_id) + if purge_modules: + self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir) + + def _collect_reverse_dependents(self, plugin_id: str) -> Set[str]: + """收集依赖指定插件的所有已加载插件。 + + Args: + plugin_id: 根插件 ID。 + + Returns: + Set[str]: 目标插件及其所有反向依赖插件集合。 + """ + impacted_plugins: Set[str] = {plugin_id} + changed = True + + while changed: + changed = False + for loaded_plugin_id in self._loader.list_plugins(): + if loaded_plugin_id in impacted_plugins: + continue + + meta = self._loader.get_plugin(loaded_plugin_id) + if meta is None: + continue + + if any(dependency in impacted_plugins for dependency in meta.dependencies): + impacted_plugins.add(loaded_plugin_id) + changed = True + + return impacted_plugins + + def _collect_reverse_dependents_for_roots(self, plugin_ids: Set[str]) -> Set[str]: + """收集多个根插件对应的反向依赖并集。 + + Args: + plugin_ids: 根插件 ID 集合。 + + Returns: + Set[str]: 所有根插件及其反向依赖并集。 + """ + + impacted_plugins: Set[str] = set() + for plugin_id in sorted(plugin_ids): + impacted_plugins.update(self._collect_reverse_dependents(plugin_id)) + return impacted_plugins + + def _build_unload_order(self, plugin_ids: Set[str]) -> List[str]: + """构建受影响插件的卸载顺序。 + + Args: + plugin_ids: 需要卸载的插件集合。 + + Returns: + List[str]: 依赖方优先的卸载顺序。 + """ + dependency_graph: Dict[str, Set[str]] = {} + for plugin_id in plugin_ids: + meta = self._loader.get_plugin(plugin_id) + if meta is None: + dependency_graph[plugin_id] = set() + continue + dependency_graph[plugin_id] = {dependency for dependency in meta.dependencies if dependency in plugin_ids} + + indegree: Dict[str, int] = { + plugin_id: len(dependencies) for plugin_id, dependencies in dependency_graph.items() + } + reverse_graph: Dict[str, Set[str]] = {plugin_id: set() for plugin_id in dependency_graph} + + for plugin_id, dependencies in dependency_graph.items(): + for dependency in dependencies: + reverse_graph.setdefault(dependency, set()).add(plugin_id) + + queue: List[str] = sorted(plugin_id for plugin_id, degree in indegree.items() if degree == 0) + load_order: List[str] = [] + + while queue: + current_plugin_id = queue.pop(0) + load_order.append(current_plugin_id) + for dependent_plugin_id in sorted(reverse_graph.get(current_plugin_id, set())): + indegree[dependent_plugin_id] -= 1 + if indegree[dependent_plugin_id] == 0: + queue.append(dependent_plugin_id) + queue.sort() + + return list(reversed(load_order)) + + @staticmethod + def _normalize_requested_plugin_ids(plugin_ids: List[str]) -> List[str]: + """规范化批量重载请求中的插件 ID 列表。""" + + normalized_plugin_ids: List[str] = [] + seen_plugin_ids: Set[str] = set() + for plugin_id in plugin_ids: + normalized_plugin_id = str(plugin_id or "").strip() + if not normalized_plugin_id or normalized_plugin_id in seen_plugin_ids: + continue + seen_plugin_ids.add(normalized_plugin_id) + normalized_plugin_ids.append(normalized_plugin_id) + return normalized_plugin_ids + + @staticmethod + def _finalize_failed_reload_messages( + failed_plugins: Dict[str, str], + rollback_failures: Dict[str, str], + ) -> Dict[str, str]: + """在重载失败后补充回滚结果说明。""" + + finalized_failures: Dict[str, str] = {} + for failed_plugin_id, failure_reason in failed_plugins.items(): + rollback_failure = rollback_failures.get(failed_plugin_id) + if rollback_failure: + finalized_failures[failed_plugin_id] = f"{failure_reason};且旧版本恢复失败: {rollback_failure}" + else: + finalized_failures[failed_plugin_id] = f"{failure_reason}(已恢复旧版本)" + + for failed_plugin_id, rollback_failure in rollback_failures.items(): + if failed_plugin_id not in finalized_failures: + finalized_failures[failed_plugin_id] = f"旧版本恢复失败: {rollback_failure}" + + return finalized_failures + + async def _reload_plugin_by_id( + self, + plugin_id: str, + reason: str, + external_available_plugins: Optional[Dict[str, str]] = None, + ) -> ReloadPluginResultPayload: + """按插件 ID 在 Runner 进程内执行精确重载。 + + Args: + plugin_id: 目标插件 ID。 + reason: 重载原因。 + external_available_plugins: 视为已满足的外部依赖插件版本映射。 + + Returns: + ReloadPluginResultPayload: 结构化重载结果。 + """ + batch_result = await self._reload_plugins_by_ids( + [plugin_id], + reason, + external_available_plugins=external_available_plugins, + ) + return ReloadPluginResultPayload( + success=batch_result.success, + requested_plugin_id=plugin_id, + reloaded_plugins=batch_result.reloaded_plugins, + unloaded_plugins=batch_result.unloaded_plugins, + inactive_plugins=batch_result.inactive_plugins, + failed_plugins=batch_result.failed_plugins, + ) + + async def _reload_plugins_by_ids( + self, + plugin_ids: List[str], + reason: str, + external_available_plugins: Optional[Dict[str, str]] = None, + ) -> ReloadPluginsResultPayload: + """按插件 ID 列表在 Runner 进程内执行一次批量重载。""" + + normalized_plugin_ids = self._normalize_requested_plugin_ids(plugin_ids) + if not normalized_plugin_ids: + return ReloadPluginsResultPayload(success=True, requested_plugin_ids=[]) + + candidates, duplicate_candidates = self._loader.discover_candidates(self._plugin_dirs) + failed_plugins: Dict[str, str] = {} + normalized_external_available = { + str(candidate_plugin_id or "").strip(): str(candidate_plugin_version or "").strip() + for candidate_plugin_id, candidate_plugin_version in (external_available_plugins or {}).items() + if str(candidate_plugin_id or "").strip() and str(candidate_plugin_version or "").strip() + } + + loaded_plugin_ids = set(self._loader.list_plugins()) + reload_root_ids: Set[str] = set() + for plugin_id in normalized_plugin_ids: + if plugin_id in duplicate_candidates: + conflict_paths = ", ".join(str(path) for path in duplicate_candidates[plugin_id]) + failed_plugins[plugin_id] = f"检测到重复插件 ID: {conflict_paths}" + continue + + plugin_is_loaded = plugin_id in loaded_plugin_ids + plugin_has_candidate = plugin_id in candidates + if not plugin_is_loaded and not plugin_has_candidate: + failed_plugins[plugin_id] = "插件不存在或未找到合法的 manifest/plugin.py" + continue + + reload_root_ids.add(plugin_id) + + if not reload_root_ids: + return ReloadPluginsResultPayload( + success=False, + requested_plugin_ids=normalized_plugin_ids, + failed_plugins=failed_plugins, + ) + + target_plugin_ids: Set[str] = {plugin_id for plugin_id in reload_root_ids if plugin_id not in loaded_plugin_ids} + if loaded_root_plugin_ids := reload_root_ids & loaded_plugin_ids: + target_plugin_ids.update(self._collect_reverse_dependents_for_roots(loaded_root_plugin_ids)) + + unload_order = self._build_unload_order(target_plugin_ids & loaded_plugin_ids) + unloaded_plugins: List[str] = [] + retained_plugin_ids = loaded_plugin_ids - set(unload_order) + rollback_metas: Dict[str, PluginMeta] = {} + + for unload_plugin_id in unload_order: + meta = self._loader.get_plugin(unload_plugin_id) + if meta is None: + continue + rollback_metas[unload_plugin_id] = meta + await self._unload_plugin(meta, reason=reason, purge_modules=False) + self._loader.purge_plugin_modules(unload_plugin_id, meta.plugin_dir) + unloaded_plugins.append(unload_plugin_id) + + reload_candidates: Dict[str, PluginCandidate] = {} + for target_plugin_id in target_plugin_ids: + candidate = candidates.get(target_plugin_id) + if candidate is None: + failed_plugins[target_plugin_id] = "插件目录已不存在" + continue + reload_candidates[target_plugin_id] = candidate + + load_order, dependency_failures = self._loader.resolve_dependencies( + reload_candidates, + extra_available={ + **normalized_external_available, + **{ + retained_plugin_id: retained_meta.version + for retained_plugin_id in retained_plugin_ids + if (retained_meta := self._loader.get_plugin(retained_plugin_id)) is not None + }, + }, + ) + failed_plugins.update(dependency_failures) + + available_plugins = { + **normalized_external_available, + **{ + retained_plugin_id: retained_meta.version + for retained_plugin_id in retained_plugin_ids + if (retained_meta := self._loader.get_plugin(retained_plugin_id)) is not None + }, + } + reloaded_plugins: List[str] = [] + inactive_plugins: List[str] = [] + inactive_plugin_ids: Set[str] = set() + + for load_plugin_id in load_order: + if load_plugin_id in failed_plugins: + continue + + candidate = reload_candidates.get(load_plugin_id) + if candidate is None: + continue + + _, manifest, _ = candidate + unsatisfied_dependency_ids = [ + dependency.id + for dependency in manifest.plugin_dependencies + if dependency.id not in available_plugins + or not self._loader.manifest_validator.is_plugin_dependency_satisfied( + dependency, + available_plugins[dependency.id], + ) + ] + if unsatisfied_dependencies := self._loader.manifest_validator.get_unsatisfied_plugin_dependencies( + manifest, + available_plugin_versions=available_plugins, + ): + if load_plugin_id not in reload_root_ids and any( + dependency_id in inactive_plugin_ids for dependency_id in unsatisfied_dependency_ids + ): + logger.info( + f"插件 {load_plugin_id} 的依赖当前未激活,保留为未激活状态: {', '.join(unsatisfied_dependencies)}" + ) + inactive_plugin_ids.add(load_plugin_id) + inactive_plugins.append(load_plugin_id) + continue + failed_plugins[load_plugin_id] = f"依赖未满足: {', '.join(unsatisfied_dependencies)}" + continue + + meta = self._loader.load_candidate(load_plugin_id, candidate) + if meta is None: + failed_plugins[load_plugin_id] = "插件模块加载失败" + continue + + activated = await self._activate_plugin(meta) + if activated == PluginActivationStatus.FAILED: + failed_plugins[load_plugin_id] = "插件初始化失败" + continue + if activated == PluginActivationStatus.INACTIVE: + inactive_plugin_ids.add(load_plugin_id) + inactive_plugins.append(load_plugin_id) + continue + + available_plugins[load_plugin_id] = meta.version + reloaded_plugins.append(load_plugin_id) + + if failed_plugins: + rollback_failures: Dict[str, str] = {} + + for reloaded_plugin_id in reversed(reloaded_plugins): + reloaded_meta = self._loader.get_plugin(reloaded_plugin_id) + if reloaded_meta is None: + continue + + try: + await self._unload_plugin( + reloaded_meta, + reason=f"{reason}_rollback_cleanup", + purge_modules=False, + ) + except Exception as exc: + rollback_failures[reloaded_plugin_id] = f"清理失败: {exc}" + finally: + self._loader.purge_plugin_modules(reloaded_plugin_id, reloaded_meta.plugin_dir) + + for rollback_plugin_id in reversed(unload_order): + rollback_meta = rollback_metas.get(rollback_plugin_id) + if rollback_meta is None: + continue + + try: + restored = await self._activate_plugin(rollback_meta) + except Exception as exc: + rollback_failures[rollback_plugin_id] = str(exc) + continue + + if restored != PluginActivationStatus.LOADED: + rollback_failures[rollback_plugin_id] = "无法重新激活旧版本" + + return ReloadPluginsResultPayload( + success=False, + requested_plugin_ids=normalized_plugin_ids, + reloaded_plugins=[], + unloaded_plugins=unloaded_plugins, + inactive_plugins=[], + failed_plugins=self._finalize_failed_reload_messages(failed_plugins, rollback_failures), + ) + + requested_plugin_success = all( + plugin_id in reloaded_plugins or plugin_id in inactive_plugins for plugin_id in reload_root_ids + ) + + return ReloadPluginsResultPayload( + success=requested_plugin_success and not failed_plugins, + requested_plugin_ids=normalized_plugin_ids, + reloaded_plugins=reloaded_plugins, + unloaded_plugins=unloaded_plugins, + inactive_plugins=inactive_plugins, + failed_plugins=failed_plugins, + ) + + async def _notify_ready( + self, + loaded_plugins: List[str], + failed_plugins: List[str], + inactive_plugins: List[str], + ) -> None: + """通知 Host 当前 Runner 已完成插件初始化。 + + Args: + loaded_plugins: 成功初始化的插件列表。 + failed_plugins: 初始化失败的插件列表。 + inactive_plugins: 因禁用或依赖不可用而未激活的插件列表。 + """ + payload = RunnerReadyPayload( + loaded_plugins=loaded_plugins, + failed_plugins=failed_plugins, + inactive_plugins=inactive_plugins, + ) + await self._rpc_client.send_request( + "runner.ready", + payload=payload.model_dump(), + timeout_ms=10000, + ) + + async def _handle_invoke(self, envelope: Envelope) -> Envelope: + """处理组件调用请求""" + try: + invoke = InvokePayload.model_validate(envelope.payload) + except Exception as e: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(e)) + + plugin_id = envelope.plugin_id + meta = self._loader.get_plugin(plugin_id) + if meta is None: + return envelope.make_error_response( + ErrorCode.E_PLUGIN_NOT_FOUND.value, + f"插件 {plugin_id} 未加载", + ) + + component_name = invoke.component_name + handler_method = self._resolve_component_handler(meta, component_name) + + # 回退: 旧版 LegacyPluginAdapter 通过 invoke_component 统一桥接 + if (handler_method is None or not callable(handler_method)) and hasattr(meta.instance, "invoke_component"): + try: + result = await meta.instance.invoke_component(component_name, **invoke.args) + resp_payload = InvokeResultPayload(success=True, result=result) + return envelope.make_response(payload=resp_payload.model_dump()) + except Exception as e: + logger.error(f"插件 {plugin_id} 组件 {component_name} (legacy) 执行异常: {e}", exc_info=True) + resp_payload = InvokeResultPayload(success=False, result=str(e)) + return envelope.make_response(payload=resp_payload.model_dump()) + + if handler_method is None or not callable(handler_method): + return envelope.make_error_response( + ErrorCode.E_METHOD_NOT_ALLOWED.value, + f"插件 {plugin_id} 无组件: {component_name}", + ) + + try: + result = ( + await handler_method(**invoke.args) + if inspect.iscoroutinefunction(handler_method) + else handler_method(**invoke.args) + ) + resp_payload = InvokeResultPayload(success=True, result=result) + return envelope.make_response(payload=resp_payload.model_dump()) + except Exception as e: + logger.error(f"插件 {plugin_id} 组件 {component_name} 执行异常: {e}", exc_info=True) + resp_payload = InvokeResultPayload(success=False, result=str(e)) + return envelope.make_response(payload=resp_payload.model_dump()) + + async def _handle_llm_provider_invoke(self, envelope: Envelope) -> Envelope: + """处理 LLM Provider 调用请求。 + + Args: + envelope: RPC 请求信封。 + + Returns: + Envelope: 标准化后的 Provider 调用结果。 + """ + try: + invoke = LLMProviderInvokePayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + plugin_id = envelope.plugin_id + meta = self._loader.get_plugin(plugin_id) + if meta is None: + return envelope.make_error_response( + ErrorCode.E_PLUGIN_NOT_FOUND.value, + f"插件 {plugin_id} 未加载", + ) + + handler_name = meta.llm_provider_handlers.get(invoke.client_type, "") + handler_method = getattr(meta.instance, handler_name, None) if handler_name else None + if handler_method is None or not callable(handler_method): + return envelope.make_error_response( + ErrorCode.E_METHOD_NOT_ALLOWED.value, + f"插件 {plugin_id} 未注册 LLM Provider: {invoke.client_type}", + ) + + try: + result = handler_method(operation=invoke.operation, request=invoke.request) + if inspect.isawaitable(result): + result = await result + resp_payload = InvokeResultPayload(success=True, result=result) + return envelope.make_response(payload=resp_payload.model_dump()) + except Exception as exc: + logger.error( + f"插件 {plugin_id} LLM Provider {invoke.client_type} 执行异常: {exc}", + exc_info=True, + ) + resp_payload = InvokeResultPayload(success=False, result=str(exc)) + return envelope.make_response(payload=resp_payload.model_dump()) + + async def _handle_event_invoke(self, envelope: Envelope) -> Envelope: + """处理 EventHandler 调用请求 + + 与通用 invoke 不同,会将返回值规范化为 + {success, continue_processing, modified_message, custom_result} 格式, + 使 EventDispatcher 可直接从 payload 顶层读取这些字段。 + """ + try: + invoke = InvokePayload.model_validate(envelope.payload) + except Exception as e: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(e)) + + plugin_id = envelope.plugin_id + meta = self._loader.get_plugin(plugin_id) + if meta is None: + return envelope.make_error_response( + ErrorCode.E_PLUGIN_NOT_FOUND.value, + f"插件 {plugin_id} 未加载", + ) + + component_name = invoke.component_name + handler_method = self._resolve_component_handler(meta, component_name) + + if handler_method is None or not callable(handler_method): + return envelope.make_error_response( + ErrorCode.E_METHOD_NOT_ALLOWED.value, + f"插件 {plugin_id} 无组件: {component_name}", + ) + + try: + raw = ( + await handler_method(**invoke.args) + if inspect.iscoroutinefunction(handler_method) + else handler_method(**invoke.args) + ) + + # 规范化返回值:将 EventHandler 返回展平到 payload 顶层 + if raw is None: + result = {"success": True, "continue_processing": True} + elif isinstance(raw, dict): + result = { + "success": True, + # 兼容 guide.md 中文档的 {"blocked": True} 写法 + "continue_processing": not raw.get("blocked", False) + if "blocked" in raw + else raw.get("continue_processing", True), + "modified_message": raw.get("modified_message"), + "custom_result": raw.get("custom_result"), + } + else: + result = {"success": True, "continue_processing": True, "custom_result": raw} + + return envelope.make_response(payload=result) + except Exception as e: + logger.error(f"插件 {plugin_id} event_handler {component_name} 执行异常: {e}", exc_info=True) + return envelope.make_response(payload={"success": False, "continue_processing": True}) + + async def _handle_hook_invoke(self, envelope: Envelope) -> Envelope: + """处理 HookHandler 调用请求。 + + Args: + envelope: RPC 请求信封。 + + Returns: + Envelope: 标准化后的 Hook 调用结果。 + """ + try: + invoke = InvokePayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + plugin_id = envelope.plugin_id + meta = self._loader.get_plugin(plugin_id) + if meta is None: + return envelope.make_error_response( + ErrorCode.E_PLUGIN_NOT_FOUND.value, + f"插件 {plugin_id} 未加载", + ) + + component_name = invoke.component_name + handler_method = self._resolve_component_handler(meta, component_name) + if handler_method is None or not callable(handler_method): + return envelope.make_error_response( + ErrorCode.E_METHOD_NOT_ALLOWED.value, + f"插件 {plugin_id} 无组件: {component_name}", + ) + + try: + raw = ( + await handler_method(**invoke.args) + if inspect.iscoroutinefunction(handler_method) + else handler_method(**invoke.args) + ) + except Exception as exc: + logger.error(f"插件 {plugin_id} hook_handler {component_name} 执行异常: {exc}", exc_info=True) + return envelope.make_response( + payload={ + "success": False, + "action": "continue", + "error_message": str(exc), + } + ) + + if raw is None: + result = {"success": True, "action": "continue"} + elif isinstance(raw, dict): + result = { + "success": True, + "action": str(raw.get("action", "continue") or "continue").strip().lower() or "continue", + "modified_kwargs": raw.get("modified_kwargs"), + "custom_result": raw.get("custom_result"), + } + else: + result = {"success": True, "action": "continue", "custom_result": raw} + + return envelope.make_response(payload=result) + + async def _handle_health(self, envelope: Envelope) -> Envelope: + """处理健康检查""" + uptime_ms = int((time.monotonic() - self._start_time) * 1000) + health = HealthPayload( + healthy=True, + loaded_plugins=self._loader.list_plugins(), + uptime_ms=uptime_ms, + ) + return envelope.make_response(payload=health.model_dump()) + + async def _handle_prepare_shutdown(self, envelope: Envelope) -> Envelope: + """处理准备关停""" + logger.info("收到 prepare_shutdown 信号") + return envelope.make_response(payload={"acknowledged": True}) + + async def _handle_shutdown(self, envelope: Envelope) -> Envelope: + """处理关停 — 调用所有插件的 on_unload 后退出""" + logger.info("收到 shutdown 信号,开始调用 on_unload") + for plugin_id in list(self._loader.list_plugins()): + meta = self._loader.get_plugin(plugin_id) + if meta is not None: + await self._unload_plugin(meta, reason="runner_shutdown") + self._shutting_down = True + return envelope.make_response(payload={"acknowledged": True}) + + async def _handle_config_updated(self, envelope: Envelope) -> Envelope: + """处理配置更新事件。""" + try: + payload = ConfigUpdatedPayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + plugin_id = envelope.plugin_id + if meta := self._loader.get_plugin(plugin_id): + try: + config_scope = payload.config_scope.value + if config_scope == "self": + self._apply_plugin_config(meta, config_data=payload.config_data) + if not hasattr(meta.instance, "on_config_update"): + raise AttributeError("插件缺少 on_config_update() 实现") + + ret = meta.instance.on_config_update( + config_scope, + payload.config_data, + payload.config_version, + ) + if asyncio.iscoroutine(ret): + await ret + except Exception as e: + logger.error(f"插件 {plugin_id} 配置更新失败: {e}") + return envelope.make_error_response(ErrorCode.E_UNKNOWN.value, str(e)) + return envelope.make_response(payload={"acknowledged": True}) + + async def _handle_inspect_plugin_config(self, envelope: Envelope) -> Envelope: + """处理插件配置元数据解析请求。 + + Args: + envelope: RPC 请求信封。 + + Returns: + Envelope: RPC 响应信封。 + """ + + try: + payload = InspectPluginConfigPayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + plugin_id = envelope.plugin_id + meta, is_temporary_meta, error_message = self._resolve_plugin_meta_for_config_request(plugin_id) + if meta is None: + return envelope.make_error_response( + ErrorCode.E_PLUGIN_NOT_FOUND.value, + error_message or f"未找到插件: {plugin_id}", + ) + + try: + result = self._inspect_plugin_config( + meta, + config_data=payload.config_data, + use_provided_config=payload.use_provided_config, + suppress_errors=payload.use_provided_config, + enforce_version=not payload.use_provided_config, + ) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + finally: + if is_temporary_meta: + self._loader.purge_plugin_modules(plugin_id, meta.plugin_dir) + + return envelope.make_response(payload=result.model_dump()) + + async def _handle_validate_plugin_config(self, envelope: Envelope) -> Envelope: + """处理插件配置校验请求。 + + Args: + envelope: RPC 请求信封。 + + Returns: + Envelope: RPC 响应信封。 + """ + + try: + payload = ValidatePluginConfigPayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + plugin_id = envelope.plugin_id + meta, is_temporary_meta, error_message = self._resolve_plugin_meta_for_config_request(plugin_id) + if meta is None: + return envelope.make_error_response( + ErrorCode.E_PLUGIN_NOT_FOUND.value, + error_message or f"未找到插件: {plugin_id}", + ) + + try: + inspection_result = self._inspect_plugin_config( + meta, + config_data=payload.config_data, + use_provided_config=True, + suppress_errors=False, + enforce_version=True, + ) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + finally: + if is_temporary_meta: + self._loader.purge_plugin_modules(plugin_id, meta.plugin_dir) + + result = ValidatePluginConfigResultPayload( + success=True, + normalized_config=inspection_result.normalized_config, + changed=inspection_result.changed, + ) + return envelope.make_response(payload=result.model_dump()) + + async def _handle_reload_plugin(self, envelope: Envelope) -> Envelope: + """处理按插件 ID 的精确重载请求。 + + Args: + envelope: RPC 请求信封。 + + Returns: + Envelope: 结构化重载结果。 + """ + try: + payload = ReloadPluginPayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + if self._reload_lock.locked(): + return envelope.make_error_response( + ErrorCode.E_RELOAD_IN_PROGRESS.value, + f"插件 {payload.plugin_id} 重载请求被拒绝:已有重载任务正在执行", + ) + + async with self._reload_lock: + result = await self._reload_plugin_by_id( + payload.plugin_id, + payload.reason, + external_available_plugins=dict(payload.external_available_plugins), + ) + return envelope.make_response(payload=result.model_dump()) + + async def _handle_reload_plugins(self, envelope: Envelope) -> Envelope: + """处理批量插件重载请求。""" + + try: + payload = ReloadPluginsPayload.model_validate(envelope.payload) + except Exception as exc: + return envelope.make_error_response(ErrorCode.E_BAD_PAYLOAD.value, str(exc)) + + if self._reload_lock.locked(): + requested_plugin_ids = ", ".join(self._normalize_requested_plugin_ids(payload.plugin_ids)) or "" + return envelope.make_error_response( + ErrorCode.E_RELOAD_IN_PROGRESS.value, + f"插件 {requested_plugin_ids} 批量重载请求被拒绝:已有重载任务正在执行", + ) + + async with self._reload_lock: + result = await self._reload_plugins_by_ids( + list(payload.plugin_ids), + payload.reason, + external_available_plugins=dict(payload.external_available_plugins), + ) + return envelope.make_response(payload=result.model_dump()) + + def request_capability(self) -> RPCClient: + """获取 RPC 客户端(供 SDK 使用,发起能力调用)""" + return self._rpc_client + + +# ─── 进程入口 ────────────────────────────────────────────── + + +async def _async_main() -> None: + """异步主入口""" + blocked_plugin_reasons_raw = os.environ.get(ENV_BLOCKED_PLUGIN_REASONS, "") + host_address = os.environ.pop(ENV_IPC_ADDRESS, "") + external_plugin_ids_raw = os.environ.get(ENV_EXTERNAL_PLUGIN_IDS, "") + session_token = os.environ.pop(ENV_SESSION_TOKEN, "") + plugin_dirs_str = os.environ.get(ENV_PLUGIN_DIRS, "") + + if not host_address or not session_token: + logger.error(f"缺少必要的环境变量: {ENV_IPC_ADDRESS}, {ENV_SESSION_TOKEN}") + sys.exit(1) + + plugin_dirs = [d for d in plugin_dirs_str.split(os.pathsep) if d] + try: + external_plugin_ids = json.loads(external_plugin_ids_raw) if external_plugin_ids_raw else {} + except json.JSONDecodeError: + logger.warning("解析外部依赖插件版本映射失败,已回退为空映射") + external_plugin_ids = {} + if not isinstance(external_plugin_ids, dict): + logger.warning("外部依赖插件版本映射格式非法,已回退为空映射") + external_plugin_ids = {} + + try: + blocked_plugin_reasons = json.loads(blocked_plugin_reasons_raw) if blocked_plugin_reasons_raw else {} + except json.JSONDecodeError: + logger.warning("解析阻止加载插件原因映射失败,已回退为空映射") + blocked_plugin_reasons = {} + if not isinstance(blocked_plugin_reasons, dict): + logger.warning("阻止加载插件原因映射格式非法,已回退为空映射") + blocked_plugin_reasons = {} + + runner_kwargs: Dict[str, Any] = { + "external_available_plugins": { + str(plugin_id): str(plugin_version) for plugin_id, plugin_version in external_plugin_ids.items() + } + } + if blocked_plugin_reasons: + runner_kwargs["blocked_plugin_reasons"] = { + str(plugin_id): str(reason) for plugin_id, reason in blocked_plugin_reasons.items() + } + + runner = PluginRunner( + host_address, + session_token, + plugin_dirs, + **runner_kwargs, + ) + + # 注册信号处理 + def _mark_runner_shutting_down() -> None: + """标记 Runner 即将进入关停流程。""" + runner._shutting_down = True + + _install_shutdown_signal_handlers(_mark_runner_shutting_down) + + await runner.run() + + +def main() -> None: + """进程入口(python -m src.plugin_runtime.runner.runner_main)""" + initialize_logging(verbose=False) + _disable_runner_console_logging() + asyncio.run(_async_main()) + + +if __name__ == "__main__": + main() diff --git a/src/plugin_runtime/tool_provider.py b/src/plugin_runtime/tool_provider.py new file mode 100644 index 00000000..6ad48b79 --- /dev/null +++ b/src/plugin_runtime/tool_provider.py @@ -0,0 +1,58 @@ +"""插件运行时工具 Provider。""" + +from __future__ import annotations + +from typing import Optional + +from src.core.tooling import ( + ToolAvailabilityContext, + ToolExecutionContext, + ToolExecutionResult, + ToolInvocation, + ToolProvider, + ToolSpec, +) + +from .component_query import component_query_service + + +class PluginToolProvider(ToolProvider): + """将插件 Tool 与兼容旧 Action 暴露为统一工具 Provider。""" + + provider_name = "plugin_runtime" + provider_type = "plugin" + + async def list_tools( + self, + context: Optional[ToolAvailabilityContext] = None, + ) -> list[ToolSpec]: + """列出插件运行时当前可用的工具声明。""" + + return list(component_query_service.get_llm_available_tool_specs(context=context).values()) + + async def invoke( + self, + invocation: ToolInvocation, + context: Optional[ToolExecutionContext] = None, + ) -> ToolExecutionResult: + """执行插件工具或兼容旧 Action 的工具调用。 + + Args: + invocation: 工具调用请求。 + context: 执行上下文。 + + Returns: + ToolExecutionResult: 工具执行结果。 + """ + + return await component_query_service.invoke_tool_as_tool( + invocation=invocation, + context=context, + ) + + async def close(self) -> None: + """关闭 Provider。 + + 插件运行时工具 Provider 不持有独立资源。 + """ + diff --git a/src/plugin_runtime/transport/__init__.py b/src/plugin_runtime/transport/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/plugin_runtime/transport/__init__.py @@ -0,0 +1 @@ + diff --git a/src/plugin_runtime/transport/base.py b/src/plugin_runtime/transport/base.py new file mode 100644 index 00000000..3b4d1084 --- /dev/null +++ b/src/plugin_runtime/transport/base.py @@ -0,0 +1,116 @@ +"""传输层抽象基类 + +定义 TransportServer 和 TransportClient 的统一接口。 +所有传输后端(UDS、Named Pipe、显式 TCP)必须实现此接口。 +业务层仅依赖此抽象,禁止直接使用具体传输实现的细节。 + +分帧协议:4-byte big-endian length prefix + payload +""" + +import asyncio +import contextlib +import struct +from abc import ABC, abstractmethod +from typing import Awaitable, Callable + +# 分帧常量 +FRAME_HEADER_SIZE = 4 # 4 字节长度前缀 +MAX_FRAME_SIZE = 16 * 1024 * 1024 # 16 MB 最大帧大小 + + +class ConnectionClosed(Exception): + """连接已关闭""" + + pass + + +class Connection: + """单个连接的抽象 + + 封装了底层 StreamReader/StreamWriter,提供分帧读写能力。 + """ + + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + self._reader = reader + self._writer = writer + self._closed = False + self._write_lock = asyncio.Lock() # 保护并发写入的帧完整性 + + async def send_frame(self, data: bytes) -> None: + """发送一帧数据(4-byte length prefix + payload)""" + if self._closed: + raise ConnectionClosed("连接已关闭") + length = len(data) + if length > MAX_FRAME_SIZE: + raise ValueError(f"帧大小 {length} 超过最大限制 {MAX_FRAME_SIZE}") + header = struct.pack(">I", length) + async with self._write_lock: + self._writer.write(header + data) + await self._writer.drain() + + async def recv_frame(self) -> bytes: + """接收一帧数据""" + if self._closed: + raise ConnectionClosed("连接已关闭") + # 读取 4 字节长度头 + header = await self._reader.readexactly(FRAME_HEADER_SIZE) + (length,) = struct.unpack(">I", header) + if length > MAX_FRAME_SIZE: + raise ValueError(f"帧大小 {length} 超过最大限制 {MAX_FRAME_SIZE}") + # 读取 payload + return await self._reader.readexactly(length) + + async def close(self) -> None: + """关闭连接""" + if self._closed: + return + self._closed = True + with contextlib.suppress(Exception): + self._writer.close() + await self._writer.wait_closed() + + @property + def is_closed(self) -> bool: + return self._closed + + +# 连接回调类型:收到新连接时调用 +ConnectionHandler = Callable[[Connection], Awaitable[None]] + + +class TransportServer(ABC): + """传输服务端抽象 + + Host 端使用,监听来自 Runner 的连接。 + """ + + @abstractmethod + async def start(self, handler: ConnectionHandler) -> None: + """启动服务端,开始监听连接 + + Args: + handler: 新连接到来时的回调函数 + """ + ... + + @abstractmethod + async def stop(self) -> None: + """停止服务端""" + ... + + @abstractmethod + def get_address(self) -> str: + """获取监听地址(供 Runner 连接用)""" + ... + + +class TransportClient(ABC): + """传输客户端抽象 + + Runner 端使用,主动连接 Host。 + """ + + @abstractmethod + async def connect(self) -> Connection: + """建立到 Host 的连接""" + ... diff --git a/src/plugin_runtime/transport/factory.py b/src/plugin_runtime/transport/factory.py new file mode 100644 index 00000000..64eaa4f3 --- /dev/null +++ b/src/plugin_runtime/transport/factory.py @@ -0,0 +1,57 @@ +"""传输层工厂 + +根据运行平台自动选择最优传输实现。 +""" + +from pathlib import Path +from typing import Optional + +import sys + +from .base import TransportClient, TransportServer + + +def create_transport_server(socket_path: Optional[str] = None) -> TransportServer: + """创建传输服务端 + + Linux/macOS 使用 UDS,Windows 使用 Named Pipe。 + + Args: + socket_path: UDS socket 路径或 Windows pipe 名称 + """ + if sys.platform != "win32": + from .uds import UDSTransportServer + + return UDSTransportServer(socket_path=Path(socket_path) if socket_path is not None else None) + else: + from .named_pipe import NamedPipeTransportServer + + return NamedPipeTransportServer(pipe_name=socket_path) + + +def create_transport_client(address: str) -> TransportClient: + """创建传输客户端 + + 根据地址格式自动判断传输类型: + - 以 '\\\\.\\pipe\\' 开头 -> Windows Named Pipe + - 包含 '/' 或 '.sock' -> UDS + - 包含 ':' -> TCP + + Args: + address: Host 端监听地址 + """ + if address.startswith("\\\\.\\pipe\\"): + from .named_pipe import NamedPipeTransportClient + + return NamedPipeTransportClient(address) + if "/" in address or address.endswith(".sock"): + from .uds import UDSTransportClient + + return UDSTransportClient(socket_path=Path(address)) + elif ":" in address: + from .tcp import TCPTransportClient + + host, port_str = address.rsplit(":", 1) + return TCPTransportClient(host=host, port=int(port_str)) + else: + raise ValueError(f"无法识别的传输地址格式: {address}") diff --git a/src/plugin_runtime/transport/named_pipe.py b/src/plugin_runtime/transport/named_pipe.py new file mode 100644 index 00000000..7fd39bc9 --- /dev/null +++ b/src/plugin_runtime/transport/named_pipe.py @@ -0,0 +1,206 @@ +"""Windows Named Pipe 传输实现。 + +适用于 Windows 平台,使用 asyncio ProactorEventLoop 的 named pipe 支持。 + +注意:Named Pipe 是 Windows 特有的 IPC 机制, +在 Linux/macOS 平台上不可用。Unix-like 平台请使用 UDS 传输。 +""" + +from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, cast + +import asyncio +import os +import re +import sys +import uuid + +from .base import Connection, ConnectionHandler, TransportClient, TransportServer + +_PIPE_PREFIX = "\\\\.\\pipe\\" +_DEFAULT_PIPE_PREFIX = "maibot-plugin" + + +class _NamedPipeServerHandle(Protocol): + """Named Pipe 服务端句柄的协议定义。""" + def close(self) -> None: ... + + +class _NamedPipeEventLoop(Protocol): + """ProactorEventLoop 的协议定义,提供 named pipe 相关方法。""" + async def start_serving_pipe( + self, + protocol_factory: Callable[[], asyncio.BaseProtocol], + address: str, + ) -> List[_NamedPipeServerHandle]: ... + + async def create_pipe_connection( + self, + protocol_factory: Callable[[], asyncio.BaseProtocol], + address: str, + ) -> Tuple[asyncio.BaseTransport, asyncio.BaseProtocol]: ... + + def call_exception_handler(self, context: Dict[str, Any]) -> None: ... + + def create_task(self, coro: Any) -> asyncio.Task[None]: ... + + +def _normalize_pipe_address(pipe_name: Optional[str] = None) -> str: + """规范化 Named Pipe 地址。 + + Args: + pipe_name: 管道名称。如果以 '\\\\.\\pipe\\' 开头则直接使用, + 否则会自动添加前缀。如果为 None 则生成随机名称。 + + Returns: + 规范化的管道地址(格式:\\\\.\\pipe\\name) + """ + if pipe_name and pipe_name.startswith(_PIPE_PREFIX): + return pipe_name + + if pipe_name: + sanitized_name = re.sub(r"[^0-9A-Za-z._-]+", "-", pipe_name).strip("-.") + else: + sanitized_name = f"{_DEFAULT_PIPE_PREFIX}-{os.getpid()}-{uuid.uuid4().hex[:8]}" + + if not sanitized_name: + sanitized_name = f"{_DEFAULT_PIPE_PREFIX}-{os.getpid()}-{uuid.uuid4().hex[:8]}" + + return f"{_PIPE_PREFIX}{sanitized_name}" + + +class NamedPipeConnection(Connection): + """基于 Windows Named Pipe 的连接。 + + 封装了底层 StreamReader/StreamWriter,提供分帧读写能力。 + """ + + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + super().__init__(reader, writer) + + +class _NamedPipeServerProtocol(asyncio.StreamReaderProtocol): + """Named Pipe 服务端协议实现。 + + 处理客户端连接的生命周期,包括连接建立、数据处理和连接关闭。 + """ + + def __init__(self, handler: ConnectionHandler, loop: asyncio.AbstractEventLoop) -> None: + self._reader: asyncio.StreamReader = asyncio.StreamReader() + super().__init__(self._reader) + self._handler: ConnectionHandler = handler + self._loop: asyncio.AbstractEventLoop = loop + self._handler_task: Optional[asyncio.Task[None]] = None + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + """连接建立时的回调。""" + super().connection_made(transport) + writer = asyncio.StreamWriter(cast(asyncio.WriteTransport, transport), self, self._reader, self._loop) + connection = NamedPipeConnection(self._reader, writer) + # 使用 asyncio.create_task 确保任务正确调度 + self._handler_task = asyncio.create_task(self._run_handler(connection)) + self._handler_task.add_done_callback(self._on_handler_done) + + async def _run_handler(self, connection: NamedPipeConnection) -> None: + """运行连接处理器。""" + try: + await self._handler(connection) + finally: + await connection.close() + + def _on_handler_done(self, task: asyncio.Task[None]) -> None: + """连接处理器完成时的回调。""" + if task.cancelled(): + return + if exc := task.exception(): + try: + self._loop.call_exception_handler( + { + "message": "Named pipe 连接处理失败", + "exception": exc, + "protocol": self, + } + ) + except Exception: + # 如果 loop 已经关闭,忽略异常 + pass + + +class NamedPipeTransportServer(TransportServer): + """Windows Named Pipe 传输服务端。 + + 使用 ProactorEventLoop 的 start_serving_pipe 方法监听客户端连接。 + """ + + def __init__(self, pipe_name: Optional[str] = None) -> None: + self._address: str = _normalize_pipe_address(pipe_name) + self._servers: List[_NamedPipeServerHandle] = [] + + async def start(self, handler: ConnectionHandler) -> None: + """启动 Named Pipe 服务端。 + + Args: + handler: 新连接到来时的回调函数 + + Raises: + RuntimeError: 当在非 Windows 平台或事件循环不支持时 + """ + if sys.platform != "win32": + raise RuntimeError("Named pipe 仅支持 Windows") + + loop = asyncio.get_running_loop() + if not hasattr(loop, "start_serving_pipe"): + raise RuntimeError("当前事件循环不支持 Windows named pipe") + pipe_loop = cast(_NamedPipeEventLoop, loop) + + self._servers = await pipe_loop.start_serving_pipe( + lambda: _NamedPipeServerProtocol(handler, loop), + self._address, + ) + + async def stop(self) -> None: + """停止 Named Pipe 服务端并清理资源。""" + for server in self._servers: + server.close() + # 等待所有服务器句柄完全关闭 + await asyncio.gather( + *[asyncio.sleep(0.1) for _ in self._servers], + return_exceptions=True + ) + self._servers.clear() + + def get_address(self) -> str: + return self._address + + +class NamedPipeTransportClient(TransportClient): + """Windows Named Pipe 传输客户端。 + + 用于主动连接到 Named Pipe 服务端。 + """ + + def __init__(self, address: str) -> None: + self._address: str = _normalize_pipe_address(address) + + async def connect(self) -> Connection: + """建立到 Named Pipe 服务端的连接。 + + Returns: + NamedPipeConnection: 连接对象 + + Raises: + NotImplementedError: 当在非 Windows 平台或事件循环不支持时 + """ + if sys.platform != "win32": + raise NotImplementedError("Named pipe 仅支持 Windows") + + loop = asyncio.get_running_loop() + if not hasattr(loop, "create_pipe_connection"): + raise NotImplementedError("当前事件循环不支持 Windows named pipe") + pipe_loop = cast(_NamedPipeEventLoop, loop) + + reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(reader) + transport, _protocol = await pipe_loop.create_pipe_connection(lambda: protocol, self._address) + # 使用返回的 protocol 创建 StreamWriter + writer = asyncio.StreamWriter(cast(asyncio.WriteTransport, transport), _protocol, reader, loop) + return NamedPipeConnection(reader, writer) \ No newline at end of file diff --git a/src/plugin_runtime/transport/tcp.py b/src/plugin_runtime/transport/tcp.py new file mode 100644 index 00000000..4f5d4ee4 --- /dev/null +++ b/src/plugin_runtime/transport/tcp.py @@ -0,0 +1,62 @@ +"""TCP 传输实现。 + +用于显式 TCP 地址场景或调试场景。 +绑定到 127.0.0.1 避免远程访问,但仍需会话令牌做身份校验。 +""" + +from typing import Optional + +import asyncio + +from .base import Connection, ConnectionHandler, TransportClient, TransportServer + + +class TCPConnection(Connection): + """基于 TCP 的连接""" + + pass + + +class TCPTransportServer(TransportServer): + """TCP 传输服务端。""" + + def __init__(self, host: str = "127.0.0.1", port: int = 0) -> None: + self._host = host + self._port = port # 0 表示自动分配 + self._server: Optional[asyncio.AbstractServer] = None + self._actual_port: int = 0 + + async def start(self, handler: ConnectionHandler) -> None: + async def _on_connect(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + conn = TCPConnection(reader, writer) + try: + await handler(conn) + finally: + await conn.close() + + self._server = await asyncio.start_server(_on_connect, self._host, self._port) + + # 获取实际分配的端口 + addr = self._server.sockets[0].getsockname() + self._actual_port = addr[1] + + async def stop(self) -> None: + if self._server: + self._server.close() + await self._server.wait_closed() + self._server = None + + def get_address(self) -> str: + return f"{self._host}:{self._actual_port}" + + +class TCPTransportClient(TransportClient): + """TCP 传输客户端""" + + def __init__(self, host: str, port: int) -> None: + self._host = host + self._port = port + + async def connect(self) -> Connection: + reader, writer = await asyncio.open_connection(self._host, self._port) + return TCPConnection(reader, writer) diff --git a/src/plugin_runtime/transport/uds.py b/src/plugin_runtime/transport/uds.py new file mode 100644 index 00000000..af71ea5d --- /dev/null +++ b/src/plugin_runtime/transport/uds.py @@ -0,0 +1,133 @@ +"""Unix Domain Socket 传输实现 + +适用于 Linux / macOS 平台。 + +注意:UDS (Unix Domain Socket) 是 Unix-like 系统特有的 IPC 机制, +在 Windows 平台上不可用。Windows 平台请使用 Named Pipe 传输。 +""" + +from pathlib import Path +from typing import Optional + +import asyncio +import os +import sys +import tempfile + +from .base import Connection, ConnectionHandler, TransportClient, TransportServer + + +class UDSConnection(Connection): + """基于 UDS 的连接 + + 封装了底层 StreamReader/StreamWriter,提供分帧读写能力。 + """ + + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + super().__init__(reader, writer) + + +# Unix domain socket 路径的系统限制(sun_path 字段长度) +# Linux: 108 字节,macOS: 104 字节,其他 Unix: 通常 104 字节 +if sys.platform == "linux": + _UDS_PATH_MAX = 108 +elif sys.platform == "darwin": # macOS + _UDS_PATH_MAX = 104 +else: + _UDS_PATH_MAX = 104 # 保守默认值 + + +class UDSTransportServer(TransportServer): + """UDS 传输服务端""" + + def __init__(self, socket_path: Optional[Path] = None) -> None: + if socket_path is None: + # 默认放在临时目录,使用 uuid 确保同一进程多实例不碰撞 + import uuid + + socket_path = Path(tempfile.gettempdir()) / f"maibot-plugin-{os.getpid()}-{uuid.uuid4().hex[:8]}.sock" + + # 如果路径超出 UDS 限制,回退到更短的路径 + if len(str(socket_path).encode()) > _UDS_PATH_MAX: + socket_path = Path("/tmp") / f"mb-{os.getpid()}-{uuid.uuid4().hex[:8]}.sock" + if len(str(socket_path).encode()) > _UDS_PATH_MAX: + raise OSError(f"UDS socket 路径过长 ({len(str(socket_path).encode())} > {_UDS_PATH_MAX} 字节): {socket_path}") + + self._socket_path: Path = socket_path + self._server: Optional[asyncio.AbstractServer] = None + + async def start(self, handler: ConnectionHandler) -> None: + """启动 UDS 服务端 + + Args: + handler: 新连接到来时的回调函数 + + Raises: + RuntimeError: 当在非 Unix 平台(如 Windows)上调用时 + """ + # 平台检查:UDS 仅在 Unix-like 系统上可用 + if sys.platform == "win32": + raise RuntimeError("UDS 不支持 Windows 平台,请使用 Named Pipe") + + # 清理残留 socket 文件 + if self._socket_path.exists(): + self._socket_path.unlink() + + # 确保父目录存在 + self._socket_path.parent.mkdir(parents=True, exist_ok=True) + + async def _on_connect(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + conn = UDSConnection(reader, writer) + try: + await handler(conn) + finally: + await conn.close() + + try: + self._server = await asyncio.start_unix_server(_on_connect, path=str(self._socket_path)) + + # 设置文件权限为仅当前用户可访问 + self._socket_path.chmod(0o600) + except Exception: + # 启动失败时清理可能创建的目录和 socket 文件 + if self._socket_path.exists(): + self._socket_path.unlink() + raise + + async def stop(self) -> None: + if self._server: + self._server.close() + await self._server.wait_closed() + self._server = None + # 清理 socket 文件 + if self._socket_path.exists(): + self._socket_path.unlink() + + def get_address(self) -> str: + return str(self._socket_path) + + +class UDSTransportClient(TransportClient): + """UDS 传输客户端 + + 用于主动连接到 UDS 服务端。 + """ + + def __init__(self, socket_path: Path) -> None: + self._socket_path: Path = socket_path + + async def connect(self) -> Connection: + """建立到 UDS 服务端的连接 + + Returns: + UDSConnection: 连接对象 + + Raises: + RuntimeError: 当在非 Unix 平台(如 Windows)上调用时 + """ + # 平台检查:UDS 仅在 Unix-like 系统上可用 + if sys.platform == "win32": + raise RuntimeError("UDS 不支持 Windows 平台,请使用 Named Pipe") + + reader, writer = await asyncio.open_unix_connection(str(self._socket_path)) + return UDSConnection(reader, writer) diff --git a/src/prompt/prompt_manager.py b/src/prompt/prompt_manager.py new file mode 100644 index 00000000..dbc0f98a --- /dev/null +++ b/src/prompt/prompt_manager.py @@ -0,0 +1,381 @@ +from collections.abc import Callable, Coroutine +from pathlib import Path +from string import Formatter +from typing import Any, Optional + +import inspect + +from src.common.i18n import get_locale +from src.common.i18n.loaders import DEFAULT_LOCALE, normalize_locale +from src.common.logger import get_logger +from src.common.prompt_i18n import list_prompt_templates + + +logger = get_logger("Prompt") + +_LEFT_BRACE = chr(0xFDE9) +_RIGHT_BRACE = chr(0xFDEA) + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +PROMPTS_DIR = PROJECT_ROOT / "prompts" +DATA_DIR = PROJECT_ROOT / "data" +CUSTOM_PROMPTS_DIR = DATA_DIR / "custom_prompts" +PROMPTS_DIR.mkdir(parents=True, exist_ok=True) +CUSTOM_PROMPTS_DIR.mkdir(parents=True, exist_ok=True) +SUFFIX_PROMPT = ".prompt" + + +def _normalize_prompt_locale(locale: str | None = None) -> str: + return normalize_locale(locale or get_locale()) + + +def _get_prompt_locale_from_path(prompt_path: Path) -> str | None: + try: + relative_path = prompt_path.resolve().relative_to(PROMPTS_DIR.resolve()) + except ValueError: + return None + + return relative_path.parts[0] if len(relative_path.parts) > 1 else None + + +def _custom_prompt_path(prompt_name: str, locale: str | None = None) -> Path: + return CUSTOM_PROMPTS_DIR / _normalize_prompt_locale(locale) / f"{prompt_name}{SUFFIX_PROMPT}" + + +def _legacy_custom_prompt_path(prompt_name: str) -> Path: + return CUSTOM_PROMPTS_DIR / f"{prompt_name}{SUFFIX_PROMPT}" + + +def _iter_custom_prompt_candidates(prompt_name: str, locale: str | None = None) -> list[Path]: + candidates: list[Path] = [] + if locale: + candidates.append(_custom_prompt_path(prompt_name, locale)) + candidates.append(_legacy_custom_prompt_path(prompt_name)) + return candidates + + +def _iter_active_custom_prompt_dirs() -> list[Path]: + prompt_dirs = [ + CUSTOM_PROMPTS_DIR / DEFAULT_LOCALE, + CUSTOM_PROMPTS_DIR / _normalize_prompt_locale(), + CUSTOM_PROMPTS_DIR, + ] + return list(dict.fromkeys(prompt_dirs)) + + +class Prompt: + def __init__(self, prompt_name: str, template: str) -> None: + self.prompt_name = prompt_name + self.template = template + self.prompt_render_context: dict[str, Callable[[str], str | Coroutine[Any, Any, str]]] = {} + self._is_cloned = False + self.__post_init__() + + def add_context(self, name: str, func_or_str: Callable[[str], str | Coroutine[Any, Any, str]] | str) -> None: + if name in self.prompt_render_context: + raise KeyError(f"Context function name '{name}' 已存在于 Prompt '{self.prompt_name}' 中") + if isinstance(func_or_str, str): + + def tmp_func(_: str) -> str: + return func_or_str + + render_function = tmp_func + else: + render_function = func_or_str + self.prompt_render_context[name] = render_function + + def clone(self) -> "Prompt": + return Prompt(self.prompt_name, self.template) + + @property + def is_cloned(self) -> bool: + return self._is_cloned + + def mark_as_cloned(self) -> None: + self._is_cloned = True + + def __post_init__(self) -> None: + if not self.prompt_name: + raise ValueError("prompt_name 不能为空") + if not self.template: + raise ValueError("template 不能为空") + tmp = self.template.replace("{{", _LEFT_BRACE).replace("}}", _RIGHT_BRACE) + if "{}" in tmp: + raise ValueError(r"模板中不允许使用未命名的占位符 '{}'") + + +class PromptManager: + def __init__(self) -> None: + self.prompts: dict[str, Prompt] = {} + """存储 Prompt 实例,禁止直接从外部访问,否则将引起不可知后果""" + self._context_construct_functions: dict[str, tuple[Callable[[str], str | Coroutine[Any, Any, str]], str]] = {} + """存储上下文构造函数及其所属模块""" + self._formatter = Formatter() # 仅用来解析模板 + """模板解析器""" + self._prompt_to_save: set[str] = set() + """需要保存的 Prompt 名称集合""" + self._prompt_save_locales: dict[str, str] = {} + """Prompt 保存时使用的语言目录""" + + def add_prompt(self, prompt: Prompt, need_save: bool = False, prompt_locale: str | None = None) -> None: + """ + 添加一个新的 Prompt 实例 + + Args: + prompt (Prompt): 要添加的 Prompt 实例 + need_save (bool): 是否需要保存该 Prompt,默认为 False + Raises: + KeyError: 如果 Prompt 名称已存在则引发该异常 + """ + if prompt.prompt_name in self.prompts or prompt.prompt_name in self._context_construct_functions: + # 确保名称无冲突 + raise KeyError(f"Prompt name '{prompt.prompt_name}' 已存在") + self.prompts[prompt.prompt_name] = prompt + if need_save: + self._prompt_to_save.add(prompt.prompt_name) + self._prompt_save_locales[prompt.prompt_name] = _normalize_prompt_locale(prompt_locale) + + def remove_prompt(self, prompt_name: str) -> None: + """ + 移除一个已存在的 Prompt 实例 + Args: + prompt_name (str): 要移除的 Prompt 名称 + Raises: + KeyError: 如果 Prompt 名称不存在则引发该异常 + """ + if prompt_name not in self.prompts: + raise KeyError(f"Prompt name '{prompt_name}' 不存在") + del self.prompts[prompt_name] + if prompt_name in self._prompt_to_save: + self._prompt_to_save.remove(prompt_name) + self._prompt_save_locales.pop(prompt_name, None) + + def replace_prompt(self, prompt: Prompt, need_save: bool = False, prompt_locale: str | None = None) -> None: + """ + 替换一个已存在的 Prompt 实例 + Args: + prompt (Prompt): 要替换的 Prompt 实例 + need_save (bool): 是否需要保存该 Prompt,默认为 False + Raises: + KeyError: 如果 Prompt 名称不存在则引发该异常 + """ + if prompt.prompt_name not in self.prompts: + raise KeyError(f"Prompt name '{prompt.prompt_name}' 不存在,无法替换") + self.prompts[prompt.prompt_name] = prompt + if need_save: + self._prompt_to_save.add(prompt.prompt_name) + self._prompt_save_locales[prompt.prompt_name] = _normalize_prompt_locale(prompt_locale) + elif prompt.prompt_name in self._prompt_to_save: + self._prompt_to_save.remove(prompt.prompt_name) + self._prompt_save_locales.pop(prompt.prompt_name, None) + + def add_context_construct_function(self, name: str, func: Callable[[str], str | Coroutine[Any, Any, str]]) -> None: + """ + 添加一个上下文构造函数 + + Args: + name (str): 上下文名称 + func (Callable[[str], str | Coroutine[Any, Any, str]]): 构造函数,接受 Prompt 名称作为参数,返回字符串或返回字符串的协程 + Raises: + KeyError: 如果上下文名称已存在则引发该异常 + """ + if name in self._context_construct_functions or name in self.prompts: + raise KeyError(f"Construct function name '{name}' 已存在") + # 获取调用栈 + frame = inspect.currentframe() + if not frame: + # 不应该出现的情况 + raise RuntimeError("无法获取调用栈") + caller_frame = frame.f_back + if not caller_frame: + # 不应该出现的情况 + raise RuntimeError("无法获取调用栈的上一级") + caller_module = caller_frame.f_globals.get("__name__", "unknown") + if caller_module == "unknown": + logger.warning("无法获取调用函数的模块名,使用 'unknown' 作为默认值") + + self._context_construct_functions[name] = func, caller_module + + def get_prompt(self, prompt_name: str) -> Prompt: + """ + 获取指定名称的 Prompt 实例的克隆 + + Args: + prompt_name (str): 要获取的 Prompt 名称 + Returns: + return (Prompt): 指定名称的 Prompt 实例的克隆 + Raises: + KeyError: 如果 Prompt 名称不存在则引发该异常 + """ + if prompt_name not in self.prompts: + raise KeyError(f"Prompt name '{prompt_name}' 不存在") + prompt = self.prompts[prompt_name].clone() + prompt.mark_as_cloned() + return prompt + + async def render_prompt(self, prompt: Prompt) -> str: + """ + 渲染一个 Prompt 实例 + + Args: + prompt (Prompt): 要渲染的 Prompt 实例 + Returns: + return (str): 渲染后的字符串 + Raises: + ValueError: 如果传入的 Prompt 实例不是通过 get_prompt 方法获取的克隆实例则引发该异常 + """ + if not prompt.is_cloned: + raise ValueError( + "只能渲染通过 PromptManager.get_prompt 方法获取的 Prompt 实例,你可能对原始实例进行了修改和渲染操作" + ) + return await self._render(prompt) + + async def _render( + self, + prompt: Prompt, + recursive_level: int = 0, + additional_construction_function_dict: dict[str, Callable[[str], str | Coroutine[Any, Any, str]]] | None = None, + ) -> str: + if additional_construction_function_dict is None: + additional_construction_function_dict = {} + prompt.template = prompt.template.replace("{{", _LEFT_BRACE).replace("}}", _RIGHT_BRACE) + if recursive_level > 10: + raise RecursionError("递归层级过深,可能存在循环引用") + field_block = {field_name for _, field_name, _, _ in self._formatter.parse(prompt.template) if field_name} + rendered_fields: dict[str, str] = {} + for field_name in field_block: + if field_name in self.prompts: + nested_prompt = self.get_prompt(field_name) + merged_context = additional_construction_function_dict | prompt.prompt_render_context + rendered_fields[field_name] = await self._render( + nested_prompt, + recursive_level + 1, + merged_context, + ) + elif field_name in prompt.prompt_render_context: + # 优先使用内部构造函数 + func = prompt.prompt_render_context[field_name] + rendered_fields[field_name] = await self._get_function_result( + func, + prompt.prompt_name, + field_name, + is_prompt_context=True, + ) + elif field_name in self._context_construct_functions: + # 随后查找全局构造函数 + func, module = self._context_construct_functions[field_name] + rendered_fields[field_name] = await self._get_function_result( + func, + prompt.prompt_name, + field_name, + is_prompt_context=False, + module=module, + ) + elif field_name in additional_construction_function_dict: + # 最后查找额外传入的构造函数 + func = additional_construction_function_dict[field_name] + rendered_fields[field_name] = await self._get_function_result( + func, + prompt.prompt_name, + field_name, + is_prompt_context=True, + ) + else: + raise KeyError(f"Prompt '{prompt.prompt_name}' 中缺少必要的内容块或构建函数: '{field_name}'") + rendered_template = prompt.template.format(**rendered_fields) + return rendered_template.replace(_LEFT_BRACE, "{").replace(_RIGHT_BRACE, "}") + + def save_prompts(self) -> None: + """ + 保存需要保存的 Prompt 实例到自定义目录,将清空未注册的自定义 Prompt 文件 + Raises: + Exception: 如果在保存过程中出现任何文件操作错误则引发该异常 + """ + # 只清理当前加载语言层的 Prompt 文件,避免误删其它语言的用户自定义模板。 + for prompt_dir in _iter_active_custom_prompt_dirs(): + if not prompt_dir.exists(): + continue + for prompt_file in prompt_dir.glob(f"*{SUFFIX_PROMPT}"): + try: + prompt_file.unlink() + except Exception as exc: + logger.error(f"删除自定义 Prompt 文件 '{prompt_file}' 时出错,错误信息: {exc}") + raise + for prompt_name in self._prompt_to_save: + prompt = self.prompts[prompt_name] + prompt_locale = self._prompt_save_locales.get(prompt_name, _normalize_prompt_locale()) + file_path = _custom_prompt_path(prompt_name, prompt_locale) + try: + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(prompt.template, encoding="utf-8") + except Exception as exc: + logger.error(f"保存 Prompt '{prompt_name}' 时出错,文件路径: '{file_path}',错误信息: {exc}") + raise + + def _load_prompt_template(self, prompt_name: str, source_path: Path) -> tuple[str, bool, str | None]: + prompt_locale = _get_prompt_locale_from_path(source_path) + for custom_prompt_path in _iter_custom_prompt_candidates(prompt_name, prompt_locale): + if custom_prompt_path.exists(): + return custom_prompt_path.read_text(encoding="utf-8"), True, prompt_locale + return source_path.read_text(encoding="utf-8"), False, prompt_locale + + def load_prompts(self) -> None: + """ + 加载全部 Prompt 实例,优先加载自定义目录下的文件,支持覆盖加载 + Raises: + Exception: 如果在加载过程中出现任何文件操作错误则引发该异常 + """ + prompt_templates = list_prompt_templates(prompts_root=PROMPTS_DIR) + for prompt_name, prompt_template in prompt_templates.items(): + try: + template, need_save, prompt_locale = self._load_prompt_template(prompt_name, prompt_template.path) + self.add_prompt( + Prompt(prompt_name=prompt_name, template=template), + need_save=need_save, + prompt_locale=prompt_locale, + ) + except Exception as exc: + logger.error(f"加载 Prompt 文件 '{prompt_template.path}' 时出错,错误信息: {exc}") + raise + loaded_custom_prompts = set(prompt_templates) + for prompt_dir in _iter_active_custom_prompt_dirs(): + if not prompt_dir.exists(): + continue + prompt_locale = prompt_dir.name if prompt_dir.parent == CUSTOM_PROMPTS_DIR else None + for prompt_file in prompt_dir.glob(f"*{SUFFIX_PROMPT}"): + if prompt_file.stem in loaded_custom_prompts: + continue # 已经加载过了,跳过 + try: + template = prompt_file.read_text(encoding="utf-8") + self.add_prompt( + Prompt(prompt_name=prompt_file.stem, template=template), + need_save=True, + prompt_locale=prompt_locale, + ) + loaded_custom_prompts.add(prompt_file.stem) + except Exception as exc: + logger.error(f"加载自定义 Prompt 文件 '{prompt_file}' 时出错,错误信息: {exc}") + raise + + async def _get_function_result( + self, + func: Callable[[str], str | Coroutine[Any, Any, str]], + prompt_name: str, + field_name: str, + is_prompt_context: bool, + module: Optional[str] = None, + ) -> str: + try: + res = func(prompt_name) + if isinstance(res, Coroutine): + res = await res + return res + except Exception as exc: + if is_prompt_context: + logger.error(f"调用 Prompt '{prompt_name}' 内部上下文构造函数 '{field_name}' 时出错,错误信息: {exc}") + else: + logger.error(f"调用上下文构造函数 '{field_name}' 时出错,所属模块: '{module}',错误信息: {exc}") + raise + + +prompt_manager = PromptManager() diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 00000000..7d6f8337 --- /dev/null +++ b/src/services/__init__.py @@ -0,0 +1,7 @@ +""" +核心服务层 + +提供与具体插件系统无关的核心业务服务。 +内部模块(chat、memory 等)应直接使用此层, +而 plugin_system.apis 仅作为面向插件的薄包装。 +""" diff --git a/src/services/database_service.py b/src/services/database_service.py new file mode 100644 index 00000000..2d569f93 --- /dev/null +++ b/src/services/database_service.py @@ -0,0 +1,228 @@ +"""数据库服务模块。""" + +import json +import time +import traceback +from datetime import date, datetime +from enum import Enum +from typing import TYPE_CHECKING, Any, Optional, cast + +from sqlalchemy import delete, func +from sqlmodel import SQLModel, select + +from src.common.database.database import get_db_session +from src.common.database.database_model import ToolRecord +from src.common.logger import get_logger + +if TYPE_CHECKING: + from src.chat.message_receive.chat_manager import BotChatSession + +logger = get_logger("database_service") + + +def _to_msgpack_value(value: Any) -> Any: + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, Enum): + return value.value + if isinstance(value, dict): + return {_to_msgpack_value(key): _to_msgpack_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [_to_msgpack_value(item) for item in value] + return value + + +def _to_dict(record: Any) -> dict[str, Any]: + if record is None: + return {} + if isinstance(record, dict): + return _to_msgpack_value(record) + if hasattr(record, "model_dump"): + return _to_msgpack_value(record.model_dump()) + return _to_msgpack_value(dict(record.__dict__)) if hasattr(record, "__dict__") else {} + + +def _get_model_field(model_class: type[SQLModel], field_name: str) -> Any: + field = getattr(model_class, field_name, None) + if field is None: + raise ValueError(f"{model_class.__name__} 不存在字段 {field_name}") + return field + + +def _build_filters(model_class: type[SQLModel], filters: Optional[dict[str, Any]] = None) -> list[Any]: + if not filters: + return [] + return [_get_model_field(model_class, field_name) == value for field_name, value in filters.items()] + + +def _apply_order_by(statement: Any, model_class: type[SQLModel], order_by: Optional[str | list[str]] = None) -> Any: + if not order_by: + return statement + + order_fields = [order_by] if isinstance(order_by, str) else order_by + clauses = [] + for item in order_fields: + descending = item.startswith("-") + field_name = item[1:] if descending else item + field = _get_model_field(model_class, field_name) + clauses.append(field.desc() if descending else field.asc()) + return statement.order_by(*clauses) + + +async def db_save( + model_class: type[SQLModel], + data: dict[str, Any], + key_field: Optional[str] = None, + key_value: Optional[Any] = None, +) -> Optional[dict[str, Any]]: + try: + with get_db_session() as session: + record = None + if key_field and key_value is not None: + key_column = _get_model_field(model_class, key_field) + record = session.exec(cast(Any, select(model_class).where(key_column == key_value))).first() + + if record is None: + record = model_class(**data) + else: + for field_name, value in data.items(): + _get_model_field(model_class, field_name) + setattr(record, field_name, value) + + session.add(record) + session.flush() + session.refresh(record) + return _to_dict(record) + except Exception as e: + logger.error(f"[DatabaseService] 保存数据库记录出错: {e}") + traceback.print_exc() + return None + + +async def db_get( + model_class: type[SQLModel], + filters: Optional[dict[str, Any]] = None, + limit: Optional[int] = None, + order_by: Optional[str | list[str]] = None, + single_result: bool = False, +) -> Optional[dict[str, Any]] | list[dict[str, Any]]: + try: + with get_db_session(auto_commit=False) as session: + statement = select(model_class) + if conditions := _build_filters(model_class, filters): + statement = statement.where(*conditions) + statement = _apply_order_by(statement, model_class, order_by) + if limit: + statement = statement.limit(limit) + results = session.exec(cast(Any, statement)).all() + data = [_to_dict(item) for item in results] + if single_result: + return data[0] if data else None + return data + except Exception as e: + logger.error(f"[DatabaseService] 获取数据库记录出错: {e}") + traceback.print_exc() + return None if single_result else [] + + +async def db_update(model_class: type[SQLModel], data: dict[str, Any], filters: Optional[dict[str, Any]] = None) -> int: + try: + with get_db_session() as session: + statement = select(model_class) + if conditions := _build_filters(model_class, filters): + statement = statement.where(*conditions) + records = session.exec(cast(Any, statement)).all() + for record in records: + for field_name, value in data.items(): + _get_model_field(model_class, field_name) + setattr(record, field_name, value) + session.add(record) + return len(records) + except Exception as e: + logger.error(f"[DatabaseService] 更新数据库记录出错: {e}") + traceback.print_exc() + return 0 + + +async def db_delete(model_class: type[SQLModel], filters: Optional[dict[str, Any]] = None) -> int: + try: + with get_db_session() as session: + statement = delete(model_class) + if conditions := _build_filters(model_class, filters): + statement = statement.where(*conditions) + result = session.exec(statement) + return result.rowcount or 0 + except Exception as e: + logger.error(f"[DatabaseService] 删除数据库记录出错: {e}") + traceback.print_exc() + return 0 + + +async def db_count(model_class: type[SQLModel], filters: Optional[dict[str, Any]] = None) -> int: + try: + with get_db_session(auto_commit=False) as session: + statement = select(func.count()).select_from(model_class) + if conditions := _build_filters(model_class, filters): + statement = statement.where(*conditions) + result = session.exec(cast(Any, statement)).one() + return int(result or 0) + except Exception as e: + logger.error(f"[DatabaseService] 统计数据库记录出错: {e}") + traceback.print_exc() + return 0 + + +async def store_tool_info( + chat_stream: "BotChatSession", + builtin_prompt: Optional[str] = None, + display_prompt: str = "", + tool_id: str = "", + tool_data: Optional[dict[str, Any]] = None, + tool_name: str = "", + tool_reasoning: str = "", +) -> Optional[dict[str, Any]]: + try: + record_data = { + "tool_id": tool_id or str(int(time.time() * 1000000)), + "timestamp": datetime.now(), + "session_id": chat_stream.session_id, + "tool_name": tool_name, + "tool_data": json.dumps(tool_data or {}, ensure_ascii=False), + "tool_reasoning": tool_reasoning, + "tool_builtin_prompt": builtin_prompt, + "tool_display_prompt": display_prompt, + } + + saved_record = await db_save(ToolRecord, data=record_data, key_field="tool_id", key_value=record_data["tool_id"]) + if saved_record: + logger.debug(f"[DatabaseService] 成功存储工具信息: {tool_name} (ID: {record_data['tool_id']})") + else: + logger.error(f"[DatabaseService] 存储工具信息失败: {tool_name}") + return saved_record + except Exception as e: + logger.error(f"[DatabaseService] 存储工具信息时发生错误: {e}") + traceback.print_exc() + return None + + +async def store_action_info( + chat_stream: "BotChatSession", + builtin_prompt: Optional[str] = None, + display_prompt: str = "", + thinking_id: str = "", + action_data: Optional[dict[str, Any]] = None, + action_name: str = "", + action_reasoning: str = "", +) -> Optional[dict[str, Any]]: + """兼容旧接口,内部转发到 ``store_tool_info``。""" + return await store_tool_info( + chat_stream=chat_stream, + builtin_prompt=builtin_prompt, + display_prompt=display_prompt, + tool_id=thinking_id, + tool_data=action_data, + tool_name=action_name, + tool_reasoning=action_reasoning, + ) diff --git a/src/services/embedding_service.py b/src/services/embedding_service.py new file mode 100644 index 00000000..1a806cd0 --- /dev/null +++ b/src/services/embedding_service.py @@ -0,0 +1,160 @@ +"""Embedding 服务层。 + +该模块负责在宿主侧收口统一的文本嵌入请求,并将其转发到 +`src.llm_models` 中的底层嵌入调度器。 +""" + +from __future__ import annotations + +from typing import Any, Coroutine, List, TypeVar + +import asyncio + +from src.common.data_models.embedding_service_data_models import EmbeddingResult +from src.common.logger import get_logger +from src.llm_models.utils_model import LLMOrchestrator +from src.services.service_task_resolver import resolve_task_name + +logger = get_logger("embedding_service") + +_CoroutineReturnT = TypeVar("_CoroutineReturnT") + + +class EmbeddingServiceClient: + """面向上层模块的 Embedding 服务对象式门面。""" + + def __init__(self, task_name: str = "embedding", request_type: str = "") -> None: + """初始化 Embedding 服务门面。 + + Args: + task_name: 任务配置名称,对应 `model_task_config` 下的字段名。 + request_type: 当前请求的业务类型标识。 + """ + self.task_name = resolve_task_name(task_name) + self.request_type = request_type + self._orchestrator = LLMOrchestrator(task_name=self.task_name, request_type=request_type) + + async def embed_text(self, embedding_input: str) -> EmbeddingResult: + """生成单条文本的嵌入向量。 + + Args: + embedding_input: 待编码的文本内容。 + + Returns: + EmbeddingResult: 统一嵌入结果对象。 + """ + raw_result = await self._orchestrator.get_embedding(embedding_input) + return EmbeddingResult( + embedding=list(raw_result.embedding), + model_name=raw_result.model_name, + ) + + async def embed_texts( + self, + embedding_inputs: List[str], + max_concurrent: int | None = None, + ) -> List[EmbeddingResult]: + """批量生成文本嵌入向量。 + + Args: + embedding_inputs: 待编码的文本列表。 + max_concurrent: 最大并发数;未提供时按串行执行。 + + Returns: + List[EmbeddingResult]: 与输入顺序一致的嵌入结果列表。 + """ + if not embedding_inputs: + return [] + + safe_max_concurrent = max(1, int(max_concurrent or 1)) + if safe_max_concurrent == 1: + results: List[EmbeddingResult] = [] + for embedding_input in embedding_inputs: + results.append(await self.embed_text(embedding_input)) + return results + + semaphore = asyncio.Semaphore(safe_max_concurrent) + + async def _embed_one(index: int, embedding_input: str) -> tuple[int, EmbeddingResult]: + """执行单条嵌入并保留原始顺序索引。 + + Args: + index: 原始输入索引。 + embedding_input: 待编码的文本内容。 + + Returns: + tuple[int, EmbeddingResult]: 输入索引与对应嵌入结果。 + """ + async with semaphore: + result = await self.embed_text(embedding_input) + return index, result + + ordered_results = await asyncio.gather( + *[_embed_one(index, embedding_input) for index, embedding_input in enumerate(embedding_inputs)] + ) + ordered_results.sort(key=lambda item: item[0]) + return [result for _, result in ordered_results] + + def embed_text_sync(self, embedding_input: str) -> EmbeddingResult: + """以同步方式生成单条文本的嵌入向量。 + + Args: + embedding_input: 待编码的文本内容。 + + Returns: + EmbeddingResult: 统一嵌入结果对象。 + """ + return self._run_coroutine_sync(self.embed_text(embedding_input)) + + def embed_texts_sync( + self, + embedding_inputs: List[str], + max_concurrent: int | None = None, + ) -> List[EmbeddingResult]: + """以同步方式批量生成文本嵌入向量。 + + Args: + embedding_inputs: 待编码的文本列表。 + max_concurrent: 最大并发数;未提供时按串行执行。 + + Returns: + List[EmbeddingResult]: 与输入顺序一致的嵌入结果列表。 + """ + return self._run_coroutine_sync( + self.embed_texts( + embedding_inputs, + max_concurrent=max_concurrent, + ) + ) + + @staticmethod + def _run_coroutine_sync(coroutine: Coroutine[Any, Any, _CoroutineReturnT]) -> _CoroutineReturnT: + """在独立事件循环中执行协程。 + + Args: + coroutine: 需要同步执行的协程对象。 + + Returns: + _CoroutineReturnT: 协程返回值。 + + Raises: + RuntimeError: 当前线程已有运行中的事件循环时抛出。 + """ + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + raise RuntimeError("当前线程存在运行中的事件循环,请改用异步 Embedding 接口") + + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + return loop.run_until_complete(coroutine) + finally: + try: + loop.run_until_complete(loop.shutdown_asyncgens()) + except Exception as exc: + logger.debug(f"关闭 EmbeddingService 临时异步生成器失败: {exc}") + asyncio.set_event_loop(None) + loop.close() diff --git a/src/services/generator_service.py b/src/services/generator_service.py new file mode 100644 index 00000000..bc5aa190 --- /dev/null +++ b/src/services/generator_service.py @@ -0,0 +1,225 @@ +""" +回复器服务模块 + +提供回复器相关的核心功能。 +""" + +import traceback +import time +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from rich.traceback import install + +from src.chat.message_receive.chat_manager import BotChatSession +from src.chat.replyer.maisaka_generator import MaisakaReplyGenerator +from src.chat.replyer.replyer_manager import replyer_manager +from src.chat.utils.utils import process_llm_response +from src.common.data_models.message_component_data_model import MessageSequence, TextComponent +from src.common.logger import get_logger +from src.core.types import ActionInfo + +if TYPE_CHECKING: + from src.common.data_models.llm_data_model import LLMGenerationDataModel + from src.common.data_models.planned_action_data_models import PlannedAction + from src.chat.message_receive.message import SessionMessage + +install(extra_lines=3) + +logger = get_logger("generator_service") + + +# ============================================================================= +# 回复器获取函数 +# ============================================================================= + + +def _get_replyer( + chat_stream: Optional[BotChatSession] = None, + chat_id: Optional[str] = None, + request_type: str = "replyer", +) -> Optional[MaisakaReplyGenerator]: + """获取回复器对象""" + if not chat_id and not chat_stream: + raise ValueError("chat_stream 和 chat_id 不可均为空") + try: + logger.debug( + f"[GeneratorService] 正在获取回复器,chat_id: {chat_id}, chat_stream: {'有' if chat_stream else '无'}" + ) + return replyer_manager.get_replyer( + chat_stream=chat_stream, + chat_id=chat_id, + request_type=request_type, + ) + except Exception as e: + logger.error(f"[GeneratorService] 获取回复器时发生意外错误: {e}", exc_info=True) + traceback.print_exc() + return None + + +def _extract_unknown_words(action_data: Optional[Dict[str, Any]]) -> Optional[List[str]]: + if not action_data: + return None + + unknown_words = action_data.get("unknown_words") + if not isinstance(unknown_words, list): + return None + + cleaned_words: List[str] = [] + for item in unknown_words: + if isinstance(item, str) and (cleaned_item := item.strip()): + cleaned_words.append(cleaned_item) + + return cleaned_words or None + + +def _build_message_sequence( + content: Optional[str], + *, + enable_splitter: bool, + enable_chinese_typo: bool, +) -> tuple[Optional[MessageSequence], List[str]]: + if not content: + return None, [] + + processed_output = process_llm_response(content, enable_splitter, enable_chinese_typo) + return MessageSequence(components=[TextComponent(text) for text in processed_output]), processed_output + + +# ============================================================================= +# 回复生成函数 +# ============================================================================= + + +async def generate_reply( + chat_stream: Optional[BotChatSession] = None, + chat_id: Optional[str] = None, + action_data: Optional[Dict[str, Any]] = None, + reply_message: Optional["SessionMessage"] = None, + think_level: int = 1, + extra_info: str = "", + reply_reason: str = "", + available_actions: Optional[Dict[str, ActionInfo]] = None, + chosen_actions: Optional[List["PlannedAction"]] = None, + unknown_words: Optional[List[str]] = None, + enable_splitter: bool = True, + enable_chinese_typo: bool = True, + request_type: str = "generator_api", + from_plugin: bool = True, + reply_time_point: Optional[float] = None, +) -> Tuple[bool, Optional["LLMGenerationDataModel"]]: + """生成回复""" + try: + if reply_time_point is None: + reply_time_point = time.time() + + logger.debug("[GeneratorService] 开始生成回复") + replyer = _get_replyer(chat_stream, chat_id, request_type=request_type) + if not replyer: + logger.error("[GeneratorService] 无法获取回复器") + return False, None + + if action_data: + if not extra_info: + extra_info = action_data.get("extra_info", "") + if not reply_reason: + reply_reason = action_data.get("reason", "") + if unknown_words is None: + unknown_words = _extract_unknown_words(action_data) + + success, llm_response = await replyer.generate_reply_with_context( + extra_info=extra_info, + available_actions=available_actions, + chosen_actions=chosen_actions, + reply_message=reply_message, + reply_reason=reply_reason, + unknown_words=unknown_words, + think_level=think_level, + from_plugin=from_plugin, + stream_id=chat_stream.session_id if chat_stream else chat_id, + reply_time_point=reply_time_point, + log_reply=False, + ) + if not success: + logger.warning("[GeneratorService] 回复生成失败") + return False, None + reply_set, processed_output = _build_message_sequence( + llm_response.content, + enable_splitter=enable_splitter, + enable_chinese_typo=enable_chinese_typo, + ) + llm_response.processed_output = processed_output + llm_response.reply_set = reply_set + logger.debug( + f"[GeneratorService] 回复生成成功,生成了 {len(reply_set.components) if reply_set else 0} 个回复项" + ) + + return success, llm_response + + except ValueError as ve: + raise ve + + except UserWarning as uw: + logger.warning(f"[GeneratorService] 中断了生成: {uw}") + return False, None + + except Exception as e: + logger.error(f"[GeneratorService] 生成回复时出错: {e}") + logger.error(traceback.format_exc()) + return False, None + + +async def rewrite_reply( + chat_stream: Optional[BotChatSession] = None, + reply_data: Optional[Dict[str, Any]] = None, + chat_id: Optional[str] = None, + enable_splitter: bool = True, + enable_chinese_typo: bool = True, + raw_reply: str = "", + reason: str = "", + reply_to: str = "", + request_type: str = "generator_api", +) -> Tuple[bool, Optional["LLMGenerationDataModel"]]: + """重写回复""" + try: + replyer = _get_replyer(chat_stream, chat_id, request_type=request_type) + if not replyer: + logger.error("[GeneratorService] 无法获取回复器") + return False, None + + logger.info("[GeneratorService] 开始重写回复") + + if reply_data: + raw_reply = raw_reply or reply_data.get("raw_reply", "") + reason = reason or reply_data.get("reason", "") + reply_to = reply_to or reply_data.get("reply_to", "") + + success, llm_response = await replyer.rewrite_reply_with_context( + raw_reply=raw_reply, + reason=reason, + reply_to=reply_to, + ) + reply_set, processed_output = _build_message_sequence( + llm_response.content if success and llm_response else None, + enable_splitter=enable_splitter, + enable_chinese_typo=enable_chinese_typo, + ) + if llm_response is not None: + llm_response.processed_output = processed_output + llm_response.reply_set = reply_set + if success: + logger.info( + f"[GeneratorService] 重写回复成功,生成了 {len(reply_set.components) if reply_set else 0} 个回复项" + ) + else: + logger.warning("[GeneratorService] 重写回复失败") + + return success, llm_response + + except ValueError as ve: + raise ve + + except Exception as e: + logger.error(f"[GeneratorService] 重写回复时出错: {e}") + return False, None + + diff --git a/src/services/html_render_service.py b/src/services/html_render_service.py new file mode 100644 index 00000000..6d564ef4 --- /dev/null +++ b/src/services/html_render_service.py @@ -0,0 +1,937 @@ +"""HTML 浏览器渲染服务。 + +负责在 Host 侧复用已有浏览器,并将 HTML 内容渲染为 PNG 图片。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from importlib import metadata +from io import BytesIO +from pathlib import Path +from typing import Any, Dict, Literal, Optional, Tuple, cast +from urllib.parse import urlparse + +import asyncio +import base64 +import contextlib +import functools +import json +import os +import shutil +import sys +import time + +from src.common.logger import PROJECT_ROOT, get_logger +from src.config.config import config_manager +from src.config.official_configs import PluginRuntimeRenderConfig + +logger = get_logger("services.html_render_service") + +_NETWORK_ALLOW_SCHEMES = frozenset({"about", "blob", "data", "file"}) +_WINDOWS_BROWSER_PATHS = ( + Path("C:/Program Files/Google/Chrome/Application/chrome.exe"), + Path("C:/Program Files (x86)/Google/Chrome/Application/chrome.exe"), + Path("C:/Program Files/Microsoft/Edge/Application/msedge.exe"), + Path("C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"), +) +_MACOS_BROWSER_PATHS = ( + Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + Path("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"), +) +_UNIX_BROWSER_NAMES = ( + "chromium", + "chromium-browser", + "google-chrome", + "google-chrome-stable", + "microsoft-edge", + "msedge", +) +_PLAYWRIGHT_MANAGED_BROWSER_PREFIXES = ("chromium-", "chrome-", "chrome-headless-shell-") + + +@dataclass(slots=True) +class HtmlRenderRequest: + """描述一次 HTML 转 PNG 请求。""" + + html: str + selector: str = "body" + viewport_width: int = 900 + viewport_height: int = 500 + device_scale_factor: float = 2.0 + full_page: bool = False + omit_background: bool = False + wait_until: str = "load" + wait_for_selector: str = "" + wait_for_timeout_ms: int = 0 + timeout_ms: int = 10000 + allow_network: bool = False + + +@dataclass(slots=True) +class HtmlRenderResult: + """描述一次 HTML 转 PNG 的输出结果。""" + + image_base64: str + mime_type: str + width: int + height: int + render_ms: int + + def to_payload(self) -> Dict[str, Any]: + """将结果序列化为能力层返回结构。 + + Returns: + Dict[str, Any]: 可直接返回给插件运行时的结构化数据。 + """ + + return { + "image_base64": self.image_base64, + "mime_type": self.mime_type, + "width": self.width, + "height": self.height, + "render_ms": self.render_ms, + } + + +@dataclass(slots=True) +class ManagedBrowserRecord: + """记录 Playwright 托管浏览器的本地状态。""" + + browser_name: str + browsers_path: str + install_source: Literal["auto_download", "existing_cache"] + playwright_version: str + recorded_at: str + last_verified_at: str + + def to_dict(self) -> Dict[str, str]: + """将浏览器记录转换为可持久化字典。 + + Returns: + Dict[str, str]: 可写入 JSON 文件的字典结构。 + """ + + return { + "browser_name": self.browser_name, + "browsers_path": self.browsers_path, + "install_source": self.install_source, + "playwright_version": self.playwright_version, + "recorded_at": self.recorded_at, + "last_verified_at": self.last_verified_at, + } + + @classmethod + def from_dict(cls, payload: Dict[str, Any]) -> Optional["ManagedBrowserRecord"]: + """从字典中恢复浏览器状态记录。 + + Args: + payload: 原始字典数据。 + + Returns: + Optional[ManagedBrowserRecord]: 解析成功时返回记录对象,否则返回 ``None``。 + """ + + browser_name = str(payload.get("browser_name", "") or "").strip() + browsers_path = str(payload.get("browsers_path", "") or "").strip() + install_source = str(payload.get("install_source", "") or "").strip() + playwright_version = str(payload.get("playwright_version", "") or "").strip() + recorded_at = str(payload.get("recorded_at", "") or "").strip() + last_verified_at = str(payload.get("last_verified_at", "") or "").strip() + if not all([browser_name, browsers_path, install_source, playwright_version, recorded_at, last_verified_at]): + return None + if install_source not in {"auto_download", "existing_cache"}: + return None + validated_install_source = cast(Literal["auto_download", "existing_cache"], install_source) + return cls( + browser_name=browser_name, + browsers_path=browsers_path, + install_source=validated_install_source, + playwright_version=playwright_version, + recorded_at=recorded_at, + last_verified_at=last_verified_at, + ) + + +class HTMLRenderService: + """HTML 浏览器渲染服务。""" + + def __init__(self) -> None: + """初始化渲染服务。""" + + self._browser: Any = None + self._browser_lock: asyncio.Lock = asyncio.Lock() + self._connected_via_cdp: bool = False + self._playwright: Any = None + self._render_count: int = 0 + self._render_semaphore: Optional[asyncio.Semaphore] = None + self._render_semaphore_limit: int = 0 + + def _get_render_config(self) -> PluginRuntimeRenderConfig: + """读取当前插件运行时的浏览器渲染配置。 + + Returns: + PluginRuntimeRenderConfig: 当前生效的浏览器渲染配置。 + """ + + return config_manager.get_global_config().plugin_runtime.render + + def _get_render_semaphore(self) -> asyncio.Semaphore: + """根据当前配置返回渲染并发信号量。 + + Returns: + asyncio.Semaphore: 控制并发的信号量对象。 + """ + + config = self._get_render_config() + limit = max(1, int(config.concurrency_limit)) + if self._render_semaphore is None or self._render_semaphore_limit != limit: + self._render_semaphore = asyncio.Semaphore(limit) + self._render_semaphore_limit = limit + return self._render_semaphore + + async def render_html_to_png(self, request: HtmlRenderRequest) -> HtmlRenderResult: + """将 HTML 内容渲染为 PNG 图片。 + + Args: + request: 本次渲染请求。 + + Returns: + HtmlRenderResult: 渲染结果。 + + Raises: + RuntimeError: 浏览器能力被禁用、Playwright 不可用或浏览器启动失败时抛出。 + ValueError: 请求参数非法时抛出。 + """ + + config = self._get_render_config() + if not config.enabled: + raise RuntimeError("插件运行时浏览器渲染能力已禁用") + + normalized_request = self._normalize_request(request, config) + semaphore = self._get_render_semaphore() + async with semaphore: + start_time = time.perf_counter() + browser = await self._ensure_browser(config) + context: Any = None + try: + context = await browser.new_context( + device_scale_factor=normalized_request.device_scale_factor, + locale="zh-CN", + viewport={ + "width": normalized_request.viewport_width, + "height": normalized_request.viewport_height, + }, + ) + page = await context.new_page() + await self._configure_page(page, normalized_request) + image_bytes = await self._capture_image(page, normalized_request) + width, height = self._measure_image_size(image_bytes) + self._render_count += 1 + await self._maybe_restart_browser(config) + return HtmlRenderResult( + image_base64=base64.b64encode(image_bytes).decode("utf-8"), + mime_type="image/png", + width=width, + height=height, + render_ms=int((time.perf_counter() - start_time) * 1000), + ) + except Exception: + await self.reset_browser(restart_playwright=False) + raise + finally: + if context is not None: + with contextlib.suppress(Exception): + await context.close() + + async def reset_browser(self, restart_playwright: bool = False) -> None: + """关闭当前缓存的浏览器实例。 + + Args: + restart_playwright: 是否同时关闭 Playwright 运行时。 + """ + + async with self._browser_lock: + await self._close_browser_unlocked(restart_playwright=restart_playwright) + + async def _close_browser_unlocked(self, restart_playwright: bool = False) -> None: + """在已持有锁的情况下关闭浏览器与 Playwright。 + + Args: + restart_playwright: 是否同时关闭 Playwright 运行时。 + """ + + if self._browser is not None: + with contextlib.suppress(Exception): + await self._browser.close() + self._browser = None + self._connected_via_cdp = False + if restart_playwright and self._playwright is not None: + with contextlib.suppress(Exception): + await self._playwright.stop() + self._playwright = None + + async def _ensure_browser(self, config: PluginRuntimeRenderConfig) -> Any: + """获取可复用的浏览器实例。 + + Args: + config: 当前浏览器渲染配置。 + + Returns: + Any: Playwright Browser 对象。 + + Raises: + RuntimeError: 当无法连接或启动浏览器时抛出。 + """ + + async with self._browser_lock: + if self._is_browser_connected(self._browser): + logger.debug("HTML 渲染服务复用进程内缓存浏览器实例") + return self._browser + + await self._close_browser_unlocked(restart_playwright=False) + self._prepare_playwright_environment(config) + playwright = await self._ensure_playwright() + browser = await self._connect_to_existing_browser(playwright, config) + if browser is None: + browser = await self._launch_browser(playwright, config) + self._connected_via_cdp = False + else: + self._connected_via_cdp = True + + self._browser = browser + self._bind_browser_events(browser) + return browser + + async def _ensure_playwright(self) -> Any: + """懒加载并启动 Playwright 运行时。 + + Returns: + Any: 已启动的 Playwright 对象。 + + Raises: + RuntimeError: 当前环境未安装 Playwright 时抛出。 + """ + + if self._playwright is not None: + return self._playwright + + try: + from playwright.async_api import async_playwright + except ImportError as exc: + raise RuntimeError( + "当前环境未安装 Python Playwright,请先在宿主环境安装 `playwright` 依赖。" + ) from exc + + self._playwright = await async_playwright().start() + return self._playwright + + @staticmethod + def _is_browser_connected(browser: Any) -> bool: + """判断浏览器对象当前是否仍然可用。 + + Args: + browser: 待检查的浏览器对象。 + + Returns: + bool: 若浏览器仍连接,则返回 ``True``。 + """ + + if browser is None: + return False + try: + return bool(browser.is_connected()) + except Exception: + return False + + async def _connect_to_existing_browser(self, playwright: Any, config: PluginRuntimeRenderConfig) -> Any: + """优先连接外部已有的 Chromium 浏览器。 + + Args: + playwright: 已启动的 Playwright 对象。 + config: 当前浏览器渲染配置。 + + Returns: + Any: 连接成功时返回 Browser;否则返回 ``None``。 + """ + + if not config.browser_ws_endpoint.strip(): + return None + + try: + timeout_ms = int(config.startup_timeout_sec * 1000) + logger.info( + "HTML 渲染服务准备连接现有浏览器: " + f"endpoint={config.browser_ws_endpoint.strip()}, timeout_ms={timeout_ms}" + ) + browser = await playwright.chromium.connect_over_cdp( + config.browser_ws_endpoint.strip(), + timeout=timeout_ms, + ) + logger.info("HTML 渲染服务已连接到现有浏览器") + return browser + except Exception as exc: + logger.warning(f"连接现有浏览器失败,将回退为本地启动: {exc}") + return None + + async def _launch_browser(self, playwright: Any, config: PluginRuntimeRenderConfig) -> Any: + """启动本地 Chromium 浏览器。 + + Args: + playwright: 已启动的 Playwright 对象。 + config: 当前浏览器渲染配置。 + + Returns: + Any: 新启动的 Browser 对象。 + + Raises: + RuntimeError: 浏览器启动失败时抛出。 + """ + + launch_options = self._build_launch_options(config) + logger.info( + "HTML 渲染服务准备启动浏览器: " + f"source={'system' if 'executable_path' in launch_options else 'managed'}, " + f"headless={bool(launch_options.get('headless'))}, " + f"timeout_ms={int(launch_options.get('timeout', 0))}" + ) + try: + browser = await playwright.chromium.launch(**launch_options) + if "executable_path" in launch_options: + logger.info(f"HTML 渲染服务已启动本机浏览器: executable_path={launch_options['executable_path']}") + else: + self._update_managed_browser_record(config, install_source="existing_cache") + logger.info("HTML 渲染服务已启动 Playwright 托管浏览器") + return browser + except Exception as exc: + if self._should_auto_download_browser(exc, launch_options, config): + logger.warning(f"HTML 渲染服务未找到可用浏览器,将尝试自动下载 Chromium: {exc}") + await self._install_chromium_browser(config) + retry_browser = await playwright.chromium.launch(**launch_options) + self._update_managed_browser_record(config, install_source="auto_download") + logger.info("HTML 渲染服务已自动下载并启动 Chromium") + return retry_browser + raise RuntimeError(f"启动本地浏览器失败: {exc}") from exc + + def _bind_browser_events(self, browser: Any) -> None: + """为浏览器绑定断线回调。 + + Args: + browser: 需要绑定事件的浏览器对象。 + """ + + try: + browser.on("disconnected", self._handle_browser_disconnected) + except Exception: + return + + def _handle_browser_disconnected(self, *_args: Any) -> None: + """处理浏览器断线事件。 + + Args: + *_args: 浏览器断线事件透传的参数。 + """ + + self._browser = None + self._connected_via_cdp = False + logger.warning("HTML 渲染浏览器已断开,将在下次请求时重新建立连接") + + def _build_launch_options(self, config: PluginRuntimeRenderConfig) -> Dict[str, Any]: + """构造本地浏览器启动参数。 + + Args: + config: 当前浏览器渲染配置。 + + Returns: + Dict[str, Any]: 可直接传给 Playwright 的启动参数。 + """ + + launch_options: Dict[str, Any] = { + "args": list(config.launch_args), + "headless": bool(config.headless), + "timeout": int(config.startup_timeout_sec * 1000), + } + executable_path = self._resolve_executable_path(config) + if executable_path: + launch_options["executable_path"] = executable_path + return launch_options + + @staticmethod + def _should_auto_download_browser( + exc: Exception, + launch_options: Dict[str, Any], + config: PluginRuntimeRenderConfig, + ) -> bool: + """判断当前启动错误是否适合自动下载 Chromium 后重试。 + + Args: + exc: 浏览器启动异常。 + launch_options: 本次启动参数。 + config: 当前浏览器渲染配置。 + + Returns: + bool: 若应自动下载后重试,则返回 ``True``。 + """ + + if "executable_path" in launch_options: + logger.debug("当前启动参数已指定本机浏览器路径,不进入自动下载分支") + return False + if not config.auto_download_chromium: + logger.warning("HTML 渲染服务未检测到可用浏览器,且已禁用自动下载 Chromium") + return False + error_text = str(exc).lower() + should_download = "executable doesn't exist" in error_text or "browser executable" in error_text + if not should_download: + logger.warning(f"浏览器启动失败,但错误不属于可自动下载恢复的类型: {exc}") + return should_download + + def _resolve_executable_path(self, config: PluginRuntimeRenderConfig) -> str: + """解析实际应使用的浏览器可执行文件路径。 + + Args: + config: 当前浏览器渲染配置。 + + Returns: + str: 命中的浏览器可执行文件路径;未命中时返回空字符串。 + """ + + configured_path = config.executable_path.strip() + if configured_path: + path = Path(configured_path).expanduser() + if path.exists(): + logger.info(f"HTML 渲染服务使用配置指定的浏览器路径: {path}") + return str(path) + logger.warning(f"配置的浏览器路径不存在,将尝试自动探测: {configured_path}") + + detected_path = self._detect_local_browser_executable() + if detected_path: + logger.info(f"HTML 渲染服务自动探测到本机浏览器: {detected_path}") + else: + logger.info("HTML 渲染服务未探测到本机浏览器,将尝试使用 Playwright 托管浏览器") + return detected_path + + def _prepare_playwright_environment(self, config: PluginRuntimeRenderConfig) -> Path: + """准备 Playwright 运行所需的共享浏览器目录环境变量。 + + Args: + config: 当前浏览器渲染配置。 + + Returns: + Path: Playwright 浏览器缓存目录。 + """ + + browsers_path = self._get_managed_browsers_path(config) + browsers_path.mkdir(parents=True, exist_ok=True) + os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(browsers_path) + logger.debug(f"HTML 渲染服务使用 Playwright 浏览器目录: {browsers_path}") + return browsers_path + + def _get_managed_browsers_path(self, config: PluginRuntimeRenderConfig) -> Path: + """获取 Playwright 托管浏览器目录。 + + Args: + config: 当前浏览器渲染配置。 + + Returns: + Path: 托管浏览器目录的绝对路径。 + """ + + configured_path = config.browser_install_root.strip() + if not configured_path: + return (PROJECT_ROOT / "data" / "playwright-browsers").resolve() + candidate_path = Path(configured_path).expanduser() + if candidate_path.is_absolute(): + return candidate_path.resolve() + return (PROJECT_ROOT / candidate_path).resolve() + + def _get_browser_state_path(self) -> Path: + """获取托管浏览器状态文件路径。 + + Returns: + Path: 浏览器状态文件路径。 + """ + + return (PROJECT_ROOT / "data" / "plugin_runtime" / "html_render_browser_state.json").resolve() + + def _load_managed_browser_record(self) -> Optional[ManagedBrowserRecord]: + """读取最近一次成功使用的托管浏览器记录。 + + Returns: + Optional[ManagedBrowserRecord]: 解析成功时返回记录对象,否则返回 ``None``。 + """ + + state_path = self._get_browser_state_path() + if not state_path.exists(): + return None + + try: + raw_payload = json.loads(state_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + logger.warning(f"HTML 渲染浏览器状态文件读取失败,将忽略并继续: {state_path}") + return None + if not isinstance(raw_payload, dict): + logger.warning(f"HTML 渲染浏览器状态文件格式无效,将忽略并继续: {state_path}") + return None + browser_record = ManagedBrowserRecord.from_dict(raw_payload) + if browser_record is not None: + logger.debug( + "HTML 渲染服务已加载浏览器状态记录: " + f"source={browser_record.install_source}, path={browser_record.browsers_path}, " + f"verified_at={browser_record.last_verified_at}" + ) + return browser_record + + def _save_managed_browser_record(self, record: ManagedBrowserRecord) -> None: + """保存托管浏览器记录。 + + Args: + record: 待保存的浏览器记录。 + """ + + state_path = self._get_browser_state_path() + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text( + json.dumps(record.to_dict(), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + logger.info( + "HTML 渲染服务已写入浏览器状态记录: " + f"path={state_path}, source={record.install_source}, browsers_path={record.browsers_path}" + ) + + def _update_managed_browser_record( + self, + config: PluginRuntimeRenderConfig, + install_source: Literal["auto_download", "existing_cache"], + ) -> None: + """更新托管 Chromium 的使用记录。 + + Args: + config: 当前浏览器渲染配置。 + install_source: 本次记录的浏览器来源。 + """ + + browsers_path = self._get_managed_browsers_path(config) + if not self._has_managed_browser_artifact(browsers_path): + return + + now_iso = datetime.now(timezone.utc).isoformat() + existing_record = self._load_managed_browser_record() + recorded_at = now_iso + if existing_record is not None and existing_record.browsers_path == str(browsers_path): + recorded_at = existing_record.recorded_at + + self._save_managed_browser_record( + ManagedBrowserRecord( + browser_name="chromium", + browsers_path=str(browsers_path), + install_source=install_source, + playwright_version=self._get_playwright_version(), + recorded_at=recorded_at, + last_verified_at=now_iso, + ) + ) + logger.info( + "HTML 渲染服务已更新托管浏览器记录: " + f"source={install_source}, browsers_path={browsers_path}, last_verified_at={now_iso}" + ) + + async def _install_chromium_browser(self, config: PluginRuntimeRenderConfig) -> None: + """自动下载 Playwright Chromium 浏览器。 + + Args: + config: 当前浏览器渲染配置。 + + Raises: + RuntimeError: 下载失败时抛出。 + """ + + browsers_path = self._prepare_playwright_environment(config) + logger.warning( + "HTML 渲染服务开始自动下载 Chromium: " + f"target_dir={browsers_path}, timeout_sec={config.download_connection_timeout_sec}" + ) + env = os.environ.copy() + env["PLAYWRIGHT_BROWSERS_PATH"] = str(browsers_path) + env["PLAYWRIGHT_DOWNLOAD_CONNECTION_TIMEOUT"] = str(int(config.download_connection_timeout_sec * 1000)) + process = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "playwright", + "install", + "chromium", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + ) + stdout_bytes, stderr_bytes = await process.communicate() + if process.returncode != 0: + stderr_text = stderr_bytes.decode("utf-8", errors="ignore").strip() + stdout_text = stdout_bytes.decode("utf-8", errors="ignore").strip() + error_detail = stderr_text or stdout_text or f"退出码 {process.returncode}" + raise RuntimeError(f"自动下载 Chromium 失败: {error_detail}") + + if not self._has_managed_browser_artifact(browsers_path): + raise RuntimeError("Chromium 下载完成后未检测到可用浏览器文件") + logger.info(f"HTML 渲染服务自动下载 Chromium 完成: target_dir={browsers_path}") + + @staticmethod + def _get_playwright_version() -> str: + """读取当前环境中的 Playwright 版本号。 + + Returns: + str: Playwright 版本字符串;读取失败时返回 ``unknown``。 + """ + + try: + return metadata.version("playwright") + except metadata.PackageNotFoundError: + return "unknown" + + @staticmethod + def _has_managed_browser_artifact(browsers_path: Path) -> bool: + """检查共享目录中是否存在可用的 Playwright 托管浏览器。 + + Args: + browsers_path: Playwright 浏览器目录。 + + Returns: + bool: 若检测到 Chromium/Chrome 相关浏览器文件夹,则返回 ``True``。 + """ + + if not browsers_path.exists(): + return False + for child_path in browsers_path.iterdir(): + if not child_path.is_dir(): + continue + if child_path.name.startswith(_PLAYWRIGHT_MANAGED_BROWSER_PREFIXES): + return True + return False + + def _detect_local_browser_executable(self) -> str: + """自动探测当前宿主系统中的可复用浏览器路径。 + + Returns: + str: 命中的浏览器可执行文件路径;未命中时返回空字符串。 + """ + + for browser_name in _UNIX_BROWSER_NAMES: + resolved_path = shutil.which(browser_name) + if resolved_path: + return resolved_path + + for candidate_path in self._get_candidate_executable_paths(): + if candidate_path.exists(): + return str(candidate_path) + return "" + + @staticmethod + def _get_candidate_executable_paths() -> Tuple[Path, ...]: + """返回当前平台常见浏览器路径候选集合。 + + Returns: + Tuple[Path, ...]: 可能存在浏览器可执行文件的路径列表。 + """ + + if sys.platform.startswith("win"): + return _WINDOWS_BROWSER_PATHS + if sys.platform == "darwin": + return _MACOS_BROWSER_PATHS + return () + + async def _configure_page(self, page: Any, request: HtmlRenderRequest) -> None: + """为页面设置超时、网络策略并写入 HTML。 + + Args: + page: Playwright 页面对象。 + request: 当前渲染请求。 + """ + + page.set_default_timeout(request.timeout_ms) + await page.route( + "**/*", + functools.partial(self._handle_network_route, allow_network=request.allow_network), + ) + await page.set_content( + request.html, + timeout=request.timeout_ms, + wait_until=request.wait_until, + ) + if request.wait_for_selector: + await page.locator(request.wait_for_selector).first.wait_for( + state="attached", + timeout=request.timeout_ms, + ) + if request.wait_for_timeout_ms > 0: + await page.wait_for_timeout(request.wait_for_timeout_ms) + + async def _handle_network_route(self, route: Any, allow_network: bool) -> None: + """处理页面资源请求的网络准入策略。 + + Args: + route: Playwright 路由对象。 + allow_network: 是否允许页面访问外部网络资源。 + """ + + request_url = str(route.request.url) + if allow_network or self._is_network_request_allowed(request_url): + await route.continue_() + return + await route.abort() + + @staticmethod + def _is_network_request_allowed(request_url: str) -> bool: + """判断某个资源 URL 是否属于本地安全资源。 + + Args: + request_url: 待判断的资源地址。 + + Returns: + bool: 若请求可在无网络模式下放行,则返回 ``True``。 + """ + + if not request_url: + return False + parsed_url = urlparse(request_url) + return parsed_url.scheme in _NETWORK_ALLOW_SCHEMES + + async def _capture_image(self, page: Any, request: HtmlRenderRequest) -> bytes: + """从页面或目标元素中截取 PNG 图片。 + + Args: + page: Playwright 页面对象。 + request: 当前渲染请求。 + + Returns: + bytes: PNG 二进制内容。 + + Raises: + RuntimeError: 目标元素不存在或截图结果为空时抛出。 + """ + + if request.full_page and request.selector == "body": + image_bytes = await page.screenshot( + full_page=True, + omit_background=request.omit_background, + timeout=request.timeout_ms, + type="png", + ) + else: + locator = page.locator(request.selector).first + await locator.wait_for(state="visible", timeout=request.timeout_ms) + image_bytes = await locator.screenshot( + omit_background=request.omit_background, + timeout=request.timeout_ms, + type="png", + ) + + if not image_bytes: + raise RuntimeError("浏览器截图结果为空") + return image_bytes + + @staticmethod + def _measure_image_size(image_bytes: bytes) -> Tuple[int, int]: + """读取 PNG 图片的真实像素尺寸。 + + Args: + image_bytes: PNG 图片二进制内容。 + + Returns: + Tuple[int, int]: 图片宽高像素值。 + """ + + from PIL import Image + + with Image.open(BytesIO(image_bytes)) as image: + return int(image.width), int(image.height) + + async def _maybe_restart_browser(self, config: PluginRuntimeRenderConfig) -> None: + """按策略决定是否重建本地浏览器实例。 + + Args: + config: 当前浏览器渲染配置。 + """ + + restart_after = int(config.restart_after_render_count) + if restart_after <= 0 or self._connected_via_cdp: + return + if self._render_count % restart_after != 0: + return + await self.reset_browser(restart_playwright=False) + logger.info("HTML 渲染服务已按累计次数策略重建本地浏览器") + + @staticmethod + def _normalize_request( + request: HtmlRenderRequest, + config: PluginRuntimeRenderConfig, + ) -> HtmlRenderRequest: + """规范化并补齐 HTML 渲染请求。 + + Args: + request: 原始渲染请求。 + config: 当前浏览器渲染配置。 + + Returns: + HtmlRenderRequest: 规范化后的请求对象。 + + Raises: + ValueError: 请求缺少必要字段或取值非法时抛出。 + """ + + html = request.html.strip() + if not html: + raise ValueError("缺少必要参数 html") + + selector = request.selector.strip() or "body" + wait_until = HTMLRenderService._normalize_wait_until(request.wait_until) + timeout_ms = request.timeout_ms + if timeout_ms <= 0: + timeout_ms = int(config.render_timeout_sec * 1000) + + return HtmlRenderRequest( + html=html, + selector=selector, + viewport_width=max(1, int(request.viewport_width)), + viewport_height=max(1, int(request.viewport_height)), + device_scale_factor=max(1.0, float(request.device_scale_factor)), + full_page=bool(request.full_page), + omit_background=bool(request.omit_background), + wait_until=wait_until, + wait_for_selector=request.wait_for_selector.strip(), + wait_for_timeout_ms=max(0, int(request.wait_for_timeout_ms)), + timeout_ms=max(1, int(timeout_ms)), + allow_network=bool(request.allow_network), + ) + + @staticmethod + def _normalize_wait_until(wait_until: str) -> str: + """规范化页面等待阶段参数。 + + Args: + wait_until: 原始等待阶段字符串。 + + Returns: + str: Playwright 支持的等待阶段值。 + """ + + normalized_wait_until = wait_until.strip().lower() + if normalized_wait_until in {"commit", "domcontentloaded", "load", "networkidle"}: + return normalized_wait_until + return "load" + + +_html_render_service: Optional[HTMLRenderService] = None + + +def get_html_render_service() -> HTMLRenderService: + """获取 HTML 浏览器渲染服务单例。 + + Returns: + HTMLRenderService: 全局唯一的浏览器渲染服务实例。 + """ + + global _html_render_service + if _html_render_service is None: + _html_render_service = HTMLRenderService() + return _html_render_service diff --git a/src/services/llm_cache_stats.py b/src/services/llm_cache_stats.py new file mode 100644 index 00000000..1d322ba4 --- /dev/null +++ b/src/services/llm_cache_stats.py @@ -0,0 +1,1520 @@ +"""LLM prompt cache statistics and local dynamic-diff diagnostics.""" + +from dataclasses import dataclass, field +from datetime import datetime +from html import escape +from math import erf, sqrt +from pathlib import Path +from threading import RLock +from typing import Any, Dict, List, Tuple + +import json +import time +import uuid + +from src.common.logger import get_logger + +logger = get_logger("llm_cache_stats") + +FOCUSED_TASK_NAMES = {"replyer", "planner"} +EXCLUDED_REQUEST_TYPES = { + "A_Memorix.ChatSummarization", + "expression.learner", + "maisaka_reply_effect_judge", +} +REPORT_INTERVAL_SECONDS = 300 +REPORT_INTERVAL_CALLS = 50 +SUMMARY_LIMIT = 5 +PROMPT_CACHE_POOL_SIZE = 128 +CACHE_STATS_DIR = Path("logs") / "llm_cache_stats" +REPORT_FILE_NAME = "report.html" +SESSION_REPORT_FILE_NAME = "sessions.html" +SNIPPET_LIMIT = 160 +PROCESS_STARTED_AT = datetime.now().isoformat(timespec="seconds") +RUN_ID = f"{datetime.now():%Y%m%d%H%M%S}-{uuid.uuid4().hex[:8]}" + + +@dataclass(slots=True) +class LLMCacheStat: + """Aggregated prompt cache stats for one task/request/model call site.""" + + task_name: str + request_type: str + model_name: str + session_id: str = "" + calls: int = 0 + cache_reported_calls: int = 0 + prompt_tokens: int = 0 + prompt_cache_hit_tokens: int = 0 + prompt_cache_miss_tokens: int = 0 + theoretical_prompt_cache_hit_tokens: int = 0 + theoretical_prompt_cache_miss_tokens: int = 0 + theoretical_compared_calls: int = 0 + theoretical_cache_pool_hits: int = 0 + common_prefix_rate_total: float = 0.0 + suspected_context_sliding_calls: int = 0 + sliding_dropped_messages_total: int = 0 + sliding_aligned_messages_total: int = 0 + dynamic_diff_counts: Dict[str, int] = field(default_factory=dict) + + @property + def prompt_cache_total_tokens(self) -> int: + return self.prompt_cache_hit_tokens + self.prompt_cache_miss_tokens + + @property + def prompt_cache_hit_rate(self) -> float: + total_tokens = self.prompt_cache_total_tokens + if total_tokens <= 0: + return 0.0 + return self.prompt_cache_hit_tokens / total_tokens * 100 + + @property + def theoretical_prompt_cache_total_tokens(self) -> int: + return self.theoretical_prompt_cache_hit_tokens + self.theoretical_prompt_cache_miss_tokens + + @property + def theoretical_prompt_cache_hit_rate(self) -> float: + total_tokens = self.theoretical_prompt_cache_total_tokens + if total_tokens <= 0: + return 0.0 + return self.theoretical_prompt_cache_hit_tokens / total_tokens * 100 + + @property + def prompt_cache_hit_rate_delta(self) -> float: + return self.prompt_cache_hit_rate - self.theoretical_prompt_cache_hit_rate + + def _format_top_dynamic_diff_paths(self) -> str: + if not self.dynamic_diff_counts: + return "" + top_items = sorted( + self.dynamic_diff_counts.items(), + key=lambda item: (-item[1], item[0]), + )[:SUMMARY_LIMIT] + return "; ".join(f"{path} ({count})" for path, count in top_items) + + def to_dict(self) -> Dict[str, int | str | float]: + return { + "task_name": self.task_name, + "request_type": self.request_type, + "model_name": self.model_name, + "session_id": self.session_id, + "calls": self.calls, + "cache_reported_calls": self.cache_reported_calls, + "prompt_tokens": self.prompt_tokens, + "prompt_cache_hit_tokens": self.prompt_cache_hit_tokens, + "prompt_cache_miss_tokens": self.prompt_cache_miss_tokens, + "prompt_cache_hit_rate": round(self.prompt_cache_hit_rate, 2), + "theoretical_prompt_cache_hit_tokens": self.theoretical_prompt_cache_hit_tokens, + "theoretical_prompt_cache_miss_tokens": self.theoretical_prompt_cache_miss_tokens, + "theoretical_compared_calls": self.theoretical_compared_calls, + "theoretical_cache_pool_hits": self.theoretical_cache_pool_hits, + "theoretical_prompt_cache_hit_rate": round(self.theoretical_prompt_cache_hit_rate, 2), + "prompt_cache_hit_rate_delta": round(self.prompt_cache_hit_rate_delta, 2), + "avg_common_prefix_rate": round(self.common_prefix_rate_total / self.calls, 2) if self.calls else 0.0, + "suspected_context_sliding_calls": self.suspected_context_sliding_calls, + "avg_sliding_dropped_messages": ( + round(self.sliding_dropped_messages_total / self.suspected_context_sliding_calls, 2) + if self.suspected_context_sliding_calls + else 0.0 + ), + "avg_sliding_aligned_messages": ( + round(self.sliding_aligned_messages_total / self.suspected_context_sliding_calls, 2) + if self.suspected_context_sliding_calls + else 0.0 + ), + "top_dynamic_diff_paths": self._format_top_dynamic_diff_paths(), + } + + +@dataclass(slots=True) +class _TheoreticalCacheMatch: + hit_tokens: int + miss_tokens: int + hit_rate: float + compared: bool + pool_size: int + best_match_rank: int + best_prompt_text: str | None + common_prefix_chars: int + + +@dataclass(slots=True) +class _DynamicDiff: + path: str + previous_value: str + current_value: str + + +@dataclass(slots=True) +class _PromptCacheDiagnostics: + current_message_count: int = 0 + best_match_message_count: int = 0 + common_prefix_messages: int = 0 + common_suffix_messages: int = 0 + common_prefix_rate: float = 0.0 + prompt_growth_chars: int = 0 + longest_aligned_message_overlap: int = 0 + aligned_previous_start_index: int = 0 + aligned_current_start_index: int = 0 + suspected_context_sliding: bool = False + sliding_dropped_head_messages: int = 0 + sliding_aligned_messages: int = 0 + sliding_new_tail_messages: int = 0 + current_first_message_role: str = "" + best_first_message_role: str = "" + current_last_message_role: str = "" + best_last_message_role: str = "" + + +@dataclass(slots=True) +class _LLMCacheStatsStore: + stats: Dict[Tuple[str, str, str, str], LLMCacheStat] = field(default_factory=dict) + prompt_pools: Dict[Tuple[str, str, str, str], List[str]] = field(default_factory=dict) + total_calls: int = 0 + run_id: str = RUN_ID + process_started_at: str = PROCESS_STARTED_AT + calls_in_run: int = 0 + last_report_at: float = 0 + calls_since_report: int = 0 + lock: RLock = field(default_factory=RLock) + + +_store = _LLMCacheStatsStore() + + +def _is_llm_cache_stats_enabled() -> bool: + """读取调试配置,默认关闭 LLM prompt cache 统计。""" + + try: + from src.config.config import global_config + return bool(global_config.debug.enable_llm_cache_stats) + except Exception: + return False + + +def _normalize_request_type(request_type: str) -> str: + normalized = str(request_type or "").strip() + return normalized or "unknown" + + +def _normalize_model_name(model_name: str) -> str: + normalized = str(model_name or "").strip() + return normalized or "unknown" + + +def _normalize_session_id(session_id: str) -> str: + normalized = str(session_id or "").strip() + return normalized or "unknown" + + +def _normalize_cache_tokens( + *, + prompt_tokens: int, + prompt_cache_hit_tokens: int, + prompt_cache_miss_tokens: int, +) -> tuple[int, int, bool]: + hit_tokens = max(int(prompt_cache_hit_tokens or 0), 0) + miss_tokens = max(int(prompt_cache_miss_tokens or 0), 0) + has_cache_report = hit_tokens > 0 or miss_tokens > 0 + + if miss_tokens == 0 and hit_tokens > 0: + miss_tokens = max(prompt_tokens - hit_tokens, 0) + elif hit_tokens == 0 and miss_tokens == 0 and prompt_tokens > 0: + # Some providers do not return cache details. Treat it as all miss, while keeping reported_calls separate. + miss_tokens = prompt_tokens + + return hit_tokens, miss_tokens, has_cache_report + + +def _longest_common_prefix_length(left: str, right: str) -> int: + max_length = min(len(left), len(right)) + for index in range(max_length): + if left[index] != right[index]: + return index + return max_length + + +def _calculate_theoretical_cache_match( + *, + prompt_tokens: int, + prompt_text: str | None, + prompt_pool: List[str], +) -> _TheoreticalCacheMatch: + """Estimate local theoretical cache hit by matching against the whole prompt pool.""" + + if not prompt_text: + return _TheoreticalCacheMatch(0, 0, 0.0, False, 0, 0, None, 0) + if not prompt_pool: + return _TheoreticalCacheMatch(0, prompt_tokens, 0.0, True, 0, 0, None, 0) + + best_prefix_length = 0 + best_match_rank = 0 + best_prompt_text: str | None = None + # rank=1 means the newest cached prompt in this local pool. + for rank, cached_prompt_text in enumerate(reversed(prompt_pool), start=1): + prefix_length = _longest_common_prefix_length(cached_prompt_text, prompt_text) + if prefix_length > best_prefix_length: + best_prefix_length = prefix_length + best_match_rank = rank + best_prompt_text = cached_prompt_text + + overlap_rate = best_prefix_length / len(prompt_text) if prompt_text else 0.0 + theoretical_hit_tokens = min(prompt_tokens, round(prompt_tokens * overlap_rate)) + theoretical_miss_tokens = max(prompt_tokens - theoretical_hit_tokens, 0) + return _TheoreticalCacheMatch( + theoretical_hit_tokens, + theoretical_miss_tokens, + overlap_rate * 100, + True, + len(prompt_pool), + best_match_rank, + best_prompt_text, + best_prefix_length, + ) + + +def _summarize_value(value: Any) -> str: + if isinstance(value, str): + normalized = value.replace("\n", "\\n") + else: + normalized = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) + if len(normalized) > SNIPPET_LIMIT: + return normalized[:SNIPPET_LIMIT] + "..." + return normalized + + +def _find_first_structural_diff(previous_value: Any, current_value: Any, path: str = "root") -> _DynamicDiff | None: + if type(previous_value) is not type(current_value): + return _DynamicDiff( + f"{path}.__type__", + type(previous_value).__name__, + type(current_value).__name__, + ) + + if isinstance(previous_value, dict): + previous_keys = set(previous_value) + current_keys = set(current_value) + for key in sorted(previous_keys | current_keys): + key_path = f"{path}.{key}" + if key not in previous_value: + return _DynamicDiff(key_path, "", _summarize_value(current_value[key])) + if key not in current_value: + return _DynamicDiff(key_path, _summarize_value(previous_value[key]), "") + nested_diff = _find_first_structural_diff(previous_value[key], current_value[key], key_path) + if nested_diff is not None: + return nested_diff + return None + + if isinstance(previous_value, list): + max_length = max(len(previous_value), len(current_value)) + for index in range(max_length): + index_path = f"{path}[{index}]" + if index >= len(previous_value): + return _DynamicDiff(index_path, "", _summarize_value(current_value[index])) + if index >= len(current_value): + return _DynamicDiff(index_path, _summarize_value(previous_value[index]), "") + nested_diff = _find_first_structural_diff(previous_value[index], current_value[index], index_path) + if nested_diff is not None: + return nested_diff + return None + + if previous_value == current_value: + return None + + if isinstance(previous_value, str) and isinstance(current_value, str): + diff_index = _longest_common_prefix_length(previous_value, current_value) + return _DynamicDiff( + f"{path}@char{diff_index}", + _summarize_value(previous_value[diff_index:]), + _summarize_value(current_value[diff_index:]), + ) + + return _DynamicDiff(path, _summarize_value(previous_value), _summarize_value(current_value)) + + +def _diagnose_dynamic_diff(previous_prompt_text: str | None, current_prompt_text: str | None) -> _DynamicDiff: + if not current_prompt_text: + return _DynamicDiff("prompt_text.unavailable", "", "") + if not previous_prompt_text: + return _DynamicDiff("cache_pool.empty", "", _summarize_value(current_prompt_text)) + + try: + previous_payload = json.loads(previous_prompt_text) + current_payload = json.loads(current_prompt_text) + except json.JSONDecodeError: + diff_index = _longest_common_prefix_length(previous_prompt_text, current_prompt_text) + return _DynamicDiff( + f"raw_prompt@char{diff_index}", + _summarize_value(previous_prompt_text[diff_index:]), + _summarize_value(current_prompt_text[diff_index:]), + ) + + diff = _find_first_structural_diff(previous_payload, current_payload) + if diff is None: + return _DynamicDiff("identical", "", "") + return diff + + +def _load_prompt_payload(prompt_text: str | None) -> dict[str, Any] | None: + if not prompt_text: + return None + try: + payload = json.loads(prompt_text) + except json.JSONDecodeError: + return None + return payload if isinstance(payload, dict) else None + + +def _extract_prompt_messages(prompt_text: str | None) -> list[dict[str, Any]]: + payload = _load_prompt_payload(prompt_text) + if payload is None: + return [] + messages = payload.get("messages") + return [message for message in messages if isinstance(message, dict)] if isinstance(messages, list) else [] + + +def _message_fingerprints(messages: list[dict[str, Any]]) -> list[str]: + return [json.dumps(message, ensure_ascii=False, sort_keys=True, default=str) for message in messages] + + +def _count_common_prefix_items(left_items: list[str], right_items: list[str]) -> int: + common_count = 0 + for left_item, right_item in zip(left_items, right_items, strict=False): + if left_item != right_item: + break + common_count += 1 + return common_count + + +def _count_common_suffix_items(left_items: list[str], right_items: list[str]) -> int: + common_count = 0 + max_count = min(len(left_items), len(right_items)) + while common_count < max_count and left_items[-common_count - 1] == right_items[-common_count - 1]: + common_count += 1 + return common_count + + +def _find_longest_message_alignment(previous_items: list[str], current_items: list[str]) -> tuple[int, int, int]: + best_overlap = 0 + best_previous_start = 0 + best_current_start = 0 + for previous_start in range(len(previous_items)): + for current_start in range(len(current_items)): + overlap = 0 + while ( + previous_start + overlap < len(previous_items) + and current_start + overlap < len(current_items) + and previous_items[previous_start + overlap] == current_items[current_start + overlap] + ): + overlap += 1 + if overlap > best_overlap: + best_overlap = overlap + best_previous_start = previous_start + best_current_start = current_start + return best_overlap, best_previous_start, best_current_start + + +def _get_message_role(messages: list[dict[str, Any]], index: int) -> str: + if not messages: + return "" + try: + value = messages[index].get("role", "") + except IndexError: + return "" + return str(value or "") + + +def _diagnose_prompt_cache_details( + *, + previous_prompt_text: str | None, + current_prompt_text: str | None, + common_prefix_chars: int, +) -> _PromptCacheDiagnostics: + current_messages = _extract_prompt_messages(current_prompt_text) + previous_messages = _extract_prompt_messages(previous_prompt_text) + current_items = _message_fingerprints(current_messages) + previous_items = _message_fingerprints(previous_messages) + current_prompt_length = len(current_prompt_text or "") + previous_prompt_length = len(previous_prompt_text or "") + common_prefix_rate = common_prefix_chars / current_prompt_length * 100 if current_prompt_length > 0 else 0.0 + + common_prefix_messages = _count_common_prefix_items(previous_items, current_items) + common_suffix_messages = _count_common_suffix_items(previous_items, current_items) + aligned_overlap, aligned_previous_start, aligned_current_start = _find_longest_message_alignment( + previous_items, + current_items, + ) + suspected_context_sliding = ( + aligned_previous_start > aligned_current_start + and aligned_overlap > common_prefix_messages + ) + sliding_dropped_head_messages = aligned_previous_start - aligned_current_start if suspected_context_sliding else 0 + + return _PromptCacheDiagnostics( + current_message_count=len(current_messages), + best_match_message_count=len(previous_messages), + common_prefix_messages=common_prefix_messages, + common_suffix_messages=common_suffix_messages, + common_prefix_rate=common_prefix_rate, + prompt_growth_chars=current_prompt_length - previous_prompt_length, + longest_aligned_message_overlap=aligned_overlap, + aligned_previous_start_index=aligned_previous_start, + aligned_current_start_index=aligned_current_start, + suspected_context_sliding=suspected_context_sliding, + sliding_dropped_head_messages=sliding_dropped_head_messages, + sliding_aligned_messages=aligned_overlap if suspected_context_sliding else 0, + sliding_new_tail_messages=( + max(len(current_messages) - aligned_current_start - aligned_overlap, 0) + if suspected_context_sliding + else 0 + ), + current_first_message_role=_get_message_role(current_messages, 0), + best_first_message_role=_get_message_role(previous_messages, 0), + current_last_message_role=_get_message_role(current_messages, -1), + best_last_message_role=_get_message_role(previous_messages, -1), + ) + + +def _get_usage_log_path(now: datetime) -> Path: + return CACHE_STATS_DIR / f"usage_{now:%Y%m%d}.jsonl" + + +def _get_report_path() -> Path: + return CACHE_STATS_DIR / REPORT_FILE_NAME + + +def _get_session_report_path() -> Path: + return CACHE_STATS_DIR / SESSION_REPORT_FILE_NAME + + +def _iter_usage_log_paths() -> list[Path]: + if not CACHE_STATS_DIR.exists(): + return [] + return sorted(CACHE_STATS_DIR.glob("usage_*.jsonl")) + + +def _read_usage_events() -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for file_path in _iter_usage_log_paths(): + try: + lines = file_path.read_text(encoding="utf-8").splitlines() + except OSError: + continue + for line in lines: + if not line.strip(): + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(event, dict): + events.append(event) + return events + + +def _write_json_line(file_path: Path, payload: Dict[str, int | str | float | bool]) -> None: + CACHE_STATS_DIR.mkdir(parents=True, exist_ok=True) + with file_path.open("a", encoding="utf-8") as file: + file.write(json.dumps(payload, ensure_ascii=False) + "\n") + + +def _format_int(value: int | str | float) -> str: + return f"{int(value):,}" + + +def _format_rate(value: int | str | float) -> str: + return f"{float(value):.2f}%" + + +def _calculate_rate(hit_tokens: int, miss_tokens: int) -> float: + total_tokens = hit_tokens + miss_tokens + return hit_tokens / total_tokens * 100 if total_tokens > 0 else 0.0 + + +def _normal_cdf(value: float) -> float: + return 0.5 * (1.0 + erf(value / sqrt(2.0))) + + +def _confidence_from_z_score(z_score: float) -> float: + p_value = 2.0 * (1.0 - _normal_cdf(abs(z_score))) + return max(0.0, min(100.0, (1.0 - p_value) * 100.0)) + + +def _format_significance_label(confidence: float, *, min_confidence: float = 95.0) -> str: + return "显著" if confidence >= min_confidence else "不显著" + + +def _calculate_two_proportion_confidence( + *, + current_hit: int, + current_total: int, + baseline_hit: int, + baseline_total: int, +) -> float: + if current_total <= 0 or baseline_total <= 0: + return 0.0 + current_rate = current_hit / current_total + baseline_rate = baseline_hit / baseline_total + pooled_rate = (current_hit + baseline_hit) / (current_total + baseline_total) + standard_error = sqrt(pooled_rate * (1.0 - pooled_rate) * (1.0 / current_total + 1.0 / baseline_total)) + if standard_error <= 0: + return 0.0 + return _confidence_from_z_score((current_rate - baseline_rate) / standard_error) + + +def _calculate_sample_variance(*, value_total: float, square_total: float, count: int) -> float: + if count <= 1: + return 0.0 + return max((square_total - (value_total * value_total / count)) / (count - 1), 0.0) + + +def _calculate_mean_difference_confidence( + *, + current_mean: float, + current_variance: float, + current_count: int, + baseline_mean: float, + baseline_variance: float, + baseline_count: int, +) -> float: + if current_count <= 1 or baseline_count <= 1: + return 0.0 + standard_error = sqrt(current_variance / current_count + baseline_variance / baseline_count) + if standard_error <= 0: + return 0.0 + return _confidence_from_z_score((current_mean - baseline_mean) / standard_error) + + +def _normalize_event_run_id(event: dict[str, Any]) -> str: + run_id = str(event.get("run_id") or "").strip() + return run_id or "legacy" + + +def _aggregate_usage_events_by_run(events: list[dict[str, Any]]) -> list[dict[str, int | str | float]]: + grouped: dict[str, dict[str, int | str | float]] = {} + for event in events: + run_id = _normalize_event_run_id(event) + item = grouped.setdefault( + run_id, + { + "run_id": run_id, + "process_started_at": str(event.get("process_started_at") or ""), + "first_seen_at": str(event.get("created_at") or ""), + "last_seen_at": str(event.get("created_at") or ""), + "calls": 0, + "prompt_tokens": 0, + "prompt_cache_hit_tokens": 0, + "prompt_cache_miss_tokens": 0, + "theoretical_prompt_cache_hit_tokens": 0, + "theoretical_prompt_cache_miss_tokens": 0, + "common_prefix_rate_total": 0.0, + "common_prefix_rate_square_total": 0.0, + "suspected_context_sliding_calls": 0, + }, + ) + created_at = str(event.get("created_at") or "") + if created_at: + if not item["first_seen_at"] or created_at < str(item["first_seen_at"]): + item["first_seen_at"] = created_at + if created_at > str(item["last_seen_at"]): + item["last_seen_at"] = created_at + item["calls"] = int(item["calls"]) + 1 + item["prompt_tokens"] = int(item["prompt_tokens"]) + int(event.get("prompt_tokens") or 0) + item["prompt_cache_hit_tokens"] = int(item["prompt_cache_hit_tokens"]) + int( + event.get("prompt_cache_hit_tokens") or 0 + ) + item["prompt_cache_miss_tokens"] = int(item["prompt_cache_miss_tokens"]) + int( + event.get("prompt_cache_miss_tokens") or 0 + ) + item["theoretical_prompt_cache_hit_tokens"] = int(item["theoretical_prompt_cache_hit_tokens"]) + int( + event.get("theoretical_prompt_cache_hit_tokens") or 0 + ) + item["theoretical_prompt_cache_miss_tokens"] = int(item["theoretical_prompt_cache_miss_tokens"]) + int( + event.get("theoretical_prompt_cache_miss_tokens") or 0 + ) + item["common_prefix_rate_total"] = float(item["common_prefix_rate_total"]) + float( + event.get("theoretical_common_prefix_rate") or 0.0 + ) + if bool(event.get("suspected_context_sliding", False)): + item["suspected_context_sliding_calls"] = int(item["suspected_context_sliding_calls"]) + 1 + + result: list[dict[str, int | str | float]] = [] + for item in grouped.values(): + calls = int(item["calls"]) + hit_tokens = int(item["prompt_cache_hit_tokens"]) + miss_tokens = int(item["prompt_cache_miss_tokens"]) + theoretical_hit_tokens = int(item["theoretical_prompt_cache_hit_tokens"]) + theoretical_miss_tokens = int(item["theoretical_prompt_cache_miss_tokens"]) + item["prompt_cache_hit_rate"] = round(_calculate_rate(hit_tokens, miss_tokens), 2) + item["theoretical_prompt_cache_hit_rate"] = round( + _calculate_rate(theoretical_hit_tokens, theoretical_miss_tokens), + 2, + ) + item["avg_common_prefix_rate"] = round(float(item["common_prefix_rate_total"]) / calls, 2) if calls else 0.0 + result.append(item) + + return sorted(result, key=lambda item: str(item["first_seen_at"])) + + +def _get_previous_run_id(run_stats: list[dict[str, int | str | float]], current_run_id: str) -> str: + run_ids = [str(item["run_id"]) for item in run_stats] + if current_run_id not in run_ids: + return "" + current_index = run_ids.index(current_run_id) + if current_index <= 0: + return "" + return run_ids[current_index - 1] + + +def _aggregate_usage_events_by_call_site( + events: list[dict[str, Any]], + *, + run_id: str, + include_session: bool = True, +) -> dict[tuple[str, ...], dict[str, int | str | float]]: + grouped: dict[tuple[str, ...], dict[str, int | str | float]] = {} + for event in events: + if _normalize_event_run_id(event) != run_id: + continue + base_key = ( + str(event.get("task_name") or ""), + str(event.get("request_type") or ""), + str(event.get("model_name") or ""), + ) + key = ( + *base_key, + _normalize_session_id(str(event.get("session_id") or "")), + ) if include_session else base_key + item = grouped.setdefault( + key, + { + "task_name": key[0], + "request_type": key[1], + "model_name": key[2], + "session_id": key[3] if include_session else "", + "calls": 0, + "prompt_cache_hit_tokens": 0, + "prompt_cache_miss_tokens": 0, + "theoretical_prompt_cache_hit_tokens": 0, + "theoretical_prompt_cache_miss_tokens": 0, + "common_prefix_rate_total": 0.0, + "common_prefix_rate_square_total": 0.0, + "suspected_context_sliding_calls": 0, + }, + ) + item["calls"] = int(item["calls"]) + 1 + item["prompt_cache_hit_tokens"] = int(item["prompt_cache_hit_tokens"]) + int( + event.get("prompt_cache_hit_tokens") or 0 + ) + item["prompt_cache_miss_tokens"] = int(item["prompt_cache_miss_tokens"]) + int( + event.get("prompt_cache_miss_tokens") or 0 + ) + item["theoretical_prompt_cache_hit_tokens"] = int(item["theoretical_prompt_cache_hit_tokens"]) + int( + event.get("theoretical_prompt_cache_hit_tokens") or 0 + ) + item["theoretical_prompt_cache_miss_tokens"] = int(item["theoretical_prompt_cache_miss_tokens"]) + int( + event.get("theoretical_prompt_cache_miss_tokens") or 0 + ) + prefix_rate = float(event.get("theoretical_common_prefix_rate") or 0.0) + item["common_prefix_rate_total"] = float(item["common_prefix_rate_total"]) + prefix_rate + item["common_prefix_rate_square_total"] = float(item["common_prefix_rate_square_total"]) + prefix_rate * prefix_rate + if bool(event.get("suspected_context_sliding", False)): + item["suspected_context_sliding_calls"] = int(item["suspected_context_sliding_calls"]) + 1 + + for item in grouped.values(): + calls = int(item["calls"]) + prefix_total = float(item["common_prefix_rate_total"]) + prefix_square_total = float(item["common_prefix_rate_square_total"]) + item["prompt_cache_hit_rate"] = round( + _calculate_rate(int(item["prompt_cache_hit_tokens"]), int(item["prompt_cache_miss_tokens"])), + 2, + ) + item["theoretical_prompt_cache_hit_rate"] = round( + _calculate_rate( + int(item["theoretical_prompt_cache_hit_tokens"]), + int(item["theoretical_prompt_cache_miss_tokens"]), + ), + 2, + ) + item["avg_common_prefix_rate"] = round(prefix_total / calls, 2) if calls else 0.0 + item["common_prefix_rate_variance"] = round( + _calculate_sample_variance( + value_total=prefix_total, + square_total=prefix_square_total, + count=calls, + ), + 4, + ) + return grouped + + +def _render_run_rows(run_stats: list[dict[str, int | str | float]], current_run_id: str) -> str: + rows: list[str] = [] + for item in reversed(run_stats[-12:]): + current_marker = "当前" if str(item["run_id"]) == current_run_id else "" + rows.append( + "" + f"{escape(current_marker)}" + f"{escape(str(item['run_id']))}" + f"{escape(str(item['process_started_at']))}" + f"{escape(str(item['first_seen_at']))}" + f"{escape(str(item['last_seen_at']))}" + f"{_format_int(item['calls'])}" + f"{_format_int(item['prompt_tokens'])}" + f"{_format_rate(item['prompt_cache_hit_rate'])}" + f"{_format_rate(item['theoretical_prompt_cache_hit_rate'])}" + f"{_format_rate(item['avg_common_prefix_rate'])}" + f"{_format_int(item['suspected_context_sliding_calls'])}" + "" + ) + return "\n".join(rows) + + +def _render_run_comparison_rows( + *, + current_by_call_site: dict[tuple[str, ...], dict[str, int | str | float]], + previous_by_call_site: dict[tuple[str, ...], dict[str, int | str | float]], + include_session: bool, +) -> str: + rows: list[str] = [] + keys = sorted(set(current_by_call_site) | set(previous_by_call_site)) + for key in keys: + current_item = current_by_call_site.get(key, {}) + previous_item = previous_by_call_site.get(key, {}) + current_api = float(current_item.get("prompt_cache_hit_rate") or 0.0) + previous_api = float(previous_item.get("prompt_cache_hit_rate") or 0.0) + current_theory = float(current_item.get("theoretical_prompt_cache_hit_rate") or 0.0) + previous_theory = float(previous_item.get("theoretical_prompt_cache_hit_rate") or 0.0) + current_prefix = float(current_item.get("avg_common_prefix_rate") or 0.0) + previous_prefix = float(previous_item.get("avg_common_prefix_rate") or 0.0) + rows.append( + "" + f"{escape(key[0])}" + f"{escape(key[1])}" + f"{escape(key[2])}" + + (f"{escape(key[3])}" if include_session and len(key) > 3 else "") + + + f"{_format_int(current_item.get('calls', 0))}" + f"{_format_int(previous_item.get('calls', 0))}" + f"{_format_rate(current_api)}" + f"{_format_rate(previous_api)}" + f"{_format_rate(current_api - previous_api)}" + f"{_format_rate(current_theory)}" + f"{_format_rate(previous_theory)}" + f"{_format_rate(current_theory - previous_theory)}" + f"{_format_rate(current_prefix)}" + f"{_format_rate(previous_prefix)}" + f"{_format_rate(current_prefix - previous_prefix)}" + f"{_format_int(current_item.get('suspected_context_sliding_calls', 0))}" + f"{_format_int(previous_item.get('suspected_context_sliding_calls', 0))}" + "" + ) + return "\n".join(rows) + + +def _format_run_time_label(run_stat: dict[str, int | str | float] | None) -> str: + if not run_stat: + return "" + first_seen_at = str(run_stat.get("first_seen_at") or "").strip() + last_seen_at = str(run_stat.get("last_seen_at") or "").strip() + process_started_at = str(run_stat.get("process_started_at") or "").strip() + if first_seen_at and last_seen_at and first_seen_at != last_seen_at: + return f"{first_seen_at} -> {last_seen_at}" + if first_seen_at: + return first_seen_at + return process_started_at + + +def _get_previous_run_stats( + run_stats: list[dict[str, int | str | float]], + current_run_id: str, +) -> list[dict[str, int | str | float]]: + return [ + item + for item in run_stats + if str(item["run_id"]) != current_run_id + ] + + +def _render_run_significance_controls( + run_stats: list[dict[str, int | str | float]], + current_run_id: str, +) -> str: + previous_run_stats = _get_previous_run_stats(run_stats, current_run_id) + if not previous_run_stats: + return ( + "
" + "No previous runs to compare." + "
" + ) + + option_payload = [ + { + "run_id": str(item["run_id"]), + "time_label": _format_run_time_label(item), + "calls": int(item.get("calls") or 0), + } + for item in previous_run_stats + ] + option_json = escape(json.dumps(option_payload, ensure_ascii=False), quote=True) + max_index = len(previous_run_stats) - 1 + return ( + "
" + "" + "" + "" + "
" + "
Baseline run
" + "
" + "
" + "
" + "
" + ) + + +def _render_run_significance_script() -> str: + return """ + +""" + + +def _build_run_significance_rows( + *, + usage_events: list[dict[str, Any]], + run_stats: list[dict[str, int | str | float]], + current_run_id: str, + include_session: bool, +) -> str: + current_by_call_site = _aggregate_usage_events_by_call_site( + usage_events, + run_id=current_run_id, + include_session=include_session, + ) + rows: list[str] = [] + previous_run_stats = _get_previous_run_stats(run_stats, current_run_id) + for previous_run_stat in previous_run_stats: + previous_run_id = str(previous_run_stat["run_id"]) + baseline_time = _format_run_time_label(previous_run_stat) + previous_by_call_site = _aggregate_usage_events_by_call_site( + usage_events, + run_id=previous_run_id, + include_session=include_session, + ) + keys = sorted(set(current_by_call_site) & set(previous_by_call_site)) + for key in keys: + current_item = current_by_call_site[key] + previous_item = previous_by_call_site[key] + current_hit = int(current_item.get("prompt_cache_hit_tokens") or 0) + current_miss = int(current_item.get("prompt_cache_miss_tokens") or 0) + previous_hit = int(previous_item.get("prompt_cache_hit_tokens") or 0) + previous_miss = int(previous_item.get("prompt_cache_miss_tokens") or 0) + current_total = current_hit + current_miss + previous_total = previous_hit + previous_miss + current_api = _calculate_rate(current_hit, current_miss) + previous_api = _calculate_rate(previous_hit, previous_miss) + api_confidence = _calculate_two_proportion_confidence( + current_hit=current_hit, + current_total=current_total, + baseline_hit=previous_hit, + baseline_total=previous_total, + ) + current_calls = int(current_item.get("calls") or 0) + previous_calls = int(previous_item.get("calls") or 0) + current_prefix = float(current_item.get("avg_common_prefix_rate") or 0.0) + previous_prefix = float(previous_item.get("avg_common_prefix_rate") or 0.0) + prefix_confidence = _calculate_mean_difference_confidence( + current_mean=current_prefix, + current_variance=float(current_item.get("common_prefix_rate_variance") or 0.0), + current_count=current_calls, + baseline_mean=previous_prefix, + baseline_variance=float(previous_item.get("common_prefix_rate_variance") or 0.0), + baseline_count=previous_calls, + ) + rows.append( + f"" + f"{escape(previous_run_id)}" + f"{escape(baseline_time)}" + f"{escape(key[0])}" + f"{escape(key[1])}" + f"{escape(key[2])}" + + (f"{escape(key[3])}" if include_session and len(key) > 3 else "") + + + f"{_format_int(current_calls)}" + f"{_format_int(previous_calls)}" + f"{_format_rate(current_api - previous_api)}" + f"{_format_rate(api_confidence)}" + f"{escape(_format_significance_label(api_confidence))}" + f"{_format_rate(current_prefix - previous_prefix)}" + f"{_format_rate(prefix_confidence)}" + f"{escape(_format_significance_label(prefix_confidence))}" + f"{_format_int(current_item.get('suspected_context_sliding_calls', 0))}" + f"{_format_int(previous_item.get('suspected_context_sliding_calls', 0))}" + "" + ) + + if not rows: + return ( + "当前 run 还没有可与历史 run 比较的同类调用点," + "或历史数据缺少 run_id。" + ) + return "\n".join(rows) + + +def _render_stat_rows(stats: List[Dict[str, int | str | float]], *, include_session: bool) -> str: + rows: list[str] = [] + for item in stats: + rows.append( + "" + f"{escape(str(item['task_name']))}" + f"{escape(str(item['request_type']))}" + f"{escape(str(item['model_name']))}" + + (f"{escape(str(item.get('session_id', '')))}" if include_session else "") + + + f"{_format_rate(item['prompt_cache_hit_rate'])}" + f"{_format_rate(item['theoretical_prompt_cache_hit_rate'])}" + f"{_format_rate(item['prompt_cache_hit_rate_delta'])}" + f"{_format_int(item['prompt_cache_hit_tokens'])}" + f"{_format_int(item['prompt_cache_miss_tokens'])}" + f"{_format_int(item['theoretical_prompt_cache_hit_tokens'])}" + f"{_format_int(item['theoretical_prompt_cache_miss_tokens'])}" + f"{_format_int(item['prompt_tokens'])}" + f"{_format_int(item['calls'])}" + f"{_format_int(item['cache_reported_calls'])}" + f"{_format_int(item['theoretical_compared_calls'])}" + f"{_format_int(item['theoretical_cache_pool_hits'])}" + f"{_format_rate(item['avg_common_prefix_rate'])}" + f"{_format_int(item['suspected_context_sliding_calls'])}" + f"{item['avg_sliding_dropped_messages']}" + f"{item['avg_sliding_aligned_messages']}" + f"{escape(str(item.get('top_dynamic_diff_paths', '')))}" + "" + ) + return "\n".join(rows) + + +def _aggregate_stats_snapshot( + stats_snapshot: List[Dict[str, int | str | float]], + *, + include_session: bool, +) -> List[Dict[str, int | str | float]]: + grouped: dict[tuple[str, ...], dict[str, int | str | float]] = {} + for item in stats_snapshot: + base_key = ( + str(item.get("task_name") or ""), + str(item.get("request_type") or ""), + str(item.get("model_name") or ""), + ) + key = (*base_key, str(item.get("session_id") or "")) if include_session else base_key + target = grouped.setdefault( + key, + { + "task_name": base_key[0], + "request_type": base_key[1], + "model_name": base_key[2], + "session_id": str(item.get("session_id") or "") if include_session else "", + "calls": 0, + "cache_reported_calls": 0, + "prompt_tokens": 0, + "prompt_cache_hit_tokens": 0, + "prompt_cache_miss_tokens": 0, + "theoretical_prompt_cache_hit_tokens": 0, + "theoretical_prompt_cache_miss_tokens": 0, + "theoretical_compared_calls": 0, + "theoretical_cache_pool_hits": 0, + "common_prefix_rate_weighted_total": 0.0, + "suspected_context_sliding_calls": 0, + "sliding_dropped_weighted_total": 0.0, + "sliding_aligned_weighted_total": 0.0, + "top_dynamic_diff_paths": "", + }, + ) + calls = int(item.get("calls") or 0) + sliding_calls = int(item.get("suspected_context_sliding_calls") or 0) + target["calls"] = int(target["calls"]) + calls + target["cache_reported_calls"] = int(target["cache_reported_calls"]) + int(item.get("cache_reported_calls") or 0) + target["prompt_tokens"] = int(target["prompt_tokens"]) + int(item.get("prompt_tokens") or 0) + target["prompt_cache_hit_tokens"] = int(target["prompt_cache_hit_tokens"]) + int(item.get("prompt_cache_hit_tokens") or 0) + target["prompt_cache_miss_tokens"] = int(target["prompt_cache_miss_tokens"]) + int(item.get("prompt_cache_miss_tokens") or 0) + target["theoretical_prompt_cache_hit_tokens"] = int(target["theoretical_prompt_cache_hit_tokens"]) + int( + item.get("theoretical_prompt_cache_hit_tokens") or 0 + ) + target["theoretical_prompt_cache_miss_tokens"] = int(target["theoretical_prompt_cache_miss_tokens"]) + int( + item.get("theoretical_prompt_cache_miss_tokens") or 0 + ) + target["theoretical_compared_calls"] = int(target["theoretical_compared_calls"]) + int( + item.get("theoretical_compared_calls") or 0 + ) + target["theoretical_cache_pool_hits"] = int(target["theoretical_cache_pool_hits"]) + int( + item.get("theoretical_cache_pool_hits") or 0 + ) + target["common_prefix_rate_weighted_total"] = float(target["common_prefix_rate_weighted_total"]) + ( + float(item.get("avg_common_prefix_rate") or 0.0) * calls + ) + target["suspected_context_sliding_calls"] = int(target["suspected_context_sliding_calls"]) + sliding_calls + target["sliding_dropped_weighted_total"] = float(target["sliding_dropped_weighted_total"]) + ( + float(item.get("avg_sliding_dropped_messages") or 0.0) * sliding_calls + ) + target["sliding_aligned_weighted_total"] = float(target["sliding_aligned_weighted_total"]) + ( + float(item.get("avg_sliding_aligned_messages") or 0.0) * sliding_calls + ) + if include_session: + target["top_dynamic_diff_paths"] = item.get("top_dynamic_diff_paths", "") + + result: list[dict[str, int | str | float]] = [] + for item in grouped.values(): + calls = int(item["calls"]) + sliding_calls = int(item["suspected_context_sliding_calls"]) + hit_tokens = int(item["prompt_cache_hit_tokens"]) + miss_tokens = int(item["prompt_cache_miss_tokens"]) + theoretical_hit_tokens = int(item["theoretical_prompt_cache_hit_tokens"]) + theoretical_miss_tokens = int(item["theoretical_prompt_cache_miss_tokens"]) + item["prompt_cache_hit_rate"] = round(_calculate_rate(hit_tokens, miss_tokens), 2) + item["theoretical_prompt_cache_hit_rate"] = round( + _calculate_rate(theoretical_hit_tokens, theoretical_miss_tokens), + 2, + ) + item["prompt_cache_hit_rate_delta"] = round( + float(item["prompt_cache_hit_rate"]) - float(item["theoretical_prompt_cache_hit_rate"]), + 2, + ) + item["avg_common_prefix_rate"] = ( + round(float(item["common_prefix_rate_weighted_total"]) / calls, 2) if calls else 0.0 + ) + item["avg_sliding_dropped_messages"] = ( + round(float(item["sliding_dropped_weighted_total"]) / sliding_calls, 2) if sliding_calls else 0.0 + ) + item["avg_sliding_aligned_messages"] = ( + round(float(item["sliding_aligned_weighted_total"]) / sliding_calls, 2) if sliding_calls else 0.0 + ) + result.append(item) + return result + + +def _render_html_report(stats_snapshot: List[Dict[str, int | str | float]], *, include_session: bool = False) -> str: + updated_at = datetime.now().isoformat(timespec="seconds") + visible_stats_snapshot = _aggregate_stats_snapshot(stats_snapshot, include_session=include_session) + usage_events = _read_usage_events() + run_stats = _aggregate_usage_events_by_run(usage_events) + current_run_id = _store.run_id + previous_run_id = _get_previous_run_id(run_stats, current_run_id) + current_by_call_site = _aggregate_usage_events_by_call_site( + usage_events, + run_id=current_run_id, + include_session=include_session, + ) + previous_by_call_site = ( + _aggregate_usage_events_by_call_site( + usage_events, + run_id=previous_run_id, + include_session=include_session, + ) if previous_run_id else {} + ) + sorted_by_rate = sorted( + visible_stats_snapshot, + key=lambda item: ( + float(item["prompt_cache_hit_rate"]), + -int(item["prompt_cache_miss_tokens"]), + ), + ) + low_stats = sorted_by_rate[:SUMMARY_LIMIT] + high_stats = list(reversed(sorted_by_rate[-SUMMARY_LIMIT:])) + all_stats = sorted( + visible_stats_snapshot, + key=lambda item: ( + str(item["task_name"]), + str(item["request_type"]), + str(item["model_name"]), + ), + ) + total_calls = sum(int(item["calls"]) for item in visible_stats_snapshot) + total_prompt_tokens = sum(int(item["prompt_tokens"]) for item in visible_stats_snapshot) + total_hit_tokens = sum(int(item["prompt_cache_hit_tokens"]) for item in visible_stats_snapshot) + total_theoretical_hit_tokens = sum(int(item["theoretical_prompt_cache_hit_tokens"]) for item in visible_stats_snapshot) + total_miss_tokens = sum(int(item["prompt_cache_miss_tokens"]) for item in visible_stats_snapshot) + total_theoretical_miss_tokens = sum(int(item["theoretical_prompt_cache_miss_tokens"]) for item in visible_stats_snapshot) + total_cache_tokens = total_hit_tokens + total_miss_tokens + total_theoretical_cache_tokens = total_theoretical_hit_tokens + total_theoretical_miss_tokens + overall_hit_rate = total_hit_tokens / total_cache_tokens * 100 if total_cache_tokens > 0 else 0.0 + overall_theoretical_hit_rate = ( + total_theoretical_hit_tokens / total_theoretical_cache_tokens * 100 + if total_theoretical_cache_tokens > 0 + else 0.0 + ) + session_head = "Session" if include_session else "" + report_title = "LLM Prompt Cache Stats By Session" if include_session else "LLM Prompt Cache Stats" + peer_report_link = ( + f"Overview report" + if include_session + else f"Session detail report" + ) + table_head = ( + f"TaskRequestModel{session_head}API hitTheory hit" + "DeltaAPI hit tokAPI miss tokTheory hit tokTheory miss tok" + "Prompt tokCallsReportedComparedPool hits" + "Avg prefixSliding callsAvg dropped msgAvg aligned msg" + "Top dynamic diff paths" + ) + run_table_head = ( + "Run IDProcess startedFirst eventLast event" + "CallsPrompt tokAPI hitTheory hitAvg prefix" + "Sliding calls" + ) + run_compare_head = ( + f"TaskRequestModel{session_head}Current callsPrevious calls" + "Current APIPrevious APIAPI delta" + "Current TheoryPrevious TheoryTheory delta" + "Current PrefixPrevious PrefixPrefix delta" + "Current SlidingPrevious Sliding" + ) + run_significance_head = ( + f"Baseline runBaseline timeTaskRequestModel{session_head}" + "Current callsBaseline calls" + "API deltaAPI confidenceAPI significant" + "Prefix deltaPrefix confidencePrefix significant" + "Current slidingBaseline sliding" + ) + + return f""" + + + + {escape(report_title)} + + + +

{escape(report_title)}

+
Updated at: {escape(updated_at)}. Current run: {escape(current_run_id)}. Process started at: {escape(_store.process_started_at)}. Grouped by task_name / request_type / model_name{escape(' / session_id' if include_session else '')}. Local prompt pool size: {PROMPT_CACHE_POOL_SIZE}. {peer_report_link}
+
+
Calls
{_format_int(total_calls)}
+
Prompt tokens
{_format_int(total_prompt_tokens)}
+
API hit tokens
{_format_int(total_hit_tokens)}
+
API hit rate
{_format_rate(overall_hit_rate)}
+
Theory hit tokens
{_format_int(total_theoretical_hit_tokens)}
+
Theory hit rate
{_format_rate(overall_theoretical_hit_rate)}
+
+

Run Comparison

+ + {run_table_head} + {_render_run_rows(run_stats, current_run_id)} +
+

Current vs Previous Run By Call Site

+ + {run_compare_head} + {_render_run_comparison_rows(current_by_call_site=current_by_call_site, previous_by_call_site=previous_by_call_site, include_session=include_session)} +
+

Current vs Every Previous Run Significance

+ {_render_run_significance_controls(run_stats, current_run_id)} + + {run_significance_head} + {_build_run_significance_rows(usage_events=usage_events, run_stats=run_stats, current_run_id=current_run_id, include_session=include_session)} +
+

Low API Hit Rate

+ + {table_head} + {_render_stat_rows(low_stats, include_session=include_session)} +
+

High API Hit Rate

+ + {table_head} + {_render_stat_rows(high_stats, include_session=include_session)} +
+

All Call Sites

+ + {table_head} + {_render_stat_rows(all_stats, include_session=include_session)} +
+ {_render_run_significance_script()} + + +""" + + +def _write_html_report(stats_snapshot: List[Dict[str, int | str | float]]) -> None: + CACHE_STATS_DIR.mkdir(parents=True, exist_ok=True) + _get_report_path().write_text(_render_html_report(stats_snapshot, include_session=False), encoding="utf-8") + _get_session_report_path().write_text(_render_html_report(stats_snapshot, include_session=True), encoding="utf-8") + + +def _write_usage_event(event: Dict[str, int | str | float | bool]) -> None: + try: + _write_json_line(_get_usage_log_path(datetime.now()), event) + except Exception as exc: + logger.warning(f"写入 LLM prompt cache 明细失败: {exc}") + + +def _write_report(stats_snapshot: List[Dict[str, int | str | float]]) -> None: + try: + _write_html_report(stats_snapshot) + except Exception as exc: + logger.warning(f"写入 LLM prompt cache HTML 报告失败: {exc}") + + +def record_llm_cache_usage( + *, + task_name: str, + request_type: str, + model_name: str, + session_id: str = "", + prompt_tokens: int, + prompt_cache_hit_tokens: int, + prompt_cache_miss_tokens: int, + prompt_text: str | None = None, +) -> None: + """Record one LLM prompt cache usage event.""" + + if not _is_llm_cache_stats_enabled(): + return + + normalized_task_name = str(task_name or "").strip() + if normalized_task_name not in FOCUSED_TASK_NAMES: + return + + normalized_request_type = _normalize_request_type(request_type) + if normalized_request_type in EXCLUDED_REQUEST_TYPES: + return + + normalized_model_name = _normalize_model_name(model_name) + normalized_session_id = _normalize_session_id(session_id) + normalized_prompt_tokens = max(int(prompt_tokens or 0), 0) + hit_tokens, miss_tokens, has_cache_report = _normalize_cache_tokens( + prompt_tokens=normalized_prompt_tokens, + prompt_cache_hit_tokens=prompt_cache_hit_tokens, + prompt_cache_miss_tokens=prompt_cache_miss_tokens, + ) + + with _store.lock: + key = (normalized_task_name, normalized_request_type, normalized_model_name, normalized_session_id) + prompt_pool = _store.prompt_pools.get(key, []) + cache_match = _calculate_theoretical_cache_match( + prompt_tokens=normalized_prompt_tokens, + prompt_text=prompt_text, + prompt_pool=prompt_pool, + ) + dynamic_diff = _diagnose_dynamic_diff(cache_match.best_prompt_text, prompt_text) + prompt_diagnostics = _diagnose_prompt_cache_details( + previous_prompt_text=cache_match.best_prompt_text, + current_prompt_text=prompt_text, + common_prefix_chars=cache_match.common_prefix_chars, + ) + if prompt_text: + next_prompt_pool = [*prompt_pool, prompt_text] + if len(next_prompt_pool) > PROMPT_CACHE_POOL_SIZE: + next_prompt_pool = next_prompt_pool[-PROMPT_CACHE_POOL_SIZE:] + _store.prompt_pools[key] = next_prompt_pool + + stat = _store.stats.get(key) + if stat is None: + stat = LLMCacheStat( + task_name=normalized_task_name, + request_type=normalized_request_type, + model_name=normalized_model_name, + session_id=normalized_session_id, + ) + _store.stats[key] = stat + + stat.calls += 1 + stat.prompt_tokens += normalized_prompt_tokens + stat.prompt_cache_hit_tokens += hit_tokens + stat.prompt_cache_miss_tokens += miss_tokens + stat.theoretical_prompt_cache_hit_tokens += cache_match.hit_tokens + stat.theoretical_prompt_cache_miss_tokens += cache_match.miss_tokens + stat.common_prefix_rate_total += prompt_diagnostics.common_prefix_rate + if prompt_diagnostics.suspected_context_sliding: + stat.suspected_context_sliding_calls += 1 + stat.sliding_dropped_messages_total += prompt_diagnostics.sliding_dropped_head_messages + stat.sliding_aligned_messages_total += prompt_diagnostics.sliding_aligned_messages + stat.dynamic_diff_counts[dynamic_diff.path] = stat.dynamic_diff_counts.get(dynamic_diff.path, 0) + 1 + if has_cache_report: + stat.cache_reported_calls += 1 + if cache_match.compared: + stat.theoretical_compared_calls += 1 + if cache_match.hit_tokens > 0: + stat.theoretical_cache_pool_hits += 1 + _store.total_calls += 1 + _store.calls_since_report += 1 + _store.calls_in_run += 1 + + api_hit_rate = hit_tokens / (hit_tokens + miss_tokens) * 100 if hit_tokens + miss_tokens > 0 else 0.0 + event = { + "created_at": datetime.now().isoformat(timespec="seconds"), + "run_id": _store.run_id, + "process_started_at": _store.process_started_at, + "call_index_in_run": _store.calls_in_run, + "task_name": normalized_task_name, + "request_type": normalized_request_type, + "model_name": normalized_model_name, + "session_id": normalized_session_id, + "prompt_tokens": normalized_prompt_tokens, + "prompt_chars": len(prompt_text or ""), + "prompt_cache_hit_tokens": hit_tokens, + "prompt_cache_miss_tokens": miss_tokens, + "prompt_cache_hit_rate": round(api_hit_rate, 2), + "theoretical_prompt_cache_hit_tokens": cache_match.hit_tokens, + "theoretical_prompt_cache_miss_tokens": cache_match.miss_tokens, + "theoretical_prompt_cache_hit_rate": round(cache_match.hit_rate, 2), + "theoretical_cache_pool_size": cache_match.pool_size, + "theoretical_best_match_rank": cache_match.best_match_rank, + "theoretical_common_prefix_chars": cache_match.common_prefix_chars, + "theoretical_common_prefix_rate": round(prompt_diagnostics.common_prefix_rate, 2), + "current_message_count": prompt_diagnostics.current_message_count, + "best_match_message_count": prompt_diagnostics.best_match_message_count, + "common_prefix_messages": prompt_diagnostics.common_prefix_messages, + "common_suffix_messages": prompt_diagnostics.common_suffix_messages, + "prompt_growth_chars": prompt_diagnostics.prompt_growth_chars, + "longest_aligned_message_overlap": prompt_diagnostics.longest_aligned_message_overlap, + "aligned_previous_start_index": prompt_diagnostics.aligned_previous_start_index, + "aligned_current_start_index": prompt_diagnostics.aligned_current_start_index, + "suspected_context_sliding": prompt_diagnostics.suspected_context_sliding, + "sliding_dropped_head_messages": prompt_diagnostics.sliding_dropped_head_messages, + "sliding_aligned_messages": prompt_diagnostics.sliding_aligned_messages, + "sliding_new_tail_messages": prompt_diagnostics.sliding_new_tail_messages, + "current_first_message_role": prompt_diagnostics.current_first_message_role, + "best_first_message_role": prompt_diagnostics.best_first_message_role, + "current_last_message_role": prompt_diagnostics.current_last_message_role, + "best_last_message_role": prompt_diagnostics.best_last_message_role, + "prompt_cache_hit_rate_delta": round(api_hit_rate - cache_match.hit_rate, 2), + "dynamic_diff_path": dynamic_diff.path, + "dynamic_diff_previous": dynamic_diff.previous_value, + "dynamic_diff_current": dynamic_diff.current_value, + "cache_reported": has_cache_report, + "theoretical_compared": cache_match.compared, + } + stats_snapshot = [stat.to_dict() for stat in _store.stats.values()] + + now = time.time() + should_update_report = ( + _store.last_report_at <= 0 + or _store.calls_since_report >= REPORT_INTERVAL_CALLS + or now - _store.last_report_at >= REPORT_INTERVAL_SECONDS + ) + if should_update_report: + _store.last_report_at = now + _store.calls_since_report = 0 + stats_snapshot_to_report = stats_snapshot + else: + stats_snapshot_to_report = [] + + _write_usage_event(event) + if stats_snapshot_to_report: + _write_report(stats_snapshot_to_report) + log_llm_cache_stats_summary(stats_snapshot_to_report) + + +def get_llm_cache_stats_snapshot() -> List[Dict[str, int | str | float]]: + """Return current in-process LLM prompt cache stats.""" + + with _store.lock: + return [stat.to_dict() for stat in _store.stats.values()] + + +def reset_llm_cache_stats() -> None: + """Reset in-process stats. Intended for tests and local debugging.""" + + with _store.lock: + _store.stats.clear() + _store.prompt_pools.clear() + _store.total_calls = 0 + _store.calls_in_run = 0 + _store.last_report_at = 0 + _store.calls_since_report = 0 + + +def log_llm_cache_stats_summary(stats_snapshot: List[Dict[str, int | str | float]] | None = None) -> None: + """Log current highest and lowest prompt cache hit-rate call sites.""" + + snapshot = stats_snapshot or get_llm_cache_stats_snapshot() + if not snapshot: + return + + sorted_stats = sorted( + snapshot, + key=lambda item: ( + float(item["prompt_cache_hit_rate"]), + -int(item["prompt_cache_miss_tokens"]), + ), + ) + low_stats = sorted_stats[:SUMMARY_LIMIT] + high_stats = list(reversed(sorted_stats[-SUMMARY_LIMIT:])) + + def _format_stat(item: Dict[str, int | str | float]) -> str: + return ( + f"{item['task_name']}/{item['request_type']}/{item['model_name']}: " + f"api_hit_rate={float(item['prompt_cache_hit_rate']):.2f}%, " + f"theory_hit_rate={float(item['theoretical_prompt_cache_hit_rate']):.2f}%, " + f"delta={float(item['prompt_cache_hit_rate_delta']):.2f}%, " + f"avg_prefix={float(item['avg_common_prefix_rate']):.2f}%, " + f"sliding_calls={item['suspected_context_sliding_calls']}, " + f"top_dynamic={item.get('top_dynamic_diff_paths', '')}, " + f"hit={item['prompt_cache_hit_tokens']}, " + f"miss={item['prompt_cache_miss_tokens']}, " + f"prompt={item['prompt_tokens']}, " + f"calls={item['calls']}, " + f"reported={item['cache_reported_calls']}" + ) + + logger.info( + "LLM prompt cache 统计摘要\n" + "低命中调用点:\n- " + "\n- ".join(_format_stat(item) for item in low_stats) + "\n" + "高命中调用点:\n- " + "\n- ".join(_format_stat(item) for item in high_stats) + ) diff --git a/src/services/llm_service.py b/src/services/llm_service.py new file mode 100644 index 00000000..92da545f --- /dev/null +++ b/src/services/llm_service.py @@ -0,0 +1,633 @@ +"""LLM 服务层。 + +该模块负责在宿主侧收口统一的 LLM 服务请求模型,并将其转发到 +`src.llm_models` 中的底层请求调度器。 +""" + +from typing import Any, Dict, List, Tuple + +import hashlib +import inspect +import json + +from src.common.data_models.embedding_service_data_models import EmbeddingResult +from src.common.data_models.llm_service_data_models import ( + LLMAudioTranscriptionResult, + LLMGenerationOptions, + LLMImageOptions, + LLMResponseResult, + LLMServiceRequest, + LLMServiceResult, + MessageFactory, + PromptInput, + PromptMessage, +) +from src.common.logger import get_logger +from src.llm_models.model_client.base_client import BaseClient +from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType +from src.llm_models.payload_content.tool_option import ToolCall +from src.llm_models.utils_model import LLMOrchestrator +from src.services.embedding_service import EmbeddingServiceClient +from src.services.llm_cache_stats import record_llm_cache_usage +from src.services.service_task_resolver import ( + get_available_models as _get_available_models, + resolve_task_name as _resolve_task_name, + resolve_task_name_from_model_config as _resolve_task_name_from_model_config, +) + +logger = get_logger("llm_service") + + +class LLMServiceClient: + """面向上层模块的 LLM 服务对象式门面。 + + 当前推荐优先使用以下正式接口: + - `generate_response` + - `generate_response_with_messages` + - `generate_response_for_image` + - `transcribe_audio` + - `embed_text`(兼容入口,推荐改用 `EmbeddingServiceClient`) + """ + + def __init__(self, task_name: str, request_type: str = "", session_id: str = "") -> None: + """初始化 LLM 服务门面。 + + Args: + task_name: 任务配置名称,对应 `model_task_config` 下的字段名。 + request_type: 当前请求的业务类型标识。 + """ + self.task_name = _resolve_task_name(task_name) + self.request_type = request_type + self.session_id = str(session_id or "").strip() + self._orchestrator = LLMOrchestrator(task_name=self.task_name, request_type=request_type) + + @staticmethod + def _normalize_generation_options(options: LLMGenerationOptions | None = None) -> LLMGenerationOptions: + """规范化文本生成选项。 + + Args: + options: 原始生成选项。 + + Returns: + LLMGenerationOptions: 可直接用于执行请求的完整选项对象。 + """ + if options is None: + return LLMGenerationOptions() + return options + + @staticmethod + def _normalize_image_options(options: LLMImageOptions | None = None) -> LLMImageOptions: + """规范化图像理解选项。 + + Args: + options: 原始图像理解选项。 + + Returns: + LLMImageOptions: 可直接用于执行请求的完整选项对象。 + """ + if options is None: + return LLMImageOptions() + return options + + @staticmethod + def _serialize_message_for_cache_stats(message: Message) -> Dict[str, Any]: + parts: list[dict[str, Any]] = [] + for part in message.parts: + if hasattr(part, "text"): + parts.append({"type": "text", "text": part.text}) + continue + + image_base64 = getattr(part, "image_base64", "") + image_digest = hashlib.sha256(image_base64.encode("utf-8")).hexdigest() if image_base64 else "" + parts.append( + { + "type": "image", + "format": getattr(part, "image_format", ""), + "size": len(image_base64), + "sha256": image_digest, + } + ) + + return { + "role": str(message.role.value if hasattr(message.role, "value") else message.role), + "parts": parts, + "tool_call_id": message.tool_call_id, + "tool_name": message.tool_name, + "tool_calls": [ + { + "id": tool_call.call_id, + "name": tool_call.func_name, + "arguments": tool_call.args, + "extra_content": tool_call.extra_content, + } + for tool_call in (message.tool_calls or []) + ], + } + + @classmethod + def _build_cache_stats_prompt_text( + cls, + *, + messages: List[Message], + tool_options: Any, + response_format: Any, + ) -> str: + payload = { + "messages": [cls._serialize_message_for_cache_stats(message) for message in messages], + "tool_options": tool_options or [], + "response_format": response_format, + } + return json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str) + + def _record_cache_stats(self, result: LLMResponseResult, prompt_text: str | None = None) -> None: + """记录当前调用的 prompt cache 统计。""" + + record_llm_cache_usage( + task_name=self.task_name, + request_type=self.request_type, + model_name=result.model_name, + session_id=self.session_id, + prompt_tokens=result.prompt_tokens, + prompt_cache_hit_tokens=result.prompt_cache_hit_tokens, + prompt_cache_miss_tokens=result.prompt_cache_miss_tokens, + prompt_text=prompt_text, + ) + + async def generate_response( + self, + prompt: str, + options: LLMGenerationOptions | None = None, + ) -> LLMResponseResult: + """生成单轮文本响应。 + + Args: + prompt: 文本提示词。 + options: 文本生成选项。 + + Returns: + LLMResponseResult: 统一文本生成结果。 + """ + active_options = self._normalize_generation_options(options) + prompt_text = self._build_cache_stats_prompt_text( + messages=[MessageBuilder().add_text_content(prompt).build()], + tool_options=active_options.tool_options, + response_format=active_options.response_format, + ) + result = await self._orchestrator.generate_response_async( + prompt=prompt, + temperature=active_options.temperature, + max_tokens=active_options.max_tokens, + tools=active_options.tool_options, + response_format=active_options.response_format, + raise_when_empty=active_options.raise_when_empty, + interrupt_flag=active_options.interrupt_flag, + ) + self._record_cache_stats(result, prompt_text=prompt_text) + return result + + async def generate_response_with_messages( + self, + message_factory: MessageFactory, + options: LLMGenerationOptions | None = None, + ) -> LLMResponseResult: + """基于消息工厂生成响应。 + + Args: + message_factory: 消息工厂,会根据客户端能力构建消息列表。 + options: 文本生成选项。 + + Returns: + LLMResponseResult: 统一文本生成结果。 + """ + active_options = self._normalize_generation_options(options) + prompt_text_holder: dict[str, str] = {} + + def cache_stats_message_factory(client: BaseClient, model_info: Any = None) -> List[Message]: + if len(inspect.signature(message_factory).parameters) >= 2: + messages = message_factory(client, model_info) + else: + messages = message_factory(client) + prompt_text_holder["prompt_text"] = self._build_cache_stats_prompt_text( + messages=messages, + tool_options=active_options.tool_options, + response_format=active_options.response_format, + ) + return messages + + result = await self._orchestrator.generate_response_with_message_async( + message_factory=cache_stats_message_factory, + temperature=active_options.temperature, + max_tokens=active_options.max_tokens, + tools=active_options.tool_options, + response_format=active_options.response_format, + raise_when_empty=active_options.raise_when_empty, + interrupt_flag=active_options.interrupt_flag, + ) + self._record_cache_stats(result, prompt_text=prompt_text_holder.get("prompt_text")) + return result + + async def generate_response_for_image( + self, + prompt: str, + image_base64: str, + image_format: str, + options: LLMImageOptions | None = None, + ) -> LLMResponseResult: + """为图像内容生成响应。 + + Args: + prompt: 文本提示词。 + image_base64: 图像的 Base64 编码字符串。 + image_format: 图像格式,例如 ``png``、``jpeg``。 + options: 图像理解选项。 + + Returns: + LLMResponseResult: 统一文本生成结果。 + """ + active_options = self._normalize_image_options(options) + image_digest = hashlib.sha256(image_base64.encode("utf-8")).hexdigest() if image_base64 else "" + prompt_text = json.dumps( + { + "messages": [ + { + "role": "user", + "parts": [ + {"type": "text", "text": prompt}, + { + "type": "image", + "format": image_format, + "size": len(image_base64), + "sha256": image_digest, + }, + ], + } + ], + "tool_options": [], + "response_format": None, + }, + ensure_ascii=False, + sort_keys=True, + ) + result = await self._orchestrator.generate_response_for_image( + prompt=prompt, + image_base64=image_base64, + image_format=image_format, + temperature=active_options.temperature, + max_tokens=active_options.max_tokens, + interrupt_flag=active_options.interrupt_flag, + ) + self._record_cache_stats(result, prompt_text=prompt_text) + return result + + async def transcribe_audio(self, voice_base64: str) -> LLMAudioTranscriptionResult: + """执行音频转写请求。 + + Args: + voice_base64: 音频的 Base64 编码字符串。 + + Returns: + LLMAudioTranscriptionResult: 音频转写结果对象。 + """ + return await self._orchestrator.generate_response_for_voice(voice_base64) + + async def embed_text(self, embedding_input: str) -> EmbeddingResult: + """兼容旧调用的文本嵌入入口。 + + Args: + embedding_input: 待编码的文本。 + + Returns: + EmbeddingResult: 向量生成结果对象。 + """ + embedding_client = EmbeddingServiceClient( + task_name=self.task_name, + request_type=self.request_type, + ) + return await embedding_client.embed_text(embedding_input) + + +def get_available_models() -> Dict[str, Any]: + """获取所有可用模型配置。 + + Returns: + Dict[str, Any]: 以模型任务名为键的配置映射。 + """ + return _get_available_models() + + +def resolve_task_name(task_name: str = "") -> str: + """根据名称解析任务配置名。 + + Args: + task_name: 目标任务配置名;为空时返回首个可用任务名。 + + Returns: + str: 解析得到的任务配置名。 + """ + return _resolve_task_name(task_name) + + +def resolve_task_name_from_model_config(model_config: Any, preferred_task_name: str = "") -> str: + """根据旧版 `TaskConfig` 风格参数解析可用任务名。 + + Args: + model_config: 旧调用方持有的任务配置对象。 + preferred_task_name: 候选任务名(可选)。 + + Returns: + str: 可用于 `LLMServiceRequest.task_name` 的任务名。 + """ + return _resolve_task_name_from_model_config( + model_config=model_config, + preferred_task_name=preferred_task_name, + ) + + +def _normalize_role(role_name: str) -> RoleType: + """将原始角色字符串转换为内部角色枚举。 + + Args: + role_name: 原始角色名称。 + + Returns: + RoleType: 规范化后的角色枚举。 + + Raises: + ValueError: 角色类型不受支持时抛出。 + """ + normalized_role_name = role_name.strip().lower() + try: + return RoleType(normalized_role_name) + except ValueError as exc: + raise ValueError(f"不支持的消息角色: {role_name}") from exc + + +def _parse_data_url_image(image_url: str) -> Tuple[str, str]: + """解析 Data URL 形式的图片内容。 + + Args: + image_url: 图片 URL。 + + Returns: + Tuple[str, str]: `(图片格式, Base64 数据)`。 + + Raises: + ValueError: 输入不是受支持的 Data URL 时抛出。 + """ + if not image_url.startswith("data:image/") or ";base64," not in image_url: + raise ValueError("仅支持 Data URL 形式的图片输入") + prefix, image_base64 = image_url.split(";base64,", maxsplit=1) + image_format = prefix.removeprefix("data:image/") + if not image_format or not image_base64: + raise ValueError("图片 Data URL 不完整") + return image_format, image_base64 + + +def _append_image_content(message_builder: MessageBuilder, content_item: Any) -> bool: + """向消息构建器追加图片片段。 + + 兼容两种输入格式: + 1. 旧序列化格式中的 `(image_format, image_base64)` 元组。 + 2. 标准字典片段中的 Data URL 或 `image_format`/`image_base64` 字段。 + """ + + if isinstance(content_item, (tuple, list)) and len(content_item) == 2: + image_format, image_base64 = content_item + if not isinstance(image_format, str) or not isinstance(image_base64, str): + raise ValueError("图片元组片段必须包含字符串类型的 image_format 和 image_base64") + + message_builder.add_image_content(image_format=image_format, image_base64=image_base64) + return True + + if not isinstance(content_item, dict): + return False + + part_type = str(content_item.get("type", "text")).strip().lower() + if part_type not in {"image", "image_url", "input_image"}: + return False + + image_url = content_item.get("image_url") + if isinstance(image_url, dict): + image_url = image_url.get("url") + if isinstance(image_url, str): + image_format, image_base64 = _parse_data_url_image(image_url) + message_builder.add_image_content(image_format=image_format, image_base64=image_base64) + return True + + image_format = content_item.get("image_format") + image_base64 = content_item.get("image_base64") + if isinstance(image_format, str) and isinstance(image_base64, str): + message_builder.add_image_content(image_format=image_format, image_base64=image_base64) + return True + + raise ValueError("图片片段缺少可识别的图片数据") + + +def _append_content_parts(message_builder: MessageBuilder, content: Any) -> None: + """将原始消息内容追加到内部消息构建器。 + + Args: + message_builder: 目标消息构建器。 + content: 原始消息内容。 + + Raises: + ValueError: 消息内容结构不受支持时抛出。 + """ + if isinstance(content, str): + message_builder.add_text_content(content) + return + + content_items: List[Any] + if isinstance(content, list): + content_items = content + elif isinstance(content, dict): + content_items = [content] + else: + raise ValueError("消息内容必须为字符串、字典或列表") + + for content_item in content_items: + if isinstance(content_item, str): + message_builder.add_text_content(content_item) + continue + if _append_image_content(message_builder, content_item): + continue + if not isinstance(content_item, dict): + raise ValueError("消息内容列表中仅支持字符串、图片元组或字典片段") + + part_type = str(content_item.get("type", "text")).strip().lower() + if part_type == "text": + text_content = content_item.get("text") + if not isinstance(text_content, str): + raise ValueError("文本片段缺少 `text` 字段") + message_builder.add_text_content(text_content) + continue + + raise ValueError(f"不支持的消息片段类型: {part_type}") + + +def _normalize_tool_arguments(arguments: Any) -> Dict[str, Any] | None: + """将原始工具参数规范化为字典。 + + Args: + arguments: 原始工具参数。 + + Returns: + Dict[str, Any] | None: 规范化后的参数字典。 + """ + if arguments is None: + return None + if isinstance(arguments, dict): + return arguments + if isinstance(arguments, str): + stripped_arguments = arguments.strip() + if not stripped_arguments: + return {} + try: + parsed_arguments = json.loads(stripped_arguments) + except json.JSONDecodeError: + return {"raw_arguments": arguments} + if isinstance(parsed_arguments, dict): + return parsed_arguments + return {"value": parsed_arguments} + return {"value": arguments} + + +def _build_tool_calls(raw_tool_calls: Any) -> List[ToolCall] | None: + """从原始消息中提取工具调用列表。 + + Args: + raw_tool_calls: 原始工具调用结构。 + + Returns: + List[ToolCall] | None: 规范化后的工具调用列表。 + + Raises: + ValueError: 工具调用结构缺失必要字段时抛出。 + """ + if raw_tool_calls is None: + return None + if not isinstance(raw_tool_calls, list): + raise ValueError("`tool_calls` 必须为列表") + + tool_calls: List[ToolCall] = [] + for raw_tool_call in raw_tool_calls: + if not isinstance(raw_tool_call, dict): + raise ValueError("工具调用项必须为字典") + + function_info = raw_tool_call.get("function") + if isinstance(function_info, dict): + func_name = function_info.get("name") + arguments = function_info.get("arguments") + else: + func_name = raw_tool_call.get("name") or raw_tool_call.get("func_name") + arguments = raw_tool_call.get("arguments") or raw_tool_call.get("args") + + call_id = raw_tool_call.get("id") or raw_tool_call.get("call_id") + if not isinstance(call_id, str) or not isinstance(func_name, str): + raise ValueError("工具调用缺少 `id` 或函数名称") + + extra_content = raw_tool_call.get("extra_content") + tool_calls.append( + ToolCall( + call_id=call_id, + func_name=func_name, + args=_normalize_tool_arguments(arguments), + extra_content=extra_content if isinstance(extra_content, dict) else None, + ) + ) + + return tool_calls or None + + +def _build_message_from_dict(raw_message: PromptMessage) -> Message: + """将原始消息字典转换为内部消息对象。 + + Args: + raw_message: 原始消息字典。 + + Returns: + Message: 规范化后的消息对象。 + + Raises: + ValueError: 原始消息结构不合法时抛出。 + """ + raw_role = raw_message.get("role") + if not isinstance(raw_role, str): + raise ValueError("消息缺少字符串类型的 `role` 字段") + + role = _normalize_role(raw_role) + message_builder = MessageBuilder().set_role(role) + + tool_calls = _build_tool_calls(raw_message.get("tool_calls")) + if tool_calls is not None: + message_builder.set_tool_calls(tool_calls) + + tool_call_id = raw_message.get("tool_call_id") + if isinstance(tool_call_id, str) and role == RoleType.Tool: + message_builder.set_tool_call_id(tool_call_id) + + if "content" in raw_message and raw_message["content"] not in (None, "", []): + _append_content_parts(message_builder, raw_message["content"]) + + return message_builder.build() + + +def _build_prompt_message_factory(prompt: PromptInput) -> MessageFactory: + """将统一提示输入转换为消息工厂。 + + Args: + prompt: 原始提示输入。 + + Returns: + MessageFactory: 惰性构建消息列表的工厂函数。 + """ + if isinstance(prompt, str): + def build_messages(_: BaseClient) -> List[Message]: + """构建单条用户消息。""" + message_builder = MessageBuilder() + message_builder.add_text_content(prompt) + return [message_builder.build()] + + return build_messages + + def build_messages(_: BaseClient) -> List[Message]: + """构建多消息对话输入。""" + return [_build_message_from_dict(raw_message) for raw_message in prompt] + + return build_messages + + +async def generate(request: LLMServiceRequest) -> LLMServiceResult: + """执行统一的 LLM 服务请求。 + + Args: + request: 服务层统一请求对象。 + + Returns: + LLMServiceResult: 统一响应对象;失败时 `success=False`。 + """ + llm_client = LLMServiceClient(task_name=request.task_name, request_type=request.request_type) + if request.message_factory is not None: + active_message_factory = request.message_factory + else: + prompt = request.prompt + if prompt is None: + raise ValueError("`prompt` 与 `message_factory` 必须且只能提供一个") + active_message_factory = _build_prompt_message_factory(prompt) + + try: + generation_result = await llm_client.generate_response_with_messages( + message_factory=active_message_factory, + options=LLMGenerationOptions( + temperature=request.temperature, + max_tokens=request.max_tokens, + tool_options=request.tool_options, + response_format=request.response_format, + interrupt_flag=request.interrupt_flag, + ), + ) + return LLMServiceResult.from_response_result(generation_result) + except Exception as exc: + error_message = f"生成内容时出错: {exc}" + logger.error(f"[LLMService] {error_message}") + return LLMServiceResult.from_error(error_message, str(exc)) diff --git a/src/services/memory_flow_service.py b/src/services/memory_flow_service.py new file mode 100644 index 00000000..5d851feb --- /dev/null +++ b/src/services/memory_flow_service.py @@ -0,0 +1,575 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, List, Optional + +import asyncio +import json +import pickle +import time + +from json_repair import repair_json + +from src.services import memory_service as memory_service_module +from src.chat.utils.utils import is_bot_self +from src.common.logger import get_logger +from src.common.message_repository import count_messages, find_messages +from src.config.config import global_config +from src.person_info.person_info import Person, get_person_id, store_person_memory_from_answer +from src.services.memory_service import memory_service +from src.services.llm_service import LLMServiceClient + +logger = get_logger("memory_flow_service") + + +class PersonFactWritebackService: + def __init__(self) -> None: + self._queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=256) + self._worker_task: Optional[asyncio.Task] = None + self._stopping = False + self._extractor = LLMServiceClient(task_name="utils", request_type="person_fact_writeback") + + async def start(self) -> None: + if self._worker_task is not None and not self._worker_task.done(): + return + self._stopping = False + self._worker_task = asyncio.create_task(self._worker_loop(), name="memory_person_fact_writeback") + + async def shutdown(self) -> None: + self._stopping = True + worker = self._worker_task + self._worker_task = None + if worker is None: + return + worker.cancel() + try: + await worker + except asyncio.CancelledError: + pass + except Exception as exc: + logger.warning(f"关闭人物事实写回 worker 失败: {exc}") + + async def enqueue(self, message: Any) -> None: + if not bool(global_config.a_memorix.integration.person_fact_writeback_enabled): + return + if self._stopping: + return + try: + self._queue.put_nowait(message) + except asyncio.QueueFull: + logger.warning("人物事实写回队列已满,跳过本次回复") + + async def _worker_loop(self) -> None: + try: + while not self._stopping: + message = await self._queue.get() + try: + await self._handle_message(message) + except Exception as exc: + logger.warning(f"人物事实写回处理失败: {exc}", exc_info=True) + finally: + self._queue.task_done() + except asyncio.CancelledError: + raise + + async def _handle_message(self, message: Any) -> None: + reply_text = str(getattr(message, "processed_plain_text", "") or "").strip() + if not reply_text: + return + if self._looks_ephemeral(reply_text): + return + + target_person = self._resolve_target_person(message) + if target_person is None or not target_person.is_known: + return + + user_evidence_messages = self._collect_user_evidence_messages(message, target_person) + if not user_evidence_messages: + return + user_evidence_text = self._format_user_evidence(user_evidence_messages) + + facts = await self._extract_facts(target_person, reply_text, user_evidence_text) + if not facts: + return + + session_id = str( + getattr(message, "session_id", "") + or getattr(getattr(message, "session", None), "session_id", "") + or "" + ).strip() + if not session_id: + return + + person_name = str( + getattr(target_person, "person_name", "") + or getattr(target_person, "nickname", "") + or "" + ).strip() + if not person_name: + return + + evidence_message_ids = [ + str(getattr(item, "message_id", "") or "").strip() + for item in user_evidence_messages + if str(getattr(item, "message_id", "") or "").strip() + ] + for fact in facts: + await store_person_memory_from_answer( + person_name, + fact, + session_id, + evidence_source="user_supported", + evidence_message_ids=evidence_message_ids, + ) + + def _resolve_target_person(self, message: Any) -> Optional[Person]: + session = getattr(message, "session", None) + session_platform = str(getattr(session, "platform", "") or getattr(message, "platform", "") or "").strip() + session_user_id = str(getattr(session, "user_id", "") or "").strip() + group_id = str(getattr(session, "group_id", "") or "").strip() + + if session_platform and session_user_id and not group_id: + if is_bot_self(session_platform, session_user_id): + return None + person_id = get_person_id(session_platform, session_user_id) + person = Person(person_id=person_id) + return person if person.is_known else None + + reply_to = str(getattr(message, "reply_to", "") or "").strip() + if not reply_to: + return None + try: + replies = find_messages(message_id=reply_to, limit=1) + except Exception as exc: + logger.debug(f"查询 reply_to 目标失败: {exc}") + return None + if not replies: + return None + reply_message = replies[0] + reply_platform = str(getattr(reply_message, "platform", "") or session_platform or "").strip() + reply_user_info = getattr(getattr(reply_message, "message_info", None), "user_info", None) + reply_user_id = str(getattr(reply_user_info, "user_id", "") or "").strip() + if not reply_platform or not reply_user_id or is_bot_self(reply_platform, reply_user_id): + return None + person_id = get_person_id(reply_platform, reply_user_id) + person = Person(person_id=person_id) + return person if person.is_known else None + + def _collect_user_evidence_messages(self, message: Any, person: Person) -> List[Any]: + session = getattr(message, "session", None) + session_id = str( + getattr(message, "session_id", "") + or getattr(session, "session_id", "") + or "" + ).strip() + if not session_id: + return [] + + evidence: List[Any] = [] + seen_ids = set() + + reply_to = str(getattr(message, "reply_to", "") or "").strip() + if reply_to: + try: + replies = find_messages(message_id=reply_to, limit=1) + except Exception as exc: + logger.debug("查询人物事实 reply_to 证据失败: %s", exc) + replies = [] + evidence.extend(self._filter_target_user_messages(replies, person, seen_ids)) + + if evidence: + return evidence[:3] + + timestamp = self._extract_message_timestamp(message) + try: + candidates = find_messages( + session_id=session_id, + before_time=timestamp, + limit=6, + limit_mode="latest", + filter_bot=True, + ) + except Exception as exc: + logger.debug("查询人物事实近期用户证据失败: %s", exc) + return [] + return self._filter_target_user_messages(candidates, person, seen_ids)[:3] + + @staticmethod + def _extract_message_timestamp(message: Any) -> float | None: + raw_timestamp = getattr(message, "timestamp", None) + if hasattr(raw_timestamp, "timestamp") and callable(raw_timestamp.timestamp): + try: + return float(raw_timestamp.timestamp()) + except Exception: + return None + if isinstance(raw_timestamp, (int, float)): + return float(raw_timestamp) + return None + + @staticmethod + def _filter_target_user_messages(messages: List[Any], person: Person, seen_ids: set) -> List[Any]: + filtered: List[Any] = [] + target_person_id = str(getattr(person, "person_id", "") or "").strip() + for item in messages: + platform = str(getattr(item, "platform", "") or "").strip() + user_info = getattr(getattr(item, "message_info", None), "user_info", None) + user_id = str(getattr(user_info, "user_id", "") or getattr(item, "user_id", "") or "").strip() + if not platform or not user_id or is_bot_self(platform, user_id): + continue + if target_person_id and get_person_id(platform, user_id) != target_person_id: + continue + text = str(getattr(item, "processed_plain_text", "") or "").strip() + if not text: + continue + message_id = str(getattr(item, "message_id", "") or "").strip() + dedup_key = message_id or f"{platform}:{user_id}:{text}" + if dedup_key in seen_ids: + continue + seen_ids.add(dedup_key) + filtered.append(item) + return filtered + + @staticmethod + def _format_user_evidence(messages: List[Any]) -> str: + lines: List[str] = [] + for item in messages[:3]: + text = str(getattr(item, "processed_plain_text", "") or "").strip() + if text: + lines.append(f"- {text}") + return "\n".join(lines) + + async def _extract_facts(self, person: Person, reply_text: str, user_evidence_text: str) -> List[str]: + person_name = str(getattr(person, "person_name", "") or getattr(person, "nickname", "") or person.person_id) + prompt = f"""你要从用户原始发言中提取“关于{person_name}的稳定事实”。 + +目标人物:{person_name} +用户原始发言证据: +{user_evidence_text} + +机器人回复: +{reply_text} + +请只提取满足以下条件的事实: +1. 必须能被“用户原始发言证据”直接支持,不能只来自机器人回复。 +2. 明确是关于目标人物本人的信息。 +3. 具有相对稳定性,可以作为长期记忆保存。 +4. 用简洁中文陈述句表达。 +5. 如果用户原始发言中出现“我/我的/自己”,默认指目标人物,请先改写成关于目标人物的第三人称事实再输出。 + +不要提取: +- 机器人的情绪、计划、临时动作、客套话 +- 仅由机器人提出的建议、猜测、玩笑、回忆或承诺 +- 只适用于当前时刻的短期安排 +- 不确定、猜测、反问 +- 与目标人物无关的信息 + +严格输出 JSON 数组,例如: +["他喜欢深夜打游戏", "他养了一只猫"] +如果没有可写入的事实,输出 []""" + try: + response_result = await self._extractor.generate_response(prompt) + except Exception as exc: + logger.debug(f"人物事实提取模型调用失败: {exc}") + return [] + return self._parse_fact_list(response_result.response) + + @staticmethod + def _parse_fact_list(raw: str) -> List[str]: + text = str(raw or "").strip() + if not text: + return [] + try: + repaired = repair_json(text) + payload = json.loads(repaired) if isinstance(repaired, str) else repaired + except Exception: + payload = None + if not isinstance(payload, list): + return [] + + items: List[str] = [] + seen = set() + for item in payload: + fact = str(item or "").strip().strip("- ") + if not fact or len(fact) < 4: + continue + if fact in seen: + continue + seen.add(fact) + items.append(fact) + return items[:5] + + @staticmethod + def _looks_ephemeral(text: str) -> bool: + content = str(text or "").strip() + if not content: + return True + ephemeral_markers = ( + "哈哈", + "好的", + "收到", + "嗯嗯", + "晚安", + "早安", + "拜拜", + "谢谢", + "在吗", + "?", + ) + if len(content) <= 8 and any(marker in content for marker in ephemeral_markers): + return True + return False + + +@dataclass +class ChatSummaryWritebackState: + last_trigger_message_count: int = 0 + last_trigger_time: float = 0.0 + + +class ChatSummaryWritebackService: + def __init__(self) -> None: + self._queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=256) + self._worker_task: Optional[asyncio.Task] = None + self._stopping = False + self._states: dict[str, ChatSummaryWritebackState] = {} + + async def start(self) -> None: + if self._worker_task is not None and not self._worker_task.done(): + return + self._stopping = False + self._worker_task = asyncio.create_task(self._worker_loop(), name="memory_chat_summary_writeback") + + async def shutdown(self) -> None: + self._stopping = True + worker = self._worker_task + self._worker_task = None + if worker is None: + return + worker.cancel() + try: + await worker + except asyncio.CancelledError: + pass + except Exception as exc: + logger.warning(f"关闭聊天摘要写回 worker 失败: {exc}") + + async def enqueue(self, message: Any) -> None: + if not bool(global_config.a_memorix.integration.chat_summary_writeback_enabled): + return + if self._stopping: + return + try: + self._queue.put_nowait(message) + except asyncio.QueueFull: + logger.warning("聊天摘要写回队列已满,跳过本次触发") + + async def _worker_loop(self) -> None: + try: + while not self._stopping: + message = await self._queue.get() + try: + await self._handle_message(message) + except Exception as exc: + logger.warning(f"聊天摘要写回处理失败: {exc}", exc_info=True) + finally: + self._queue.task_done() + except asyncio.CancelledError: + raise + + async def _handle_message(self, message: Any) -> None: + session_id = self._resolve_session_id(message) + if not session_id: + return + + total_message_count = count_messages(session_id=session_id) + if total_message_count <= 0: + return + + threshold = self._message_threshold() + state = self._states.get(session_id) + if state is None: + restored_count = await self._load_last_trigger_message_count( + session_id=session_id, + total_message_count=total_message_count, + ) + state = ChatSummaryWritebackState( + last_trigger_message_count=restored_count, + last_trigger_time=time.time() if restored_count > 0 else 0.0, + ) + self._states[session_id] = state + pending_message_count = max(0, total_message_count - state.last_trigger_message_count) + if pending_message_count < threshold: + return + + context_length = self._context_length() + message_time = self._extract_message_timestamp(message) + result = await memory_service.ingest_summary( + external_id=f"chat_auto_summary:{session_id}:{total_message_count}", + chat_id=session_id, + text="", + participants=[], + time_end=message_time, + metadata={ + "generate_from_chat": True, + "context_length": context_length, + "writeback_source": "memory_flow_service", + "trigger": "message_threshold", + "trigger_message_count": total_message_count, + }, + respect_filter=True, + user_id=self._extract_session_user_id(message), + group_id=self._extract_session_group_id(message), + ) + if not getattr(result, "success", False): + logger.warning( + f"聊天摘要自动写回失败: session_id={session_id} detail={getattr(result, 'detail', '')}", + ) + return + + state.last_trigger_message_count = total_message_count + state.last_trigger_time = time.time() + logger.info( + f"聊天摘要自动写回成功: session_id={session_id} trigger=message_threshold " + f"total_messages={total_message_count} context_length={context_length} " + f"detail={getattr(result, 'detail', '')}", + ) + + async def _load_last_trigger_message_count(self, *, session_id: str, total_message_count: int) -> int: + """从已落库的聊天摘要恢复触发游标,避免服务重启后重复摘要。""" + try: + runtime_manager = getattr(memory_service_module, "a_memorix_host_service", None) + ensure_kernel = getattr(runtime_manager, "_ensure_kernel", None) + if not callable(ensure_kernel): + return 0 + + kernel = await ensure_kernel() + metadata_store = getattr(kernel, "metadata_store", None) + if metadata_store is None: + return 0 + + paragraphs = metadata_store.get_paragraphs_by_source(f"chat_summary:{session_id}") + if not paragraphs: + return 0 + + latest_paragraph = max(paragraphs, key=self._paragraph_created_at) + metadata = self._paragraph_metadata(latest_paragraph) + trigger_message_count = self._coerce_positive_int(metadata.get("trigger_message_count")) + if trigger_message_count > 0: + return min(total_message_count, trigger_message_count) + + # 兼容旧摘要数据:没有触发计数时,只能退化为对齐当前计数, + # 至少避免重启后立刻重复写入一条相近摘要。 + return total_message_count + except Exception as exc: + logger.debug(f"恢复聊天摘要写回游标失败: session_id={session_id} error={exc}") + return 0 + + @staticmethod + def _paragraph_created_at(paragraph: dict[str, Any]) -> float: + try: + return float(paragraph.get("created_at") or 0.0) + except Exception: + return 0.0 + + @staticmethod + def _paragraph_metadata(paragraph: dict[str, Any]) -> dict[str, Any]: + metadata = paragraph.get("metadata") + if isinstance(metadata, dict): + return metadata + if isinstance(metadata, (bytes, bytearray)): + try: + parsed = pickle.loads(metadata) + except Exception: + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + @staticmethod + def _coerce_positive_int(value: Any) -> int: + try: + number = int(value or 0) + except Exception: + return 0 + return max(0, number) + + @staticmethod + def _resolve_session_id(message: Any) -> str: + return str( + getattr(message, "session_id", "") + or getattr(getattr(message, "session", None), "session_id", "") + or "" + ).strip() + + @staticmethod + def _extract_session_user_id(message: Any) -> str: + return str( + getattr(getattr(message, "session", None), "user_id", "") + or getattr(message, "user_id", "") + or "" + ).strip() + + @staticmethod + def _extract_session_group_id(message: Any) -> str: + return str( + getattr(getattr(message, "session", None), "group_id", "") + or getattr(message, "group_id", "") + or "" + ).strip() + + @staticmethod + def _extract_message_timestamp(message: Any) -> float | None: + raw_timestamp = getattr(message, "timestamp", None) + if isinstance(raw_timestamp, datetime): + return raw_timestamp.timestamp() + if hasattr(raw_timestamp, "timestamp") and callable(raw_timestamp.timestamp): + try: + return float(raw_timestamp.timestamp()) + except Exception: + return None + if isinstance(raw_timestamp, (int, float)): + return float(raw_timestamp) + return None + + @staticmethod + def _message_threshold() -> int: + return max(1, int(global_config.a_memorix.integration.chat_summary_writeback_message_threshold)) + + @staticmethod + def _context_length() -> int: + return max(1, int(global_config.a_memorix.integration.chat_summary_writeback_context_length)) + + +class MemoryAutomationService: + def __init__(self) -> None: + self.fact_writeback = PersonFactWritebackService() + self.chat_summary_writeback = ChatSummaryWritebackService() + self._started = False + + async def start(self) -> None: + if self._started: + return + await self.fact_writeback.start() + await self.chat_summary_writeback.start() + self._started = True + + async def shutdown(self) -> None: + if not self._started: + return + await self.chat_summary_writeback.shutdown() + await self.fact_writeback.shutdown() + self._started = False + + async def on_incoming_message(self, message: Any) -> None: + del message + if not self._started: + await self.start() + + async def on_message_sent(self, message: Any) -> None: + if not self._started: + await self.start() + await self.fact_writeback.enqueue(message) + await self.chat_summary_writeback.enqueue(message) + + +memory_automation_service = MemoryAutomationService() diff --git a/src/services/memory_service.py b/src/services/memory_service.py new file mode 100644 index 00000000..d7ec6cd4 --- /dev/null +++ b/src/services/memory_service.py @@ -0,0 +1,478 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from src.A_memorix.host_service import a_memorix_host_service +from src.common.logger import get_logger + + +logger = get_logger("memory_service") + + +@dataclass +class MemoryHit: + content: str + score: float = 0.0 + hit_type: str = "" + source: str = "" + hash_value: str = "" + metadata: Dict[str, Any] = field(default_factory=dict) + episode_id: str = "" + title: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "content": self.content, + "score": self.score, + "type": self.hit_type, + "source": self.source, + "hash": self.hash_value, + "metadata": self.metadata, + "episode_id": self.episode_id, + "title": self.title, + } + + +@dataclass +class MemorySearchResult: + summary: str = "" + hits: List[MemoryHit] = field(default_factory=list) + filtered: bool = False + success: bool = True + error: str = "" + + def to_text(self, limit: int = 5) -> str: + if not self.hits: + return "" + lines = [] + for index, item in enumerate(self.hits[: max(1, int(limit))], start=1): + content = item.content.strip().replace("\n", " ") + if len(content) > 160: + content = content[:160] + "..." + lines.append(f"{index}. {content}") + return "\n".join(lines) + + def to_dict(self) -> Dict[str, Any]: + return { + "success": self.success, + "error": self.error, + "summary": self.summary, + "hits": [item.to_dict() for item in self.hits], + "filtered": self.filtered, + } + + +@dataclass +class MemoryWriteResult: + success: bool + stored_ids: List[str] = field(default_factory=list) + skipped_ids: List[str] = field(default_factory=list) + detail: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "success": self.success, + "stored_ids": self.stored_ids, + "skipped_ids": self.skipped_ids, + "detail": self.detail, + } + + +@dataclass +class PersonProfileResult: + summary: str = "" + traits: List[str] = field(default_factory=list) + evidence: List[Dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return {"summary": self.summary, "traits": self.traits, "evidence": self.evidence} + + +class MemoryService: + async def _invoke(self, component_name: str, args: Optional[Dict[str, Any]] = None, *, timeout_ms: int = 30000) -> Any: + response = await a_memorix_host_service.invoke( + component_name, + args or {}, + timeout_ms=max(1000, int(timeout_ms or 30000)), + ) + if isinstance(response, dict): + return response + payload = getattr(response, "payload", None) + if isinstance(payload, dict): + if isinstance(payload.get("result"), dict): + return payload["result"] + return payload + model_dump = getattr(response, "model_dump", None) + if callable(model_dump): + dumped = model_dump() + if isinstance(dumped, dict): + inner_payload = dumped.get("payload") + if isinstance(inner_payload, dict): + if isinstance(inner_payload.get("result"), dict): + return inner_payload["result"] + return inner_payload + return response + + async def _invoke_admin( + self, + component_name: str, + *, + action: str, + timeout_ms: int = 30000, + **kwargs, + ) -> Dict[str, Any]: + payload = await self._invoke(component_name, {"action": action, **kwargs}, timeout_ms=timeout_ms) + return payload if isinstance(payload, dict) else {"success": False, "error": "invalid_payload"} + + @staticmethod + def _coerce_write_result(payload: Any) -> MemoryWriteResult: + if not isinstance(payload, dict): + return MemoryWriteResult(success=False, detail="invalid_payload") + stored_ids = [str(item) for item in (payload.get("stored_ids") or []) if str(item).strip()] + skipped_ids = [str(item) for item in (payload.get("skipped_ids") or []) if str(item).strip()] + detail = str(payload.get("detail") or payload.get("reason") or "") + if stored_ids or skipped_ids: + success = True + elif "success" in payload: + success = bool(payload.get("success")) + else: + success = not bool(detail) + return MemoryWriteResult( + success=success, + stored_ids=stored_ids, + skipped_ids=skipped_ids, + detail=detail, + ) + + @staticmethod + def _coerce_search_result(payload: Any) -> MemorySearchResult: + if not isinstance(payload, dict): + return MemorySearchResult(success=False, error="invalid_payload") + hits: List[MemoryHit] = [] + for item in payload.get("hits", []) or []: + if not isinstance(item, dict): + continue + metadata = item.get("metadata", {}) or {} + if not isinstance(metadata, dict): + metadata = {} + if "source_branches" in item and "source_branches" not in metadata: + metadata["source_branches"] = item.get("source_branches") or [] + if "rank" in item and "rank" not in metadata: + metadata["rank"] = item.get("rank") + hits.append( + MemoryHit( + content=str(item.get("content", "") or ""), + score=float(item.get("score", 0.0) or 0.0), + hit_type=str(item.get("type", "") or ""), + source=str(item.get("source", "") or ""), + hash_value=str(item.get("hash", "") or ""), + metadata=metadata, + episode_id=str(item.get("episode_id", "") or ""), + title=str(item.get("title", "") or ""), + ) + ) + success_raw = payload.get("success") + error = str(payload.get("error", "") or "") + success = (not bool(error)) if success_raw is None else bool(success_raw) + return MemorySearchResult( + summary=str(payload.get("summary", "") or ""), + hits=hits, + filtered=bool(payload.get("filtered", False)), + success=success, + error=error, + ) + + @staticmethod + def _coerce_profile_result(payload: Any) -> PersonProfileResult: + if not isinstance(payload, dict): + return PersonProfileResult() + return PersonProfileResult( + summary=str(payload.get("summary", "") or ""), + traits=[str(item) for item in (payload.get("traits") or []) if str(item).strip()], + evidence=[item for item in (payload.get("evidence") or []) if isinstance(item, dict)], + ) + + async def search( + self, + query: str, + *, + limit: int = 5, + mode: str = "search", + chat_id: str = "", + person_id: str = "", + time_start: str | float | None = None, + time_end: str | float | None = None, + respect_filter: bool = True, + user_id: str = "", + group_id: str = "", + ) -> MemorySearchResult: + clean_query = str(query or "").strip() + normalized_time_start = None if time_start in {None, ""} else time_start + normalized_time_end = None if time_end in {None, ""} else time_end + if not clean_query and normalized_time_start is None and normalized_time_end is None: + return MemorySearchResult() + try: + payload = await self._invoke( + "search_memory", + { + "query": clean_query, + "limit": max(1, int(limit)), + "mode": mode, + "chat_id": chat_id, + "person_id": person_id, + "time_start": normalized_time_start, + "time_end": normalized_time_end, + "respect_filter": bool(respect_filter), + "user_id": str(user_id or "").strip(), + "group_id": str(group_id or "").strip(), + }, + ) + return self._coerce_search_result(payload) + except Exception as exc: + logger.warning(f"长期记忆搜索失败: {exc}") + return MemorySearchResult(success=False, error=str(exc)) + + async def enqueue_feedback_task( + self, + *, + query_tool_id: str, + session_id: str, + query_timestamp: Any = None, + structured_content: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + try: + payload = await self._invoke( + "enqueue_feedback_task", + { + "query_tool_id": str(query_tool_id or "").strip(), + "session_id": str(session_id or "").strip(), + "query_timestamp": query_timestamp, + "structured_content": structured_content if isinstance(structured_content, dict) else {}, + }, + timeout_ms=10000, + ) + except Exception as exc: + logger.warning(f"反馈纠错任务入队失败: {exc}") + return {"success": False, "queued": False, "reason": str(exc)} + return payload if isinstance(payload, dict) else {"success": False, "queued": False, "reason": "invalid_payload"} + + async def ingest_summary( + self, + *, + external_id: str, + chat_id: str, + text: str, + participants: Optional[List[str]] = None, + time_start: float | None = None, + time_end: float | None = None, + tags: Optional[List[str]] = None, + metadata: Optional[Dict[str, Any]] = None, + respect_filter: bool = True, + user_id: str = "", + group_id: str = "", + ) -> MemoryWriteResult: + try: + payload = await self._invoke( + "ingest_summary", + { + "external_id": external_id, + "chat_id": chat_id, + "text": text, + "participants": participants or [], + "time_start": time_start, + "time_end": time_end, + "tags": tags or [], + "metadata": metadata or {}, + "respect_filter": bool(respect_filter), + "user_id": str(user_id or "").strip(), + "group_id": str(group_id or "").strip(), + }, + ) + return self._coerce_write_result(payload) + except Exception as exc: + logger.warning(f"长期记忆写入摘要失败: {exc}") + return MemoryWriteResult(success=False, detail=str(exc)) + + async def ingest_text( + self, + *, + external_id: str, + source_type: str, + text: str, + chat_id: str = "", + person_ids: Optional[List[str]] = None, + participants: Optional[List[str]] = None, + timestamp: float | None = None, + time_start: float | None = None, + time_end: float | None = None, + tags: Optional[List[str]] = None, + metadata: Optional[Dict[str, Any]] = None, + entities: Optional[List[str]] = None, + relations: Optional[List[Dict[str, Any]]] = None, + respect_filter: bool = True, + user_id: str = "", + group_id: str = "", + ) -> MemoryWriteResult: + try: + payload = await self._invoke( + "ingest_text", + { + "external_id": external_id, + "source_type": source_type, + "text": text, + "chat_id": chat_id, + "person_ids": person_ids or [], + "participants": participants or [], + "timestamp": timestamp, + "time_start": time_start, + "time_end": time_end, + "tags": tags or [], + "metadata": metadata or {}, + "entities": entities or [], + "relations": relations or [], + "respect_filter": bool(respect_filter), + "user_id": str(user_id or "").strip(), + "group_id": str(group_id or "").strip(), + }, + ) + return self._coerce_write_result(payload) + except Exception as exc: + logger.warning(f"长期记忆写入文本失败: {exc}") + return MemoryWriteResult(success=False, detail=str(exc)) + + async def get_person_profile(self, person_id: str, *, chat_id: str = "", limit: int = 10) -> PersonProfileResult: + clean_person_id = str(person_id or "").strip() + if not clean_person_id: + return PersonProfileResult() + try: + payload = await self._invoke( + "get_person_profile", + {"person_id": clean_person_id, "chat_id": chat_id, "limit": max(1, int(limit))}, + ) + return self._coerce_profile_result(payload) + except Exception as exc: + logger.warning(f"获取人物画像失败: {exc}") + return PersonProfileResult() + + async def maintain_memory( + self, + *, + action: str, + target: str = "", + hours: float | None = None, + reason: str = "", + limit: int = 50, + ) -> MemoryWriteResult: + try: + payload = await self._invoke( + "maintain_memory", + {"action": action, "target": target, "hours": hours, "reason": reason, "limit": limit}, + ) + if not isinstance(payload, dict): + return MemoryWriteResult(success=False, detail="invalid_payload") + return MemoryWriteResult(success=bool(payload.get("success")), detail=str(payload.get("detail", "") or "")) + except Exception as exc: + logger.warning(f"记忆维护失败: {exc}") + return MemoryWriteResult(success=False, detail=str(exc)) + + async def memory_stats(self) -> Dict[str, Any]: + try: + payload = await self._invoke("memory_stats", {}) + return payload if isinstance(payload, dict) else {} + except Exception as exc: + logger.warning(f"获取记忆统计失败: {exc}") + return {} + + async def graph_admin(self, *, action: str, **kwargs) -> Dict[str, Any]: + try: + return await self._invoke_admin("memory_graph_admin", action=action, **kwargs) + except Exception as exc: + logger.warning(f"图谱管理调用失败: {exc}") + return {"success": False, "error": str(exc)} + + async def source_admin(self, *, action: str, **kwargs) -> Dict[str, Any]: + try: + return await self._invoke_admin("memory_source_admin", action=action, **kwargs) + except Exception as exc: + logger.warning(f"来源管理调用失败: {exc}") + return {"success": False, "error": str(exc)} + + async def episode_admin(self, *, action: str, **kwargs) -> Dict[str, Any]: + try: + return await self._invoke_admin("memory_episode_admin", action=action, **kwargs) + except Exception as exc: + logger.warning(f"Episode 管理调用失败: {exc}") + return {"success": False, "error": str(exc)} + + async def profile_admin(self, *, action: str, **kwargs) -> Dict[str, Any]: + try: + return await self._invoke_admin("memory_profile_admin", action=action, **kwargs) + except Exception as exc: + logger.warning(f"画像管理调用失败: {exc}") + return {"success": False, "error": str(exc)} + + async def feedback_admin(self, *, action: str, **kwargs) -> Dict[str, Any]: + try: + return await self._invoke_admin("memory_feedback_admin", action=action, **kwargs) + except Exception as exc: + logger.warning(f"反馈纠错管理调用失败: {exc}") + return {"success": False, "error": str(exc)} + + async def runtime_admin(self, *, action: str, **kwargs) -> Dict[str, Any]: + try: + return await self._invoke_admin("memory_runtime_admin", action=action, **kwargs) + except Exception as exc: + logger.warning(f"运行时管理调用失败: {exc}") + return {"success": False, "error": str(exc)} + + async def import_admin(self, *, action: str, timeout_ms: int = 120000, **kwargs) -> Dict[str, Any]: + try: + return await self._invoke_admin("memory_import_admin", action=action, timeout_ms=timeout_ms, **kwargs) + except Exception as exc: + logger.warning(f"导入管理调用失败: {exc}") + return {"success": False, "error": str(exc)} + + async def tuning_admin(self, *, action: str, timeout_ms: int = 120000, **kwargs) -> Dict[str, Any]: + try: + return await self._invoke_admin("memory_tuning_admin", action=action, timeout_ms=timeout_ms, **kwargs) + except Exception as exc: + logger.warning(f"调优管理调用失败: {exc}") + return {"success": False, "error": str(exc)} + + async def v5_admin(self, *, action: str, timeout_ms: int = 30000, **kwargs) -> Dict[str, Any]: + try: + return await self._invoke_admin("memory_v5_admin", action=action, timeout_ms=timeout_ms, **kwargs) + except Exception as exc: + logger.warning(f"V5 记忆管理调用失败: {exc}") + return {"success": False, "error": str(exc)} + + async def delete_admin(self, *, action: str, timeout_ms: int = 120000, **kwargs) -> Dict[str, Any]: + try: + return await self._invoke_admin("memory_delete_admin", action=action, timeout_ms=timeout_ms, **kwargs) + except Exception as exc: + logger.warning(f"删除管理调用失败: {exc}") + return {"success": False, "error": str(exc)} + + async def get_recycle_bin(self, *, limit: int = 50) -> Dict[str, Any]: + try: + payload = await self._invoke("maintain_memory", {"action": "recycle_bin", "limit": max(1, int(limit or 50))}) + return payload if isinstance(payload, dict) else {"success": False, "error": "invalid_payload"} + except Exception as exc: + logger.warning(f"获取回收站失败: {exc}") + return {"success": False, "error": str(exc)} + + async def restore_memory(self, *, target: str) -> MemoryWriteResult: + return await self.maintain_memory(action="restore", target=target) + + async def reinforce_memory(self, *, target: str) -> MemoryWriteResult: + return await self.maintain_memory(action="reinforce", target=target) + + async def freeze_memory(self, *, target: str) -> MemoryWriteResult: + return await self.maintain_memory(action="freeze", target=target) + + async def protect_memory(self, *, target: str, hours: float | None = None) -> MemoryWriteResult: + return await self.maintain_memory(action="protect", target=target, hours=hours) + + +memory_service = MemoryService() diff --git a/src/services/message_service.py b/src/services/message_service.py new file mode 100644 index 00000000..257ff72f --- /dev/null +++ b/src/services/message_service.py @@ -0,0 +1,295 @@ +"""消息服务模块。""" + +import re +from datetime import datetime +from typing import List, Optional, Tuple + +from sqlmodel import col, select + +from src.chat.message_receive.message import SessionMessage +from src.common.data_models.tool_record_data_model import MaiToolRecord +from src.common.database.database import get_db_session +from src.common.database.database_model import Images, ImageType, ToolRecord +from src.common.message_repository import count_messages, find_messages +from src.common.utils.math_utils import translate_timestamp_to_human_readable +from src.common.utils.utils_action import ActionUtils +from src.config.config import global_config + + +def _build_readable_line( + message: SessionMessage, + *, + replace_bot_name: bool, + timestamp_mode: Optional[str], + show_message_id_prefix: bool, +) -> str: + plain_text = (message.processed_plain_text or "").strip() + if replace_bot_name and global_config.bot.nickname: + plain_text = plain_text.replace(global_config.bot.nickname, "你") + user_name = ( + message.message_info.user_info.user_cardname + or message.message_info.user_info.user_nickname + or message.message_info.user_info.user_id + ) + prefix: List[str] = [] + if timestamp_mode: + prefix.append(f"[{translate_timestamp_to_human_readable(message.timestamp.timestamp(), mode=timestamp_mode)}]") + if show_message_id_prefix: + prefix.append(f"[消息ID: {message.message_id}]") + prefix.append(f"{user_name}说:") + return " ".join(prefix) + plain_text + + +def _normalize_messages(messages: List[SessionMessage]) -> List[SessionMessage]: + normalized: List[SessionMessage] = [] + for message in messages: + normalized.append(message) + return normalized + + +def get_messages_by_time( + start_time: float, end_time: float, limit: int = 0, limit_mode: str = "latest", filter_mai: bool = False +) -> List[SessionMessage]: + if not isinstance(start_time, (int, float)) or not isinstance(end_time, (int, float)): + raise ValueError("start_time 和 end_time 必须是数字类型") + if limit < 0: + raise ValueError("limit 不能为负数") + messages = find_messages( + start_time=start_time, + end_time=end_time, + limit=limit, + limit_mode=limit_mode, + filter_bot=filter_mai, + ) + return _normalize_messages(messages) + + +def get_messages_by_time_in_chat( + chat_id: str, + start_time: float, + end_time: float, + limit: int = 0, + limit_mode: str = "latest", + filter_mai: bool = False, + filter_command: bool = False, + filter_intercept_message_level: Optional[int] = None, +) -> List[SessionMessage]: + if not isinstance(start_time, (int, float)) or not isinstance(end_time, (int, float)): + raise ValueError("start_time 和 end_time 必须是数字类型") + if limit < 0: + raise ValueError("limit 不能为负数") + if not chat_id: + raise ValueError("chat_id 不能为空") + if not isinstance(chat_id, str): + raise ValueError("chat_id 必须是字符串类型") + messages = find_messages( + session_id=chat_id, + start_time=start_time, + end_time=end_time, + limit=limit, + limit_mode=limit_mode, + filter_bot=filter_mai, + filter_command=filter_command, + filter_intercept_message_level=filter_intercept_message_level, + ) + return _normalize_messages(messages) + + +def get_message_by_id(message_id: str, chat_id: Optional[str] = None) -> Optional[SessionMessage]: + """按消息 ID 查询单条消息,可选限定会话。""" + + normalized_message_id = str(message_id or "").strip() + if not normalized_message_id: + raise ValueError("message_id 不能为空") + + normalized_chat_id = str(chat_id or "").strip() + messages = find_messages( + session_id=normalized_chat_id or None, + message_id=normalized_message_id, + limit=1, + limit_mode="latest", + ) + normalized_messages = _normalize_messages(messages) + return normalized_messages[0] if normalized_messages else None + + +def get_messages_before_time(timestamp: float, limit: int = 0, filter_mai: bool = False) -> List[SessionMessage]: + if not isinstance(timestamp, (int, float)): + raise ValueError("timestamp 必须是数字类型") + if limit < 0: + raise ValueError("limit 不能为负数") + messages = find_messages( + before_time=timestamp, + limit=limit, + limit_mode="latest", + filter_bot=filter_mai, + ) + return _normalize_messages(messages) + + +def get_messages_before_time_in_chat( + chat_id: str, + timestamp: float, + limit: int = 0, + filter_mai: bool = False, + filter_intercept_message_level: Optional[int] = None, +) -> List[SessionMessage]: + if not isinstance(timestamp, (int, float)): + raise ValueError("timestamp 必须是数字类型") + if limit < 0: + raise ValueError("limit 不能为负数") + if not chat_id: + raise ValueError("chat_id 不能为空") + if not isinstance(chat_id, str): + raise ValueError("chat_id 必须是字符串类型") + messages = find_messages( + session_id=chat_id, + before_time=timestamp, + limit=limit, + limit_mode="latest", + filter_bot=filter_mai, + filter_intercept_message_level=filter_intercept_message_level, + ) + return _normalize_messages(messages) + + +# ============================================================================= +# 消息计数函数 +# ============================================================================= + + +def count_new_messages(chat_id: str, start_time: float = 0.0, end_time: Optional[float] = None) -> int: + if not isinstance(start_time, (int, float)): + raise ValueError("start_time 必须是数字类型") + if not chat_id: + raise ValueError("chat_id 不能为空") + if not isinstance(chat_id, str): + raise ValueError("chat_id 必须是字符串类型") + return count_messages(session_id=chat_id, after_time=start_time, end_time=end_time) + + +# ============================================================================= +# 消息格式化函数 +# ============================================================================= + + +def build_readable_messages( + messages: List[SessionMessage], + replace_bot_name: bool = True, + timestamp_mode: str = "relative", + read_mark: float = 0.0, + truncate: bool = False, + show_actions: bool = False, +) -> str: + normalized_messages = _normalize_messages(messages) + lines: List[str] = [] + unread_mark_added = False + for message in normalized_messages: + if read_mark and not unread_mark_added and message.timestamp.timestamp() > read_mark: + lines.append("--- 以上消息是你已经看过,请关注以下未读的新消息 ---") + unread_mark_added = True + line = _build_readable_line( + message, + replace_bot_name=replace_bot_name, + timestamp_mode=timestamp_mode, + show_message_id_prefix=False, + ) + if truncate and len(line) > 200: + line = f"{line[:200]}......(内容太长了)" + lines.append(line) + if show_actions and normalized_messages: + if action_lines := ActionUtils.build_readable_action_records( + get_actions_by_timestamp_with_chat( + normalized_messages[0].session_id, + normalized_messages[0].timestamp.timestamp(), + normalized_messages[-1].timestamp.timestamp(), + ), + "relative", + ): + lines.append(action_lines) + return "\n".join(lines) + + +def build_readable_messages_with_id( + messages: List[SessionMessage], + replace_bot_name: bool = True, + timestamp_mode: str = "relative", + read_mark: float = 0.0, + truncate: bool = False, + show_actions: bool = False, +) -> Tuple[str, List[Tuple[str, SessionMessage]]]: + normalized_messages = _normalize_messages(messages) + lines: List[str] = [] + message_id_list: List[Tuple[str, SessionMessage]] = [] + unread_mark_added = False + for message in normalized_messages: + if read_mark and not unread_mark_added and message.timestamp.timestamp() > read_mark: + lines.append("--- 以上消息是你已经看过,请关注以下未读的新消息 ---") + unread_mark_added = True + line = _build_readable_line( + message, + replace_bot_name=replace_bot_name, + timestamp_mode=timestamp_mode, + show_message_id_prefix=True, + ) + if truncate and len(line) > 200: + line = f"{line[:200]}......(内容太长了)" + lines.append(line) + message_id_list.append((message.message_id, message)) + if show_actions and normalized_messages: + if action_lines := ActionUtils.build_readable_action_records( + get_actions_by_timestamp_with_chat( + normalized_messages[0].session_id, + normalized_messages[0].timestamp.timestamp(), + normalized_messages[-1].timestamp.timestamp(), + ), + "relative", + ): + lines.append(action_lines) + return "\n".join(lines), message_id_list + + +def get_actions_by_timestamp_with_chat( + chat_id: str, + timestamp_start: float, + timestamp_end: float, + limit: Optional[int] = None, +) -> List[MaiToolRecord]: + with get_db_session() as session: + statement = ( + select(ToolRecord) + .where(col(ToolRecord.session_id) == chat_id) + .where(col(ToolRecord.timestamp) >= datetime.fromtimestamp(timestamp_start)) + .where(col(ToolRecord.timestamp) <= datetime.fromtimestamp(timestamp_end)) + .order_by(col(ToolRecord.timestamp)) + ) + if limit is not None: + statement = statement.limit(limit) + return [MaiToolRecord.from_db_instance(item) for item in session.exec(statement).all()] + + +def replace_user_references(text: str, platform: str, replace_bot_name: bool = False) -> str: + del platform + if not text: + return text + + def _replace(match: re.Match[str]) -> str: + prefix = match.group(1) or "" + user_name = match.group(2) + if replace_bot_name and user_name == global_config.bot.nickname: + user_name = "你" + return f"{prefix}{user_name}" + + text = re.sub(r"(回复|@)?<([^:<>]+):[^<>]+>", _replace, text) + return text + + +def translate_pid_to_description(pid: str) -> str: + with get_db_session() as session: + statement = ( + select(Images).where((col(Images.id) == int(pid)) & (col(Images.image_type) == ImageType.IMAGE)) + if pid.isdigit() + else None + ) + image = session.exec(statement).first() if statement is not None else None + return image.description.strip() if image and image.description and image.description.strip() else "[图片]" diff --git a/src/services/send_service.py b/src/services/send_service.py new file mode 100644 index 00000000..bd2e986d --- /dev/null +++ b/src/services/send_service.py @@ -0,0 +1,1294 @@ +""" +发送服务模块。 + +统一封装内部模块的出站消息发送逻辑: + +1. 内部模块统一调用本模块。 +2. send service 只负责构造和预处理消息。 +3. 具体走插件链还是 legacy 旧链,由 Platform IO 内部统一决策。 +""" + +from copy import deepcopy +from typing import Any, Dict, List, Optional + +import asyncio +import base64 +import hashlib +import time +import traceback +from datetime import datetime + +from src.chat.message_receive.chat_manager import BotChatSession +from src.chat.message_receive.chat_manager import chat_manager as _chat_manager +from src.chat.message_receive.message import SessionMessage +from src.chat.utils.utils import calculate_typing_time, get_bot_account +from src.common.data_models.mai_message_data_model import GroupInfo, MaiMessage, MessageInfo, UserInfo +from src.common.data_models.message_component_data_model import ( + AtComponent, + DictComponent, + EmojiComponent, + ForwardNodeComponent, + ImageComponent, + MessageSequence, + ReplyComponent, + StandardMessageComponents, + TextComponent, + VoiceComponent, +) +from src.common.logger import get_logger +from src.common.utils.utils_message import MessageUtils +from src.config.config import global_config +from src.platform_io import DeliveryBatch, get_platform_io_manager +from src.platform_io.route_key_factory import RouteKeyFactory +from src.plugin_runtime.hook_payloads import deserialize_session_message, serialize_session_message +from src.plugin_runtime.hook_schema_utils import build_object_schema +from src.plugin_runtime.host.hook_dispatcher import HookDispatchResult +from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry + +logger = get_logger("send_service") + + +def register_send_service_hook_specs(registry: HookSpecRegistry) -> List[HookSpec]: + """注册发送服务内置 Hook 规格。 + + Args: + registry: 目标 Hook 规格注册中心。 + + Returns: + List[HookSpec]: 实际注册的 Hook 规格列表。 + """ + + return registry.register_hook_specs( + [ + HookSpec( + name="send_service.after_build_message", + description="在出站 SessionMessage 构建完成后触发,可改写消息体或取消发送。", + parameters_schema=build_object_schema( + { + "message": { + "type": "object", + "description": "待发送消息的序列化 SessionMessage。", + }, + "stream_id": { + "type": "string", + "description": "目标会话 ID。", + }, + "processed_plain_text": { + "type": "string", + "description": "可选的预处理纯文本内容。", + }, + "typing": { + "type": "boolean", + "description": "是否模拟打字。", + }, + "set_reply": { + "type": "boolean", + "description": "是否附带引用回复。", + }, + "storage_message": { + "type": "boolean", + "description": "发送成功后是否写库。", + }, + "show_log": { + "type": "boolean", + "description": "是否输出发送日志。", + }, + }, + required=[ + "message", + "stream_id", + "processed_plain_text", + "typing", + "set_reply", + "storage_message", + "show_log", + ], + ), + default_timeout_ms=5000, + allow_abort=True, + allow_kwargs_mutation=True, + ), + HookSpec( + name="send_service.before_send", + description="在真正调用 Platform IO 发送前触发,可改写消息或取消本次发送。", + parameters_schema=build_object_schema( + { + "message": { + "type": "object", + "description": "待发送消息的序列化 SessionMessage。", + }, + "typing": { + "type": "boolean", + "description": "是否模拟打字。", + }, + "set_reply": { + "type": "boolean", + "description": "是否附带引用回复。", + }, + "reply_message_id": { + "type": "string", + "description": "被引用消息 ID。", + }, + "storage_message": { + "type": "boolean", + "description": "发送成功后是否写库。", + }, + "show_log": { + "type": "boolean", + "description": "是否输出发送日志。", + }, + }, + required=["message", "typing", "set_reply", "storage_message", "show_log"], + ), + default_timeout_ms=5000, + allow_abort=True, + allow_kwargs_mutation=True, + ), + HookSpec( + name="send_service.after_send", + description="在发送流程结束后触发,用于观察最终发送结果。", + parameters_schema=build_object_schema( + { + "message": { + "type": "object", + "description": "本次发送消息的序列化 SessionMessage。", + }, + "sent": { + "type": "boolean", + "description": "本次发送是否成功。", + }, + "typing": { + "type": "boolean", + "description": "是否模拟打字。", + }, + "set_reply": { + "type": "boolean", + "description": "是否附带引用回复。", + }, + "reply_message_id": { + "type": "string", + "description": "被引用消息 ID。", + }, + "storage_message": { + "type": "boolean", + "description": "发送成功后是否写库。", + }, + "show_log": { + "type": "boolean", + "description": "是否输出发送日志。", + }, + }, + required=["message", "sent", "typing", "set_reply", "storage_message", "show_log"], + ), + default_timeout_ms=5000, + allow_abort=False, + allow_kwargs_mutation=False, + ), + ] + ) + + +def _get_runtime_manager() -> Any: + """获取插件运行时管理器。 + + Returns: + Any: 插件运行时管理器单例。 + """ + + from src.plugin_runtime.integration import get_plugin_runtime_manager + + return get_plugin_runtime_manager() + + +def _coerce_bool(value: Any, default: bool) -> bool: + """将任意值安全转换为布尔值。 + + Args: + value: 待转换的值。 + default: 当值为空时使用的默认值。 + + Returns: + bool: 转换后的布尔值。 + """ + + if value is None: + return default + return bool(value) + + +async def _invoke_send_hook( + hook_name: str, + message: SessionMessage, + **kwargs: Any, +) -> tuple[HookDispatchResult, SessionMessage]: + """触发携带出站消息的命名 Hook。 + + Args: + hook_name: 目标 Hook 名称。 + message: 当前待发送消息。 + **kwargs: 需要附带的额外参数。 + + Returns: + tuple[HookDispatchResult, SessionMessage]: Hook 聚合结果以及可能被改写后的消息对象。 + """ + + hook_result = await _get_runtime_manager().invoke_hook( + hook_name, + message=serialize_session_message(message), + **kwargs, + ) + mutated_message = message + raw_message = hook_result.kwargs.get("message") + if raw_message is not None: + try: + mutated_message = deserialize_session_message(raw_message) + except Exception as exc: + logger.warning(f"Hook {hook_name} 返回的 message 无法反序列化,已忽略: {exc}") + return hook_result, mutated_message + + +def _inherit_platform_io_route_metadata(target_stream: BotChatSession) -> Dict[str, object]: + """从目标会话继承 Platform IO 路由元数据。 + + Args: + target_stream: 当前消息要发送到的会话对象。 + + Returns: + Dict[str, object]: 可安全透传到出站消息 ``additional_config`` 中的 + 路由辅助字段。 + """ + inherited_metadata: Dict[str, object] = {} + + context_message = target_stream.context.message if target_stream.context else None + if context_message is not None: + additional_config = context_message.message_info.additional_config + if isinstance(additional_config, dict): + for key in (*RouteKeyFactory.ACCOUNT_ID_KEYS, *RouteKeyFactory.SCOPE_KEYS): + value = additional_config.get(key) + if value is None: + continue + normalized_value = str(value).strip() + if normalized_value: + inherited_metadata[key] = value + + # 当目标会话没有可继承的上下文消息时,至少补齐当前平台账号, + # 让按 ``platform + account_id`` 绑定的路由仍有机会命中。 + if not RouteKeyFactory.extract_components(inherited_metadata)[0]: + bot_account = get_bot_account(target_stream.platform) + if bot_account: + inherited_metadata["platform_io_account_id"] = bot_account + + if target_stream.group_id and (normalized_group_id := str(target_stream.group_id).strip()): + inherited_metadata["platform_io_target_group_id"] = normalized_group_id + + if target_stream.user_id and (normalized_user_id := str(target_stream.user_id).strip()): + inherited_metadata["platform_io_target_user_id"] = normalized_user_id + + return inherited_metadata + + +def _build_binary_component_from_base64(component_type: str, raw_data: str) -> StandardMessageComponents: + """根据 Base64 数据构造二进制消息组件。 + + Args: + component_type: 组件类型名称。 + raw_data: Base64 编码后的二进制数据。 + + Returns: + StandardMessageComponents: 转换后的内部消息组件。 + + Raises: + ValueError: 当组件类型不受支持时抛出。 + """ + binary_data = base64.b64decode(raw_data) + binary_hash = hashlib.sha256(binary_data).hexdigest() + + if component_type == "image": + return ImageComponent(binary_hash=binary_hash, binary_data=binary_data) + if component_type == "emoji": + return EmojiComponent(binary_hash=binary_hash, binary_data=binary_data) + if component_type == "voice": + return VoiceComponent(binary_hash=binary_hash, binary_data=binary_data) + raise ValueError(f"不支持的二进制组件类型: {component_type}") + + +def _build_message_sequence_from_custom_message( + message_type: str, + content: str | Dict[str, Any], +) -> MessageSequence: + """根据自定义消息类型构造内部消息组件序列。 + + Args: + message_type: 自定义消息类型。 + content: 自定义消息内容。 + + Returns: + MessageSequence: 转换后的消息组件序列。 + """ + normalized_type = message_type.strip().lower() + + if normalized_type == "text": + return MessageSequence(components=[TextComponent(text=str(content))]) + + if normalized_type in {"image", "emoji", "voice"}: + return MessageSequence( + components=[_build_binary_component_from_base64(normalized_type, str(content))] + ) + + if normalized_type == "at": + return MessageSequence(components=[AtComponent(target_user_id=str(content))]) + + if normalized_type == "reply": + return MessageSequence(components=[ReplyComponent(target_message_id=str(content))]) + + if normalized_type == "dict" and isinstance(content, dict): + return MessageSequence(components=[DictComponent(data=deepcopy(content))]) + + return MessageSequence( + components=[ + DictComponent( + data={ + "type": normalized_type, + "data": deepcopy(content), + } + ) + ] + ) + + +def _clone_message_sequence(message_sequence: MessageSequence) -> MessageSequence: + """复制消息组件序列,避免原对象被发送流程修改。 + + Args: + message_sequence: 原始消息组件序列。 + + Returns: + MessageSequence: 深拷贝后的消息组件序列。 + """ + return deepcopy(message_sequence) + + +def _detect_outbound_message_flags(message_sequence: MessageSequence) -> Dict[str, bool]: + """根据消息组件序列推断出站消息标记。 + + Args: + message_sequence: 待发送的消息组件序列。 + + Returns: + Dict[str, bool]: 包含 ``is_emoji``、``is_picture``、``is_command`` 的标记字典。 + """ + if len(message_sequence.components) != 1: + return { + "is_emoji": False, + "is_picture": False, + "is_command": False, + } + + component = message_sequence.components[0] + is_command = False + if isinstance(component, DictComponent) and isinstance(component.data, dict): + is_command = str(component.data.get("type") or "").strip().lower() == "command" + + return { + "is_emoji": isinstance(component, EmojiComponent), + "is_picture": isinstance(component, ImageComponent), + "is_command": is_command, + } + + +def _describe_message_sequence(message_sequence: MessageSequence) -> str: + """生成消息组件序列的简短描述文本。 + + Args: + message_sequence: 待描述的消息组件序列。 + + Returns: + str: 适用于日志的简短类型描述。 + """ + if len(message_sequence.components) != 1: + return "message_sequence" + + component = message_sequence.components[0] + if isinstance(component, DictComponent) and isinstance(component.data, dict): + custom_type = str(component.data.get("type") or "").strip() + return custom_type or "dict" + + if isinstance(component, TextComponent): + return component.format_name + + if isinstance(component, ImageComponent): + return component.format_name + + if isinstance(component, EmojiComponent): + return component.format_name + + if isinstance(component, VoiceComponent): + return component.format_name + + if isinstance(component, AtComponent): + return component.format_name + + if isinstance(component, ReplyComponent): + return component.format_name + + if isinstance(component, ForwardNodeComponent): + return component.format_name + + return "unknown" + + +def _build_processed_plain_text(message: SessionMessage) -> str: + """为出站消息构造轻量纯文本摘要。 + + Args: + message: 待发送的内部消息对象。 + + Returns: + str: 适用于日志与打字时长估算的纯文本摘要。 + """ + processed_parts: List[str] = [] + for component in message.raw_message.components: + if isinstance(component, TextComponent): + processed_parts.append(component.text) + continue + + if isinstance(component, ImageComponent): + processed_parts.append(component.content.strip() or "[图片]") + continue + + if isinstance(component, EmojiComponent): + processed_parts.append(component.content.strip() or "[表情]") + continue + + if isinstance(component, VoiceComponent): + processed_parts.append(component.content.strip() or "[语音]") + continue + + if isinstance(component, AtComponent): + at_target = component.target_user_cardname or component.target_user_nickname or component.target_user_id + processed_parts.append(f"@{at_target}") + continue + + if isinstance(component, ReplyComponent): + processed_parts.append(component.target_message_content or "[回复消息]") + continue + + if isinstance(component, DictComponent): + raw_type = component.data.get("type") if isinstance(component.data, dict) else None + if isinstance(raw_type, str) and raw_type.strip(): + processed_parts.append(f"[{raw_type.strip()}消息]") + else: + processed_parts.append("[自定义消息]") + continue + + return " ".join(part for part in processed_parts if part) + + +def _build_outbound_log_preview(message: SessionMessage, max_length: int = 160) -> str: + """构造出站消息的日志预览文本。 + + Args: + message: 待发送的内部消息对象。 + max_length: 预览文本最大长度。 + + Returns: + str: 适用于日志展示的消息摘要。 + """ + preview_text = (message.processed_plain_text or "").strip() + if not preview_text: + preview_text = f"[{_describe_message_sequence(message.raw_message)}]" + + normalized_preview = " ".join(preview_text.split()) + if len(normalized_preview) <= max_length: + return normalized_preview + return f"{normalized_preview[:max_length]}..." + + +def _build_outbound_session_message( + message_sequence: MessageSequence, + stream_id: str, + processed_plain_text: str = "", + reply_message: Optional[MaiMessage] = None, + selected_expressions: Optional[List[int]] = None, +) -> Optional[SessionMessage]: + """根据目标会话构建待发送的内部消息对象。 + + Args: + message_sequence: 待发送的消息组件序列。 + stream_id: 目标会话 ID。 + processed_plain_text: 可选的预处理纯文本内容。 + reply_message: 被回复的锚点消息。 + selected_expressions: 可选的表情候选索引列表。 + + Returns: + Optional[SessionMessage]: 构建成功时返回内部消息对象;若目标会话或 + 机器人账号不存在,则返回 ``None``。 + """ + target_stream = _chat_manager.get_session_by_session_id(stream_id) + if target_stream is None: + logger.error(f"[SendService] 未找到聊天流: {stream_id}") + return None + + bot_user_id = get_bot_account(target_stream.platform) + if not bot_user_id: + logger.error(f"[SendService] 平台 {target_stream.platform} 未配置机器人账号,无法发送消息") + return None + + current_time = time.time() + message_id = f"send_api_{int(current_time * 1000)}" + anchor_message = reply_message.deepcopy() if reply_message is not None else None + + group_info: Optional[GroupInfo] = None + if target_stream.group_id: + group_name = "" + if ( + target_stream.context + and target_stream.context.message + and target_stream.context.message.message_info.group_info + ): + group_name = target_stream.context.message.message_info.group_info.group_name + group_info = GroupInfo( + group_id=target_stream.group_id, + group_name=group_name, + ) + + additional_config: Dict[str, object] = _inherit_platform_io_route_metadata(target_stream) + if selected_expressions is not None: + additional_config["selected_expressions"] = selected_expressions + + outbound_message = SessionMessage( + message_id=message_id, + timestamp=datetime.fromtimestamp(current_time), + platform=target_stream.platform, + ) + outbound_message.message_info = MessageInfo( + user_info=UserInfo( + user_id=bot_user_id, + user_nickname=global_config.bot.nickname, + ), + group_info=group_info, + additional_config=additional_config, + ) + outbound_message.raw_message = _clone_message_sequence(message_sequence) + outbound_message.session_id = target_stream.session_id + outbound_message.processed_plain_text = processed_plain_text.strip() or _build_processed_plain_text(outbound_message) + outbound_message.reply_to = anchor_message.message_id if anchor_message is not None else None + message_flags = _detect_outbound_message_flags(outbound_message.raw_message) + outbound_message.is_emoji = message_flags["is_emoji"] + outbound_message.is_picture = message_flags["is_picture"] + outbound_message.is_command = message_flags["is_command"] + outbound_message.initialized = True + return outbound_message + + +def _ensure_reply_component(message: SessionMessage, reply_message_id: str) -> None: + """为消息补充回复组件。 + + Args: + message: 待发送的内部消息对象。 + reply_message_id: 被引用消息的 ID。 + """ + if message.raw_message.components: + first_component = message.raw_message.components[0] + if isinstance(first_component, ReplyComponent) and first_component.target_message_id == reply_message_id: + return + + message.raw_message.components.insert(0, ReplyComponent(target_message_id=reply_message_id)) + + +async def _prepare_message_for_platform_io( + message: SessionMessage, + *, + typing: bool, + set_reply: bool, + reply_message_id: Optional[str], +) -> None: + """为 Platform IO 发送链预处理消息。 + + Args: + message: 待发送的内部消息对象。 + typing: 是否模拟打字等待。 + set_reply: 是否构建引用回复组件。 + reply_message_id: 被引用消息的 ID。 + + Raises: + ValueError: 当要求设置引用回复但缺少 ``reply_message_id`` 时抛出。 + """ + if set_reply: + if not reply_message_id: + raise ValueError("set_reply=True 时必须提供 reply_message_id") + _ensure_reply_component(message, reply_message_id) + + if set_reply or not message.processed_plain_text: + message.processed_plain_text = _build_processed_plain_text(message) + if typing: + typing_time = calculate_typing_time( + input_string=message.processed_plain_text or "", + is_emoji=message.is_emoji, + ) + await asyncio.sleep(typing_time) + + +def _store_sent_message(message: SessionMessage) -> None: + """将已成功发送的消息写入数据库。 + + Args: + message: 已成功发送的内部消息对象。 + """ + MessageUtils.store_message_to_db(message) + + +async def _apply_successful_delivery_receipt(message: SessionMessage, delivery_batch: DeliveryBatch) -> None: + """将成功回执中的平台消息 ID 回填到内部消息。 + + Args: + message: 已发送成功的内部消息对象。 + delivery_batch: Platform IO 返回的批量回执。 + """ + if not delivery_batch.sent_receipts: + return + + original_message_id = str(message.message_id or "").strip() + external_message_id = str(delivery_batch.sent_receipts[0].external_message_id or "").strip() + if not external_message_id or external_message_id == original_message_id: + return + + message.message_id = external_message_id + + +async def _dispatch_adapter_callbacks(delivery_batch: DeliveryBatch) -> None: + """分发适配器随成功回执返回的自定义回调。 + + Args: + delivery_batch: Platform IO 返回的批量回执。 + """ + try: + from src.common.message_server import api as message_server_api + + global_api = getattr(message_server_api, "global_api", None) + custom_handlers = getattr(global_api, "_custom_message_handlers", None) + if not isinstance(custom_handlers, dict): + return + + for receipt in delivery_batch.sent_receipts: + raw_callbacks = receipt.metadata.get("adapter_callbacks") + if not isinstance(raw_callbacks, list): + continue + + for raw_callback in raw_callbacks: + if not isinstance(raw_callback, dict): + continue + + callback_name = str(raw_callback.get("name") or "").strip() + payload = raw_callback.get("payload") + if not callback_name or not isinstance(payload, dict): + continue + + handler = custom_handlers.get(callback_name) + if handler is None: + continue + + await handler(payload) + except Exception as exc: + logger.warning(f"[SendService] 分发适配器回调失败: {exc}") + + +async def _notify_memory_automation_on_message_sent(message: SessionMessage) -> None: + """在发送成功后通知长期记忆自动化服务。 + + Args: + message: 已成功发送的内部消息对象。 + """ + try: + from src.services.memory_flow_service import memory_automation_service + + await memory_automation_service.on_message_sent(message) + except Exception as exc: + session_id = message.session_id or "unknown-session" + logger.warning(f"[{session_id}] 长期记忆人物事实写回注册失败: {exc}") + + +def _sync_sent_message_to_maisaka_history( + message: SessionMessage, + *, + source_kind: str, +) -> None: + """将已发送成功的消息同步到当前会话对应的 Maisaka 历史。""" + + session_id = str(message.session_id or "").strip() + if not session_id: + return + + try: + from src.chat.heart_flow.heartflow_manager import heartflow_manager + + runtime = heartflow_manager.heartflow_chat_list.get(session_id) + if runtime is None: + return + runtime.append_sent_message_to_chat_history(message, source_kind=source_kind) + except Exception as exc: + logger.warning(f"[SendService] 同步消息到 Maisaka 历史失败: session_id={session_id} error={exc}") + + +def _log_platform_io_failures(delivery_batch: DeliveryBatch) -> None: + """输出 Platform IO 批量发送失败详情。 + + Args: + delivery_batch: Platform IO 返回的批量回执。 + """ + failed_details = "; ".join( + f"driver={receipt.driver_id} status={receipt.status} error={receipt.error}" + for receipt in delivery_batch.failed_receipts + ) or "未命中任何发送路由" + logger.warning(f"[SendService] Platform IO 发送失败: platform={delivery_batch.route_key.platform} {failed_details}") + + +async def _send_via_platform_io( + message: SessionMessage, + *, + typing: bool, + set_reply: bool, + reply_message_id: Optional[str], + storage_message: bool, + show_log: bool, +) -> Optional[SessionMessage]: + """通过 Platform IO 发送消息。 + + Args: + message: 待发送的内部消息对象。 + typing: 是否模拟打字等待。 + set_reply: 是否设置引用回复。 + reply_message_id: 被引用消息的 ID。 + storage_message: 发送成功后是否写入数据库。 + show_log: 是否输出发送成功日志。 + + Returns: + bool: 发送成功时返回 ``True``。 + """ + before_send_result, message = await _invoke_send_hook( + "send_service.before_send", + message, + typing=typing, + set_reply=set_reply, + reply_message_id=reply_message_id, + storage_message=storage_message, + show_log=show_log, + ) + if before_send_result.aborted: + logger.info(f"[SendService] 消息 {message.message_id} 在发送前被 Hook 中止") + return None + + before_kwargs = before_send_result.kwargs + typing = _coerce_bool(before_kwargs.get("typing"), typing) + set_reply = _coerce_bool(before_kwargs.get("set_reply"), set_reply) + storage_message = _coerce_bool(before_kwargs.get("storage_message"), storage_message) + show_log = _coerce_bool(before_kwargs.get("show_log"), show_log) + raw_reply_message_id = before_kwargs.get("reply_message_id", reply_message_id) + reply_message_id = None if raw_reply_message_id in {None, ""} else str(raw_reply_message_id) + + platform_io_manager = get_platform_io_manager() + try: + await platform_io_manager.ensure_send_pipeline_ready() + except Exception as exc: + logger.error(f"[SendService] 准备 Platform IO 发送管线失败: {exc}") + logger.debug(traceback.format_exc()) + return None + + try: + route_key = platform_io_manager.build_route_key_from_message(message) + except Exception as exc: + logger.warning(f"[SendService] 根据消息构造 Platform IO 路由键失败: {exc}") + return None + + try: + await _prepare_message_for_platform_io( + message, + typing=typing, + set_reply=set_reply, + reply_message_id=reply_message_id, + ) + delivery_batch = await platform_io_manager.send_message( + message, + route_key, + metadata={"show_log": False}, + ) + except Exception as exc: + logger.error(f"[SendService] Platform IO 发送异常: {exc}") + logger.debug(traceback.format_exc()) + return None + + sent = bool(delivery_batch.has_success) + if sent: + await _apply_successful_delivery_receipt(message, delivery_batch) + await _dispatch_adapter_callbacks(delivery_batch) + await _invoke_send_hook( + "send_service.after_send", + message, + sent=sent, + typing=typing, + set_reply=set_reply, + reply_message_id=reply_message_id, + storage_message=storage_message, + show_log=show_log, + ) + + if delivery_batch.has_success: + if storage_message: + _store_sent_message(message) + await _notify_memory_automation_on_message_sent(message) + if show_log: + successful_driver_ids = [ + receipt.driver_id or "unknown" + for receipt in delivery_batch.sent_receipts + ] + logger.info( + f"[SendService] 已通过 Platform IO 将消息发往平台 '{route_key.platform}' " + f"(drivers: {', '.join(successful_driver_ids)}) " + f"message={_build_outbound_log_preview(message)}" + ) + return message + + _log_platform_io_failures(delivery_batch) + return None + + +async def send_session_message_with_message( + message: SessionMessage, + *, + typing: bool = False, + set_reply: bool = False, + reply_message_id: Optional[str] = None, + storage_message: bool = True, + show_log: bool = True, + sync_to_maisaka_history: bool = False, + maisaka_source_kind: str = "outbound_send", +) -> Optional[SessionMessage]: + """统一发送一条内部消息,并返回最终发送成功的消息对象。""" + if not message.message_id: + logger.error("[SendService] 消息缺少 message_id,无法发送") + raise ValueError("消息缺少 message_id,无法发送") + + sent_message = await _send_via_platform_io( + message, + typing=typing, + set_reply=set_reply, + reply_message_id=reply_message_id, + storage_message=storage_message, + show_log=show_log, + ) + if sent_message is not None and sync_to_maisaka_history: + _sync_sent_message_to_maisaka_history( + sent_message, + source_kind=str(maisaka_source_kind or "outbound_send"), + ) + return sent_message + + +async def send_session_message( + message: SessionMessage, + *, + typing: bool = False, + set_reply: bool = False, + reply_message_id: Optional[str] = None, + storage_message: bool = True, + show_log: bool = True, + sync_to_maisaka_history: bool = False, + maisaka_source_kind: str = "outbound_send", +) -> bool: + """统一发送一条内部消息。 + + 该方法是内部模块的统一发送入口: + + 1. 构造并维护内部消息对象。 + 2. 由 Platform IO 统一决定走插件链还是 legacy 旧链。 + 3. send service 不再自行判断底层发送路径。 + + Args: + message: 待发送的内部消息对象。 + typing: 是否模拟打字等待。 + set_reply: 是否设置引用回复。 + reply_message_id: 被引用消息的 ID。 + storage_message: 发送成功后是否写入数据库。 + show_log: 是否输出发送日志。 + + Returns: + bool: 发送成功时返回 ``True``,否则返回 ``False``。 + """ + if not message.message_id: + logger.error("[SendService] 消息缺少 message_id,无法发送") + raise ValueError("消息缺少 message_id,无法发送") + + return ( + await send_session_message_with_message( + message, + typing=typing, + set_reply=set_reply, + reply_message_id=reply_message_id, + storage_message=storage_message, + show_log=show_log, + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) + is not None + ) + + +async def _send_to_target( + message_sequence: MessageSequence, + stream_id: str, + processed_plain_text: str = "", + typing: bool = False, + set_reply: bool = False, + reply_message: Optional[MaiMessage] = None, + storage_message: bool = True, + show_log: bool = True, + selected_expressions: Optional[List[int]] = None, + sync_to_maisaka_history: bool = False, + maisaka_source_kind: str = "outbound_send", +) -> bool: + """向指定目标构建并发送消息,并返回是否发送成功。""" + return ( + await _send_to_target_with_message( + message_sequence=message_sequence, + stream_id=stream_id, + processed_plain_text=processed_plain_text, + typing=typing, + set_reply=set_reply, + reply_message=reply_message, + storage_message=storage_message, + show_log=show_log, + selected_expressions=selected_expressions, + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) + is not None + ) + + +async def _send_to_target_with_message( + message_sequence: MessageSequence, + stream_id: str, + processed_plain_text: str = "", + typing: bool = False, + set_reply: bool = False, + reply_message: Optional[MaiMessage] = None, + storage_message: bool = True, + show_log: bool = True, + selected_expressions: Optional[List[int]] = None, + sync_to_maisaka_history: bool = False, + maisaka_source_kind: str = "outbound_send", +) -> Optional[SessionMessage]: + """向指定目标构建并发送消息。 + + Args: + message_sequence: 待发送的消息组件序列。 + stream_id: 目标会话 ID。 + processed_plain_text: 可选的预处理纯文本内容。 + typing: 是否显示输入中状态。 + set_reply: 是否在发送时附带引用回复。 + reply_message: 被回复的消息对象。 + storage_message: 是否将发送结果写入消息存储。 + show_log: 是否输出发送日志。 + selected_expressions: 可选的表情候选索引列表。 + + Returns: + bool: 发送成功返回 ``True``,否则返回 ``False``。 + """ + try: + if set_reply and reply_message is None: + logger.warning("[SendService] 使用引用回复,但未提供回复消息") + return None + + if show_log: + logger.debug(f"[SendService] 发送{_describe_message_sequence(message_sequence)}消息到 {stream_id}") + + outbound_message = _build_outbound_session_message( + message_sequence=message_sequence, + stream_id=stream_id, + processed_plain_text=processed_plain_text, + reply_message=reply_message, + selected_expressions=selected_expressions, + ) + if outbound_message is None: + return None + + after_build_result, outbound_message = await _invoke_send_hook( + "send_service.after_build_message", + outbound_message, + stream_id=stream_id, + processed_plain_text=processed_plain_text, + typing=typing, + set_reply=set_reply, + storage_message=storage_message, + show_log=show_log, + ) + if after_build_result.aborted: + logger.info(f"[SendService] 消息 {outbound_message.message_id} 在构建后被 Hook 中止") + return None + + after_build_kwargs = after_build_result.kwargs + typing = _coerce_bool(after_build_kwargs.get("typing"), typing) + set_reply = _coerce_bool(after_build_kwargs.get("set_reply"), set_reply) + storage_message = _coerce_bool(after_build_kwargs.get("storage_message"), storage_message) + show_log = _coerce_bool(after_build_kwargs.get("show_log"), show_log) + + sent_message = await send_session_message_with_message( + outbound_message, + typing=typing, + set_reply=set_reply, + reply_message_id=reply_message.message_id if reply_message is not None else None, + storage_message=storage_message, + show_log=show_log, + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) + if sent_message is not None: + logger.debug(f"[SendService] 成功发送消息到 {stream_id}") + return sent_message + + logger.error("[SendService] 发送消息失败") + return None + except Exception as exc: + logger.error(f"[SendService] 发送消息时出错: {exc}") + traceback.print_exc() + return None + + +async def text_to_stream_with_message( + text: str, + stream_id: str, + typing: bool = False, + set_reply: bool = False, + reply_message: Optional[MaiMessage] = None, + storage_message: bool = True, + selected_expressions: Optional[List[int]] = None, + sync_to_maisaka_history: bool = False, + maisaka_source_kind: str = "outbound_send", +) -> Optional[SessionMessage]: + """向指定流发送文本消息,并返回发送成功后的消息对象。""" + return await _send_to_target_with_message( + message_sequence=MessageSequence(components=[TextComponent(text=text)]), + stream_id=stream_id, + typing=typing, + set_reply=set_reply, + reply_message=reply_message, + storage_message=storage_message, + selected_expressions=selected_expressions, + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) + + +async def text_to_stream( + text: str, + stream_id: str, + typing: bool = False, + set_reply: bool = False, + reply_message: Optional[MaiMessage] = None, + storage_message: bool = True, + selected_expressions: Optional[List[int]] = None, + sync_to_maisaka_history: bool = False, + maisaka_source_kind: str = "outbound_send", +) -> bool: + """向指定流发送文本消息。 + + Args: + text: 要发送的文本内容。 + stream_id: 目标会话 ID。 + typing: 是否显示输入中状态。 + set_reply: 是否附带引用回复。 + reply_message: 被回复的消息对象。 + storage_message: 是否在发送成功后写入数据库。 + selected_expressions: 可选的表情候选索引列表。 + + Returns: + bool: 发送成功时返回 ``True``。 + """ + return ( + await text_to_stream_with_message( + text=text, + stream_id=stream_id, + typing=typing, + set_reply=set_reply, + reply_message=reply_message, + storage_message=storage_message, + selected_expressions=selected_expressions, + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) + is not None + ) + + +async def emoji_to_stream_with_message( + emoji_base64: str, + stream_id: str, + storage_message: bool = True, + set_reply: bool = False, + reply_message: Optional[MaiMessage] = None, + sync_to_maisaka_history: bool = False, + maisaka_source_kind: str = "outbound_send", +) -> Optional[SessionMessage]: + """向指定流发送表情消息,并返回发送成功后的消息对象。""" + return await _send_to_target_with_message( + message_sequence=_build_message_sequence_from_custom_message("emoji", emoji_base64), + stream_id=stream_id, + typing=False, + storage_message=storage_message, + set_reply=set_reply, + reply_message=reply_message, + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) + + +async def emoji_to_stream( + emoji_base64: str, + stream_id: str, + storage_message: bool = True, + set_reply: bool = False, + reply_message: Optional[MaiMessage] = None, + sync_to_maisaka_history: bool = False, + maisaka_source_kind: str = "outbound_send", +) -> bool: + """向指定流发送表情消息。 + + Args: + emoji_base64: 表情图片的 Base64 内容。 + stream_id: 目标会话 ID。 + storage_message: 是否在发送成功后写入数据库。 + set_reply: 是否附带引用回复。 + reply_message: 被回复的消息对象。 + + Returns: + bool: 发送成功时返回 ``True``。 + """ + return ( + await emoji_to_stream_with_message( + emoji_base64=emoji_base64, + stream_id=stream_id, + storage_message=storage_message, + set_reply=set_reply, + reply_message=reply_message, + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) + is not None + ) + + +async def image_to_stream( + image_base64: str, + stream_id: str, + storage_message: bool = True, + set_reply: bool = False, + reply_message: Optional[MaiMessage] = None, + sync_to_maisaka_history: bool = False, + maisaka_source_kind: str = "outbound_send", +) -> bool: + """向指定流发送图片消息。 + + Args: + image_base64: 图片的 Base64 内容。 + stream_id: 目标会话 ID。 + storage_message: 是否在发送成功后写入数据库。 + set_reply: 是否附带引用回复。 + reply_message: 被回复的消息对象。 + + Returns: + bool: 发送成功时返回 ``True``。 + """ + return await _send_to_target( + message_sequence=_build_message_sequence_from_custom_message("image", image_base64), + stream_id=stream_id, + typing=False, + storage_message=storage_message, + set_reply=set_reply, + reply_message=reply_message, + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) + + +async def custom_to_stream( + message_type: str, + content: str | Dict[str, Any], + stream_id: str, + processed_plain_text: str = "", + typing: bool = False, + reply_message: Optional[MaiMessage] = None, + set_reply: bool = False, + storage_message: bool = True, + show_log: bool = True, + sync_to_maisaka_history: bool = False, + maisaka_source_kind: str = "outbound_send", +) -> bool: + """向指定流发送自定义类型消息。 + + Args: + message_type: 自定义消息类型。 + content: 自定义消息内容。 + stream_id: 目标会话 ID。 + processed_plain_text: 可选的预处理纯文本内容。 + typing: 是否显示输入中状态。 + reply_message: 被回复的消息对象。 + set_reply: 是否附带引用回复。 + storage_message: 是否在发送成功后写入数据库。 + show_log: 是否输出发送日志。 + + Returns: + bool: 发送成功时返回 ``True``。 + """ + return await _send_to_target( + message_sequence=_build_message_sequence_from_custom_message(message_type, content), + stream_id=stream_id, + processed_plain_text=processed_plain_text, + typing=typing, + reply_message=reply_message, + set_reply=set_reply, + storage_message=storage_message, + show_log=show_log, + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) + + +async def custom_reply_set_to_stream( + reply_set: MessageSequence, + stream_id: str, + processed_plain_text: str = "", + typing: bool = False, + reply_message: Optional[MaiMessage] = None, + set_reply: bool = False, + storage_message: bool = True, + show_log: bool = True, + sync_to_maisaka_history: bool = False, + maisaka_source_kind: str = "outbound_send", +) -> bool: + """向指定流发送消息组件序列。 + + Args: + reply_set: 待发送的消息组件序列。 + stream_id: 目标会话 ID。 + processed_plain_text: 可选的预处理纯文本内容。 + typing: 是否显示输入中状态。 + reply_message: 被回复的消息对象。 + set_reply: 是否附带引用回复。 + storage_message: 是否在发送成功后写入数据库。 + show_log: 是否输出发送日志。 + + Returns: + bool: 发送成功时返回 ``True``。 + """ + return await _send_to_target( + message_sequence=reply_set, + stream_id=stream_id, + processed_plain_text=processed_plain_text, + typing=typing, + reply_message=reply_message, + set_reply=set_reply, + storage_message=storage_message, + show_log=show_log, + sync_to_maisaka_history=sync_to_maisaka_history, + maisaka_source_kind=maisaka_source_kind, + ) diff --git a/src/services/service_task_resolver.py b/src/services/service_task_resolver.py new file mode 100644 index 00000000..b8c129b4 --- /dev/null +++ b/src/services/service_task_resolver.py @@ -0,0 +1,108 @@ +"""服务层模型任务解析工具。""" + +from typing import Any, Dict + +from src.common.logger import get_logger +from src.config.config import config_manager +from src.config.model_configs import TaskConfig + +logger = get_logger("service_task_resolver") + + +def get_available_models() -> Dict[str, TaskConfig]: + """获取当前所有可用的模型任务配置。 + + Returns: + Dict[str, TaskConfig]: 以任务名为键的可用任务配置映射。 + """ + try: + models = config_manager.get_model_config().model_task_config + available_models: Dict[str, TaskConfig] = {} + for attr_name in dir(models): + if attr_name.startswith("__"): + continue + try: + attr_value = getattr(models, attr_name) + except Exception as exc: + logger.debug(f"获取模型任务配置属性 {attr_name} 失败: {exc}") + continue + if not callable(attr_value) and isinstance(attr_value, TaskConfig): + available_models[attr_name] = attr_value + return available_models + except Exception as exc: + logger.error(f"获取可用模型配置失败: {exc}") + return {} + + +def resolve_task_name(task_name: str = "") -> str: + """根据任务名解析实际可用的模型任务名称。 + + Args: + task_name: 目标任务名;为空时返回首个可用任务。 + + Returns: + str: 解析后的模型任务名。 + + Raises: + RuntimeError: 当前没有任何可用模型配置时抛出。 + ValueError: 指定任务名不存在时抛出。 + """ + models = get_available_models() + if not models: + raise RuntimeError("没有可用的模型配置") + + normalized_task_name = task_name.strip() + if not normalized_task_name: + return next(iter(models.keys())) + if normalized_task_name not in models: + raise ValueError(f"未找到名为 `{normalized_task_name}` 的模型配置") + return normalized_task_name + + +def resolve_task_name_from_model_config(model_config: Any, preferred_task_name: str = "") -> str: + """根据旧版模型配置对象解析任务名。 + + Args: + model_config: 旧调用方持有的任务配置对象。 + preferred_task_name: 候选任务名。 + + Returns: + str: 解析后的模型任务名。 + + Raises: + RuntimeError: 当前没有任何可用模型配置时抛出。 + ValueError: 无法解析任何可用任务名时抛出。 + """ + models = get_available_models() + if not models: + raise RuntimeError("没有可用的模型配置") + + normalized_preferred = str(preferred_task_name or "").strip() + if normalized_preferred and normalized_preferred in models: + return normalized_preferred + + for task_name, task_cfg in models.items(): + if task_cfg is model_config: + return task_name + + requested_model_list_raw = getattr(model_config, "model_list", []) + requested_model_list = [str(item).strip() for item in (requested_model_list_raw or []) if str(item).strip()] + if requested_model_list: + for task_name, task_cfg in models.items(): + candidate_list = [str(item).strip() for item in getattr(task_cfg, "model_list", []) if str(item).strip()] + if candidate_list == requested_model_list: + return task_name + + for requested_model in requested_model_list: + for task_name, task_cfg in models.items(): + candidate_list = [str(item).strip() for item in getattr(task_cfg, "model_list", []) if str(item).strip()] + if requested_model in candidate_list: + logger.info( + "旧版 model_config 未命中任务配置," + f"按模型 `{requested_model}` 近似映射到任务 `{task_name}`" + ) + return task_name + + if normalized_preferred: + logger.warning(f"无法映射旧版 model_config,回退默认任务: preferred={normalized_preferred}") + return resolve_task_name("") diff --git a/src/services/statistics_service.py b/src/services/statistics_service.py new file mode 100644 index 00000000..889746fb --- /dev/null +++ b/src/services/statistics_service.py @@ -0,0 +1,491 @@ +from datetime import datetime, timedelta +from typing import Any, Dict, List + +from sqlalchemy import desc, func, or_ +from sqlmodel import col, select + +from src.common.database.database import get_db_session +from src.common.database.database_model import Messages, ModelUsage, OnlineTime, ToolRecord +from src.common.logger import get_logger +from src.common.message_repository import count_messages +from src.manager.local_store_manager import local_storage +from src.webui.schemas.statistics import DashboardData, ModelStatistics, StatisticsSummary, TimeSeriesData + +logger = get_logger("statistics_service") + +DASHBOARD_STATISTICS_CACHE_KEY = "webui_dashboard_statistics_cache" +DASHBOARD_STATISTICS_CACHE_VERSION = 1 +DEFAULT_DASHBOARD_CACHE_MAX_AGE_SECONDS = 600 +DEFAULT_DASHBOARD_CACHE_HOURS = (24, 168, 720) +_SPARSE_TIME_SERIES_FIELDS = ("hourly_data", "daily_data") + + +async def get_dashboard_statistics(hours: int = 24, *, use_cache: bool = True) -> DashboardData: + """获取 WebUI 仪表盘统计数据。""" + if use_cache: + cached_data = get_cached_dashboard_statistics(hours) + if cached_data is not None: + return cached_data + + return build_empty_dashboard_statistics() + + +def build_empty_dashboard_statistics() -> DashboardData: + """构造空的 WebUI 仪表盘统计数据。""" + return DashboardData( + summary=StatisticsSummary(), + model_stats=[], + hourly_data=[], + daily_data=[], + recent_activity=[], + ) + + +async def compute_dashboard_statistics(hours: int = 24) -> DashboardData: + """获取 WebUI 仪表盘统计数据。""" + now = datetime.now() + start_time = now - timedelta(hours=hours) + + summary = await get_summary_statistics(start_time, now) + model_stats = await get_model_statistics(start_time) + hourly_data = await get_hourly_statistics(start_time, now) + daily_data = await get_daily_statistics(now - timedelta(days=7), now) + recent_activity = await get_recent_activity(limit=10) + + return DashboardData( + summary=summary, + model_stats=model_stats, + hourly_data=hourly_data, + daily_data=daily_data, + recent_activity=recent_activity, + ) + + +def get_cached_dashboard_statistics( + hours: int = 24, + *, + max_age_seconds: int = DEFAULT_DASHBOARD_CACHE_MAX_AGE_SECONDS, +) -> DashboardData | None: + """从本地快照读取 WebUI 仪表盘统计数据。""" + raw_cache = local_storage[DASHBOARD_STATISTICS_CACHE_KEY] + if not isinstance(raw_cache, dict): + return None + if raw_cache.get("version") != DASHBOARD_STATISTICS_CACHE_VERSION: + return None + + generated_at = raw_cache.get("generated_at") + if not isinstance(generated_at, (int, float)): + return None + if datetime.now().timestamp() - float(generated_at) > max_age_seconds: + return None + + entries = raw_cache.get("entries") + if not isinstance(entries, dict): + return None + + entry = entries.get(str(hours)) + if not isinstance(entry, dict): + return None + + try: + expanded_entry = _expand_dashboard_cache_entry(entry, hours=hours, generated_at=float(generated_at)) + return DashboardData.model_validate(expanded_entry) + except Exception as e: + logger.warning(f"读取 WebUI 统计缓存失败,将实时计算: {e}") + return None + + +def store_dashboard_statistics_cache(entries: dict[int, DashboardData], *, generated_at: datetime | None = None) -> None: + """保存 WebUI 仪表盘统计数据快照。""" + snapshot_time = generated_at or datetime.now() + local_storage[DASHBOARD_STATISTICS_CACHE_KEY] = { + "version": DASHBOARD_STATISTICS_CACHE_VERSION, + "generated_at": snapshot_time.timestamp(), + "entries": {str(hours): _compact_dashboard_cache_entry(data) for hours, data in entries.items()}, + } + + +def update_dashboard_statistics_cache_entry( + hours: int, + data: DashboardData, + *, + generated_at: datetime | None = None, +) -> None: + """更新单个 WebUI 仪表盘统计缓存条目。""" + raw_cache = local_storage[DASHBOARD_STATISTICS_CACHE_KEY] + entries: dict[str, Any] = {} + if isinstance(raw_cache, dict) and isinstance(raw_cache.get("entries"), dict): + entries.update(raw_cache["entries"]) + + snapshot_time = generated_at or datetime.now() + entries[str(hours)] = _compact_dashboard_cache_entry(data) + local_storage[DASHBOARD_STATISTICS_CACHE_KEY] = { + "version": DASHBOARD_STATISTICS_CACHE_VERSION, + "generated_at": snapshot_time.timestamp(), + "entries": entries, + } + + +async def refresh_dashboard_statistics_cache(hours_values: tuple[int, ...] = DEFAULT_DASHBOARD_CACHE_HOURS) -> None: + """刷新 WebUI 仪表盘统计数据快照。""" + cache_entries: dict[int, DashboardData] = {} + for hours in hours_values: + cache_entries[hours] = await compute_dashboard_statistics(hours=hours) + store_dashboard_statistics_cache(cache_entries) + + +def _compact_dashboard_cache_entry(data: DashboardData) -> dict[str, Any]: + """压缩 WebUI 仪表盘缓存条目,去掉全 0 时间桶。""" + entry = data.model_dump(mode="json") + for field_name in _SPARSE_TIME_SERIES_FIELDS: + series = entry.get(field_name) + if isinstance(series, list): + entry[field_name] = [item for item in series if not _is_empty_time_series_item(item)] + entry["sparse"] = True + return entry + + +def _expand_dashboard_cache_entry(entry: dict[str, Any], *, hours: int, generated_at: float) -> dict[str, Any]: + """将稀疏缓存条目展开为前端需要的完整时间序列。""" + if entry.get("sparse") is not True: + return entry + + expanded = dict(entry) + generated_datetime = datetime.fromtimestamp(generated_at) + expanded["hourly_data"] = _expand_time_series( + sparse_series=entry.get("hourly_data"), + start_time=generated_datetime - timedelta(hours=hours), + end_time=generated_datetime, + step=timedelta(hours=1), + timestamp_format="%Y-%m-%dT%H:00:00", + ) + expanded["daily_data"] = _expand_time_series( + sparse_series=entry.get("daily_data"), + start_time=generated_datetime - timedelta(days=7), + end_time=generated_datetime, + step=timedelta(days=1), + timestamp_format="%Y-%m-%dT00:00:00", + ) + expanded.pop("sparse", None) + return expanded + + +def _expand_time_series( + *, + sparse_series: Any, + start_time: datetime, + end_time: datetime, + step: timedelta, + timestamp_format: str, +) -> list[dict[str, Any]]: + sparse_items = sparse_series if isinstance(sparse_series, list) else [] + sparse_by_timestamp = { + item.get("timestamp"): item + for item in sparse_items + if isinstance(item, dict) and isinstance(item.get("timestamp"), str) + } + + result: list[dict[str, Any]] = [] + current = _floor_time_for_format(start_time, timestamp_format) + while current <= end_time: + timestamp = current.strftime(timestamp_format) + item = sparse_by_timestamp.get(timestamp) + if isinstance(item, dict): + result.append(item) + else: + result.append({"timestamp": timestamp, "requests": 0, "cost": 0.0, "tokens": 0}) + current += step + return result + + +def _floor_time_for_format(value: datetime, timestamp_format: str) -> datetime: + if "%H" in timestamp_format: + return value.replace(minute=0, second=0, microsecond=0) + return value.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _is_empty_time_series_item(item: Any) -> bool: + if not isinstance(item, dict): + return False + return ( + int(item.get("requests") or 0) == 0 + and float(item.get("cost") or 0.0) == 0.0 + and int(item.get("tokens") or 0) == 0 + ) + + +async def get_summary_statistics(start_time: datetime, end_time: datetime) -> StatisticsSummary: + """获取指定时间范围内的摘要统计数据。""" + summary = StatisticsSummary( + total_requests=0, + total_cost=0.0, + total_tokens=0, + online_time=0.0, + total_messages=0, + total_replies=0, + avg_response_time=0.0, + cost_per_hour=0.0, + tokens_per_hour=0.0, + ) + + with get_db_session(auto_commit=False) as session: + statement = select( + func.count().label("total_requests"), + func.sum(col(ModelUsage.cost)).label("total_cost"), + func.sum(col(ModelUsage.total_tokens)).label("total_tokens"), + func.avg(col(ModelUsage.time_cost)).label("avg_response_time"), + ).where(col(ModelUsage.timestamp) >= start_time, col(ModelUsage.timestamp) <= end_time) + result = session.exec(statement).first() + + if result: + total_requests, total_cost, total_tokens, avg_response_time = result + summary.total_requests = total_requests or 0 + summary.total_cost = float(total_cost or 0.0) + summary.total_tokens = total_tokens or 0 + summary.avg_response_time = float(avg_response_time or 0.0) + + with get_db_session(auto_commit=False) as session: + statement = select(OnlineTime).where( + or_( + col(OnlineTime.start_timestamp) >= start_time, + col(OnlineTime.end_timestamp) >= start_time, + ) + ) + online_records = session.exec(statement).all() + + for record in online_records: + start = max(record.start_timestamp, start_time) + end = min(record.end_timestamp, end_time) + if end > start: + summary.online_time += (end - start).total_seconds() + + summary.total_messages = count_messages(start_time=start_time.timestamp(), end_time=end_time.timestamp()) + summary.total_replies = count_messages( + start_time=start_time.timestamp(), + end_time=end_time.timestamp(), + has_reply_to=True, + ) + + if summary.online_time > 0: + online_hours = summary.online_time / 3600.0 + summary.cost_per_hour = summary.total_cost / online_hours + summary.tokens_per_hour = summary.total_tokens / online_hours + + return summary + + +async def get_model_statistics(start_time: datetime) -> List[ModelStatistics]: + """获取指定时间之后的模型统计数据。""" + statement = ( + select(ModelUsage) + .where(col(ModelUsage.timestamp) >= start_time) + .order_by(desc(col(ModelUsage.timestamp))) + .limit(200) + ) + + with get_db_session(auto_commit=False) as session: + records = session.exec(statement).all() + + aggregates: Dict[str, Dict[str, float | int]] = {} + for record in records: + model_name = record.model_assign_name or record.model_name or "unknown" + if model_name not in aggregates: + aggregates[model_name] = { + "request_count": 0, + "total_cost": 0.0, + "total_tokens": 0, + "total_time_cost": 0.0, + "time_cost_count": 0, + } + + bucket = aggregates[model_name] + bucket["request_count"] = int(bucket["request_count"]) + 1 + bucket["total_cost"] = float(bucket["total_cost"]) + float(record.cost or 0.0) + bucket["total_tokens"] = int(bucket["total_tokens"]) + int(record.total_tokens or 0) + if record.time_cost: + bucket["total_time_cost"] = float(bucket["total_time_cost"]) + float(record.time_cost) + bucket["time_cost_count"] = int(bucket["time_cost_count"]) + 1 + + result: List[ModelStatistics] = [] + for model_name, bucket in sorted( + aggregates.items(), + key=lambda item: float(item[1]["request_count"]), + reverse=True, + )[:10]: + time_cost_count = int(bucket["time_cost_count"]) + avg_time_cost = float(bucket["total_time_cost"]) / time_cost_count if time_cost_count > 0 else 0.0 + result.append( + ModelStatistics( + model_name=model_name, + request_count=int(bucket["request_count"]), + total_cost=float(bucket["total_cost"]), + total_tokens=int(bucket["total_tokens"]), + avg_response_time=avg_time_cost, + ) + ) + + return result + + +async def get_hourly_statistics(start_time: datetime, end_time: datetime) -> List[TimeSeriesData]: + """按小时聚合 LLM 请求、费用和 token。""" + hour_expr = func.strftime("%Y-%m-%dT%H:00:00", col(ModelUsage.timestamp)) + statement = ( + select( + hour_expr.label("hour"), + func.count().label("requests"), + func.sum(col(ModelUsage.cost)).label("cost"), + func.sum(col(ModelUsage.total_tokens)).label("tokens"), + ) + .where(col(ModelUsage.timestamp) >= start_time, col(ModelUsage.timestamp) <= end_time) + .group_by(hour_expr) + ) + + with get_db_session(auto_commit=False) as session: + rows = session.exec(statement).all() + + data_dict = {row[0]: row for row in rows} + result = [] + current = start_time.replace(minute=0, second=0, microsecond=0) + while current <= end_time: + hour_str = current.strftime("%Y-%m-%dT%H:00:00") + if hour_str in data_dict: + row = data_dict[hour_str] + result.append( + TimeSeriesData( + timestamp=hour_str, + requests=row[1] or 0, + cost=float(row[2] or 0.0), + tokens=row[3] or 0, + ) + ) + else: + result.append(TimeSeriesData(timestamp=hour_str, requests=0, cost=0.0, tokens=0)) + current += timedelta(hours=1) + + return result + + +async def get_daily_statistics(start_time: datetime, end_time: datetime) -> List[TimeSeriesData]: + """按天聚合 LLM 请求、费用和 token。""" + day_expr = func.strftime("%Y-%m-%dT00:00:00", col(ModelUsage.timestamp)) + statement = ( + select( + day_expr.label("day"), + func.count().label("requests"), + func.sum(col(ModelUsage.cost)).label("cost"), + func.sum(col(ModelUsage.total_tokens)).label("tokens"), + ) + .where(col(ModelUsage.timestamp) >= start_time, col(ModelUsage.timestamp) <= end_time) + .group_by(day_expr) + ) + + with get_db_session(auto_commit=False) as session: + rows = session.exec(statement).all() + + data_dict = {row[0]: row for row in rows} + result = [] + current = start_time.replace(hour=0, minute=0, second=0, microsecond=0) + while current <= end_time: + day_str = current.strftime("%Y-%m-%dT00:00:00") + if day_str in data_dict: + row = data_dict[day_str] + result.append( + TimeSeriesData( + timestamp=day_str, + requests=row[1] or 0, + cost=float(row[2] or 0.0), + tokens=row[3] or 0, + ) + ) + else: + result.append(TimeSeriesData(timestamp=day_str, requests=0, cost=0.0, tokens=0)) + current += timedelta(days=1) + + return result + + +async def get_recent_activity(limit: int = 10) -> List[Dict[str, Any]]: + """获取最近的 LLM 调用记录。""" + with get_db_session(auto_commit=False) as session: + statement = select(ModelUsage).order_by(desc(col(ModelUsage.timestamp))).limit(limit) + records = session.exec(statement).all() + + activities = [] + for record in records: + activities.append( + { + "timestamp": record.timestamp.isoformat(), + "model": record.model_assign_name or record.model_name, + "request_type": record.request_type, + "tokens": record.total_tokens or 0, + "cost": record.cost or 0.0, + "time_cost": record.time_cost or 0.0, + "status": None, + } + ) + + return activities + + +def fetch_online_time_since(query_start_time: datetime) -> list[tuple[datetime, datetime]]: + """获取指定时间之后仍有覆盖的在线时间区间。""" + with get_db_session(auto_commit=False) as session: + statement = select(OnlineTime).where(col(OnlineTime.end_timestamp) >= query_start_time) + records = session.exec(statement).all() + return [(record.start_timestamp, record.end_timestamp) for record in records] + + +def fetch_model_usage_since(query_start_time: datetime) -> list[dict[str, object]]: + """获取指定时间之后的 LLM 使用记录。""" + with get_db_session(auto_commit=False) as session: + statement = select(ModelUsage).where(col(ModelUsage.timestamp) >= query_start_time) + records = session.exec(statement).all() + return [ + { + "timestamp": record.timestamp, + "request_type": record.request_type, + "model_api_provider_name": record.model_api_provider_name, + "model_assign_name": record.model_assign_name, + "model_name": record.model_name, + "prompt_tokens": record.prompt_tokens, + "completion_tokens": record.completion_tokens, + "cost": record.cost, + "time_cost": record.time_cost, + } + for record in records + ] + + +def fetch_messages_since(query_start_time: datetime) -> list[Messages]: + """获取指定时间之后的消息记录。""" + with get_db_session(auto_commit=False) as session: + statement = select(Messages).where(col(Messages.timestamp) >= query_start_time) + return list(session.exec(statement).all()) + + +def fetch_tool_records_since(query_start_time: datetime) -> list[ToolRecord]: + """获取指定时间之后的工具调用记录。""" + with get_db_session(auto_commit=False) as session: + statement = select(ToolRecord).where(col(ToolRecord.timestamp) >= query_start_time) + return list(session.exec(statement).all()) + + +def get_earliest_statistics_time(fallback_time: datetime) -> datetime: + """获取统计数据中最早的记录时间。""" + try: + with get_db_session(auto_commit=False) as session: + start_times = [ + session.exec(select(func.min(ModelUsage.timestamp))).first(), + session.exec(select(func.min(Messages.timestamp))).first(), + session.exec(select(func.min(OnlineTime.start_timestamp))).first(), + session.exec(select(func.min(ToolRecord.timestamp))).first(), + ] + except Exception as e: + logger.warning(f"获取全量统计起始时间失败,将使用回退时间: {e}") + return fallback_time + + valid_start_times = [item for item in start_times if isinstance(item, datetime)] + if valid_start_times: + return min(valid_start_times) + return fallback_time diff --git a/src/webui/app.py b/src/webui/app.py new file mode 100644 index 00000000..6258aaf3 --- /dev/null +++ b/src/webui/app.py @@ -0,0 +1,265 @@ +"""FastAPI 应用工厂 - 创建和配置 WebUI 应用实例""" + +from importlib import import_module +from os import getenv +from pathlib import Path +from typing import Any, Dict, Tuple + +import mimetypes + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse + +from src.common.i18n import t +from src.common.logger import get_logger + +logger = get_logger("webui.app") + +_DASHBOARD_PACKAGE_NAME = "maibot-dashboard" +_LOCAL_DASHBOARD_ENV = "MAIBOT_WEBUI_USE_LOCAL_DASHBOARD" +_STATISTICS_REPORT_PATH_ENV = "MAIBOT_STATISTICS_REPORT_PATH" +_DEFAULT_STATISTICS_REPORT_PATH = "maibot_statistics.html" +_MANUAL_INSTALL_COMMAND = f"pip install {_DASHBOARD_PACKAGE_NAME}" + + +def _resolve_safe_static_file_path(static_path: Path, full_path: str) -> Path | None: + static_root = static_path.resolve() + + try: + candidate_path = (static_root / full_path).resolve() + candidate_path.relative_to(static_root) + except (OSError, RuntimeError, ValueError): + logger.warning(t("startup.webui_path_traversal_detected", full_path=full_path)) + return None + + return candidate_path + + +def _get_project_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _resolve_statistics_report_path() -> Path: + configured_path = getenv(_STATISTICS_REPORT_PATH_ENV, "").strip() + report_path = Path(configured_path or _DEFAULT_STATISTICS_REPORT_PATH) + if report_path.is_absolute(): + return report_path.resolve() + + return (_get_project_root() / report_path).resolve() + + +def _is_local_dashboard_enabled() -> bool: + return getenv(_LOCAL_DASHBOARD_ENV, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _validate_static_path(static_path: Path | None) -> Tuple[str, Dict[str, Any]] | None: + if static_path is None: + return "startup.webui_static_dir_missing", {} + + if not static_path.exists(): + return "startup.webui_static_dir_missing_with_path", {"static_path": static_path} + + index_path = static_path / "index.html" + if not index_path.exists(): + return "startup.webui_index_missing", {"index_path": index_path} + + return None + + +def _ensure_static_path_ready() -> Path | None: + static_path = _resolve_static_path() + validation_error = _validate_static_path(static_path) + if validation_error is None: + return static_path + + logger.warning(t("startup.webui_static_assets_unavailable")) + error_key, error_kwargs = validation_error + logger.warning(t(error_key, **error_kwargs)) + logger.warning(t("startup.webui_dashboard_package_hint", command=_MANUAL_INSTALL_COMMAND)) + return None + + +def create_app( + host: str = "0.0.0.0", + port: int = 8001, + enable_static: bool = True, +) -> FastAPI: + """ + 创建 WebUI FastAPI 应用实例 + + Args: + host: 服务器主机地址 + port: 服务器端口 + enable_static: 是否启用静态文件服务 + """ + app = FastAPI(title="MaiBot WebUI") + + _setup_anti_crawler(app) + _setup_cors(app, port) + _register_api_routes(app) + _setup_robots_txt(app) + + if enable_static: + _setup_static_files(app) + + return app + + +def _setup_cors(app: FastAPI, port: int): + app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:7999", + "http://127.0.0.1:7999", + f"http://localhost:{port}", + f"http://127.0.0.1:{port}", + ], + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=[ + "Content-Type", + "Accept", + "Origin", + "X-Requested-With", + ], + expose_headers=["Content-Length", "Content-Type"], + ) + logger.debug(t("startup.webui_cors_configured")) + + +def _setup_anti_crawler(app: FastAPI): + try: + from src.config.config import global_config + from src.webui.middleware import AntiCrawlerMiddleware + + anti_crawler_mode = global_config.webui.anti_crawler_mode + app.add_middleware(AntiCrawlerMiddleware, mode=anti_crawler_mode) + + mode_descriptions = { + "false": t("startup.webui_anti_crawler_mode_disabled"), + "strict": t("startup.webui_anti_crawler_mode_strict"), + "loose": t("startup.webui_anti_crawler_mode_loose"), + "basic": t("startup.webui_anti_crawler_mode_basic"), + } + mode_desc = mode_descriptions.get(anti_crawler_mode, t("startup.webui_anti_crawler_mode_basic")) + logger.debug(t("startup.webui_anti_crawler_configured", mode_desc=mode_desc)) + except Exception as e: + logger.error(t("startup.webui_anti_crawler_config_failed", error=e), exc_info=True) + + +def _setup_robots_txt(app: FastAPI): + try: + from src.webui.middleware import create_robots_txt_response + + @app.get("/robots.txt", include_in_schema=False) + async def robots_txt(): + return create_robots_txt_response() + + logger.debug(t("startup.webui_robots_route_registered")) + except Exception as e: + logger.error(t("startup.webui_robots_route_register_failed", error=e), exc_info=True) + + +def _register_api_routes(app: FastAPI): + try: + from src.webui.routers import get_all_routers + + for router in get_all_routers(): + app.include_router(router) + + logger.debug(t("startup.webui_api_routes_registered")) + except Exception as e: + logger.error(t("startup.webui_api_routes_register_failed", error=e), exc_info=True) + + +def _setup_static_files(app: FastAPI): + mimetypes.init() + mimetypes.add_type("application/javascript", ".js") + mimetypes.add_type("application/javascript", ".mjs") + mimetypes.add_type("text/css", ".css") + mimetypes.add_type("application/json", ".json") + + static_path = _ensure_static_path_ready() + if static_path is None: + return + + if not static_path.exists(): + logger.warning(t("startup.webui_static_dir_missing_with_path", static_path=static_path)) + logger.warning(t("startup.webui_dashboard_package_hint", command=_MANUAL_INSTALL_COMMAND)) + return + + if not (static_path / "index.html").exists(): + logger.warning(t("startup.webui_index_missing", index_path=static_path / "index.html")) + logger.warning(t("startup.webui_dashboard_package_hint", command=_MANUAL_INSTALL_COMMAND)) + return + + @app.get("/maibot_statistics.html", include_in_schema=False) + async def serve_statistics_report(): + report_path = _resolve_statistics_report_path() + if not report_path.exists() or not report_path.is_file(): + raise HTTPException(status_code=404, detail=t("core.not_found")) + + response = FileResponse(report_path, media_type="text/html") + response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive" + return response + + @app.get("/{full_path:path}", include_in_schema=False) + async def serve_spa(full_path: str): + if full_path == "api" or full_path.startswith("api/"): + raise HTTPException(status_code=404, detail=t("core.not_found")) + + if not full_path or full_path == "/": + response = FileResponse(static_path / "index.html", media_type="text/html") + response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive" + return response + + file_path = _resolve_safe_static_file_path(static_path, full_path) + if file_path is None: + raise HTTPException(status_code=404, detail=t("core.not_found")) + + if file_path.exists() and file_path.is_file(): + media_type = mimetypes.guess_type(str(file_path))[0] + response = FileResponse(file_path, media_type=media_type) + if str(file_path).endswith(".html"): + response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive" + return response + + response = FileResponse(static_path / "index.html", media_type="text/html") + response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive" + return response + + logger.debug(t("startup.webui_static_files_configured", static_path=static_path)) + + +def _resolve_static_path() -> Path | None: + if _is_local_dashboard_enabled(): + static_path = _get_project_root() / "dashboard" / "dist" + if static_path.is_dir() and (static_path / "index.html").exists(): + return static_path + + try: + module = import_module("maibot_dashboard") + get_dist_path = getattr(module, "get_dist_path", None) + if callable(get_dist_path): + package_path = get_dist_path() + if isinstance(package_path, Path) and package_path.exists(): + return package_path + except Exception: + pass + + return None + + +def show_access_token(): + """显示 WebUI Access Token(供启动时调用)""" + try: + from src.webui.core import get_token_manager + + token_manager = get_token_manager() + current_token = token_manager.get_token() + logger.info(t("startup.webui_access_token", token=current_token)) + except Exception as e: + logger.error(t("startup.webui_access_token_failed", error=e)) diff --git a/src/webui/config_schema.py b/src/webui/config_schema.py new file mode 100644 index 00000000..2b02bb56 --- /dev/null +++ b/src/webui/config_schema.py @@ -0,0 +1,154 @@ +from typing import Any, Dict, List, get_args, get_origin + +from pydantic_core import PydanticUndefined + +import inspect + +from src.config.config_base import ConfigBase + + +class ConfigSchemaGenerator: + @staticmethod + def _build_label(label: str) -> Dict[str, str]: + return {"zh_CN": label} + + @classmethod + def generate_schema(cls, config_class: type[ConfigBase], include_nested: bool = True) -> Dict[str, Any]: + return cls.generate_config_schema(config_class, include_nested=include_nested) + + @classmethod + def generate_config_schema(cls, config_class: type[ConfigBase], include_nested: bool = True) -> Dict[str, Any]: + fields: List[Dict[str, Any]] = [] + nested: Dict[str, Dict[str, Any]] = {} + + for field_name, field_info in config_class.model_fields.items(): + if field_name in {"field_docs", "_validate_any", "suppress_any_warning"}: + continue + + field_schema = cls._build_field_schema(config_class, field_name, field_info.annotation, field_info) + fields.append(field_schema) + + if include_nested: + nested_schema = cls._build_nested_schema(field_info.annotation) + if nested_schema is not None: + nested[field_name] = nested_schema + + schema: Dict[str, Any] = { + "className": config_class.__name__, + "classDoc": (config_class.__doc__ or "").strip(), + "fields": fields, + "nested": nested, + } + + # 将 UI 分组元数据写入 schema + ui_parent = getattr(config_class, "__ui_parent__", "") + ui_label = getattr(config_class, "__ui_label__", "") + ui_icon = getattr(config_class, "__ui_icon__", "") + if ui_parent: + schema["uiParent"] = ui_parent + if ui_label: + schema["uiLabel"] = ui_label + if ui_icon: + schema["uiIcon"] = ui_icon + + return schema + + @classmethod + def _build_nested_schema(cls, annotation: Any) -> Dict[str, Any] | None: + origin = get_origin(annotation) + args = get_args(annotation) + + if inspect.isclass(annotation) and issubclass(annotation, ConfigBase): + return cls.generate_config_schema(annotation) + + if origin in {list, set, tuple} and args: + first = args[0] + if inspect.isclass(first) and issubclass(first, ConfigBase): + return cls.generate_config_schema(first) + + return None + + @classmethod + def _build_field_schema( + cls, config_class: type[ConfigBase], field_name: str, annotation: Any, field_info: Any + ) -> Dict[str, Any]: + field_docs = config_class.get_class_field_docs() + field_type = cls._map_field_type(annotation) + raw_description = field_docs.get(field_name, field_info.description or "") + # `_wrap_` 标记在配置类 docstring 中表示该说明应作为块级注释(独立成行) + # 在前端展示时把它转为换行符,使描述以新行起始或在中间换行 + description = raw_description.replace("_wrap_", "\n").strip("\n") + schema: Dict[str, Any] = { + "name": field_name, + "type": field_type, + "label": cls._build_label(field_name), + "description": description, + "required": field_info.is_required(), + } + + if field_info.default is not PydanticUndefined: + schema["default"] = field_info.default + + origin = get_origin(annotation) + args = get_args(annotation) + + if origin in {list, set} and args: + schema["items"] = {"type": cls._map_field_type(args[0])} + + if options := cls._extract_options(annotation): + schema["options"] = options + + # Task 1c: Merge json_schema_extra (x-widget, x-icon, step, etc.) + if hasattr(field_info, "json_schema_extra") and field_info.json_schema_extra: + schema.update(field_info.json_schema_extra) + + # Task 1d: Map Pydantic constraints to minValue/maxValue (frontend naming convention) + if hasattr(field_info, "metadata") and field_info.metadata: + for constraint in field_info.metadata: + if hasattr(constraint, "ge"): + schema["minValue"] = constraint.ge + if hasattr(constraint, "le"): + schema["maxValue"] = constraint.le + + return schema + + @staticmethod + def _extract_options(annotation: Any) -> List[str] | None: + origin = get_origin(annotation) + if origin is None: + return None + if str(origin) != "typing.Literal": + return None + + args = get_args(annotation) + options = [str(item) for item in args] + return options or None + + @classmethod + def _map_field_type(cls, annotation: Any) -> str: + origin = get_origin(annotation) + args = get_args(annotation) + + if origin in {list, set, tuple}: + return "array" + if inspect.isclass(annotation) and issubclass(annotation, ConfigBase): + return "object" + if annotation is bool: + return "boolean" + if annotation is int: + return "integer" + if annotation is float: + return "number" + if annotation is str: + return "string" + + if origin in {list, set, tuple} and args: + return "array" + + if origin in {dict}: + return "object" + + if origin is not None and str(origin) == "typing.Literal": + return "select" + + return "string" diff --git a/src/webui/core/__init__.py b/src/webui/core/__init__.py new file mode 100644 index 00000000..37078485 --- /dev/null +++ b/src/webui/core/__init__.py @@ -0,0 +1,32 @@ +from .auth import ( + COOKIE_MAX_AGE, + COOKIE_NAME, + clear_auth_cookie, + get_current_token, + is_token_valid, + set_auth_cookie, + verify_auth_token_from_cookie_or_header, +) +from .rate_limiter import ( + RateLimiter, + check_api_rate_limit, + check_auth_rate_limit, + get_rate_limiter, +) +from .security import TokenManager, get_token_manager + +__all__ = [ + "TokenManager", + "get_token_manager", + "RateLimiter", + "get_rate_limiter", + "check_auth_rate_limit", + "check_api_rate_limit", + "COOKIE_NAME", + "COOKIE_MAX_AGE", + "get_current_token", + "is_token_valid", + "set_auth_cookie", + "clear_auth_cookie", + "verify_auth_token_from_cookie_or_header", +] diff --git a/src/webui/core/auth.py b/src/webui/core/auth.py new file mode 100644 index 00000000..99f7ed94 --- /dev/null +++ b/src/webui/core/auth.py @@ -0,0 +1,161 @@ +"""WebUI 认证模块。""" + +from typing import Optional + +from fastapi import Cookie, HTTPException, Request, Response + +from src.common.logger import get_logger +from src.config.config import global_config + +from .security import get_token_manager + +logger = get_logger("webui.auth") + +# Cookie 配置 +COOKIE_NAME = "maibot_session" +COOKIE_MAX_AGE = 7 * 24 * 60 * 60 # 7天 + + +def _is_secure_environment() -> bool: + """ + 检测是否应该启用安全 Cookie(HTTPS) + + Returns: + bool: 如果应该使用 secure cookie 则返回 True + """ + # 从配置读取 + if global_config.webui.secure_cookie: + logger.info("配置中启用了 secure_cookie") + return True + + # 检查是否是生产环境 + if global_config.webui.mode == "production": + logger.info("WebUI运行在生产模式,启用 secure cookie") + return True + + # 默认:开发环境不启用(因为通常是 HTTP) + logger.debug("WebUI运行在开发模式,禁用 secure cookie") + return False + + +def get_current_token( + maibot_session: Optional[str] = Cookie(None), +) -> str: + """ + 获取当前请求的 token,仅从 HttpOnly Cookie 获取。 + + Args: + maibot_session: Cookie 中的 token + + Returns: + 验证通过的 token + + Raises: + HTTPException: 认证失败时抛出 401 错误 + """ + if not is_token_valid(maibot_session): + raise HTTPException(status_code=401, detail="Token 无效或已过期") + + assert maibot_session is not None + return maibot_session + + +def is_token_valid(maibot_session: Optional[str]) -> bool: + """判断认证 token 是否存在且有效。""" + if not maibot_session: + return False + + token_manager = get_token_manager() + return token_manager.verify_token(maibot_session) + + +def set_auth_cookie(response: Response, token: str, request: Optional[Request] = None) -> None: + """ + 设置认证 Cookie + + Args: + response: FastAPI Response 对象 + token: 要设置的 token + request: FastAPI Request 对象(可选,用于检测协议) + """ + # 根据环境和实际请求协议决定安全设置 + is_secure = _is_secure_environment() + + # 如果提供了 request,检测实际使用的协议 + if request: + # 检查 X-Forwarded-Proto header(代理/负载均衡器) + forwarded_proto = request.headers.get("x-forwarded-proto", "").lower() + if forwarded_proto: + is_https = forwarded_proto == "https" + logger.debug(f"检测到 X-Forwarded-Proto: {forwarded_proto}, is_https={is_https}") + else: + # 检查 request.url.scheme + is_https = request.url.scheme == "https" + logger.debug(f"检测到 scheme: {request.url.scheme}, is_https={is_https}") + + # 如果是 HTTP 连接,强制禁用 secure 标志 + if not is_https and is_secure: + logger.warning("=" * 80) + logger.warning("检测到 HTTP 连接但环境配置要求 HTTPS (secure cookie)") + logger.warning("已自动禁用 secure 标志以允许登录,但建议修改配置:") + logger.warning("1. 在配置文件中设置: webui.secure_cookie = false") + logger.warning("2. 如果使用反向代理,请确保正确配置 X-Forwarded-Proto 头") + logger.warning("=" * 80) + is_secure = False + + # 设置 Cookie + response.set_cookie( + key=COOKIE_NAME, + value=token, + max_age=COOKIE_MAX_AGE, + httponly=True, # 防止 JS 读取,阻止 XSS 窃取 + samesite="lax", # 使用 lax 以兼容更多场景(开发和生产) + secure=is_secure, # 根据实际协议决定 + path="/", # 确保 Cookie 在所有路径下可用 + ) + + logger.info( + f"已设置认证 Cookie: {token[:8]}... (secure={is_secure}, samesite=lax, httponly=True, path=/, max_age={COOKIE_MAX_AGE})" + ) + logger.debug(f"完整 token 前缀: {token[:20]}...") + + +def clear_auth_cookie(response: Response) -> None: + """ + 清除认证 Cookie + + Args: + response: FastAPI Response 对象 + """ + # 保持与 set_auth_cookie 相同的安全设置 + is_secure = _is_secure_environment() + + response.delete_cookie( + key=COOKIE_NAME, + httponly=True, + samesite="strict" if is_secure else "lax", + secure=is_secure, + path="/", + ) + logger.debug("已清除认证 Cookie") + + +def verify_auth_token_from_cookie_or_header( + maibot_session: Optional[str] = None, +) -> bool: + """ + 验证认证 Cookie。 + + Args: + maibot_session: Cookie 中的 token + + Returns: + 验证成功返回 True + + Raises: + HTTPException: 认证失败时抛出 401 错误 + """ + if not is_token_valid(maibot_session): + raise HTTPException(status_code=401, detail="Token 无效或已过期") + + return True diff --git a/src/webui/core/rate_limiter.py b/src/webui/core/rate_limiter.py new file mode 100644 index 00000000..303c0420 --- /dev/null +++ b/src/webui/core/rate_limiter.py @@ -0,0 +1,247 @@ +""" +WebUI 请求频率限制模块 +防止暴力破解和 API 滥用 +""" + +import time +from collections import defaultdict +from typing import Dict, List, Optional, Tuple + +from fastapi import HTTPException, Request + +from src.common.logger import get_logger + +logger = get_logger("webui.rate_limiter") + + +class RateLimiter: + """ + 简单的内存请求频率限制器 + + 使用滑动窗口算法实现 + """ + + def __init__(self): + # 存储格式: {key: [(timestamp, count), ...]} + self._requests: Dict[str, List] = defaultdict(list) + # 被封禁的 IP: {ip: unblock_timestamp} + self._blocked: Dict[str, float] = {} + + def _get_client_ip(self, request: Request) -> str: + """获取客户端 IP 地址""" + # 检查代理头 + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + # 取第一个 IP(最原始的客户端) + return forwarded.split(",")[0].strip() + + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip + + # 直接连接的客户端 + if request.client: + return request.client.host + + return "unknown" + + def _cleanup_old_requests(self, key: str, window_seconds: int): + """清理过期的请求记录""" + now = time.time() + cutoff = now - window_seconds + self._requests[key] = [(ts, count) for ts, count in self._requests[key] if ts > cutoff] + + def _cleanup_expired_blocks(self): + """清理过期的封禁""" + now = time.time() + expired = [ip for ip, unblock_time in self._blocked.items() if now > unblock_time] + for ip in expired: + del self._blocked[ip] + logger.info(f"🔓 IP {ip} 封禁已解除") + + def is_blocked(self, request: Request) -> Tuple[bool, Optional[int]]: + """ + 检查 IP 是否被封禁 + + Returns: + (是否被封禁, 剩余封禁秒数) + """ + self._cleanup_expired_blocks() + ip = self._get_client_ip(request) + + if ip in self._blocked: + remaining = int(self._blocked[ip] - time.time()) + return True, max(0, remaining) + + return False, None + + def check_rate_limit( + self, request: Request, max_requests: int, window_seconds: int, key_suffix: str = "" + ) -> Tuple[bool, int]: + """ + 检查请求是否超过频率限制 + + Args: + request: FastAPI Request 对象 + max_requests: 窗口期内允许的最大请求数 + window_seconds: 窗口时间(秒) + key_suffix: 键后缀,用于区分不同的限制规则 + + Returns: + (是否允许, 剩余请求数) + """ + ip = self._get_client_ip(request) + key = f"{ip}:{key_suffix}" if key_suffix else ip + + # 清理过期记录 + self._cleanup_old_requests(key, window_seconds) + + # 计算当前窗口内的请求数 + current_count = sum(count for _, count in self._requests[key]) + + if current_count >= max_requests: + return False, 0 + + # 记录新请求 + now = time.time() + self._requests[key].append((now, 1)) + + remaining = max_requests - current_count - 1 + return True, remaining + + def block_ip(self, request: Request, duration_seconds: int): + """ + 封禁 IP + + Args: + request: FastAPI Request 对象 + duration_seconds: 封禁时长(秒) + """ + ip = self._get_client_ip(request) + self._blocked[ip] = time.time() + duration_seconds + logger.warning(f"🔒 IP {ip} 已被封禁 {duration_seconds} 秒") + + def record_failed_attempt( + self, request: Request, max_failures: int = 5, window_seconds: int = 300, block_duration: int = 600 + ) -> Tuple[bool, int]: + """ + 记录失败尝试(如登录失败) + + 如果在窗口期内失败次数过多,自动封禁 IP + + Args: + request: FastAPI Request 对象 + max_failures: 允许的最大失败次数 + window_seconds: 统计窗口(秒) + block_duration: 封禁时长(秒) + + Returns: + (是否被封禁, 剩余尝试次数) + """ + ip = self._get_client_ip(request) + key = f"{ip}:auth_failures" + + # 清理过期记录 + self._cleanup_old_requests(key, window_seconds) + + # 计算当前失败次数 + current_failures = sum(count for _, count in self._requests[key]) + + # 记录本次失败 + now = time.time() + self._requests[key].append((now, 1)) + current_failures += 1 + + remaining = max_failures - current_failures + + # 检查是否需要封禁 + if current_failures >= max_failures: + self.block_ip(request, block_duration) + logger.warning(f"⚠️ IP {ip} 认证失败次数过多 ({current_failures}/{max_failures}),已封禁") + return True, 0 + + if current_failures >= max_failures - 2: + logger.warning(f"⚠️ IP {ip} 认证失败 {current_failures}/{max_failures} 次") + + return False, max(0, remaining) + + def reset_failures(self, request: Request): + """ + 重置失败计数(认证成功后调用) + """ + ip = self._get_client_ip(request) + key = f"{ip}:auth_failures" + if key in self._requests: + del self._requests[key] + + +# 全局单例 +_rate_limiter: Optional[RateLimiter] = None + + +def get_rate_limiter() -> RateLimiter: + """获取 RateLimiter 单例""" + global _rate_limiter + if _rate_limiter is None: + _rate_limiter = RateLimiter() + return _rate_limiter + + +async def check_auth_rate_limit(request: Request): + """ + 认证接口的频率限制依赖 + + 规则: + - 每个 IP 每分钟最多 10 次认证请求 + - 连续失败 5 次后封禁 10 分钟 + """ + limiter = get_rate_limiter() + + # 检查是否被封禁 + blocked, remaining_block = limiter.is_blocked(request) + if blocked: + raise HTTPException( + status_code=429, + detail=f"请求过于频繁,请在 {remaining_block} 秒后重试", + headers={"Retry-After": str(remaining_block)}, + ) + + # 检查频率限制 + allowed, remaining = limiter.check_rate_limit( + request, + max_requests=10, # 每分钟 10 次 + window_seconds=60, + key_suffix="auth", + ) + + if not allowed: + raise HTTPException(status_code=429, detail="认证请求过于频繁,请稍后重试", headers={"Retry-After": "60"}) + + +async def check_api_rate_limit(request: Request): + """ + 普通 API 的频率限制依赖 + + 规则:每个 IP 每分钟最多 100 次请求 + """ + limiter = get_rate_limiter() + + # 检查是否被封禁 + blocked, remaining_block = limiter.is_blocked(request) + if blocked: + raise HTTPException( + status_code=429, + detail=f"请求过于频繁,请在 {remaining_block} 秒后重试", + headers={"Retry-After": str(remaining_block)}, + ) + + # 检查频率限制 + allowed, _ = limiter.check_rate_limit( + request, + max_requests=100, # 每分钟 100 次 + window_seconds=60, + key_suffix="api", + ) + + if not allowed: + raise HTTPException(status_code=429, detail="请求过于频繁,请稍后重试", headers={"Retry-After": "60"}) diff --git a/src/webui/core/security.py b/src/webui/core/security.py new file mode 100644 index 00000000..11dd37f4 --- /dev/null +++ b/src/webui/core/security.py @@ -0,0 +1,309 @@ +""" +WebUI Token 管理模块 +负责生成、保存、验证和更新访问令牌 +""" + +import json +import secrets +from pathlib import Path +from typing import Dict, Optional, Tuple + +from src.common.logger import get_logger + +logger = get_logger("webui") + + +class TokenManager: + """Token 管理器""" + + def __init__(self, config_path: Optional[Path] = None): + """ + 初始化 Token 管理器 + + Args: + config_path: 配置文件路径,默认为项目根目录的 data/webui.json + """ + if config_path is None: + # 获取项目根目录 (src/webui/core -> src/webui -> src -> 根目录) + project_root = Path(__file__).parent.parent.parent.parent + config_path = project_root / "data" / "webui.json" + + self.config_path = config_path + self.config_path.parent.mkdir(parents=True, exist_ok=True) + + # 确保配置文件存在并包含有效的 token + self._ensure_config() + + def _ensure_config(self): + """确保配置文件存在且包含有效的 token""" + if not self.config_path.exists(): + logger.info(f"WebUI 配置文件不存在,正在创建: {self.config_path}") + self._create_new_token() + else: + # 验证配置文件格式 + try: + config = self._load_config() + if not config.get("access_token"): + logger.warning("WebUI 配置文件中缺少 access_token,正在重新生成") + self._create_new_token() + else: + logger.info(f"WebUI Token 已加载: {config['access_token'][:8]}...") + except Exception as e: + logger.error(f"读取 WebUI 配置文件失败: {e},正在重新创建") + self._create_new_token() + + def _load_config(self) -> Dict: + """加载配置文件""" + try: + with open(self.config_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + logger.error(f"加载 WebUI 配置失败: {e}") + return {} + + def _save_config(self, config: Dict): + """保存配置文件""" + try: + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + logger.info(f"WebUI 配置已保存到: {self.config_path}") + except Exception as e: + logger.error(f"保存 WebUI 配置失败: {e}") + raise + + def _create_new_token(self) -> str: + """生成新的 64 位随机 token""" + # 生成 64 位十六进制字符串 (32 字节 = 64 hex 字符) + token = secrets.token_hex(32) + + config = { + "access_token": token, + "created_at": self._get_current_timestamp(), + "updated_at": self._get_current_timestamp(), + "first_setup_completed": False, # 标记首次配置未完成 + } + + self._save_config(config) + logger.info(f"新的 WebUI Token 已生成: {token[:8]}...") + + return token + + def _get_current_timestamp(self) -> str: + """获取当前时间戳字符串""" + from datetime import datetime + + return datetime.now().isoformat() + + def get_token(self) -> str: + """获取当前有效的 token""" + config = self._load_config() + return config.get("access_token", "") + + def verify_token(self, token: str) -> bool: + """ + 验证 token 是否有效 + + Args: + token: 待验证的 token + + Returns: + bool: token 是否有效 + """ + if not token: + return False + + current_token = self.get_token() + if not current_token: + logger.error("系统中没有有效的 token") + return False + + # 使用 secrets.compare_digest 防止时序攻击 + is_valid = secrets.compare_digest(token, current_token) + + if is_valid: + logger.debug("Token 验证成功") + else: + logger.warning("Token 验证失败") + + return is_valid + + def update_token(self, new_token: str) -> Tuple[bool, str]: + """ + 更新 token + + Args: + new_token: 新的 token (最少 10 位,必须包含大小写字母和特殊符号) + + Returns: + tuple[bool, str]: (是否更新成功, 错误消息) + """ + # 验证新 token 格式 + is_valid, error_msg = self._validate_custom_token(new_token) + if not is_valid: + logger.error(f"Token 格式无效: {error_msg}") + return False, error_msg + + try: + config = self._load_config() + old_token = config.get("access_token", "")[:8] + + config["access_token"] = new_token + config["updated_at"] = self._get_current_timestamp() + + self._save_config(config) + logger.info(f"Token 已更新: {old_token}... -> {new_token[:8]}...") + + return True, "Token 更新成功" + except Exception as e: + logger.error(f"更新 Token 失败: {e}") + return False, f"更新失败: {str(e)}" + + def regenerate_token(self) -> str: + """ + 重新生成 token(保留 first_setup_completed 状态) + + Returns: + str: 新生成的 token + """ + logger.info("正在重新生成 WebUI Token...") + + # 生成新的 64 位十六进制字符串 + new_token = secrets.token_hex(32) + + # 加载现有配置,保留 first_setup_completed 状态 + config = self._load_config() + old_token = config.get("access_token", "")[:8] if config.get("access_token") else "无" + first_setup_completed = config.get("first_setup_completed", True) # 默认为 True,表示已完成配置 + + config["access_token"] = new_token + config["updated_at"] = self._get_current_timestamp() + config["first_setup_completed"] = first_setup_completed # 保留原来的状态 + + self._save_config(config) + logger.info(f"WebUI Token 已重新生成: {old_token}... -> {new_token[:8]}...") + + return new_token + + def _validate_token_format(self, token: str) -> bool: + """ + 验证 token 格式是否正确(旧的 64 位十六进制验证,保留用于系统生成的 token) + + Args: + token: 待验证的 token + + Returns: + bool: 格式是否正确 + """ + if not token or not isinstance(token, str): + return False + + # 必须是 64 位十六进制字符串 + if len(token) != 64: + return False + + # 验证是否为有效的十六进制字符串 + try: + int(token, 16) + return True + except ValueError: + return False + + def _validate_custom_token(self, token: str) -> Tuple[bool, str]: + """ + 验证自定义 token 格式 + + 要求: + - 最少 10 位 + - 包含大写字母 + - 包含小写字母 + - 包含特殊符号 + + Args: + token: 待验证的 token + + Returns: + tuple[bool, str]: (是否有效, 错误消息) + """ + if not token or not isinstance(token, str): + return False, "Token 不能为空" + + # 检查长度 + if len(token) < 10: + return False, "Token 长度至少为 10 位" + + # 检查是否包含大写字母 + has_upper = any(c.isupper() for c in token) + if not has_upper: + return False, "Token 必须包含大写字母" + + # 检查是否包含小写字母 + has_lower = any(c.islower() for c in token) + if not has_lower: + return False, "Token 必须包含小写字母" + + # 检查是否包含特殊符号 + special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?/" + has_special = any(c in special_chars for c in token) + if not has_special: + return False, f"Token 必须包含特殊符号 ({special_chars})" + + return True, "Token 格式正确" + + def is_first_setup(self) -> bool: + """ + 检查是否为首次配置 + + Returns: + bool: 是否为首次配置 + """ + config = self._load_config() + return not config.get("first_setup_completed", False) + + def mark_setup_completed(self) -> bool: + """ + 标记首次配置已完成 + + Returns: + bool: 是否标记成功 + """ + try: + config = self._load_config() + config["first_setup_completed"] = True + config["setup_completed_at"] = self._get_current_timestamp() + self._save_config(config) + logger.info("首次配置已标记为完成") + return True + except Exception as e: + logger.error(f"标记首次配置完成失败: {e}") + return False + + def reset_setup_status(self) -> bool: + """ + 重置首次配置状态,允许重新进入配置向导 + + Returns: + bool: 是否重置成功 + """ + try: + config = self._load_config() + config["first_setup_completed"] = False + if "setup_completed_at" in config: + del config["setup_completed_at"] + self._save_config(config) + logger.info("首次配置状态已重置") + return True + except Exception as e: + logger.error(f"重置首次配置状态失败: {e}") + return False + + +# 全局单例 +_token_manager_instance: Optional[TokenManager] = None + + +def get_token_manager() -> TokenManager: + """获取 TokenManager 单例""" + global _token_manager_instance + if _token_manager_instance is None: + _token_manager_instance = TokenManager() + return _token_manager_instance diff --git a/src/webui/dashboard_update.py b/src/webui/dashboard_update.py new file mode 100644 index 00000000..083b0baf --- /dev/null +++ b/src/webui/dashboard_update.py @@ -0,0 +1,286 @@ +"""WebUI dashboard 版本检查与自动更新。""" + +from __future__ import annotations + +from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError, version as get_package_version +from pathlib import Path +from typing import Any, Dict, Literal, Optional + +import asyncio +import os +import shutil +import subprocess +import sys +import time + +import httpx + +from src.common.logger import get_logger + +logger = get_logger("webui_dashboard_update") + +DASHBOARD_PACKAGE_NAME = "maibot-dashboard" +PYPI_JSON_URL = f"https://pypi.org/pypi/{DASHBOARD_PACKAGE_NAME}/json" +PYPI_PROJECT_URL = f"https://pypi.org/project/{DASHBOARD_PACKAGE_NAME}/" +PYPI_CACHE_TTL_SECONDS = 60 * 60 * 6 + +PackageRunner = Literal["uv", "pip", "unknown"] +_pypi_version_cache: Dict[str, Any] = {"checked_at": 0.0, "latest_version": None} + + +@dataclass(frozen=True) +class DashboardVersionInfo: + """WebUI dashboard 版本检查结果。""" + + current_version: str + latest_version: Optional[str] + has_update: bool + package_name: str = DASHBOARD_PACKAGE_NAME + pypi_url: str = PYPI_PROJECT_URL + + +@dataclass(frozen=True) +class DashboardUpdateResult: + """WebUI dashboard 自动更新结果。""" + + checked: bool + updated: bool + current_version: str + latest_version: Optional[str] + runner: PackageRunner + message: str + + +def get_installed_dashboard_version() -> str: + try: + return get_package_version(DASHBOARD_PACKAGE_NAME) + except PackageNotFoundError: + return "unknown" + + +def normalize_version(version: str) -> tuple[int, ...]: + clean_version = version.strip().lower().removeprefix("v") + numeric_part = clean_version.split("-", 1)[0].split("+", 1)[0] + parts = [] + for item in numeric_part.split("."): + number = "" + for char in item: + if not char.isdigit(): + break + number += char + parts.append(int(number) if number else 0) + return tuple(parts) + + +def is_newer_version(latest_version: Optional[str], current_version: str) -> bool: + if not latest_version or not current_version or current_version == "unknown": + return False + + latest_parts = normalize_version(latest_version) + current_parts = normalize_version(current_version) + width = max(len(latest_parts), len(current_parts)) + return latest_parts + (0,) * (width - len(latest_parts)) > current_parts + (0,) * (width - len(current_parts)) + + +async def get_latest_dashboard_version_from_pypi() -> Optional[str]: + now = time.time() + cached_version = _pypi_version_cache.get("latest_version") + checked_at = float(_pypi_version_cache.get("checked_at", 0.0)) + if cached_version and now - checked_at < PYPI_CACHE_TTL_SECONDS: + return str(cached_version) + + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(PYPI_JSON_URL) + response.raise_for_status() + payload = response.json() + except Exception as e: + logger.debug(f"检查 WebUI PyPI 版本失败: {e}") + return str(cached_version) if cached_version else None + + latest_version = payload.get("info", {}).get("version") + if isinstance(latest_version, str) and latest_version.strip(): + _pypi_version_cache["checked_at"] = now + _pypi_version_cache["latest_version"] = latest_version.strip() + return latest_version.strip() + + return str(cached_version) if cached_version else None + + +async def get_dashboard_version_info(current_version: Optional[str] = None) -> DashboardVersionInfo: + resolved_current_version = current_version or get_installed_dashboard_version() + latest_version = await get_latest_dashboard_version_from_pypi() + + return DashboardVersionInfo( + current_version=resolved_current_version, + latest_version=latest_version, + has_update=is_newer_version(latest_version, resolved_current_version), + ) + + +def _get_parent_command_line() -> str: + parent_pid = _get_parent_pid(os.getpid()) + if parent_pid is None: + return "" + return _get_process_command_line(parent_pid) + + +def _get_parent_pid(pid: int) -> Optional[int]: + if os.name == "nt": + return _get_windows_parent_pid(pid) + stat_path = Path(f"/proc/{pid}/stat") + try: + stat_text = stat_path.read_text(encoding="utf-8") + except OSError: + return None + parts = stat_text.split() + if len(parts) >= 4 and parts[3].isdigit(): + return int(parts[3]) + return None + + +def _get_process_command_line(pid: int) -> str: + if os.name == "nt": + return _get_windows_process_command_line(pid) + cmdline_path = Path(f"/proc/{pid}/cmdline") + try: + return cmdline_path.read_text(encoding="utf-8").replace("\x00", " ").strip() + except OSError: + return "" + + +def _get_windows_parent_pid(pid: int) -> Optional[int]: + output = _run_wmic_query(pid, "ParentProcessId") + parent_pid = output.get("ParentProcessId") + if parent_pid and parent_pid.isdigit(): + return int(parent_pid) + return None + + +def _get_windows_process_command_line(pid: int) -> str: + output = _run_wmic_query(pid, "CommandLine") + return output.get("CommandLine", "") + + +def _run_wmic_query(pid: int, field: str) -> Dict[str, str]: + wmic_path = shutil.which("wmic") + if not wmic_path: + return {} + + try: + result = subprocess.run( + [wmic_path, "process", "where", f"ProcessId={pid}", "get", field, "/format:list"], + check=False, + capture_output=True, + encoding="utf-8", + errors="ignore", + timeout=3, + ) + except (OSError, subprocess.SubprocessError): + return {} + + values = {} + for line in result.stdout.splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + return values + + +def detect_package_runner() -> PackageRunner: + """检测当前进程更像是由 uv 还是 pip/普通 python 启动。""" + + uv_markers = ["UV", "UV_PROJECT_ENVIRONMENT", "UV_RUN_RECURSION_DEPTH"] + if any(os.getenv(marker) for marker in uv_markers): + return "uv" + + parent_command = _get_parent_command_line().lower() + if parent_command: + executable = parent_command.split(maxsplit=1)[0] + if executable.endswith("uv.exe") or executable.endswith("/uv") or executable == "uv": + return "uv" + if " pip " in f" {parent_command} " or executable.endswith("pip.exe") or executable.endswith("/pip"): + return "pip" + + if sys.prefix != sys.base_prefix or os.getenv("VIRTUAL_ENV"): + return "pip" + + return "unknown" + + +def _build_update_command(runner: PackageRunner) -> list[str]: + if runner == "uv" and shutil.which("uv"): + return ["uv", "pip", "install", "--python", sys.executable, "--upgrade", DASHBOARD_PACKAGE_NAME] + return [sys.executable, "-m", "pip", "install", "--upgrade", DASHBOARD_PACKAGE_NAME] + + +async def auto_update_dashboard_if_needed() -> DashboardUpdateResult: + version_info = await get_dashboard_version_info() + runner = detect_package_runner() + if not version_info.latest_version: + return DashboardUpdateResult( + checked=True, + updated=False, + current_version=version_info.current_version, + latest_version=None, + runner=runner, + message="无法获取 WebUI 最新版本,跳过自动更新", + ) + if not version_info.has_update: + return DashboardUpdateResult( + checked=True, + updated=False, + current_version=version_info.current_version, + latest_version=version_info.latest_version, + runner=runner, + message="WebUI 已是最新版本", + ) + + update_runner = runner if runner != "unknown" else "pip" + command = _build_update_command(update_runner) + logger.info( + f"检测到 WebUI 新版本: {version_info.current_version} -> {version_info.latest_version}," + f"使用 {update_runner} 自动更新" + ) + + try: + process = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + except OSError as e: + logger.warning(f"WebUI 自动更新启动失败: {e}") + return DashboardUpdateResult( + checked=True, + updated=False, + current_version=version_info.current_version, + latest_version=version_info.latest_version, + runner=update_runner, + message=f"自动更新启动失败: {e}", + ) + + if process.returncode != 0: + error_text = stderr.decode(errors="ignore").strip() or stdout.decode(errors="ignore").strip() + logger.warning(f"WebUI 自动更新失败: {error_text}") + return DashboardUpdateResult( + checked=True, + updated=False, + current_version=version_info.current_version, + latest_version=version_info.latest_version, + runner=update_runner, + message=f"自动更新失败: {error_text}", + ) + + logger.info(f"WebUI 自动更新完成: {version_info.current_version} -> {version_info.latest_version}") + return DashboardUpdateResult( + checked=True, + updated=True, + current_version=version_info.current_version, + latest_version=version_info.latest_version, + runner=update_runner, + message="WebUI 自动更新完成,重启后生效", + ) diff --git a/src/webui/dependencies.py b/src/webui/dependencies.py new file mode 100644 index 00000000..41584a69 --- /dev/null +++ b/src/webui/dependencies.py @@ -0,0 +1,70 @@ +from typing import Optional + +from fastapi import Cookie, Depends, Request + +from .core import check_auth_rate_limit, get_current_token, is_token_valid + + +async def require_auth( + maibot_session: Optional[str] = Cookie(None), +) -> str: + """ + FastAPI 依赖:要求有效认证 + + 用于保护需要认证的路由,自动从 Cookie 获取并验证 token + + Returns: + 验证通过的 token + + Raises: + HTTPException 401: 认证失败 + """ + return get_current_token(maibot_session) + + +async def require_auth_with_rate_limit( + request: Request, + maibot_session: Optional[str] = Cookie(None), + _rate_limit: None = Depends(check_auth_rate_limit), +) -> str: + """ + FastAPI 依赖:要求有效认证 + 频率限制 + + 组合了认证检查和频率限制,适用于敏感操作 + + Returns: + 验证通过的 token + + Raises: + HTTPException 401: 认证失败 + HTTPException 429: 请求过于频繁 + """ + return get_current_token(maibot_session) + + +def get_optional_token( + maibot_session: Optional[str] = Cookie(None), +) -> Optional[str]: + """ + FastAPI 依赖:可选获取 token(不验证) + + 用于某些需要知道是否有 token 但不强制验证的场景 + + Returns: + token 字符串或 None + """ + return maibot_session or None + + +async def verify_token_optional( + maibot_session: Optional[str] = Cookie(None), +) -> bool: + """ + FastAPI 依赖:可选验证 token + + 返回 token 是否有效,不抛出异常 + + Returns: + True 如果 token 有效,否则 False + """ + return is_token_valid(maibot_session) diff --git a/src/webui/logs_ws.py b/src/webui/logs_ws.py new file mode 100644 index 00000000..411c7769 --- /dev/null +++ b/src/webui/logs_ws.py @@ -0,0 +1,157 @@ +"""WebSocket 日志推送模块""" + +import json +from pathlib import Path +from typing import Dict, List, Optional, Set + +from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect + +from src.common.logger import get_logger +from src.webui.core import get_token_manager +from src.webui.routers.websocket.auth import verify_ws_token +from src.webui.routers.websocket.manager import websocket_manager + +logger = get_logger("webui.logs_ws") +router = APIRouter() + +# 全局 WebSocket 连接池 +active_connections: Set[WebSocket] = set() + + +def load_recent_logs(limit: int = 100) -> List[Dict]: + """从日志文件中加载最近的日志 + + Args: + limit: 返回的最大日志条数 + + Returns: + 日志列表 + """ + logs = [] + log_dir = Path("logs") + + if not log_dir.exists(): + return logs + + # 获取所有日志文件,按修改时间排序 + log_files = sorted(log_dir.glob("app_*.log.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True) + + # 用于生成唯一 ID 的计数器 + log_counter = 0 + + # 从最新的文件开始读取 + for log_file in log_files: + if len(logs) >= limit: + break + + try: + with open(log_file, "r", encoding="utf-8") as f: + lines = f.readlines() + # 从文件末尾开始读取 + for line in reversed(lines): + if len(logs) >= limit: + break + try: + log_entry = json.loads(line.strip()) + # 转换为前端期望的格式 + # 使用时间戳 + 计数器生成唯一 ID + timestamp_id = ( + log_entry.get("timestamp", "0").replace("-", "").replace(" ", "").replace(":", "") + ) + formatted_log = { + "id": f"{timestamp_id}_{log_counter}", + "timestamp": log_entry.get("timestamp", ""), + "level": log_entry.get("level", "INFO").upper(), + "module": log_entry.get("logger_name", ""), + "message": log_entry.get("event", ""), + } + logs.append(formatted_log) + log_counter += 1 + except (json.JSONDecodeError, KeyError): + continue + except Exception as e: + logger.error(f"读取日志文件失败 {log_file}: {e}") + continue + + # 反转列表,使其按时间顺序排列(旧到新) + return list(reversed(logs)) + + +@router.websocket("/ws/logs") +async def websocket_logs(websocket: WebSocket, token: Optional[str] = Query(None)): + """WebSocket 日志推送端点 + + 客户端连接后会持续接收服务器端的日志消息 + 支持三种认证方式(按优先级): + 1. query 参数 token(推荐,通过 /api/webui/ws-token 获取临时 token) + 2. Cookie 中的 maibot_session + + 示例:ws://host/ws/logs?token=xxx + """ + is_authenticated = False + + # 方式 1: 尝试验证临时 WebSocket token(推荐方式) + if token and verify_ws_token(token): + is_authenticated = True + logger.debug("WebSocket 使用临时 token 认证成功") + + # 方式 2: 尝试从 Cookie 获取 session token + if not is_authenticated: + cookie_token = websocket.cookies.get("maibot_session") + if cookie_token: + token_manager = get_token_manager() + if token_manager.verify_token(cookie_token): + is_authenticated = True + logger.debug("WebSocket 使用 Cookie 认证成功") + + if not is_authenticated: + logger.warning("WebSocket 连接被拒绝:认证失败") + await websocket.close(code=4001, reason="认证失败,请重新登录") + return + + await websocket.accept() + active_connections.add(websocket) + logger.info(f"📡 WebSocket 客户端已连接(已认证),当前连接数: {len(active_connections)}") + + # 连接建立后,立即发送历史日志 + try: + recent_logs = load_recent_logs(limit=100) + logger.info(f"发送 {len(recent_logs)} 条历史日志到客户端") + + for log_entry in recent_logs: + await websocket.send_text(json.dumps(log_entry, ensure_ascii=False)) + except Exception as e: + logger.error(f"发送历史日志失败: {e}") + + try: + # 保持连接,等待客户端消息或断开 + while True: + # 接收客户端消息(用于心跳或控制指令) + data = await websocket.receive_text() + + # 可以处理客户端的控制消息,例如: + # - "ping" -> 心跳检测 + # - {"filter": "ERROR"} -> 设置日志级别过滤 + if data == "ping": + await websocket.send_text("pong") + + except WebSocketDisconnect: + active_connections.discard(websocket) + logger.info(f"📡 WebSocket 客户端已断开,当前连接数: {len(active_connections)}") + except Exception as e: + logger.error(f"❌ WebSocket 错误: {e}") + active_connections.discard(websocket) + + +async def broadcast_log(log_data: Dict): + """广播日志到所有连接的 WebSocket 客户端 + + Args: + log_data: 日志数据字典 + """ + await websocket_manager.broadcast_to_topic( + domain="logs", + topic="main", + event="entry", + data={"entry": log_data}, + ) diff --git a/src/webui/middleware/__init__.py b/src/webui/middleware/__init__.py new file mode 100644 index 00000000..c271cc35 --- /dev/null +++ b/src/webui/middleware/__init__.py @@ -0,0 +1,17 @@ +from .anti_crawler import ( + ALLOWED_IPS, + ANTI_CRAWLER_MODE, + TRUST_XFF, + TRUSTED_PROXIES, + AntiCrawlerMiddleware, + create_robots_txt_response, +) + +__all__ = [ + "AntiCrawlerMiddleware", + "create_robots_txt_response", + "ANTI_CRAWLER_MODE", + "ALLOWED_IPS", + "TRUSTED_PROXIES", + "TRUST_XFF", +] diff --git a/src/webui/middleware/anti_crawler.py b/src/webui/middleware/anti_crawler.py new file mode 100644 index 00000000..690b79be --- /dev/null +++ b/src/webui/middleware/anti_crawler.py @@ -0,0 +1,824 @@ +""" +WebUI 防爬虫模块 +提供爬虫检测和阻止功能,保护 WebUI 不被搜索引擎和恶意爬虫访问 +""" + +import ipaddress +import re +import time +from collections import deque +from typing import Dict, List, Optional, Tuple + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import PlainTextResponse + +from src.common.logger import get_logger + +logger = get_logger("webui.anti_crawler") + +# 常见爬虫 User-Agent 列表(使用更精确的关键词,避免误报) +CRAWLER_USER_AGENTS = { + # 搜索引擎爬虫(精确匹配) + "googlebot", + "bingbot", + "baiduspider", + "yandexbot", + "slurp", # Yahoo + "duckduckbot", + "sogou", + "exabot", + "facebot", + "ia_archiver", # Internet Archive + # 通用爬虫(移除过于宽泛的关键词) + "crawler", + "spider", + "scraper", + "wget", # 保留wget,因为通常用于自动化脚本 + "scrapy", # 保留scrapy,因为这是爬虫框架 + # 安全扫描工具(这些是明确的扫描工具) + "masscan", + "nmap", + "nikto", + "sqlmap", + # 注意:移除了以下过于宽泛的关键词以避免误报: + # - "bot" (会误匹配GitHub-Robot等) + # - "curl" (正常工具) + # - "python-requests" (正常库) + # - "httpx" (正常库) + # - "aiohttp" (正常库) +} + +# 资产测绘工具 User-Agent 标识 +ASSET_SCANNER_USER_AGENTS = { + # 知名资产测绘平台 + "shodan", + "censys", + "zoomeye", + "fofa", + "quake", + "hunter", + "binaryedge", + "onyphe", + "securitytrails", + "virustotal", + "passivetotal", + # 安全扫描工具 + "acunetix", + "appscan", + "burpsuite", + "nessus", + "openvas", + "qualys", + "rapid7", + "tenable", + "veracode", + "zap", + "awvs", # Acunetix Web Vulnerability Scanner + "netsparker", + "skipfish", + "w3af", + "arachni", + # 其他扫描工具 + "masscan", + "zmap", + "nmap", + "whatweb", + "wpscan", + "joomscan", + "dnsenum", + "subfinder", + "amass", + "sublist3r", + "theharvester", +} + +# 资产测绘工具常用的HTTP头标识 +ASSET_SCANNER_HEADERS = { + # 常见的扫描工具自定义头 + "x-scan": {"shodan", "censys", "zoomeye", "fofa"}, + "x-scanner": {"nmap", "masscan", "zmap"}, + "x-probe": {"masscan", "zmap"}, + # 其他可疑头(移除反向代理标准头) + "x-originating-ip": set(), + "x-remote-ip": set(), + "x-remote-addr": set(), + # 注意:移除了以下反向代理标准头以避免误报: + # - "x-forwarded-proto" (反向代理标准头) + # - "x-real-ip" (反向代理标准头,已在_get_client_ip中使用) +} + +# 仅检查特定HTTP头中的可疑模式(收紧匹配范围) +# 只检查这些特定头,不检查所有头 +SCANNER_SPECIFIC_HEADERS = { + "x-scan", + "x-scanner", + "x-probe", + "x-originating-ip", + "x-remote-ip", + "x-remote-addr", +} + +# 防爬虫模式配置 +# false: 禁用 +# strict: 严格模式(更严格的检测,更低的频率限制) +# loose: 宽松模式(较宽松的检测,较高的频率限制) +# basic: 基础模式(只记录恶意访问,不阻止,不限制请求数,不跟踪IP) + + +# IP白名单配置(从配置文件读取,逗号分隔) +# 支持格式: +# - 精确IP:127.0.0.1, 192.168.1.100 +# - CIDR格式:192.168.1.0/24, 172.17.0.0/16 (适用于Docker网络) +# - 通配符:192.168.*.*, 10.*.*.*, *.*.*.* (匹配所有) +# - IPv6:::1, 2001:db8::/32 +def _parse_allowed_ips(ip_string: str) -> List: + """ + 解析IP白名单字符串,支持精确IP、CIDR格式和通配符 + + Args: + ip_string: 逗号分隔的IP字符串 + + Returns: + IP白名单列表,每个元素可能是: + - ipaddress.IPv4Network/IPv6Network对象(CIDR格式) + - ipaddress.IPv4Address/IPv6Address对象(精确IP) + - str(通配符模式,已转换为正则表达式) + """ + allowed = [] + if not ip_string: + return allowed + + for ip_entry in ip_string.split(","): + ip_entry = ip_entry.strip() # 去除空格 + if not ip_entry: + continue + + # 跳过注释行(以#开头) + if ip_entry.startswith("#"): + continue + + # 检查通配符格式(包含*) + if "*" in ip_entry: + # 处理通配符 + pattern = _convert_wildcard_to_regex(ip_entry) + if pattern: + allowed.append(pattern) + else: + logger.warning(f"无效的通配符IP格式,已忽略: {ip_entry}") + continue + + try: + # 尝试解析为CIDR格式(包含/) + if "/" in ip_entry: + allowed.append(ipaddress.ip_network(ip_entry, strict=False)) + else: + # 精确IP地址 + allowed.append(ipaddress.ip_address(ip_entry)) + except (ValueError, AttributeError) as e: + logger.warning(f"无效的IP白名单条目,已忽略: {ip_entry} ({e})") + + return allowed + + +def _convert_wildcard_to_regex(wildcard_pattern: str) -> Optional[str]: + """ + 将通配符IP模式转换为正则表达式 + + 支持的格式: + - 192.168.*.* 或 192.168.* + - 10.*.*.* 或 10.* + - *.*.*.* 或 * + + Args: + wildcard_pattern: 通配符模式字符串 + + Returns: + 正则表达式字符串,如果格式无效则返回None + """ + # 去除空格 + pattern = wildcard_pattern.strip() + + # 处理单个*(匹配所有) + if pattern == "*": + return r".*" + + # 处理IPv4通配符格式 + # 支持:192.168.*.*, 192.168.*, 10.*.*.*, 10.* 等 + parts = pattern.split(".") + + if len(parts) > 4: + return None # IPv4最多4段 + + # 构建正则表达式 + regex_parts = [] + for part in parts: + part = part.strip() + if part == "*": + regex_parts.append(r"\d+") # 匹配任意数字 + elif part.isdigit(): + # 验证数字范围(0-255) + num = int(part) + if 0 <= num <= 255: + regex_parts.append(re.escape(part)) + else: + return None # 无效的数字 + else: + return None # 无效的格式 + + # 如果部分少于4段,补充.* + while len(regex_parts) < 4: + regex_parts.append(r"\d+") + + # 组合成正则表达式 + regex = r"^" + r"\.".join(regex_parts) + r"$" + return regex + + +# 从配置读取防爬虫设置(延迟导入避免循环依赖) +def _get_anti_crawler_config(): + """获取防爬虫配置""" + from src.config.config import global_config + + return { + "mode": global_config.webui.anti_crawler_mode, + "allowed_ips": _parse_allowed_ips(global_config.webui.allowed_ips), + "trusted_proxies": _parse_allowed_ips(global_config.webui.trusted_proxies), + "trust_xff": global_config.webui.trust_xff, + } + + +# 初始化配置(将在模块加载时执行) +_config = _get_anti_crawler_config() +ANTI_CRAWLER_MODE = _config["mode"] +ALLOWED_IPS = _config["allowed_ips"] +TRUSTED_PROXIES = _config["trusted_proxies"] +TRUST_XFF = _config["trust_xff"] + + +def _get_mode_config(mode: str) -> Dict: + """ + 根据模式获取配置参数 + + Args: + mode: 防爬虫模式 (false/strict/loose/basic) + + Returns: + 配置字典,包含所有相关参数 + """ + mode = mode.lower() + + if mode == "false": + return { + "enabled": False, + "rate_limit_window": 60, + "rate_limit_max_requests": 1000, # 禁用时设置很高的值 + "max_tracked_ips": 0, + "check_user_agent": False, + "check_asset_scanner": False, + "check_rate_limit": False, + "block_on_detect": False, # 不阻止 + } + elif mode == "strict": + return { + "enabled": True, + "rate_limit_window": 60, + "rate_limit_max_requests": 15, # 严格模式:更低的请求数 + "max_tracked_ips": 20000, + "check_user_agent": True, + "check_asset_scanner": True, + "check_rate_limit": True, + "block_on_detect": True, # 阻止恶意访问 + } + elif mode == "loose": + return { + "enabled": True, + "rate_limit_window": 60, + "rate_limit_max_requests": 60, # 宽松模式:更高的请求数 + "max_tracked_ips": 5000, + "check_user_agent": True, + "check_asset_scanner": True, + "check_rate_limit": True, + "block_on_detect": True, # 阻止恶意访问 + } + else: # basic (默认模式) + return { + "enabled": True, + "rate_limit_window": 60, + "rate_limit_max_requests": 1000, # 不限制请求数 + "max_tracked_ips": 0, # 不跟踪IP + "check_user_agent": True, # 检测但不阻止 + "check_asset_scanner": True, # 检测但不阻止 + "check_rate_limit": False, # 不限制请求频率 + "block_on_detect": False, # 只记录,不阻止 + } + + +class AntiCrawlerMiddleware(BaseHTTPMiddleware): + """防爬虫中间件""" + + def __init__(self, app, mode: str = "standard"): + """ + 初始化防爬虫中间件 + + Args: + app: FastAPI 应用实例 + mode: 防爬虫模式 (false/strict/loose/standard) + """ + super().__init__(app) + self.mode = mode.lower() + # 根据模式获取配置 + config = _get_mode_config(self.mode) + self.enabled = config["enabled"] + self.rate_limit_window = config["rate_limit_window"] + self.rate_limit_max_requests = config["rate_limit_max_requests"] + self.max_tracked_ips = config["max_tracked_ips"] + self.check_user_agent = config["check_user_agent"] + self.check_asset_scanner = config["check_asset_scanner"] + self.check_rate_limit = config["check_rate_limit"] + self.block_on_detect = config["block_on_detect"] # 是否阻止检测到的恶意访问 + + # 用于存储每个IP的请求时间戳(使用deque提高性能) + self.request_times: Dict[str, deque] = {} + # 上次清理时间 + self.last_cleanup = time.time() + # 将关键词列表转换为集合以提高查找性能 + self.crawler_keywords_set = set(CRAWLER_USER_AGENTS) + self.scanner_keywords_set = set(ASSET_SCANNER_USER_AGENTS) + + def _is_crawler_user_agent(self, user_agent: Optional[str]) -> bool: + """ + 检测是否为爬虫 User-Agent + + Args: + user_agent: User-Agent 字符串 + + Returns: + 如果是爬虫则返回 True + """ + if not user_agent: + # 没有 User-Agent 的请求记录日志但不直接阻止 + # 改为只记录,让频率限制来处理 + logger.debug("请求缺少User-Agent") + return False # 不再直接阻止无User-Agent的请求 + + user_agent_lower = user_agent.lower() + + # 使用集合查找提高性能(检查是否包含爬虫关键词) + for crawler_keyword in self.crawler_keywords_set: + if crawler_keyword in user_agent_lower: + return True + + return False + + def _is_asset_scanner_header(self, request: Request) -> bool: + """ + 检测是否为资产测绘工具的HTTP头(只检查特定头,收紧匹配) + + Args: + request: 请求对象 + + Returns: + 如果检测到资产测绘工具头则返回 True + """ + # 只检查特定的扫描工具头,不检查所有头 + for header_name, header_value in request.headers.items(): + header_name_lower = header_name.lower() + header_value_lower = header_value.lower() if header_value else "" + + # 检查已知的扫描工具头 + if header_name_lower in ASSET_SCANNER_HEADERS: + # 如果该头有特定的工具集合,检查值是否匹配 + expected_tools = ASSET_SCANNER_HEADERS[header_name_lower] + if expected_tools: + for tool in expected_tools: + if tool in header_value_lower: + return True + else: + # 如果没有特定工具集合,只要存在该头就视为可疑 + if header_value_lower: + return True + + # 只检查特定头中的可疑模式(收紧匹配) + if header_name_lower in SCANNER_SPECIFIC_HEADERS: + # 检查头值中是否包含已知扫描工具名称 + for tool in self.scanner_keywords_set: + if tool in header_value_lower: + return True + + return False + + def _detect_asset_scanner(self, request: Request) -> Tuple[bool, Optional[str]]: + """ + 检测资产测绘工具 + + Args: + request: 请求对象 + + Returns: + (是否检测到, 检测到的工具名称) + """ + user_agent = request.headers.get("User-Agent") + + # 检查 User-Agent(使用集合查找提高性能) + if user_agent: + user_agent_lower = user_agent.lower() + for scanner_keyword in self.scanner_keywords_set: + if scanner_keyword in user_agent_lower: + return True, scanner_keyword + + # 检查HTTP头 + if self._is_asset_scanner_header(request): + # 尝试从User-Agent或头中提取工具名称 + detected_tool = None + if user_agent: + user_agent_lower = user_agent.lower() + for tool in self.scanner_keywords_set: + if tool in user_agent_lower: + detected_tool = tool + break + + # 检查HTTP头中的工具标识(只检查特定头) + if not detected_tool: + for header_name, header_value in request.headers.items(): + header_name_lower = header_name.lower() + if header_name_lower in SCANNER_SPECIFIC_HEADERS: + header_value_lower = (header_value or "").lower() + for tool in self.scanner_keywords_set: + if tool in header_value_lower: + detected_tool = tool + break + if detected_tool: + break + + return True, detected_tool or "unknown_scanner" + + return False, None + + def _check_rate_limit(self, client_ip: str) -> bool: + """ + 检查请求频率限制 + + Args: + client_ip: 客户端IP地址 + + Returns: + 如果超过限制则返回 True(需要阻止) + """ + # 检查IP白名单 + if self._is_ip_allowed(client_ip): + return False + + current_time = time.time() + + # 定期清理过期的请求记录(每5分钟清理一次) + if current_time - self.last_cleanup > 300: + self._cleanup_old_requests(current_time) + self.last_cleanup = current_time + + # 限制跟踪的IP数量,防止内存泄漏 + if self.max_tracked_ips > 0 and len(self.request_times) > self.max_tracked_ips: + # 清理最旧的记录(删除最久未访问的IP) + self._cleanup_oldest_ips() + + # 获取或创建该IP的请求时间deque(不使用maxlen,避免限流变松) + if client_ip not in self.request_times: + self.request_times[client_ip] = deque() + + request_times = self.request_times[client_ip] + + # 移除时间窗口外的请求记录(从左侧弹出过期记录) + while request_times and current_time - request_times[0] >= self.rate_limit_window: + request_times.popleft() + + # 检查是否超过限制 + if len(request_times) >= self.rate_limit_max_requests: + return True + + # 记录当前请求时间 + request_times.append(current_time) + return False + + def _cleanup_old_requests(self, current_time: float): + """清理过期的请求记录(只清理当前需要检查的IP,不全量遍历)""" + # 这个方法现在主要用于定期清理,实际清理在_check_rate_limit中按需进行 + # 清理最久未访问的IP记录 + if len(self.request_times) > self.max_tracked_ips * 0.8: + self._cleanup_oldest_ips() + + def _cleanup_oldest_ips(self): + """清理最久未访问的IP记录(全量遍历找真正的oldest)""" + if not self.request_times: + return + + # 先收集空deque的IP(优先删除) + empty_ips = [] + # 找到最久未访问的IP(最旧时间戳) + oldest_ip = None + oldest_time = float("inf") + + # 全量遍历找真正的oldest(超限时性能可接受) + for ip, times in self.request_times.items(): + if not times: + # 空deque,记录待删除 + empty_ips.append(ip) + else: + # 找到最旧的时间戳 + if times[0] < oldest_time: + oldest_time = times[0] + oldest_ip = ip + + # 先删除空deque的IP + for ip in empty_ips: + del self.request_times[ip] + + # 如果没有空deque可删除,且仍需要清理,删除最旧的一个IP + if not empty_ips and oldest_ip: + del self.request_times[oldest_ip] + + def _is_trusted_proxy(self, ip: str) -> bool: + """ + 检查IP是否在信任的代理列表中 + + Args: + ip: IP地址字符串 + + Returns: + 如果是信任的代理则返回 True + """ + if not TRUSTED_PROXIES or ip == "unknown": + return False + + # 检查代理列表中的每个条目 + for trusted_entry in TRUSTED_PROXIES: + # 通配符模式(字符串,正则表达式) + if isinstance(trusted_entry, str): + try: + if re.match(trusted_entry, ip): + return True + except re.error: + continue + # CIDR格式(网络对象) + elif isinstance(trusted_entry, (ipaddress.IPv4Network, ipaddress.IPv6Network)): + try: + client_ip_obj = ipaddress.ip_address(ip) + if client_ip_obj in trusted_entry: + return True + except (ValueError, AttributeError): + continue + # 精确IP(地址对象) + elif isinstance(trusted_entry, (ipaddress.IPv4Address, ipaddress.IPv6Address)): + try: + client_ip_obj = ipaddress.ip_address(ip) + if client_ip_obj == trusted_entry: + return True + except (ValueError, AttributeError): + continue + + return False + + def _get_client_ip(self, request: Request) -> str: + """ + 获取客户端真实IP地址(带基本验证和代理信任检查) + + Args: + request: 请求对象 + + Returns: + 客户端IP地址 + """ + # 获取直接连接的客户端IP(用于验证代理) + direct_client_ip = None + if request.client: + direct_client_ip = request.client.host + + # 检查是否信任X-Forwarded-For头 + # TRUST_XFF 只表示"启用代理解析能力",但仍要求直连 IP 在 TRUSTED_PROXIES 中 + use_xff = False + if TRUST_XFF and TRUSTED_PROXIES and direct_client_ip: + # 只有在启用 TRUST_XFF 且直连 IP 在信任列表中时,才信任 XFF + use_xff = self._is_trusted_proxy(direct_client_ip) + + # 如果信任代理,优先从 X-Forwarded-For 获取 + if use_xff: + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + # X-Forwarded-For 可能包含多个IP,取第一个 + ip = forwarded_for.split(",")[0].strip() + # 基本验证IP格式 + if self._validate_ip(ip): + return ip + + # 从 X-Real-IP 获取(如果信任代理) + if use_xff: + real_ip = request.headers.get("X-Real-IP") + if real_ip: + ip = real_ip.strip() + if self._validate_ip(ip): + return ip + + # 使用直接连接的客户端IP + if direct_client_ip and self._validate_ip(direct_client_ip): + return direct_client_ip + + return "unknown" + + def _validate_ip(self, ip: str) -> bool: + """ + 验证IP地址格式 + + Args: + ip: IP地址字符串 + + Returns: + 如果格式有效则返回 True + """ + try: + ipaddress.ip_address(ip) + return True + except (ValueError, AttributeError): + return False + + def _is_ip_allowed(self, ip: str) -> bool: + """ + 检查IP是否在白名单中(支持精确IP、CIDR格式和通配符) + + Args: + ip: 客户端IP地址 + + Returns: + 如果IP在白名单中则返回 True + """ + if not ALLOWED_IPS or ip == "unknown": + return False + + # 检查白名单中的每个条目 + for allowed_entry in ALLOWED_IPS: + # 通配符模式(字符串,正则表达式) + if isinstance(allowed_entry, str): + try: + if re.match(allowed_entry, ip): + return True + except re.error: + # 正则表达式错误,跳过 + continue + # CIDR格式(网络对象) + elif isinstance(allowed_entry, (ipaddress.IPv4Network, ipaddress.IPv6Network)): + try: + client_ip_obj = ipaddress.ip_address(ip) + if client_ip_obj in allowed_entry: + return True + except (ValueError, AttributeError): + # IP格式无效,跳过 + continue + # 精确IP(地址对象) + elif isinstance(allowed_entry, (ipaddress.IPv4Address, ipaddress.IPv6Address)): + try: + client_ip_obj = ipaddress.ip_address(ip) + if client_ip_obj == allowed_entry: + return True + except (ValueError, AttributeError): + # IP格式无效,跳过 + continue + + return False + + def _has_valid_auth(self, request: Request) -> bool: + """ + 检查请求是否携带有效的认证 Cookie + + 已认证用户跳过防爬虫检查,避免正常登录用户被频率限制误拦截。 + + Args: + request: 请求对象 + + Returns: + 如果认证 Cookie 有效则返回 True + """ + # 延迟导入避免循环依赖(anti_crawler → auth → security → config) + from src.webui.core.auth import COOKIE_NAME, is_token_valid + + cookie_value = request.cookies.get(COOKIE_NAME) + if not cookie_value: + return False + + return is_token_valid(cookie_value) + + async def dispatch(self, request: Request, call_next): + """ + 处理请求 + + Args: + request: 请求对象 + call_next: 下一个中间件或路由处理函数 + + Returns: + 响应对象 + """ + # 如果未启用,直接通过 + if not self.enabled: + return await call_next(request) + + # 允许访问 robots.txt(由专门的路由处理) + if request.url.path == "/robots.txt": + return await call_next(request) + + # 允许访问静态资源(CSS、JS、图片等) + # 注意:.json 已移除,避免 API 路径绕过防护 + # 静态资源只在特定前缀下放行(/static/、/assets/、/dist/) + static_extensions = { + ".css", + ".js", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg", + ".ico", + ".woff", + ".woff2", + ".ttf", + ".eot", + } + static_prefixes = {"/static/", "/assets/", "/dist/"} + + # 检查是否是静态资源路径(特定前缀下的静态文件) + path = request.url.path + is_static_path = any(path.startswith(prefix) for prefix in static_prefixes) and any( + path.endswith(ext) for ext in static_extensions + ) + + # 也允许根路径下的静态文件(如 /favicon.ico) + is_root_static = path.count("/") == 1 and any(path.endswith(ext) for ext in static_extensions) + + if is_static_path or is_root_static: + return await call_next(request) + + # 获取客户端IP(只获取一次,避免重复调用) + client_ip = self._get_client_ip(request) + + # 检查IP白名单(优先检查,白名单IP直接通过) + if self._is_ip_allowed(client_ip): + return await call_next(request) + + # 检查是否为已认证用户(有有效的 maibot_session Cookie) + if self._has_valid_auth(request): + return await call_next(request) + + # 获取 User-Agent + user_agent = request.headers.get("User-Agent") + + # 检测资产测绘工具(优先检测,因为更危险) + if self.check_asset_scanner: + is_scanner, scanner_name = self._detect_asset_scanner(request) + if is_scanner: + logger.warning( + f"🚫 检测到资产测绘工具请求 - IP: {client_ip}, 工具: {scanner_name}, " + f"User-Agent: {user_agent}, Path: {request.url.path}" + ) + # 根据配置决定是否阻止 + if self.block_on_detect: + return PlainTextResponse( + "Access Denied: Asset scanning tools are not allowed", + status_code=403, + ) + + # 检测爬虫 User-Agent + if self.check_user_agent and self._is_crawler_user_agent(user_agent): + logger.warning(f"🚫 检测到爬虫请求 - IP: {client_ip}, User-Agent: {user_agent}, Path: {request.url.path}") + # 根据配置决定是否阻止 + if self.block_on_detect: + return PlainTextResponse( + "Access Denied: Crawlers are not allowed", + status_code=403, + ) + + # 检查请求频率限制 + if self.check_rate_limit and self._check_rate_limit(client_ip): + logger.warning(f"🚫 请求频率过高 - IP: {client_ip}, User-Agent: {user_agent}, Path: {request.url.path}") + return PlainTextResponse( + "Too Many Requests: Rate limit exceeded", + status_code=429, + ) + + # 正常请求,继续处理 + return await call_next(request) + + +def create_robots_txt_response() -> PlainTextResponse: + """ + 创建 robots.txt 响应 + + Returns: + robots.txt 响应对象 + """ + robots_content = """User-agent: * +Disallow: / + +# 禁止所有爬虫访问 +""" + return PlainTextResponse( + content=robots_content, + media_type="text/plain", + headers={"Cache-Control": "public, max-age=86400"}, # 缓存24小时 + ) diff --git a/src/webui/routers/__init__.py b/src/webui/routers/__init__.py new file mode 100644 index 00000000..67b4e326 --- /dev/null +++ b/src/webui/routers/__init__.py @@ -0,0 +1,33 @@ +"""WebUI 路由聚合模块 - 提供统一的路由注册接口""" + +from typing import List + +from fastapi import APIRouter + + +def get_api_router() -> APIRouter: + """获取主 API 路由器(包含所有子路由)""" + from src.webui.routes import router as main_router + + return main_router + + +def get_all_routers() -> List[APIRouter]: + """获取所有需要独立注册的路由器列表""" + from src.webui.routers.chat import router as chat_router + from src.webui.routers.knowledge import router as knowledge_router + from src.webui.routers.memory import compat_router as memory_compat_router + from src.webui.routes import router as main_router + + return [ + main_router, + memory_compat_router, + knowledge_router, + chat_router, + ] + + +__all__ = [ + "get_api_router", + "get_all_routers", +] diff --git a/src/webui/routers/chat/__init__.py b/src/webui/routers/chat/__init__.py new file mode 100644 index 00000000..084c0459 --- /dev/null +++ b/src/webui/routers/chat/__init__.py @@ -0,0 +1,18 @@ +from typing import Tuple + +from .routes import router +from .service import WEBUI_CHAT_PLATFORM, ChatConnectionManager, chat_manager + + +def get_webui_chat_broadcaster() -> Tuple[ChatConnectionManager, str]: + """获取 WebUI 聊天广播器,供外部模块使用。""" + return chat_manager, WEBUI_CHAT_PLATFORM + + +__all__ = [ + "ChatConnectionManager", + "WEBUI_CHAT_PLATFORM", + "chat_manager", + "get_webui_chat_broadcaster", + "router", +] diff --git a/src/webui/routers/chat/routes.py b/src/webui/routers/chat/routes.py new file mode 100644 index 00000000..3c03227a --- /dev/null +++ b/src/webui/routers/chat/routes.py @@ -0,0 +1,130 @@ +"""本地聊天室路由 - WebUI 与麦麦直接对话。""" + +from typing import Dict, Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import case, func +from sqlmodel import col, select + +from src.common.database.database import get_db_session +from src.common.database.database_model import PersonInfo +from src.common.logger import get_logger +from src.config.config import global_config +from src.webui.dependencies import require_auth + +from .service import ( + WEBUI_CHAT_PLATFORM, + chat_history, + chat_manager, + normalize_webui_user_id, +) + +logger = get_logger("webui.chat") + +router = APIRouter(prefix="/api/chat", tags=["LocalChat"], dependencies=[Depends(require_auth)]) + + +@router.get("/history") +async def get_chat_history( + limit: int = Query(default=50, ge=1, le=200), + user_id: Optional[str] = Query(default=None), + group_id: Optional[str] = Query(default=None), +) -> Dict[str, object]: + """获取聊天历史记录。 + + 优先按 ``group_id`` 加载虚拟群聊历史;未提供时使用规范化后的 ``user_id`` 加载 WebUI 私聊历史。 + """ + if group_id: + history = chat_history.get_history(limit, group_id=group_id) + else: + normalized_user_id = normalize_webui_user_id(user_id) + history = chat_history.get_history(limit, user_id=normalized_user_id) + return {"success": True, "messages": history, "total": len(history)} + + +@router.get("/platforms") +async def get_available_platforms() -> Dict[str, object]: + """获取可用平台列表。""" + try: + with get_db_session() as session: + statement = ( + select(PersonInfo.platform, func.count().label("count")) + .group_by(PersonInfo.platform) + .order_by(func.count().desc()) + ) + platforms = session.exec(statement).all() + + result = [{"platform": platform, "count": count} for platform, count in platforms if platform] + return {"success": True, "platforms": result} + except Exception as e: + logger.error(f"获取平台列表失败: {e}") + return {"success": False, "error": str(e), "platforms": []} + + +@router.get("/persons") +async def get_persons_by_platform( + platform: str = Query(..., description="平台名称"), + search: Optional[str] = Query(default=None, description="搜索关键词"), + limit: int = Query(default=50, ge=1, le=200), +) -> Dict[str, object]: + """获取指定平台的用户列表。""" + try: + statement = select(PersonInfo).where(col(PersonInfo.platform) == platform) + if search: + statement = statement.where( + (col(PersonInfo.person_name).contains(search)) + | (col(PersonInfo.user_nickname).contains(search)) + | (col(PersonInfo.user_id).contains(search)) + ) + + statement = statement.order_by( + case((col(PersonInfo.last_known_time).is_(None), 1), else_=0), + col(PersonInfo.last_known_time).desc(), + ).limit(limit) + + with get_db_session() as session: + persons = session.exec(statement).all() + + result = [ + { + "person_id": person.person_id, + "user_id": person.user_id, + "person_name": person.person_name, + "nickname": person.user_nickname, + "is_known": person.is_known, + "platform": person.platform, + "display_name": person.person_name or person.user_nickname or person.user_id, + } + for person in persons + ] + return {"success": True, "persons": result, "total": len(result)} + except Exception as e: + logger.error(f"获取用户列表失败: {e}") + return {"success": False, "error": str(e), "persons": []} + + +@router.delete("/history") +async def clear_chat_history( + user_id: Optional[str] = Query(default=None), + group_id: Optional[str] = Query(default=None), +) -> Dict[str, object]: + """清空聊天历史记录。 + + 优先按 ``group_id`` 清理虚拟群聊历史;未提供时使用规范化后的 ``user_id`` 清理 WebUI 私聊历史。 + """ + if group_id: + deleted = chat_history.clear_history(group_id=group_id) + else: + normalized_user_id = normalize_webui_user_id(user_id) + deleted = chat_history.clear_history(user_id=normalized_user_id) + return {"success": True, "message": f"已清空 {deleted} 条聊天记录"} + + +@router.get("/info") +async def get_chat_info() -> Dict[str, object]: + """获取聊天室信息。""" + return { + "bot_name": global_config.bot.nickname, + "platform": WEBUI_CHAT_PLATFORM, + "active_sessions": len(chat_manager.active_connections), + } diff --git a/src/webui/routers/chat/serializers.py b/src/webui/routers/chat/serializers.py new file mode 100644 index 00000000..32104f88 --- /dev/null +++ b/src/webui/routers/chat/serializers.py @@ -0,0 +1,175 @@ +"""提供 WebUI 聊天路由使用的消息序列化能力。""" + +from typing import Any, Dict, List, Optional + +import base64 + +from src.common.data_models.message_component_data_model import ( + AtComponent, + DictComponent, + EmojiComponent, + ForwardComponent, + ForwardNodeComponent, + ImageComponent, + MessageSequence, + ReplyComponent, + StandardMessageComponents, + TextComponent, + VoiceComponent, +) + + +def serialize_message_sequence(message_sequence: MessageSequence) -> List[Dict[str, Any]]: + """将内部统一消息组件序列转换为 WebUI 富文本消息段。 + + Args: + message_sequence: 内部统一消息组件序列。 + + Returns: + List[Dict[str, Any]]: 可直接广播给 WebUI 前端的消息段列表。 + """ + serialized_segments: List[Dict[str, Any]] = [] + for component in message_sequence.components: + serialized_segment = serialize_message_component(component) + if serialized_segment is not None: + serialized_segments.append(serialized_segment) + return serialized_segments + + +def serialize_message_component(component: StandardMessageComponents) -> Optional[Dict[str, Any]]: + """将单个内部消息组件转换为 WebUI 消息段。 + + Args: + component: 待序列化的内部消息组件。 + + Returns: + Optional[Dict[str, Any]]: 序列化后的 WebUI 消息段;若组件不应展示则返回 ``None``。 + """ + if isinstance(component, TextComponent): + return {"type": "text", "data": component.text} + + if isinstance(component, ImageComponent): + return _serialize_binary_component( + segment_type="image", + mime_type="image/png", + binary_data=component.binary_data, + fallback_text=component.content, + ) + + if isinstance(component, EmojiComponent): + return _serialize_binary_component( + segment_type="emoji", + mime_type="image/gif", + binary_data=component.binary_data, + fallback_text=component.content, + ) + + if isinstance(component, VoiceComponent): + return _serialize_binary_component( + segment_type="voice", + mime_type="audio/wav", + binary_data=component.binary_data, + fallback_text=component.content, + ) + + if isinstance(component, AtComponent): + return { + "type": "at", + "data": { + "target_user_id": component.target_user_id, + "target_user_nickname": component.target_user_nickname, + "target_user_cardname": component.target_user_cardname, + }, + } + + if isinstance(component, ReplyComponent): + return { + "type": "reply", + "data": { + "target_message_id": component.target_message_id, + "target_message_content": component.target_message_content, + "target_message_sender_id": component.target_message_sender_id, + "target_message_sender_nickname": component.target_message_sender_nickname, + "target_message_sender_cardname": component.target_message_sender_cardname, + }, + } + + if isinstance(component, ForwardNodeComponent): + return { + "type": "forward", + "data": [_serialize_forward_component(item) for item in component.forward_components], + } + + if isinstance(component, DictComponent): + return _serialize_dict_component(component.data) + + return {"type": "unknown", "data": str(component)} + + +def _serialize_binary_component( + segment_type: str, + mime_type: str, + binary_data: bytes, + fallback_text: str, +) -> Dict[str, Any]: + """序列化带二进制负载的消息组件。 + + Args: + segment_type: WebUI 消息段类型。 + mime_type: 对应的数据 MIME 类型。 + binary_data: 组件二进制数据。 + fallback_text: 二进制缺失时可退化展示的文本。 + + Returns: + Dict[str, Any]: 序列化后的 WebUI 消息段。 + """ + if binary_data: + encoded_payload = base64.b64encode(binary_data).decode() + return {"type": segment_type, "data": f"data:{mime_type};base64,{encoded_payload}"} + + if fallback_text: + return {"type": "text", "data": fallback_text} + + return {"type": "unknown", "original_type": segment_type, "data": ""} + + +def _serialize_forward_component(component: ForwardComponent) -> Dict[str, Any]: + """序列化单个转发节点。 + + Args: + component: 待序列化的转发节点组件。 + + Returns: + Dict[str, Any]: WebUI 可消费的转发节点字典。 + """ + return { + "message_id": component.message_id, + "user_id": component.user_id, + "user_nickname": component.user_nickname, + "user_cardname": component.user_cardname, + "content": serialize_message_sequence(MessageSequence(component.content)), + } + + +def _serialize_dict_component(data: Dict[str, Any]) -> Dict[str, Any]: + """最佳努力地序列化非标准字典组件。 + + Args: + data: 原始字典组件内容。 + + Returns: + Dict[str, Any]: 序列化后的 WebUI 消息段。 + """ + raw_type = str(data.get("type") or "dict").strip() + raw_payload = data.get("data", data) + + if raw_type in {"text", "image", "emoji", "voice", "video", "file", "music", "face"}: + return {"type": raw_type, "data": raw_payload} + + if raw_type == "reply": + return {"type": "reply", "data": raw_payload} + + if raw_type == "forward" and isinstance(raw_payload, list): + return {"type": "forward", "data": raw_payload} + + return {"type": "unknown", "original_type": raw_type, "data": raw_payload} diff --git a/src/webui/routers/chat/service.py b/src/webui/routers/chat/service.py new file mode 100644 index 00000000..1ccaf9f8 --- /dev/null +++ b/src/webui/routers/chat/service.py @@ -0,0 +1,1226 @@ +"""WebUI 聊天运行时服务。""" + +from dataclasses import dataclass +import time +import uuid +from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple, cast + +from pydantic import BaseModel +from sqlmodel import col, delete, select + +from src.chat.message_receive.bot import chat_bot +from src.chat.message_receive.message import SessionMessage +from src.chat.utils.utils import is_bot_self +from src.common.database.database import get_db_session +from src.common.database.database_model import Messages, PersonInfo +from src.common.logger import get_logger +from src.common.message_repository import find_messages +from src.common.utils.utils_session import SessionUtils +from src.config.config import global_config + +from .serializers import serialize_message_sequence + +logger = get_logger("webui.chat") + +WEBUI_CHAT_GROUP_ID = "webui_local_chat" +WEBUI_CHAT_PLATFORM = "webui" +VIRTUAL_GROUP_ID_PREFIX = "webui_virtual_group_" +WEBUI_USER_ID_PREFIX = "webui_user_" + +AsyncMessageSender = Callable[[Dict[str, Any]], Awaitable[None]] + + +class VirtualIdentityConfig(BaseModel): + """虚拟身份配置。""" + + enabled: bool = False + platform: Optional[str] = None + person_id: Optional[str] = None + user_id: Optional[str] = None + user_nickname: Optional[str] = None + group_id: Optional[str] = None + group_name: Optional[str] = None + + +class ChatHistoryMessage(BaseModel): + """聊天历史消息。""" + + id: str + type: str + content: str + timestamp: float + sender_name: str + sender_id: Optional[str] = None + is_bot: bool = False + + +@dataclass +class ChatSessionConnection: + """逻辑聊天会话连接信息。""" + + session_id: str + connection_id: str + client_session_id: str + user_id: str + user_name: str + channel_key: str + virtual_config: Optional[VirtualIdentityConfig] + sender: AsyncMessageSender + + +class ChatHistoryManager: + """聊天历史管理器。""" + + def __init__(self, max_messages: int = 200) -> None: + """初始化聊天历史管理器。 + + Args: + max_messages: 内存中允许处理的最大消息数。 + """ + self.max_messages = max_messages + + def _message_to_dict(self, msg: SessionMessage, group_id: Optional[str] = None) -> Dict[str, Any]: + """将内部消息对象转换为前端可消费的字典。 + + Args: + msg: 内部统一消息对象。 + group_id: 当前会话所属的群组标识。 + + Returns: + Dict[str, Any]: 面向 WebUI 的消息字典。 + """ + del group_id + user_info = msg.message_info.user_info + user_id = user_info.user_id or "" + is_bot = is_bot_self(msg.platform, user_id) + + # 将存库中的 raw_message 序列化为前端可识别的富文本消息段, + # 避免“刚刚收到的机器人回复是富文本,刷新后变成纯文本”的体验不一致。 + segments: List[Dict[str, Any]] = [] + try: + raw_message = getattr(msg, "raw_message", None) + if raw_message is not None and getattr(raw_message, "components", None): + segments = serialize_message_sequence(raw_message) + except Exception as exc: # 仅记录警告,退化为纯文本 + logger.debug(f"序列化历史消息段失败,退化为纯文本: {exc}") + segments = [] + + is_rich = bool(segments) and not ( + len(segments) == 1 and segments[0].get("type") == "text" + ) + + return { + "id": msg.message_id, + "type": "bot" if is_bot else "user", + "content": msg.processed_plain_text or "", + "timestamp": msg.timestamp.timestamp(), + "sender_name": user_info.user_nickname or (global_config.bot.nickname if is_bot else "未知用户"), + "sender_id": "bot" if is_bot else user_id, + "is_bot": is_bot, + "message_type": "rich" if is_rich else "text", + "segments": segments if is_rich else None, + } + + def _enrich_reply_segments( + self, + segments: List[Dict[str, Any]], + message_index: Dict[str, SessionMessage], + session_id: Optional[str], + ) -> None: + """回填历史消息中 reply 段缺失的发送者/原内容字段。 + + DB 中持久化的 ReplyComponent 通常只保留了 ``target_message_id``, + ``target_message_content`` / ``target_message_sender_*`` 字段为空。 + 这里基于当前会话已加载的消息列表(必要时回查数据库)进行补全。 + + Args: + segments: 单条历史消息的消息段列表,原地修改。 + message_index: 当前会话已加载消息的 ``message_id -> SessionMessage`` 索引。 + session_id: 当前会话 ID,用于按 ID 单查时缩小范围。 + """ + for segment in segments: + if not isinstance(segment, dict) or segment.get("type") != "reply": + continue + data = segment.get("data") + if not isinstance(data, dict): + continue + target_message_id = data.get("target_message_id") + if not target_message_id: + continue + + has_content = bool(str(data.get("target_message_content") or "").strip()) + has_sender = any( + str(data.get(key) or "").strip() + for key in ( + "target_message_sender_id", + "target_message_sender_nickname", + "target_message_sender_cardname", + ) + ) + if has_content and has_sender: + continue + + target_msg = message_index.get(str(target_message_id)) + if target_msg is None: + # 退化为按 ID 单查(仅当不在当前窗口内时才付出 DB 代价) + try: + from src.services.message_service import get_message_by_id + + target_msg = get_message_by_id(str(target_message_id), session_id or None) + except Exception as exc: + logger.debug(f"按 ID 回查 reply 目标消息失败: {exc}") + target_msg = None + if target_msg is None: + continue + + user_info = target_msg.message_info.user_info + if not has_content: + content_text = target_msg.processed_plain_text or "" + data["target_message_content"] = content_text + if not has_sender: + data["target_message_sender_id"] = user_info.user_id or "" + data["target_message_sender_nickname"] = user_info.user_nickname or "" + data["target_message_sender_cardname"] = ( + getattr(user_info, "user_cardname", "") or "" + ) + + def _resolve_session_id( + self, + group_id: Optional[str] = None, + user_id: Optional[str] = None, + ) -> Optional[str]: + """根据会话标识解析内部聊天会话 ID。 + + 优先按虚拟群聊解析;否则按 WebUI 私聊解析。 + + Args: + group_id: 群组标识(虚拟群聊模式)。 + user_id: 用户标识(私聊模式)。 + + Returns: + Optional[str]: 内部聊天会话 ID;当 group_id 与 user_id 均未提供时返回 ``None``。 + """ + if group_id: + return SessionUtils.calculate_session_id(WEBUI_CHAT_PLATFORM, group_id=group_id) + if user_id: + return SessionUtils.calculate_session_id(WEBUI_CHAT_PLATFORM, user_id=user_id) + return None + + def get_history( + self, + limit: int = 50, + group_id: Optional[str] = None, + user_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """获取指定会话的历史消息。 + + Args: + limit: 最大返回条数。 + group_id: 群组标识(虚拟群聊模式)。 + user_id: 用户标识(私聊模式)。 + + Returns: + List[Dict[str, Any]]: 历史消息列表。 + """ + session_id = self._resolve_session_id(group_id=group_id, user_id=user_id) + if session_id is None: + logger.debug("获取聊天历史时缺少 group_id 与 user_id,返回空列表") + return [] + try: + messages = find_messages( + session_id=session_id, + limit=limit, + limit_mode="latest", + filter_command=False, + ) + # 构建 message_id -> SessionMessage 索引,用于回填历史中 reply 段的发送者/内容 + # (DB 中通常只存了 target_message_id,target_message_content/sender_* 缺失)。 + message_index: Dict[str, SessionMessage] = {} + for m in messages: + mid = getattr(m, "message_id", None) + if mid: + message_index[str(mid)] = m + + result: List[Dict[str, Any]] = [] + for msg in messages: + item = self._message_to_dict(msg, group_id) + segments = item.get("segments") + if segments: + self._enrich_reply_segments(segments, message_index, session_id) + result.append(item) + logger.debug( + f"从数据库加载了 {len(result)} 条聊天记录 (group_id={group_id}, user_id={user_id})" + ) + return result + except Exception as exc: + logger.error(f"从数据库加载聊天记录失败: {exc}") + return [] + + def clear_history( + self, + group_id: Optional[str] = None, + user_id: Optional[str] = None, + ) -> int: + """清空指定会话的历史消息。 + + Args: + group_id: 群组标识(虚拟群聊模式)。 + user_id: 用户标识(私聊模式)。 + + Returns: + int: 被删除的消息数量。 + """ + session_id = self._resolve_session_id(group_id=group_id, user_id=user_id) + if session_id is None: + return 0 + try: + with get_db_session() as session: + statement = delete(Messages).where(col(Messages.session_id) == session_id) + result = session.exec(statement) + deleted = result.rowcount or 0 + logger.info( + f"已清空 {deleted} 条聊天记录 (group_id={group_id}, user_id={user_id})" + ) + return deleted + except Exception as exc: + logger.error(f"清空聊天记录失败: {exc}") + return 0 + + +class ChatConnectionManager: + """统一聊天逻辑会话管理器。""" + + def __init__(self) -> None: + """初始化聊天逻辑会话管理器。""" + self.active_connections: Dict[str, ChatSessionConnection] = {} + self.client_sessions: Dict[Tuple[str, str], str] = {} + self.connection_sessions: Dict[str, Set[str]] = {} + self.group_sessions: Dict[str, Set[str]] = {} + self.user_sessions: Dict[str, Set[str]] = {} + + def _bind_channel(self, session_id: str, channel_key: str) -> None: + """为会话绑定逻辑频道索引。 + + Args: + session_id: 内部会话 ID。 + channel_key: 频道键(``group:`` 或 ``private:``)。 + """ + channel_session_ids = self.group_sessions.setdefault(channel_key, set()) + channel_session_ids.add(session_id) + + def _unbind_channel(self, session_id: str, channel_key: str) -> None: + """移除会话与逻辑频道的索引关系。 + + Args: + session_id: 内部会话 ID。 + channel_key: 频道键。 + """ + channel_session_ids = self.group_sessions.get(channel_key) + if channel_session_ids is None: + return + + channel_session_ids.discard(session_id) + if not channel_session_ids: + del self.group_sessions[channel_key] + + async def connect( + self, + session_id: str, + connection_id: str, + client_session_id: str, + user_id: str, + user_name: str, + virtual_config: Optional[VirtualIdentityConfig], + sender: AsyncMessageSender, + ) -> None: + """注册一个新的逻辑聊天会话。 + + Args: + session_id: 内部逻辑会话 ID。 + connection_id: 物理 WebSocket 连接 ID。 + client_session_id: 前端标签页使用的会话 ID。 + user_id: 规范化后的用户 ID。 + user_name: 当前展示昵称。 + virtual_config: 当前虚拟身份配置。 + sender: 发送消息到前端的异步回调。 + """ + channel_key = compute_channel_key(virtual_config, user_id) + existing_session_id = self.client_sessions.get((connection_id, client_session_id)) + if existing_session_id is not None and existing_session_id == session_id: + # 同一物理连接 + 前端会话重复打开(常见于 React StrictMode 双挂载或客户端去抖失败), + # 直接复用现有会话并仅刷新可变字段,避免反复断开/重连产生噪声日志。 + existing = self.active_connections.get(existing_session_id) + if existing is not None: + if existing.channel_key != channel_key: + self._unbind_channel(existing_session_id, existing.channel_key) + self._bind_channel(existing_session_id, channel_key) + existing.channel_key = channel_key + existing.user_id = user_id + existing.user_name = user_name + existing.virtual_config = virtual_config + existing.sender = sender + logger.debug( + f"WebUI 聊天会话复用: session={session_id}, connection={connection_id}, " + f"client_session={client_session_id}, channel={channel_key}", + ) + return + if existing_session_id is not None: + self.disconnect(existing_session_id) + + session_connection = ChatSessionConnection( + session_id=session_id, + connection_id=connection_id, + client_session_id=client_session_id, + user_id=user_id, + user_name=user_name, + channel_key=channel_key, + virtual_config=virtual_config, + sender=sender, + ) + + self.active_connections[session_id] = session_connection + self.client_sessions[(connection_id, client_session_id)] = session_id + self.connection_sessions.setdefault(connection_id, set()).add(session_id) + self.user_sessions.setdefault(user_id, set()).add(session_id) + self._bind_channel(session_id, channel_key) + logger.info( + f"WebUI 聊天会话已连接: session={session_id}, connection={connection_id}, " + f"client_session={client_session_id}, user={user_id}, channel={channel_key}", + ) + + def disconnect(self, session_id: str) -> None: + """断开一个逻辑聊天会话。 + + Args: + session_id: 内部逻辑会话 ID。 + """ + session_connection = self.active_connections.pop(session_id, None) + if session_connection is None: + return + + self.client_sessions.pop((session_connection.connection_id, session_connection.client_session_id), None) + self._unbind_channel(session_id, session_connection.channel_key) + + connection_session_ids = self.connection_sessions.get(session_connection.connection_id) + if connection_session_ids is not None: + connection_session_ids.discard(session_id) + if not connection_session_ids: + del self.connection_sessions[session_connection.connection_id] + + user_session_ids = self.user_sessions.get(session_connection.user_id) + if user_session_ids is not None: + user_session_ids.discard(session_id) + if not user_session_ids: + del self.user_sessions[session_connection.user_id] + + logger.info(f"WebUI 聊天会话已断开: session={session_id}") + + def disconnect_connection(self, connection_id: str) -> None: + """断开物理连接下的全部逻辑聊天会话。 + + Args: + connection_id: 物理 WebSocket 连接 ID。 + """ + session_ids = list(self.connection_sessions.get(connection_id, set())) + for session_id in session_ids: + self.disconnect(session_id) + + def get_session(self, session_id: str) -> Optional[ChatSessionConnection]: + """获取逻辑聊天会话信息。 + + Args: + session_id: 内部逻辑会话 ID。 + + Returns: + Optional[ChatSessionConnection]: 会话存在时返回对应信息。 + """ + return self.active_connections.get(session_id) + + def get_session_id(self, connection_id: str, client_session_id: str) -> Optional[str]: + """根据连接 ID 和前端会话 ID 查询内部会话 ID。 + + Args: + connection_id: 物理 WebSocket 连接 ID。 + client_session_id: 前端标签页使用的会话 ID。 + + Returns: + Optional[str]: 找到时返回内部会话 ID。 + """ + return self.client_sessions.get((connection_id, client_session_id)) + + def update_session_context( + self, + session_id: str, + user_name: str, + virtual_config: Optional[VirtualIdentityConfig], + ) -> None: + """更新会话上下文信息。 + + Args: + session_id: 内部逻辑会话 ID。 + user_name: 最新昵称。 + virtual_config: 最新虚拟身份配置。 + """ + session_connection = self.active_connections.get(session_id) + if session_connection is None: + return + + next_channel_key = compute_channel_key(virtual_config, session_connection.user_id) + if next_channel_key != session_connection.channel_key: + self._unbind_channel(session_id, session_connection.channel_key) + self._bind_channel(session_id, next_channel_key) + session_connection.channel_key = next_channel_key + + session_connection.user_name = user_name + session_connection.virtual_config = virtual_config + + async def send_message(self, session_id: str, message: Dict[str, Any]) -> None: + """向指定逻辑会话发送消息。 + + Args: + session_id: 内部逻辑会话 ID。 + message: 待发送的消息内容。 + """ + session_connection = self.active_connections.get(session_id) + if session_connection is None: + return + + try: + await session_connection.sender(message) + except Exception as exc: + logger.error(f"发送聊天消息失败: session={session_id}, error={exc}") + + async def broadcast(self, message: Dict[str, Any]) -> None: + """向全部逻辑聊天会话广播消息。 + + Args: + message: 待广播的消息内容。 + """ + for session_id in list(self.active_connections.keys()): + await self.send_message(session_id, message) + + async def broadcast_to_channel(self, channel_key: str, message: Dict[str, Any]) -> None: + """向指定逻辑频道下的全部会话广播消息。 + + Args: + channel_key: 频道键(``group:`` 或 ``private:``)。 + message: 待广播的消息内容。 + """ + for session_id in list(self.group_sessions.get(channel_key, set())): + await self.send_message(session_id, message) + + async def broadcast_to_group( + self, + group_id: Optional[str], + message: Dict[str, Any], + *, + user_id: Optional[str] = None, + ) -> None: + """向指定群组或私聊会话广播消息。 + + 当 ``group_id`` 非空时按群聊广播;否则按 ``user_id`` 私聊广播。 + + Args: + group_id: 群组标识;为空时使用 ``user_id``。 + message: 待广播的消息内容。 + user_id: 私聊接收方用户 ID。 + """ + if group_id: + channel_key = f"group:{group_id}" + elif user_id: + channel_key = f"private:{user_id}" + else: + return + await self.broadcast_to_channel(channel_key, message) + + +chat_history = ChatHistoryManager() +chat_manager = ChatConnectionManager() + + +def is_virtual_mode_enabled(virtual_config: Optional[VirtualIdentityConfig]) -> bool: + """判断当前是否启用了虚拟身份模式。 + + Args: + virtual_config: 虚拟身份配置。 + + Returns: + bool: 已启用时返回 ``True``。 + """ + return bool(virtual_config and virtual_config.enabled) + + +def compute_channel_key(virtual_config: Optional[VirtualIdentityConfig], user_id: str) -> str: + """计算当前会话的逻辑频道键。 + + 虚拟身份启用时使用虚拟群聊 ID,否则使用当前 WebUI 用户 ID 作为私聊频道。 + + Args: + virtual_config: 虚拟身份配置。 + user_id: 当前 WebUI 用户 ID。 + + Returns: + str: 频道键,格式为 ``group:`` 或 ``private:``。 + """ + if is_virtual_mode_enabled(virtual_config): + assert virtual_config is not None + return f"group:{virtual_config.group_id}" + return f"private:{user_id}" + + +def normalize_webui_user_id(user_id: Optional[str]) -> str: + """标准化 WebUI 用户 ID。 + + Args: + user_id: 原始用户 ID。 + + Returns: + str: 带统一前缀的用户 ID。 + """ + if not user_id: + return f"{WEBUI_USER_ID_PREFIX}{uuid.uuid4().hex[:16]}" + if user_id.startswith(WEBUI_USER_ID_PREFIX): + return user_id + return f"{WEBUI_USER_ID_PREFIX}{user_id}" + + +def get_person_by_person_id(person_id: str) -> Optional[PersonInfo]: + """根据人物 ID 查询人物信息。 + + Args: + person_id: 人物 ID。 + + Returns: + Optional[PersonInfo]: 查到时返回人物信息。 + """ + with get_db_session() as session: + statement = select(PersonInfo).where(col(PersonInfo.person_id) == person_id).limit(1) + return session.exec(statement).first() + + +def build_virtual_identity_config(person: PersonInfo, group_id: str, group_name: str) -> VirtualIdentityConfig: + """根据人物信息构建虚拟身份配置。 + + Args: + person: 人物信息对象。 + group_id: 逻辑群组 ID。 + group_name: 逻辑群组名称。 + + Returns: + VirtualIdentityConfig: 虚拟身份配置对象。 + """ + return VirtualIdentityConfig( + enabled=True, + platform=person.platform, + person_id=person.person_id, + user_id=person.user_id, + user_nickname=person.person_name or person.user_nickname or person.user_id, + group_id=group_id, + group_name=group_name, + ) + + +def resolve_initial_virtual_identity( + platform: Optional[str], + person_id: Optional[str], + group_name: Optional[str], + group_id: Optional[str], +) -> Optional[VirtualIdentityConfig]: + """根据初始参数解析虚拟身份配置。 + + Args: + platform: 平台名称。 + person_id: 人物 ID。 + group_name: 群组名称。 + group_id: 群组 ID。 + + Returns: + Optional[VirtualIdentityConfig]: 解析成功时返回虚拟身份配置。 + """ + if not (platform and person_id): + return None + + try: + person = get_person_by_person_id(person_id) + if person is None: + return None + + virtual_group_id = group_id or f"{VIRTUAL_GROUP_ID_PREFIX}{platform}_{person.user_id}" + virtual_config = build_virtual_identity_config( + person=person, + group_id=virtual_group_id, + group_name=group_name or "WebUI虚拟群聊", + ) + logger.info( + f"虚拟身份模式已通过参数激活: {virtual_config.user_nickname} @ " + f"{virtual_config.platform}, group_id={virtual_group_id}", + ) + return virtual_config + except Exception as exc: + logger.warning(f"通过参数配置虚拟身份失败: {exc}") + return None + + +def build_session_info_message( + session_id: str, + user_id: str, + user_name: str, + virtual_config: Optional[VirtualIdentityConfig], +) -> Dict[str, Any]: + """构建会话信息消息。 + + Args: + session_id: 内部逻辑会话 ID。 + user_id: 规范化后的用户 ID。 + user_name: 当前昵称。 + virtual_config: 虚拟身份配置。 + + Returns: + Dict[str, Any]: 会话信息消息。 + """ + # bot_qq 用于前端从 QQ 头像公开接口拉取机器人头像(qq_account == 0 表示未配置,不推送)。 + bot_qq_account = int(getattr(global_config.bot, "qq_account", 0) or 0) + session_info_data: Dict[str, Any] = { + "type": "session_info", + "session_id": session_id, + "user_id": user_id, + "user_name": user_name, + "bot_name": global_config.bot.nickname, + } + if bot_qq_account > 0: + session_info_data["bot_qq"] = str(bot_qq_account) + + if is_virtual_mode_enabled(virtual_config): + assert virtual_config is not None + session_info_data["virtual_mode"] = True + session_info_data["group_id"] = virtual_config.group_id + session_info_data["virtual_identity"] = { + "platform": virtual_config.platform, + "user_id": virtual_config.user_id, + "user_nickname": virtual_config.user_nickname, + "group_name": virtual_config.group_name, + } + + return session_info_data + + +def get_active_history_group_id(virtual_config: Optional[VirtualIdentityConfig]) -> Optional[str]: + """获取当前虚拟身份对应的历史群组 ID。 + + Args: + virtual_config: 虚拟身份配置。 + + Returns: + Optional[str]: 虚拟身份启用时返回对应群组 ID;否则返回 ``None`` 表示使用私聊。 + """ + if is_virtual_mode_enabled(virtual_config): + assert virtual_config is not None + return virtual_config.group_id + return None + + +def get_current_group_id(virtual_config: Optional[VirtualIdentityConfig]) -> Optional[str]: + """获取当前会话的有效群组 ID。 + + Args: + virtual_config: 虚拟身份配置。 + + Returns: + Optional[str]: 虚拟身份启用时返回对应群组 ID;否则返回 ``None``(默认私聊模式)。 + """ + return get_active_history_group_id(virtual_config) + + +def build_welcome_message(virtual_config: Optional[VirtualIdentityConfig]) -> str: + """构建欢迎消息。 + + Args: + virtual_config: 虚拟身份配置。 + + Returns: + str: 欢迎消息文本。 + """ + if is_virtual_mode_enabled(virtual_config): + assert virtual_config is not None + return ( + f"已以 {virtual_config.user_nickname} 的身份连接到「{virtual_config.group_name}」," + f"开始与 {global_config.bot.nickname} 对话吧!" + ) + return f"已连接到本地聊天室,可以开始与 {global_config.bot.nickname} 对话了!" + + +async def send_chat_error(session_id: str, content: str) -> None: + """向指定会话发送错误消息。 + + Args: + session_id: 内部逻辑会话 ID。 + content: 错误消息内容。 + """ + await chat_manager.send_message( + session_id, + { + "type": "error", + "content": content, + "timestamp": time.time(), + }, + ) + + +async def send_initial_chat_state( + session_id: str, + user_id: str, + user_name: str, + virtual_config: Optional[VirtualIdentityConfig], + include_welcome: bool = True, +) -> None: + """向新会话发送初始化状态。 + + Args: + session_id: 内部逻辑会话 ID。 + user_id: 规范化后的用户 ID。 + user_name: 当前昵称。 + virtual_config: 虚拟身份配置。 + include_welcome: 是否发送欢迎消息。 + """ + await chat_manager.send_message( + session_id, + build_session_info_message( + session_id=session_id, + user_id=user_id, + user_name=user_name, + virtual_config=virtual_config, + ), + ) + + history_group_id = get_active_history_group_id(virtual_config) + history_user_id = None if history_group_id else user_id + history = chat_history.get_history( + 50, + group_id=history_group_id, + user_id=history_user_id, + ) + await chat_manager.send_message( + session_id, + { + "type": "history", + "messages": history, + "group_id": get_current_group_id(virtual_config), + }, + ) + + if include_welcome: + await chat_manager.send_message( + session_id, + { + "type": "system", + "content": build_welcome_message(virtual_config), + "timestamp": time.time(), + }, + ) + + +def resolve_sender_identity( + current_user_name: str, + normalized_user_id: str, + virtual_config: Optional[VirtualIdentityConfig], +) -> Tuple[str, str]: + """解析当前发送者身份。 + + Args: + current_user_name: 当前昵称。 + normalized_user_id: 规范化后的用户 ID。 + virtual_config: 虚拟身份配置。 + + Returns: + Tuple[str, str]: ``(发送者昵称, 发送者用户 ID)``。 + """ + if is_virtual_mode_enabled(virtual_config): + assert virtual_config is not None + return virtual_config.user_nickname or current_user_name, virtual_config.user_id or normalized_user_id + return current_user_name, normalized_user_id + + +def create_message_data( + content: str, + user_id: str, + user_name: str, + message_id: Optional[str] = None, + is_at_bot: bool = True, + virtual_config: Optional[VirtualIdentityConfig] = None, +) -> Dict[str, Any]: + """构建发送给聊天核心的消息数据。 + + Args: + content: 文本内容。 + user_id: 用户 ID。 + user_name: 用户昵称。 + message_id: 消息 ID。 + is_at_bot: 是否默认艾特机器人。 + virtual_config: 虚拟身份配置。 + + Returns: + Dict[str, Any]: 聊天核心可处理的消息数据。 + """ + if message_id is None: + message_id = str(uuid.uuid4()) + + if virtual_config and virtual_config.enabled: + platform = virtual_config.platform or WEBUI_CHAT_PLATFORM + group_id: Optional[str] = ( + virtual_config.group_id or f"{VIRTUAL_GROUP_ID_PREFIX}{uuid.uuid4().hex[:8]}" + ) + group_name: Optional[str] = virtual_config.group_name or "WebUI虚拟群聊" + actual_user_id = virtual_config.user_id or user_id + actual_user_nickname = virtual_config.user_nickname or user_name + else: + platform = WEBUI_CHAT_PLATFORM + group_id = None + group_name = None + actual_user_id = user_id + actual_user_nickname = user_name + + message_info: Dict[str, Any] = { + "platform": platform, + "message_id": message_id, + "time": time.time(), + "user_info": { + "user_id": actual_user_id, + "user_nickname": actual_user_nickname, + "user_cardname": actual_user_nickname, + "platform": platform, + }, + "additional_config": { + "at_bot": is_at_bot, + }, + } + if group_id is not None: + message_info["group_info"] = { + "group_id": group_id, + "group_name": group_name, + "platform": platform, + } + + return { + "message_info": message_info, + "message_segment": { + "type": "seglist", + "data": [ + { + "type": "text", + "data": content, + }, + ], + }, + "raw_message": content, + "processed_plain_text": content, + } + + +async def handle_chat_message( + session_id: str, + data: Dict[str, Any], + current_user_name: str, + normalized_user_id: str, + current_virtual_config: Optional[VirtualIdentityConfig], +) -> str: + """处理用户发送的聊天消息。 + + Args: + session_id: 内部逻辑会话 ID。 + data: 前端提交的消息数据。 + current_user_name: 当前昵称。 + normalized_user_id: 规范化后的用户 ID。 + current_virtual_config: 当前虚拟身份配置。 + + Returns: + str: 处理后的最新昵称。 + """ + content = str(data.get("content", "")).strip() + if not content: + return current_user_name + + next_user_name = str(data.get("user_name", current_user_name)) + message_id = str(uuid.uuid4()) + timestamp = time.time() + sender_name, sender_user_id = resolve_sender_identity( + current_user_name=next_user_name, + normalized_user_id=normalized_user_id, + virtual_config=current_virtual_config, + ) + target_group_id = get_current_group_id(current_virtual_config) + + await chat_manager.broadcast_to_group( + target_group_id, + { + "type": "user_message", + "content": content, + "group_id": target_group_id, + "message_id": message_id, + "timestamp": timestamp, + "sender": { + "name": sender_name, + "user_id": sender_user_id, + "is_bot": False, + }, + "virtual_mode": is_virtual_mode_enabled(current_virtual_config), + }, + user_id=normalized_user_id, + ) + + message_data = create_message_data( + content=content, + user_id=normalized_user_id, + user_name=next_user_name, + message_id=message_id, + is_at_bot=True, + virtual_config=current_virtual_config, + ) + + try: + await chat_manager.broadcast_to_group( + target_group_id, + {"type": "typing", "is_typing": True}, + user_id=normalized_user_id, + ) + await chat_bot.message_process(message_data) + except Exception as exc: + logger.error(f"处理消息时出错: {exc}") + await send_chat_error(session_id, f"处理消息时出错: {str(exc)}") + finally: + await chat_manager.broadcast_to_group( + target_group_id, + {"type": "typing", "is_typing": False}, + user_id=normalized_user_id, + ) + + return next_user_name + + +async def handle_chat_ping(session_id: str) -> None: + """处理聊天心跳。 + + Args: + session_id: 内部逻辑会话 ID。 + """ + await chat_manager.send_message(session_id, {"type": "pong", "timestamp": time.time()}) + + +async def handle_nickname_update(session_id: str, data: Dict[str, Any], current_user_name: str) -> str: + """处理昵称更新请求。 + + Args: + session_id: 内部逻辑会话 ID。 + data: 前端提交的数据。 + current_user_name: 当前昵称。 + + Returns: + str: 更新后的昵称。 + """ + new_name = str(data.get("user_name", "")).strip() + if not new_name: + return current_user_name + + await chat_manager.send_message( + session_id, + { + "type": "nickname_updated", + "user_name": new_name, + "timestamp": time.time(), + }, + ) + return new_name + + +async def enable_virtual_identity( + session_id: str, + session_prefix: str, + virtual_data: Dict[str, Any], +) -> Optional[VirtualIdentityConfig]: + """启用虚拟身份模式。 + + Args: + session_id: 内部逻辑会话 ID。 + session_prefix: 会话前缀,用于生成默认群组 ID。 + virtual_data: 前端提交的虚拟身份配置。 + + Returns: + Optional[VirtualIdentityConfig]: 启用成功时返回新的虚拟身份配置。 + """ + if not virtual_data.get("platform") or not virtual_data.get("person_id"): + await send_chat_error(session_id, "虚拟身份配置缺少必要字段: platform 和 person_id") + return None + + person_id_value = str(virtual_data.get("person_id")) + try: + person = get_person_by_person_id(person_id_value) + if person is None: + await send_chat_error(session_id, f"找不到用户: {person_id_value}") + return None + + custom_group_id = str(virtual_data.get("group_id") or "").strip() + if custom_group_id: + current_group_id = custom_group_id + if not current_group_id.startswith(VIRTUAL_GROUP_ID_PREFIX): + current_group_id = f"{VIRTUAL_GROUP_ID_PREFIX}{current_group_id}" + else: + current_group_id = f"{VIRTUAL_GROUP_ID_PREFIX}{session_prefix}" + + current_virtual_config = build_virtual_identity_config( + person=person, + group_id=current_group_id, + group_name=str(virtual_data.get("group_name", "WebUI虚拟群聊")), + ) + + await chat_manager.send_message( + session_id, + { + "type": "virtual_identity_set", + "config": { + "enabled": True, + "platform": current_virtual_config.platform, + "user_id": current_virtual_config.user_id, + "user_nickname": current_virtual_config.user_nickname, + "group_id": current_virtual_config.group_id, + "group_name": current_virtual_config.group_name, + }, + "timestamp": time.time(), + }, + ) + await chat_manager.send_message( + session_id, + { + "type": "history", + "messages": chat_history.get_history(50, current_virtual_config.group_id), + "group_id": current_virtual_config.group_id, + }, + ) + await chat_manager.send_message( + session_id, + { + "type": "system", + "content": ( + f"已切换到虚拟身份模式:以 {current_virtual_config.user_nickname} 的身份在" + f"「{current_virtual_config.group_name}」与 {global_config.bot.nickname} 对话" + ), + "timestamp": time.time(), + }, + ) + return current_virtual_config + except Exception as exc: + logger.error(f"设置虚拟身份失败: {exc}") + await send_chat_error(session_id, f"设置虚拟身份失败: {str(exc)}") + return None + + +async def disable_virtual_identity(session_id: str, normalized_user_id: str) -> None: + """关闭虚拟身份模式。 + + Args: + session_id: 内部逻辑会话 ID。 + normalized_user_id: 规范化后的 WebUI 用户 ID,用于加载私聊历史。 + """ + await chat_manager.send_message( + session_id, + { + "type": "virtual_identity_set", + "config": {"enabled": False}, + "timestamp": time.time(), + }, + ) + await chat_manager.send_message( + session_id, + { + "type": "history", + "messages": chat_history.get_history(50, user_id=normalized_user_id), + "group_id": None, + }, + ) + await chat_manager.send_message( + session_id, + { + "type": "system", + "content": "已切换回 WebUI 独立用户模式", + "timestamp": time.time(), + }, + ) + + +async def handle_virtual_identity_update( + session_id: str, + session_id_prefix: str, + data: Dict[str, Any], + current_virtual_config: Optional[VirtualIdentityConfig], + normalized_user_id: str, +) -> Optional[VirtualIdentityConfig]: + """处理虚拟身份切换请求。 + + Args: + session_id: 内部逻辑会话 ID。 + session_id_prefix: 会话前缀。 + data: 前端提交的数据。 + current_virtual_config: 当前虚拟身份配置。 + normalized_user_id: 规范化后的 WebUI 用户 ID。 + + Returns: + Optional[VirtualIdentityConfig]: 更新后的虚拟身份配置。 + """ + virtual_data = cast(Dict[str, Any], data.get("config", {})) + if virtual_data.get("enabled"): + next_config = await enable_virtual_identity(session_id, session_id_prefix, virtual_data) + return next_config if next_config is not None else current_virtual_config + + await disable_virtual_identity(session_id, normalized_user_id) + return None + + +async def dispatch_chat_event( + session_id: str, + session_id_prefix: str, + data: Dict[str, Any], + current_user_name: str, + normalized_user_id: str, + current_virtual_config: Optional[VirtualIdentityConfig], +) -> Tuple[str, Optional[VirtualIdentityConfig]]: + """分发聊天事件到对应的处理器。 + + Args: + session_id: 内部逻辑会话 ID。 + session_id_prefix: 会话前缀。 + data: 前端提交的数据。 + current_user_name: 当前昵称。 + normalized_user_id: 规范化后的用户 ID。 + current_virtual_config: 当前虚拟身份配置。 + + Returns: + Tuple[str, Optional[VirtualIdentityConfig]]: ``(最新昵称, 最新虚拟身份配置)``。 + """ + event_type = data.get("type") + if event_type == "message": + next_user_name = await handle_chat_message( + session_id=session_id, + data=data, + current_user_name=current_user_name, + normalized_user_id=normalized_user_id, + current_virtual_config=current_virtual_config, + ) + return next_user_name, current_virtual_config + + if event_type == "ping": + await handle_chat_ping(session_id) + return current_user_name, current_virtual_config + + if event_type == "update_nickname": + next_user_name = await handle_nickname_update(session_id, data, current_user_name) + return next_user_name, current_virtual_config + + if event_type == "set_virtual_identity": + next_virtual_config = await handle_virtual_identity_update( + session_id=session_id, + session_id_prefix=session_id_prefix, + data=data, + current_virtual_config=current_virtual_config, + normalized_user_id=normalized_user_id, + ) + return current_user_name, next_virtual_config + + return current_user_name, current_virtual_config diff --git a/src/webui/routers/config.py b/src/webui/routers/config.py new file mode 100644 index 00000000..4ceb7a05 --- /dev/null +++ b/src/webui/routers/config.py @@ -0,0 +1,917 @@ +""" +配置管理API路由 +""" + +from pathlib import Path +from typing import Annotated, Any, Dict, List, Tuple, Union, get_args, get_origin +import copy +import os +import types + +from fastapi import APIRouter, Body, Depends, HTTPException, Query +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field +import tomlkit + +from src.common.logger import get_logger +from src.common.prompt_i18n import clear_prompt_cache, list_prompt_templates +from src.config.config import CONFIG_DIR, PROJECT_ROOT, Config, ModelConfig +from src.config.config_base import AttributeData, ConfigBase +from src.config.model_configs import ( + APIProvider, + ModelInfo, + ModelTaskConfig, +) +from src.config.official_configs import ( + AMemorixConfig, + BotConfig, + ChatConfig, + ChineseTypoConfig, + DebugConfig, + EmojiConfig, + ExpressionConfig, + KeywordReactionConfig, + MaimMessageConfig, + MessageReceiveConfig, + PersonalityConfig, + ResponsePostProcessConfig, + ResponseSplitterConfig, + TelemetryConfig, + VoiceConfig, +) +from src.webui.config_schema import ConfigSchemaGenerator +from src.webui.dependencies import require_auth +from src.webui.utils.toml_utils import _update_toml_doc, save_toml_with_format + +logger = get_logger("webui") + +# 模块级别的类型别名(解决 B008 ruff 错误) +ConfigBody = Annotated[Dict[str, Any], Body()] +SectionBody = Annotated[Any, Body()] +RawContentBody = Annotated[str, Body(embed=True)] +PathBody = Annotated[Dict[str, str], Body()] +PromptContentBody = Annotated[str, Body(embed=True)] + +router = APIRouter(prefix="/config", tags=["config"], dependencies=[Depends(require_auth)]) + +PROMPTS_DIR = PROJECT_ROOT / "prompts" +CUSTOM_PROMPTS_DIR = PROJECT_ROOT / "data" / "custom_prompts" +MAISAKA_PROMPT_PREVIEW_DIR = (PROJECT_ROOT / "logs" / "maisaka_prompt").resolve() + + +class PromptFileInfo(BaseModel): + """Prompt 文件信息。""" + + name: str = Field(..., description="Prompt 文件名") + size: int = Field(..., description="文件大小") + modified_at: float = Field(..., description="最后修改时间戳") + display_name: str = Field(default="", description="Prompt 展示名称") + advanced: bool = Field(default=False, description="是否为高级 Prompt") + description: str = Field(default="", description="Prompt 描述") + customized: bool = Field(default=False, description="是否存在用户自定义覆盖") + + +class PromptCatalogResponse(BaseModel): + """Prompt 目录响应。""" + + success: bool = True + languages: List[str] + files: Dict[str, List[PromptFileInfo]] + + +class PromptFileResponse(BaseModel): + """Prompt 文件内容响应。""" + + success: bool = True + language: str + filename: str + content: str + customized: bool = False + + +def _safe_prompt_path(language: str, filename: str) -> Path: + """校验并解析 prompts 下的文件路径。""" + + normalized_language = language.strip() + normalized_filename = filename.strip() + + if not normalized_language or any(part in normalized_language for part in ("..", "/", "\\")): + raise HTTPException(status_code=400, detail="无效的 Prompt 语言目录") + if not normalized_filename.endswith(".prompt") or any(part in normalized_filename for part in ("..", "/", "\\")): + raise HTTPException(status_code=400, detail="无效的 Prompt 文件名") + + prompt_path = (PROMPTS_DIR / normalized_language / normalized_filename).resolve() + prompts_root = PROMPTS_DIR.resolve() + try: + prompt_path.relative_to(prompts_root) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Prompt 路径越界") from exc + return prompt_path + + +def _safe_custom_prompt_path(language: str, filename: str) -> Path: + """校验并解析 data/custom_prompts 下的用户覆盖文件路径。""" + + normalized_language = language.strip() + normalized_filename = filename.strip() + + if not normalized_language or any(part in normalized_language for part in ("..", "/", "\\")): + raise HTTPException(status_code=400, detail="无效的 Prompt 语言目录") + if not normalized_filename.endswith(".prompt") or any(part in normalized_filename for part in ("..", "/", "\\")): + raise HTTPException(status_code=400, detail="无效的 Prompt 文件名") + + prompt_path = (CUSTOM_PROMPTS_DIR / normalized_language / normalized_filename).resolve() + custom_prompts_root = CUSTOM_PROMPTS_DIR.resolve() + try: + prompt_path.relative_to(custom_prompts_root) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Prompt 路径越界") from exc + return prompt_path + + +def _safe_maisaka_prompt_preview_path(relative_path: str) -> Path: + """校验并解析 MaiSaka Prompt HTML 预览路径。""" + + normalized_path = relative_path.strip().replace("\\", "/") + if not normalized_path or normalized_path.startswith("/") or ".." in Path(normalized_path).parts: + raise HTTPException(status_code=400, detail="无效的 Prompt 预览路径") + + preview_path = (MAISAKA_PROMPT_PREVIEW_DIR / normalized_path).resolve() + try: + preview_path.relative_to(MAISAKA_PROMPT_PREVIEW_DIR) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Prompt 预览路径越界") from exc + + if preview_path.suffix.lower() != ".html": + raise HTTPException(status_code=400, detail="只允许打开 HTML Prompt 预览") + return preview_path + + +def _toml_to_plain_dict(obj: Any) -> Any: + """递归转换 tomlkit 文档/Table 为纯 Python 字典,避免 from_dict 触发 tomlkit __setitem__""" + if isinstance(obj, dict): + return {str(k): _toml_to_plain_dict(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_toml_to_plain_dict(v) for v in obj] + return obj + + +def _coerce_numeric_value(value: Any, target_type: Any) -> Any: + """根据配置字段类型,把旧 WebUI 可能写入的数字字符串还原为数字。""" + if target_type is str: + if isinstance(value, (int, float)): + return str(value) + return value + + if target_type is int: + if isinstance(value, str): + try: + parsed_value = float(value.strip()) + except ValueError: + return value + if parsed_value.is_integer(): + return int(parsed_value) + return value + + if target_type is float: + if isinstance(value, str): + try: + return float(value.strip()) + except ValueError: + return value + return value + + return value + + +def _coerce_value_by_annotation(value: Any, annotation: Any) -> Any: + """递归按 ConfigBase 字段注解修正数据类型,避免保存时把数字写成字符串。""" + value = _coerce_numeric_value(value, annotation) + origin = get_origin(annotation) + args = get_args(annotation) + + if origin in {Union, types.UnionType}: + for candidate_type in args: + if candidate_type is type(None): + continue + coerced_value = _coerce_value_by_annotation(value, candidate_type) + if coerced_value != value or type(coerced_value) is not type(value): + return coerced_value + return value + + if origin in {list, List} and isinstance(value, list) and args: + item_type = args[0] + return [_coerce_value_by_annotation(item, item_type) for item in value] + + if origin in {dict, Dict} and isinstance(value, dict) and len(args) >= 2: + value_type = args[1] + return {key: _coerce_value_by_annotation(item, value_type) for key, item in value.items()} + + if isinstance(value, dict) and isinstance(annotation, type) and issubclass(annotation, ConfigBase): + return _coerce_config_numeric_values(value, annotation) + + return value + + +def _coerce_config_numeric_values(data: Dict[str, Any], config_type: type[ConfigBase]) -> Dict[str, Any]: + """按配置类 schema 统一修正所有数字字段类型。""" + for field_name, field_info in config_type.model_fields.items(): + if field_name in data: + data[field_name] = _coerce_value_by_annotation(data[field_name], field_info.annotation) + return data + + +# ===== 架构获取接口 ===== + + +@router.get("/prompts", response_model=PromptCatalogResponse) +async def list_prompt_files(): + """列出 prompts 目录下的语言和 Prompt 文件。""" + + try: + if not PROMPTS_DIR.exists(): + return PromptCatalogResponse(languages=[], files={}) + + languages: List[str] = [] + files: Dict[str, List[PromptFileInfo]] = {} + for language_dir in sorted(PROMPTS_DIR.iterdir(), key=lambda item: item.name): + if not language_dir.is_dir(): + continue + + language = language_dir.name + prompt_template_infos = list_prompt_templates(locale=language, prompts_root=PROMPTS_DIR) + prompt_files: List[PromptFileInfo] = [] + for prompt_file in sorted(language_dir.glob("*.prompt"), key=lambda item: item.name): + custom_prompt_file = _safe_custom_prompt_path(language, prompt_file.name) + effective_prompt_file = custom_prompt_file if custom_prompt_file.exists() else prompt_file + stat = effective_prompt_file.stat() + template_info = prompt_template_infos.get(prompt_file.stem) + metadata = template_info.metadata if template_info and template_info.path == prompt_file else None + prompt_files.append( + PromptFileInfo( + name=prompt_file.name, + size=stat.st_size, + modified_at=stat.st_mtime, + display_name=metadata.display_name if metadata else "", + advanced=metadata.advanced if metadata else False, + description=metadata.description if metadata else "", + customized=custom_prompt_file.exists(), + ) + ) + + languages.append(language) + files[language] = prompt_files + + return PromptCatalogResponse(languages=languages, files=files) + except HTTPException: + raise + except Exception as e: + logger.error(f"列出 Prompt 文件失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"列出 Prompt 文件失败: {str(e)}") from e + + +@router.get("/prompts/{language}/{filename}", response_model=PromptFileResponse) +async def get_prompt_file(language: str, filename: str): + """读取指定语言下的 Prompt 文件内容。""" + + prompt_path = _safe_prompt_path(language, filename) + custom_prompt_path = _safe_custom_prompt_path(language, filename) + if not prompt_path.exists() or not prompt_path.is_file(): + raise HTTPException(status_code=404, detail="Prompt 文件不存在") + + try: + effective_prompt_path = custom_prompt_path if custom_prompt_path.exists() else prompt_path + content = effective_prompt_path.read_text(encoding="utf-8") + return PromptFileResponse( + language=language, + filename=filename, + content=content, + customized=custom_prompt_path.exists(), + ) + except Exception as e: + logger.error(f"读取 Prompt 文件失败: {prompt_path} {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"读取 Prompt 文件失败: {str(e)}") from e + + +@router.get("/prompts/{language}/{filename}/default", response_model=PromptFileResponse) +async def get_default_prompt_file(language: str, filename: str): + """只读获取内置 Prompt 模板内容,不读取或修改用户自定义覆盖。""" + + prompt_path = _safe_prompt_path(language, filename) + if not prompt_path.exists() or not prompt_path.is_file(): + raise HTTPException(status_code=404, detail="Prompt 文件不存在") + + try: + content = prompt_path.read_text(encoding="utf-8") + return PromptFileResponse(language=language, filename=filename, content=content, customized=False) + except Exception as e: + logger.error(f"读取默认 Prompt 文件失败: {prompt_path} {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"读取默认 Prompt 文件失败: {str(e)}") from e + + +@router.put("/prompts/{language}/{filename}", response_model=PromptFileResponse) +async def update_prompt_file(language: str, filename: str, content: PromptContentBody): + """更新指定语言下的 Prompt 文件内容。""" + + prompt_path = _safe_prompt_path(language, filename) + custom_prompt_path = _safe_custom_prompt_path(language, filename) + if not prompt_path.parent.exists() or not prompt_path.parent.is_dir(): + raise HTTPException(status_code=404, detail="Prompt 语言目录不存在") + if not prompt_path.exists() or not prompt_path.is_file(): + raise HTTPException(status_code=404, detail="Prompt 文件不存在") + + try: + custom_prompt_path.parent.mkdir(parents=True, exist_ok=True) + custom_prompt_path.write_text(content, encoding="utf-8", newline="\n") + clear_prompt_cache() + return PromptFileResponse(language=language, filename=filename, content=content, customized=True) + except Exception as e: + logger.error(f"保存 Prompt 文件失败: {prompt_path} {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"保存 Prompt 文件失败: {str(e)}") from e + + +@router.delete("/prompts/{language}/{filename}", response_model=PromptFileResponse) +async def reset_prompt_file(language: str, filename: str): + """删除用户自定义覆盖,恢复使用内置 Prompt 模板。""" + + prompt_path = _safe_prompt_path(language, filename) + custom_prompt_path = _safe_custom_prompt_path(language, filename) + if not prompt_path.exists() or not prompt_path.is_file(): + raise HTTPException(status_code=404, detail="Prompt 文件不存在") + + try: + if custom_prompt_path.exists(): + custom_prompt_path.unlink() + clear_prompt_cache() + content = prompt_path.read_text(encoding="utf-8") + return PromptFileResponse(language=language, filename=filename, content=content, customized=False) + except Exception as e: + logger.error(f"恢复 Prompt 默认模板失败: {prompt_path} {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"恢复 Prompt 默认模板失败: {str(e)}") from e + + +@router.get("/maisaka-prompt-preview", response_class=FileResponse) +async def get_maisaka_prompt_preview(path: str = Query(..., description="logs/maisaka_prompt 下的相对 HTML 路径")): + """打开 MaiSaka 监控中生成的 Prompt HTML 预览。""" + + preview_path = _safe_maisaka_prompt_preview_path(path) + if not preview_path.exists() or not preview_path.is_file(): + raise HTTPException(status_code=404, detail="Prompt 预览文件不存在") + return FileResponse(preview_path, media_type="text/html") + + +@router.get("/schema/bot") +async def get_bot_config_schema(): + """获取麦麦主程序配置架构""" + try: + # Config 类包含所有子配置 + schema = ConfigSchemaGenerator.generate_config_schema(Config) + return {"success": True, "schema": schema} + except Exception as e: + logger.error(f"获取配置架构失败: {e}") + raise HTTPException(status_code=500, detail=f"获取配置架构失败: {str(e)}") from e + + +@router.get("/schema/model") +async def get_model_config_schema(): + """获取模型配置架构(包含提供商和模型任务配置)""" + try: + schema = ConfigSchemaGenerator.generate_config_schema(ModelConfig) + return {"success": True, "schema": schema} + except Exception as e: + logger.error(f"获取模型配置架构失败: {e}") + raise HTTPException(status_code=500, detail=f"获取模型配置架构失败: {str(e)}") from e + + +# ===== 子配置架构获取接口 ===== + + +@router.get("/schema/section/{section_name}") +async def get_config_section_schema(section_name: str): + """ + 获取指定配置节的架构 + + 支持的section_name: + - bot: BotConfig + - personality: PersonalityConfig + - chat: ChatConfig + - message_receive: MessageReceiveConfig + - emoji: EmojiConfig + - expression: ExpressionConfig + - keyword_reaction: KeywordReactionConfig + - chinese_typo: ChineseTypoConfig + - response_post_process: ResponsePostProcessConfig + - response_splitter: ResponseSplitterConfig + - telemetry: TelemetryConfig + - maim_message: MaimMessageConfig + - debug: DebugConfig + - voice: VoiceConfig + - jargon: JargonConfig + - model_task_config: ModelTaskConfig + - api_provider: APIProvider + - model_info: ModelInfo + """ + section_map = { + "bot": BotConfig, + "personality": PersonalityConfig, + "chat": ChatConfig, + "message_receive": MessageReceiveConfig, + "emoji": EmojiConfig, + "expression": ExpressionConfig, + "keyword_reaction": KeywordReactionConfig, + "chinese_typo": ChineseTypoConfig, + "response_post_process": ResponsePostProcessConfig, + "response_splitter": ResponseSplitterConfig, + "telemetry": TelemetryConfig, + "maim_message": MaimMessageConfig, + "a_memorix": AMemorixConfig, + "debug": DebugConfig, + "voice": VoiceConfig, + "model_task_config": ModelTaskConfig, + "api_provider": APIProvider, + "model_info": ModelInfo, + } + + if section_name not in section_map: + raise HTTPException(status_code=404, detail=f"配置节 '{section_name}' 不存在") + + try: + config_class = section_map[section_name] + schema = ConfigSchemaGenerator.generate_schema(config_class, include_nested=False) + return {"success": True, "schema": schema} + except Exception as e: + logger.error(f"获取配置节架构失败: {e}") + raise HTTPException(status_code=500, detail=f"获取配置节架构失败: {str(e)}") from e + + +# ===== 配置读取接口 ===== + + +@router.get("/bot") +async def get_bot_config(): + """获取麦麦主程序配置""" + try: + config_path = os.path.join(CONFIG_DIR, "bot_config.toml") + if not os.path.exists(config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(config_path, "r", encoding="utf-8") as f: + config_data = tomlkit.load(f) + + return {"success": True, "config": config_data} + except HTTPException: + raise + except Exception as e: + logger.error(f"读取配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"读取配置文件失败: {str(e)}") from e + + +@router.get("/model") +async def get_model_config(): + """获取模型配置(包含提供商和模型任务配置)""" + try: + config_path = os.path.join(CONFIG_DIR, "model_config.toml") + if not os.path.exists(config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(config_path, "r", encoding="utf-8") as f: + config_data = tomlkit.load(f) + + return {"success": True, "config": config_data} + except HTTPException: + raise + except Exception as e: + logger.error(f"读取配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"读取配置文件失败: {str(e)}") from e + + +# ===== 配置更新接口 ===== + + +@router.post("/bot") +async def update_bot_config(config_data: ConfigBody): + """更新麦麦主程序配置""" + try: + config_data = _coerce_config_numeric_values(config_data, Config) + + # 验证配置数据 + try: + Config.from_dict(AttributeData(), copy.deepcopy(config_data)) + except Exception as e: + raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") from e + + # 保存配置文件(自动保留注释和格式) + config_path = os.path.join(CONFIG_DIR, "bot_config.toml") + save_toml_with_format(config_data, config_path) + + logger.info("麦麦主程序配置已更新") + return {"success": True, "message": "配置已保存"} + except HTTPException: + raise + except Exception as e: + logger.error(f"保存配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"保存配置文件失败: {str(e)}") from e + + +@router.post("/model") +async def update_model_config(config_data: ConfigBody): + """更新模型配置""" + try: + config_data = _coerce_config_numeric_values(config_data, ModelConfig) + + # 验证配置数据 + try: + ModelConfig.from_dict(AttributeData(), copy.deepcopy(config_data)) + except Exception as e: + raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") from e + + # 保存配置文件(自动保留注释和格式) + config_path = os.path.join(CONFIG_DIR, "model_config.toml") + save_toml_with_format(config_data, config_path) + + logger.info("模型配置已更新") + return {"success": True, "message": "配置已保存"} + except HTTPException: + raise + except Exception as e: + logger.error(f"保存配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"保存配置文件失败: {str(e)}") from e + + +# ===== 配置节更新接口 ===== + + +@router.post("/bot/section/{section_name}") +async def update_bot_config_section(section_name: str, section_data: SectionBody): + """更新麦麦主程序配置的指定节(保留注释和格式)""" + try: + # 读取现有配置 + config_path = os.path.join(CONFIG_DIR, "bot_config.toml") + if not os.path.exists(config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(config_path, "r", encoding="utf-8") as f: + config_data = tomlkit.load(f) + + # 更新指定节 + if section_name not in config_data: + raise HTTPException(status_code=404, detail=f"配置节 '{section_name}' 不存在") + + # 使用递归合并保留注释(对于字典类型) + # 对于数组类型(如 platforms, aliases),直接替换 + if isinstance(section_data, list): + # 列表直接替换 + config_data[section_name] = section_data + elif isinstance(section_data, dict) and isinstance(config_data[section_name], dict): + # 字典递归合并 + _update_toml_doc(config_data[section_name], section_data) + else: + # 其他类型直接替换 + config_data[section_name] = section_data + + # 验证完整配置 + try: + plain_config_data = _coerce_config_numeric_values(_toml_to_plain_dict(config_data), Config) + Config.from_dict(AttributeData(), copy.deepcopy(plain_config_data)) + except Exception as e: + raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") from e + + config_data = plain_config_data + + # 保存配置(格式化数组为多行,保留注释) + save_toml_with_format(config_data, config_path) + + logger.info(f"配置节 '{section_name}' 已更新(保留注释)") + return {"success": True, "message": f"配置节 '{section_name}' 已保存"} + except HTTPException: + raise + except Exception as e: + logger.error(f"更新配置节失败: {e}") + raise HTTPException(status_code=500, detail=f"更新配置节失败: {str(e)}") from e + + +# ===== 原始 TOML 文件操作接口 ===== + + +@router.get("/bot/raw") +async def get_bot_config_raw(): + """获取麦麦主程序配置的原始 TOML 内容""" + try: + config_path = os.path.join(CONFIG_DIR, "bot_config.toml") + if not os.path.exists(config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(config_path, "r", encoding="utf-8") as f: + raw_content = f.read() + + return {"success": True, "content": raw_content} + except HTTPException: + raise + except Exception as e: + logger.error(f"读取配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"读取配置文件失败: {str(e)}") from e + + +@router.post("/bot/raw") +async def update_bot_config_raw(raw_content: RawContentBody): + """更新麦麦主程序配置(直接保存原始 TOML 内容,会先验证格式)""" + try: + # 验证 TOML 格式 + try: + config_data = tomlkit.loads(raw_content) + except Exception as e: + raise HTTPException(status_code=400, detail=f"TOML 格式错误: {str(e)}") from e + + # 验证配置数据结构 + try: + Config.from_dict(AttributeData(), _toml_to_plain_dict(config_data)) + except Exception as e: + raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") from e + + # 保存配置文件 + config_path = os.path.join(CONFIG_DIR, "bot_config.toml") + with open(config_path, "w", encoding="utf-8") as f: + f.write(raw_content) + + logger.info("麦麦主程序配置已更新(原始模式)") + return {"success": True, "message": "配置已保存"} + except HTTPException: + raise + except Exception as e: + logger.error(f"保存配置文件失败: {e}") + raise HTTPException(status_code=500, detail=f"保存配置文件失败: {str(e)}") from e + + +@router.post("/model/section/{section_name}") +async def update_model_config_section(section_name: str, section_data: SectionBody): + """更新模型配置的指定节(保留注释和格式)""" + try: + # 读取现有配置 + config_path = os.path.join(CONFIG_DIR, "model_config.toml") + if not os.path.exists(config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(config_path, "r", encoding="utf-8") as f: + config_data = tomlkit.load(f) + + # 更新指定节 + if section_name not in config_data: + raise HTTPException(status_code=404, detail=f"配置节 '{section_name}' 不存在") + + # 使用递归合并保留注释(对于字典类型) + # 对于数组表(如 [[models]], [[api_providers]]),直接替换 + if isinstance(section_data, list): + # 列表直接替换 + config_data[section_name] = section_data + elif isinstance(section_data, dict) and isinstance(config_data[section_name], dict): + # 字典递归合并 + _update_toml_doc(config_data[section_name], section_data) + else: + # 其他类型直接替换 + config_data[section_name] = section_data + + # 验证完整配置 + try: + plain_config_data = _coerce_config_numeric_values(_toml_to_plain_dict(config_data), ModelConfig) + ModelConfig.from_dict(AttributeData(), copy.deepcopy(plain_config_data)) + except Exception as e: + logger.error(f"配置数据验证失败,详细错误: {str(e)}") + # 特殊处理:如果是更新 api_providers,检查是否有模型引用了已删除的provider + if section_name == "api_providers" and "api_provider" in str(e): + provider_names = {p.get("name") for p in section_data if isinstance(p, dict)} + models = plain_config_data.get("models", []) + orphaned_models: List[str] = [ + str(model_name) + for m in models + if isinstance(m, dict) + and m.get("api_provider") not in provider_names + and (model_name := m.get("name")) is not None + ] + if orphaned_models: + error_msg = f"以下模型引用了已删除的提供商: {', '.join(orphaned_models)}。请先在模型管理页面删除这些模型,或重新分配它们的提供商。" + raise HTTPException(status_code=400, detail=error_msg) from e + raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") from e + + config_data = plain_config_data + + # 保存配置(格式化数组为多行,保留注释) + save_toml_with_format(config_data, config_path) + + logger.info(f"配置节 '{section_name}' 已更新(保留注释)") + return {"success": True, "message": f"配置节 '{section_name}' 已保存"} + except HTTPException: + raise + except Exception as e: + logger.error(f"更新配置节失败: {e}") + raise HTTPException(status_code=500, detail=f"更新配置节失败: {str(e)}") from e + + +# ===== 适配器配置管理接口 ===== + + +def _normalize_adapter_path(path: str) -> str: + """将路径转换为绝对路径(如果是相对路径,则相对于项目根目录)""" + if not path: + return path + + # 如果已经是绝对路径,直接返回 + if os.path.isabs(path): + return path + + # 相对路径,转换为相对于项目根目录的绝对路径 + return os.path.normpath(os.path.join(PROJECT_ROOT, path)) + + +def _get_allowed_adapter_config_roots() -> Tuple[Path, ...]: + project_root = Path(PROJECT_ROOT).resolve() + return ( + project_root, + (project_root.parent / "MaiBot-Napcat-Adapter").resolve(), + Path("/MaiMBot/adapters-config").resolve(), + ) + + +def _resolve_safe_adapter_config_path(path: str) -> Path: + normalized_path = _normalize_adapter_path(path) + candidate_path = Path(normalized_path).expanduser().resolve() + + if candidate_path.suffix.lower() != ".toml": + raise HTTPException(status_code=400, detail="只支持 .toml 格式的配置文件") + + for allowed_root in _get_allowed_adapter_config_roots(): + try: + candidate_path.relative_to(allowed_root) + return candidate_path + except ValueError: + continue + + raise HTTPException(status_code=400, detail="适配器配置路径超出允许范围") + + +def _to_relative_path(path: str) -> str: + """尝试将绝对路径转换为相对于项目根目录的相对路径,如果无法转换则返回原路径""" + if not path or not os.path.isabs(path): + return path + + try: + # 尝试获取相对路径 + rel_path = os.path.relpath(path, PROJECT_ROOT) + # 如果相对路径不是以 .. 开头(说明文件在项目目录内),则返回相对路径 + if not rel_path.startswith(".."): + return rel_path + except (ValueError, TypeError): + # 在 Windows 上,如果路径在不同驱动器,relpath 会抛出 ValueError + pass + + # 无法转换为相对路径,返回绝对路径 + return path + + +@router.get("/adapter-config/path") +async def get_adapter_config_path(): + """获取保存的适配器配置文件路径""" + try: + # 从 data/webui.json 读取路径偏好 + webui_data_path = os.path.join("data", "webui.json") + if not os.path.exists(webui_data_path): + return {"success": True, "path": None} + + import json + + with open(webui_data_path, "r", encoding="utf-8") as f: + webui_data = json.load(f) + + adapter_config_path = webui_data.get("adapter_config_path") + if not adapter_config_path: + return {"success": True, "path": None} + + try: + abs_path = str(_resolve_safe_adapter_config_path(adapter_config_path)) + except HTTPException: + logger.warning(f"已忽略不安全的适配器配置路径: {adapter_config_path}") + return {"success": True, "path": None} + + # 检查文件是否存在并返回最后修改时间 + if os.path.exists(abs_path): + import datetime + + mtime = os.path.getmtime(abs_path) + last_modified = datetime.datetime.fromtimestamp(mtime).isoformat() + # 返回相对路径(如果可能) + display_path = _to_relative_path(abs_path) + return {"success": True, "path": display_path, "lastModified": last_modified} + else: + # 文件不存在,返回原路径 + return {"success": True, "path": adapter_config_path, "lastModified": None} + + except Exception as e: + logger.error(f"获取适配器配置路径失败: {e}") + raise HTTPException(status_code=500, detail=f"获取配置路径失败: {str(e)}") from e + + +@router.post("/adapter-config/path") +async def save_adapter_config_path(data: PathBody): + """保存适配器配置文件路径偏好""" + try: + path = data.get("path") + if not path: + raise HTTPException(status_code=400, detail="路径不能为空") + + # 保存到 data/webui.json + webui_data_path = os.path.join("data", "webui.json") + import json + + # 读取现有数据 + if os.path.exists(webui_data_path): + with open(webui_data_path, "r", encoding="utf-8") as f: + webui_data = json.load(f) + else: + webui_data = {} + + abs_path = str(_resolve_safe_adapter_config_path(path)) + + # 尝试转换为相对路径保存(如果文件在项目目录内) + save_path = _to_relative_path(abs_path) + + # 更新路径 + webui_data["adapter_config_path"] = save_path + + # 保存 + os.makedirs("data", exist_ok=True) + with open(webui_data_path, "w", encoding="utf-8") as f: + json.dump(webui_data, f, ensure_ascii=False, indent=2) + + logger.info(f"适配器配置路径已保存: {save_path}(绝对路径: {abs_path})") + return {"success": True, "message": "路径已保存"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"保存适配器配置路径失败: {e}") + raise HTTPException(status_code=500, detail=f"保存路径失败: {str(e)}") from e + + +@router.get("/adapter-config") +async def get_adapter_config(path: str): + """从指定路径读取适配器配置文件""" + try: + if not path: + raise HTTPException(status_code=400, detail="路径参数不能为空") + + abs_path = str(_resolve_safe_adapter_config_path(path)) + + # 检查文件是否存在 + if not os.path.exists(abs_path): + raise HTTPException(status_code=404, detail=f"配置文件不存在: {path}") + + # 读取文件内容 + with open(abs_path, "r", encoding="utf-8") as f: + content = f.read() + + logger.info(f"已读取适配器配置: {path} (绝对路径: {abs_path})") + return {"success": True, "content": content} + + except HTTPException: + raise + except Exception as e: + logger.error(f"读取适配器配置失败: {e}") + raise HTTPException(status_code=500, detail=f"读取配置失败: {str(e)}") from e + + +@router.post("/adapter-config") +async def save_adapter_config(data: PathBody): + """保存适配器配置到指定路径""" + try: + path = data.get("path") + content = data.get("content") + + if not path: + raise HTTPException(status_code=400, detail="路径不能为空") + if content is None: + raise HTTPException(status_code=400, detail="配置内容不能为空") + + abs_path = str(_resolve_safe_adapter_config_path(path)) + + # 验证 TOML 格式 + try: + tomlkit.loads(content) + except Exception as e: + raise HTTPException(status_code=400, detail=f"TOML 格式错误: {str(e)}") from e + + # 确保目录存在 + dir_path = os.path.dirname(abs_path) + if dir_path: + os.makedirs(dir_path, exist_ok=True) + + # 保存文件 + with open(abs_path, "w", encoding="utf-8") as f: + f.write(content) + + logger.info(f"适配器配置已保存: {path} (绝对路径: {abs_path})") + return {"success": True, "message": "配置已保存"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"保存适配器配置失败: {e}") + raise HTTPException(status_code=500, detail=f"保存配置失败: {str(e)}") from e diff --git a/src/webui/routers/emoji/__init__.py b/src/webui/routers/emoji/__init__.py new file mode 100644 index 00000000..1b2eccc4 --- /dev/null +++ b/src/webui/routers/emoji/__init__.py @@ -0,0 +1,3 @@ +from .routes import router + +__all__ = ["router"] diff --git a/src/webui/routers/emoji/routes.py b/src/webui/routers/emoji/routes.py new file mode 100644 index 00000000..e0739790 --- /dev/null +++ b/src/webui/routers/emoji/routes.py @@ -0,0 +1,1014 @@ +"""表情包管理 API 路由""" + +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Cookie, HTTPException, Query +from fastapi.responses import FileResponse, JSONResponse +from PIL import Image +from sqlalchemy import func +from sqlmodel import col, select + +import asyncio +import hashlib +import io +import os +import re + +from src.common.database.database import get_db_session +from src.common.database.database_model import Images, ImageType +from src.webui.core import get_token_manager +from src.webui.core import verify_auth_token_from_cookie_or_header as verify_auth_token + +from .schemas import ( + BatchDeleteRequest, + BatchDeleteResponse, + DescriptionForm, + EmojiDeleteResponse, + EmojiDetailResponse, + EmojiFile, + EmojiFiles, + EmojiListResponse, + EmojiUpdateRequest, + EmojiUpdateResponse, + EmojiUploadResponse, + EmotionForm, + IsRegisteredForm, + ThumbnailCacheStatsResponse, + ThumbnailCleanupResponse, + ThumbnailPreheatResponse, + emoji_to_response, +) +from .support import ( + EMOJI_DIR, + THUMBNAIL_CACHE_DIR, + background_generate_thumbnail, + cleanup_orphaned_thumbnails, + ensure_thumbnail_cache_dir, + generate_thumbnail, + get_generating_lock, + get_generating_thumbnails, + get_thumbnail_cache_path, + get_thumbnail_executor, + logger, +) + +router = APIRouter(prefix="/emoji", tags=["Emoji"]) + + +def _normalize_emoji_description(description: str = "", emotion: str = "") -> str: + """将上传参数中的描述或情绪标签归一化为可存储描述。 + + Args: + description: 用户输入的表情包描述。 + emotion: 用户输入的情绪标签。 + + Returns: + str: 归一化后的描述字符串。 + """ + normalized_description = str(description or "").strip() + normalized_emotion = str(emotion or "").strip() + if normalized_description: + return normalized_description + if not normalized_emotion: + return "" + + tags = re.split(r"[,,、;;\s]+", normalized_emotion) + return ",".join(item.strip() for item in tags if item.strip()) + + +@router.get("/list", response_model=EmojiListResponse) +async def get_emoji_list( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + search: Optional[str] = Query(None, description="搜索关键词"), + is_registered: Optional[bool] = Query(None, description="是否已注册筛选"), + is_banned: Optional[bool] = Query(None, description="是否被禁用筛选"), + sort_by: Optional[str] = Query("query_count", description="排序字段"), + sort_order: Optional[str] = Query("desc", description="排序方向"), + maibot_session: Optional[str] = Cookie(None), +) -> EmojiListResponse: + """获取表情包列表。 + + Args: + page: 页码,从 1 开始。 + page_size: 每页数量,范围为 1-100。 + search: 搜索关键词,用于匹配描述或哈希。 + is_registered: 是否已注册筛选条件。 + is_banned: 是否被禁用筛选条件。 + sort_by: 排序字段。 + sort_order: 排序方向。 + maibot_session: WebUI 登录会话 Cookie。 + + Returns: + EmojiListResponse: 分页后的表情包列表。 + """ + try: + verify_auth_token(maibot_session) + + statement = select(Images).where(col(Images.image_type) == ImageType.EMOJI) + + if search: + statement = statement.where( + (col(Images.description).contains(search)) | (col(Images.image_hash).contains(search)) + ) + + if is_registered is not None: + statement = statement.where(col(Images.is_registered) == is_registered) + + if is_banned is not None: + statement = statement.where(col(Images.is_banned) == is_banned) + + sort_field_map = { + "usage_count": col(Images.query_count), + "query_count": col(Images.query_count), + "register_time": col(Images.register_time), + "record_time": col(Images.record_time), + "last_used_time": col(Images.last_used_time), + } + sort_field = sort_field_map.get(sort_by or "query_count", col(Images.query_count)) + statement = statement.order_by(sort_field.asc() if sort_order == "asc" else sort_field.desc()) + + offset = (page - 1) * page_size + statement = statement.offset(offset).limit(page_size) + + with get_db_session() as session: + emojis = session.exec(statement).all() + + count_statement = select(func.count()).select_from(Images).where(col(Images.image_type) == ImageType.EMOJI) + if search: + count_statement = count_statement.where( + (col(Images.description).contains(search)) | (col(Images.image_hash).contains(search)) + ) + if is_registered is not None: + count_statement = count_statement.where(col(Images.is_registered) == is_registered) + if is_banned is not None: + count_statement = count_statement.where(col(Images.is_banned) == is_banned) + total = session.exec(count_statement).one() + data = [emoji_to_response(emoji) for emoji in emojis] + + return EmojiListResponse( + success=True, + total=total, + page=page, + page_size=page_size, + data=data, + ) + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取表情包列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取表情包列表失败: {str(e)}") from e + + +@router.get("/{emoji_id}", response_model=EmojiDetailResponse) +async def get_emoji_detail(emoji_id: int, maibot_session: Optional[str] = Cookie(None)) -> EmojiDetailResponse: + """获取表情包详细信息。 + + Args: + emoji_id: 表情包 ID。 + maibot_session: WebUI 登录会话 Cookie。 + + Returns: + EmojiDetailResponse: 表情包详细信息。 + """ + try: + verify_auth_token(maibot_session) + + with get_db_session() as session: + statement = select(Images).where( + col(Images.id) == emoji_id, + col(Images.image_type) == ImageType.EMOJI, + ) + if not (emoji := session.exec(statement).first()): + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + return EmojiDetailResponse(success=True, data=emoji_to_response(emoji)) + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取表情包详情失败: {e}") + raise HTTPException(status_code=500, detail=f"获取表情包详情失败: {str(e)}") from e + + +@router.patch("/{emoji_id}", response_model=EmojiUpdateResponse) +async def update_emoji( + emoji_id: int, + request: EmojiUpdateRequest, + maibot_session: Optional[str] = Cookie(None), +) -> EmojiUpdateResponse: + """增量更新表情包。 + + Args: + emoji_id: 表情包 ID。 + request: 只包含需要更新字段的请求数据。 + maibot_session: WebUI 登录会话 Cookie。 + + Returns: + EmojiUpdateResponse: 更新结果和更新后的表情包数据。 + """ + try: + verify_auth_token(maibot_session) + + with get_db_session() as session: + statement = select(Images).where( + col(Images.id) == emoji_id, + col(Images.image_type) == ImageType.EMOJI, + ) + emoji = session.exec(statement).first() + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + update_data = request.model_dump(exclude_unset=True) + if not update_data: + raise HTTPException(status_code=400, detail="未提供任何需要更新的字段") + + if "is_registered" in update_data and update_data["is_registered"] and not emoji.is_registered: + update_data["register_time"] = datetime.now() + + if "emotion" in update_data: + normalized_description = _normalize_emoji_description( + description=update_data.get("description", ""), + emotion=update_data.get("emotion", ""), + ) + update_data["description"] = normalized_description + update_data.pop("emotion", None) + + if "description" in update_data: + emoji.description = update_data["description"] + if "is_registered" in update_data: + emoji.is_registered = update_data["is_registered"] + if "is_banned" in update_data: + emoji.is_banned = update_data["is_banned"] + if "register_time" in update_data: + emoji.register_time = update_data["register_time"] + + session.add(emoji) + logger.info(f"表情包已更新: ID={emoji_id}, 字段: {list(update_data.keys())}") + + return EmojiUpdateResponse( + success=True, + message=f"成功更新 {len(update_data)} 个字段", + data=emoji_to_response(emoji), + ) + except HTTPException: + raise + except Exception as e: + logger.exception(f"更新表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"更新表情包失败: {str(e)}") from e + + +@router.delete("/{emoji_id}", response_model=EmojiDeleteResponse) +async def delete_emoji(emoji_id: int, maibot_session: Optional[str] = Cookie(None)) -> EmojiDeleteResponse: + """删除表情包。 + + Args: + emoji_id: 表情包 ID。 + maibot_session: WebUI 登录会话 Cookie。 + + Returns: + EmojiDeleteResponse: 删除结果。 + """ + try: + verify_auth_token(maibot_session) + + with get_db_session() as session: + statement = select(Images).where( + col(Images.id) == emoji_id, + col(Images.image_type) == ImageType.EMOJI, + ) + emoji = session.exec(statement).first() + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + emoji_hash = emoji.image_hash + session.delete(emoji) + logger.info(f"表情包已删除: ID={emoji_id}, hash={emoji_hash}") + return EmojiDeleteResponse(success=True, message=f"成功删除表情包: {emoji_hash}") + except HTTPException: + raise + except Exception as e: + logger.exception(f"删除表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"删除表情包失败: {str(e)}") from e + + +@router.get("/stats/summary") +async def get_emoji_stats(maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """获取表情包统计数据。 + + Args: + maibot_session: WebUI 登录会话 Cookie。 + + Returns: + Dict[str, Any]: 表情包总数、格式分布和高频使用统计。 + """ + try: + verify_auth_token(maibot_session) + + with get_db_session() as session: + total_statement = select(func.count()).select_from(Images).where(col(Images.image_type) == ImageType.EMOJI) + registered_statement = ( + select(func.count()) + .select_from(Images) + .where( + col(Images.image_type) == ImageType.EMOJI, + col(Images.is_registered), + ) + ) + banned_statement = ( + select(func.count()) + .select_from(Images) + .where( + col(Images.image_type) == ImageType.EMOJI, + col(Images.is_banned), + ) + ) + + total = session.exec(total_statement).one() + registered = session.exec(registered_statement).one() + banned = session.exec(banned_statement).one() + + formats: Dict[str, int] = {} + format_statement = select(Images.full_path).where(col(Images.image_type) == ImageType.EMOJI) + for full_path in session.exec(format_statement).all(): + suffix = Path(full_path).suffix.lower().lstrip(".") + fmt = suffix or "unknown" + formats[fmt] = formats.get(fmt, 0) + 1 + + top_used_statement = ( + select(Images) + .where(col(Images.image_type) == ImageType.EMOJI) + .order_by(col(Images.query_count).desc()) + .limit(10) + ) + top_used_list = [ + { + "id": emoji.id, + "emoji_hash": emoji.image_hash, + "description": emoji.description, + "usage_count": emoji.query_count, + } + for emoji in session.exec(top_used_statement).all() + ] + + return { + "success": True, + "data": { + "total": total, + "registered": registered, + "banned": banned, + "unregistered": total - registered, + "formats": formats, + "top_used": top_used_list, + }, + } + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取统计数据失败: {e}") + raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e + + +@router.post("/{emoji_id}/register", response_model=EmojiUpdateResponse) +async def register_emoji(emoji_id: int, maibot_session: Optional[str] = Cookie(None)) -> EmojiUpdateResponse: + """注册表情包。 + + Args: + emoji_id: 表情包 ID。 + maibot_session: WebUI 登录会话 Cookie。 + + Returns: + EmojiUpdateResponse: 注册结果和更新后的表情包数据。 + """ + try: + verify_auth_token(maibot_session) + + with get_db_session() as session: + statement = select(Images).where( + col(Images.id) == emoji_id, + col(Images.image_type) == ImageType.EMOJI, + ) + emoji = session.exec(statement).first() + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + if emoji.is_registered: + return EmojiUpdateResponse(success=True, message="表情包已注册", data=emoji_to_response(emoji)) + + emoji.is_registered = True + emoji.is_banned = False + emoji.register_time = datetime.now() + session.add(emoji) + + logger.info(f"表情包已注册: ID={emoji_id}") + return EmojiUpdateResponse(success=True, message="表情包注册成功", data=emoji_to_response(emoji)) + except HTTPException: + raise + except Exception as e: + logger.exception(f"注册表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"注册表情包失败: {str(e)}") from e + + +@router.post("/{emoji_id}/ban", response_model=EmojiUpdateResponse) +async def ban_emoji(emoji_id: int, maibot_session: Optional[str] = Cookie(None)) -> EmojiUpdateResponse: + """禁用表情包。 + + Args: + emoji_id: 表情包 ID。 + maibot_session: WebUI 登录会话 Cookie。 + + Returns: + EmojiUpdateResponse: 禁用结果和更新后的表情包数据。 + """ + try: + verify_auth_token(maibot_session) + + with get_db_session() as session: + statement = select(Images).where( + col(Images.id) == emoji_id, + col(Images.image_type) == ImageType.EMOJI, + ) + emoji = session.exec(statement).first() + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + + emoji.is_banned = True + emoji.is_registered = False + session.add(emoji) + + logger.info(f"表情包已禁用: ID={emoji_id}") + return EmojiUpdateResponse(success=True, message="表情包禁用成功", data=emoji_to_response(emoji)) + except HTTPException: + raise + except Exception as e: + logger.exception(f"禁用表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"禁用表情包失败: {str(e)}") from e + + +@router.get("/{emoji_id}/thumbnail", response_model=None) +async def get_emoji_thumbnail( + emoji_id: int, + token: Optional[str] = Query(None, description="访问令牌"), + maibot_session: Optional[str] = Cookie(None), + original: bool = Query(False, description="是否返回原图"), +) -> FileResponse | JSONResponse: + """获取表情包缩略图。 + + Args: + emoji_id: 表情包 ID。 + token: URL 中携带的访问令牌。 + maibot_session: WebUI 登录会话 Cookie。 + original: 是否返回原图。 + + Returns: + FileResponse | JSONResponse: 缩略图文件、原图文件或生成中的状态响应。 + """ + try: + token_manager = get_token_manager() + is_valid = False + + if maibot_session and token_manager.verify_token(maibot_session): + is_valid = True + elif token and token_manager.verify_token(token): + is_valid = True + + if not is_valid: + raise HTTPException(status_code=401, detail="Token 无效或已过期") + + with get_db_session() as session: + statement = select(Images).where( + col(Images.id) == emoji_id, + col(Images.image_type) == ImageType.EMOJI, + ) + emoji = session.exec(statement).first() + + if not emoji: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包") + if not os.path.exists(emoji.full_path): + raise HTTPException(status_code=404, detail="表情包文件不存在") + + if original: + mime_types = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "webp": "image/webp", + "bmp": "image/bmp", + } + suffix = Path(emoji.full_path).suffix.lower().lstrip(".") + media_type = mime_types.get(suffix, "application/octet-stream") + return FileResponse( + path=emoji.full_path, + media_type=media_type, + filename=f"{emoji.image_hash}.{suffix}", + ) + + cache_path = get_thumbnail_cache_path(emoji.image_hash) + if cache_path.exists(): + return FileResponse( + path=str(cache_path), + media_type="image/webp", + filename=f"{emoji.image_hash}_thumb.webp", + ) + + generating_lock = get_generating_lock() + generating_thumbnails = get_generating_thumbnails() + with generating_lock: + if emoji.image_hash not in generating_thumbnails: + generating_thumbnails.add(emoji.image_hash) + get_thumbnail_executor().submit(background_generate_thumbnail, emoji.full_path, emoji.image_hash) + + return JSONResponse( + status_code=202, + content={ + "status": "generating", + "message": "缩略图正在生成中,请稍后重试", + "emoji_id": emoji_id, + }, + headers={"Retry-After": "1"}, + ) + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取表情包缩略图失败: {e}") + raise HTTPException(status_code=500, detail=f"获取表情包缩略图失败: {str(e)}") from e + + +@router.post("/batch/delete", response_model=BatchDeleteResponse) +async def batch_delete_emojis( + request: BatchDeleteRequest, + maibot_session: Optional[str] = Cookie(None), +) -> BatchDeleteResponse: + """批量删除表情包。 + + Args: + request: 包含要删除表情包 ID 列表的请求。 + maibot_session: WebUI 登录会话 Cookie。 + + Returns: + BatchDeleteResponse: 批量删除结果。 + """ + try: + verify_auth_token(maibot_session) + + if not request.emoji_ids: + raise HTTPException(status_code=400, detail="未提供要删除的表情包ID") + + deleted_count = 0 + failed_count = 0 + failed_ids: List[int] = [] + + for emoji_id in request.emoji_ids: + try: + with get_db_session() as session: + statement = select(Images).where( + col(Images.id) == emoji_id, + col(Images.image_type) == ImageType.EMOJI, + ) + if emoji := session.exec(statement).first(): + session.delete(emoji) + deleted_count += 1 + logger.info(f"批量删除表情包: {emoji_id}") + else: + failed_count += 1 + failed_ids.append(emoji_id) + except Exception as e: + logger.error(f"删除表情包 {emoji_id} 失败: {e}") + failed_count += 1 + failed_ids.append(emoji_id) + + message = f"成功删除 {deleted_count} 个表情包" + if failed_count > 0: + message += f",{failed_count} 个失败" + + return BatchDeleteResponse( + success=True, + message=message, + deleted_count=deleted_count, + failed_count=failed_count, + failed_ids=failed_ids, + ) + except HTTPException: + raise + except Exception as e: + logger.exception(f"批量删除表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"批量删除失败: {str(e)}") from e + + +@router.post("/upload", response_model=EmojiUploadResponse) +async def upload_emoji( + file: EmojiFile, + description: DescriptionForm = "", + emotion: EmotionForm = "", + is_registered: IsRegisteredForm = True, + maibot_session: Optional[str] = Cookie(None), +) -> EmojiUploadResponse: + """上传并注册表情包。 + + Args: + file: 上传的表情包文件。 + description: 表情包描述。 + emotion: 情绪标签。 + is_registered: 是否上传后直接注册。 + maibot_session: WebUI 登录会话 Cookie。 + + Returns: + EmojiUploadResponse: 上传结果和新表情包数据。 + """ + try: + verify_auth_token(maibot_session) + + if not file.content_type: + raise HTTPException(status_code=400, detail="无法识别文件类型") + + allowed_types = ["image/jpeg", "image/png", "image/gif", "image/webp"] + if file.content_type not in allowed_types: + raise HTTPException( + status_code=400, + detail=f"不支持的文件类型: {file.content_type},支持: {', '.join(allowed_types)}", + ) + + file_content = await file.read() + if not file_content: + raise HTTPException(status_code=400, detail="文件内容为空") + + try: + with Image.open(io.BytesIO(file_content)) as img: + img.verify() + except Exception as e: + raise HTTPException(status_code=400, detail=f"无效的图片文件: {str(e)}") from e + + with Image.open(io.BytesIO(file_content)) as img: + img_format = img.format.lower() if img.format else "png" + + emoji_hash = hashlib.md5(file_content).hexdigest() + + with get_db_session() as session: + existing_statement = select(Images).where( + col(Images.image_hash) == emoji_hash, + col(Images.image_type) == ImageType.EMOJI, + ) + if existing_emoji := session.exec(existing_statement).first(): + raise HTTPException(status_code=409, detail=f"已存在相同的表情包 (ID: {existing_emoji.id})") + + os.makedirs(EMOJI_DIR, exist_ok=True) + + timestamp = int(datetime.now().timestamp()) + filename = f"emoji_{timestamp}_{emoji_hash[:8]}.{img_format}" + full_path = os.path.join(EMOJI_DIR, filename) + + counter = 1 + while os.path.exists(full_path): + filename = f"emoji_{timestamp}_{emoji_hash[:8]}_{counter}.{img_format}" + full_path = os.path.join(EMOJI_DIR, filename) + counter += 1 + + with open(full_path, "wb") as output_file: + _ = output_file.write(file_content) + + logger.info(f"表情包文件已保存: {full_path}") + final_description = _normalize_emoji_description(description=description, emotion=emotion) + + current_time = datetime.now() + with get_db_session() as session: + emoji = Images( + image_type=ImageType.EMOJI, + full_path=full_path, + image_hash=emoji_hash, + description=final_description, + query_count=0, + is_registered=is_registered, + is_banned=False, + record_time=current_time, + register_time=current_time if is_registered else None, + last_used_time=None, + ) + session.add(emoji) + session.flush() + + logger.info(f"表情包已上传并注册: ID={emoji.id}, hash={emoji_hash}") + return EmojiUploadResponse( + success=True, + message="表情包上传成功" + ("并已注册" if is_registered else ""), + data=emoji_to_response(emoji), + ) + except HTTPException: + raise + except Exception as e: + logger.exception(f"上传表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}") from e + + +@router.post("/batch/upload") +async def batch_upload_emoji( + files: EmojiFiles, + emotion: EmotionForm = "", + is_registered: IsRegisteredForm = True, + maibot_session: Optional[str] = Cookie(None), +) -> Dict[str, Any]: + """批量上传表情包。 + + Args: + files: 上传的表情包文件列表。 + emotion: 批量应用的情绪标签。 + is_registered: 是否上传后直接注册。 + maibot_session: WebUI 登录会话 Cookie。 + + Returns: + Dict[str, Any]: 每个文件的上传结果和汇总统计。 + """ + try: + verify_auth_token(maibot_session) + + results: Dict[str, Any] = { + "success": True, + "total": len(files), + "uploaded": 0, + "failed": 0, + "details": [], + } + + allowed_types = ["image/jpeg", "image/png", "image/gif", "image/webp"] + os.makedirs(EMOJI_DIR, exist_ok=True) + + for file in files: + try: + if file.content_type not in allowed_types: + results["failed"] += 1 + results["details"].append( + { + "filename": file.filename, + "success": False, + "error": f"不支持的文件类型: {file.content_type}", + } + ) + continue + + file_content = await file.read() + if not file_content: + results["failed"] += 1 + results["details"].append({"filename": file.filename, "success": False, "error": "文件内容为空"}) + continue + + try: + with Image.open(io.BytesIO(file_content)) as img: + img_format = img.format.lower() if img.format else "png" + except Exception as e: + results["failed"] += 1 + results["details"].append( + {"filename": file.filename, "success": False, "error": f"无效的图片: {str(e)}"} + ) + continue + + emoji_hash = hashlib.md5(file_content).hexdigest() + + with get_db_session() as session: + existing_statement = select(Images).where( + col(Images.image_hash) == emoji_hash, + col(Images.image_type) == ImageType.EMOJI, + ) + if session.exec(existing_statement).first(): + results["failed"] += 1 + results["details"].append( + {"filename": file.filename, "success": False, "error": "已存在相同的表情包"} + ) + continue + + timestamp = int(datetime.now().timestamp()) + filename = f"emoji_{timestamp}_{emoji_hash[:8]}.{img_format}" + full_path = os.path.join(EMOJI_DIR, filename) + + counter = 1 + while os.path.exists(full_path): + filename = f"emoji_{timestamp}_{emoji_hash[:8]}_{counter}.{img_format}" + full_path = os.path.join(EMOJI_DIR, filename) + counter += 1 + + with open(full_path, "wb") as output_file: + _ = output_file.write(file_content) + + current_time = datetime.now() + final_description = _normalize_emoji_description(emotion=emotion) + + with get_db_session() as session: + emoji = Images( + image_type=ImageType.EMOJI, + full_path=full_path, + image_hash=emoji_hash, + description=final_description, + query_count=0, + is_registered=is_registered, + is_banned=False, + record_time=current_time, + register_time=current_time if is_registered else None, + last_used_time=None, + ) + session.add(emoji) + session.flush() + + results["uploaded"] += 1 + results["details"].append({"filename": file.filename, "success": True, "id": emoji.id}) + except Exception as e: + results["failed"] += 1 + results["details"].append({"filename": file.filename, "success": False, "error": str(e)}) + + results["message"] = f"成功上传 {results['uploaded']} 个,失败 {results['failed']} 个" + return results + except HTTPException: + raise + except Exception as e: + logger.exception(f"批量上传表情包失败: {e}") + raise HTTPException(status_code=500, detail=f"批量上传失败: {str(e)}") from e + + +@router.get("/thumbnail-cache/stats", response_model=ThumbnailCacheStatsResponse) +async def get_thumbnail_cache_stats(maibot_session: Optional[str] = Cookie(None)) -> ThumbnailCacheStatsResponse: + """获取缩略图缓存统计信息。 + + Args: + maibot_session: WebUI 登录会话 Cookie。 + + Returns: + ThumbnailCacheStatsResponse: 缩略图缓存数量、大小和覆盖率统计。 + """ + try: + verify_auth_token(maibot_session) + + ensure_thumbnail_cache_dir() + cache_files = list(THUMBNAIL_CACHE_DIR.glob("*.webp")) + total_count = len(cache_files) + total_size_mb = round(sum(item.stat().st_size for item in cache_files) / (1024 * 1024), 2) + + with get_db_session() as session: + count_statement = select(func.count()).select_from(Images).where(col(Images.image_type) == ImageType.EMOJI) + emoji_count = session.exec(count_statement).one() + + coverage_percent = round((total_count / emoji_count * 100) if emoji_count > 0 else 0, 1) + return ThumbnailCacheStatsResponse( + success=True, + cache_dir=str(THUMBNAIL_CACHE_DIR.absolute()), + total_count=total_count, + total_size_mb=total_size_mb, + emoji_count=emoji_count, + coverage_percent=coverage_percent, + ) + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取缩略图缓存统计失败: {e}") + raise HTTPException(status_code=500, detail=f"获取统计失败: {str(e)}") from e + + +@router.post("/thumbnail-cache/cleanup", response_model=ThumbnailCleanupResponse) +async def cleanup_thumbnail_cache(maibot_session: Optional[str] = Cookie(None)) -> ThumbnailCleanupResponse: + """清理孤立的缩略图缓存。 + + Args: + maibot_session: WebUI 登录会话 Cookie。 + + Returns: + ThumbnailCleanupResponse: 清理结果和删除数量。 + """ + try: + verify_auth_token(maibot_session) + + cleaned, kept = cleanup_orphaned_thumbnails() + return ThumbnailCleanupResponse( + success=True, + message=f"清理完成:删除 {cleaned} 个孤立缓存,保留 {kept} 个有效缓存", + cleaned_count=cleaned, + kept_count=kept, + ) + except HTTPException: + raise + except Exception as e: + logger.exception(f"清理缩略图缓存失败: {e}") + raise HTTPException(status_code=500, detail=f"清理失败: {str(e)}") from e + + +@router.post("/thumbnail-cache/preheat", response_model=ThumbnailPreheatResponse) +async def preheat_thumbnail_cache( + limit: int = Query(100, ge=1, le=1000, description="最多预热数量"), + maibot_session: Optional[str] = Cookie(None), +) -> ThumbnailPreheatResponse: + """预热缩略图缓存。 + + Args: + limit: 最多预热的缩略图数量。 + maibot_session: WebUI 登录会话 Cookie。 + + Returns: + ThumbnailPreheatResponse: 预热生成、跳过和失败数量统计。 + """ + try: + verify_auth_token(maibot_session) + + ensure_thumbnail_cache_dir() + + with get_db_session() as session: + statement = ( + select(Images) + .where( + col(Images.image_type) == ImageType.EMOJI, + col(Images.is_banned).is_(False), + ) + .order_by(col(Images.query_count).desc()) + .limit(limit * 2) + ) + emojis = [ + { + "image_hash": emoji.image_hash, + "full_path": emoji.full_path, + } + for emoji in session.exec(statement).all() + ] + + generated = 0 + skipped = 0 + failed = 0 + + for emoji in emojis: + if generated >= limit: + break + + image_hash = emoji["image_hash"] + full_path = emoji["full_path"] + + cache_path = get_thumbnail_cache_path(image_hash) + if cache_path.exists(): + skipped += 1 + continue + if not os.path.exists(full_path): + failed += 1 + continue + + try: + loop = asyncio.get_event_loop() + await loop.run_in_executor( + get_thumbnail_executor(), generate_thumbnail, full_path, image_hash + ) + generated += 1 + except Exception as e: + logger.warning(f"预热缩略图失败 {image_hash}: {e}") + failed += 1 + + return ThumbnailPreheatResponse( + success=True, + message=f"预热完成:生成 {generated} 个,跳过 {skipped} 个已缓存,失败 {failed} 个", + generated_count=generated, + skipped_count=skipped, + failed_count=failed, + ) + except HTTPException: + raise + except Exception as e: + logger.exception(f"预热缩略图缓存失败: {e}") + raise HTTPException(status_code=500, detail=f"预热失败: {str(e)}") from e + + +@router.delete("/thumbnail-cache/clear", response_model=ThumbnailCleanupResponse) +async def clear_all_thumbnail_cache(maibot_session: Optional[str] = Cookie(None)) -> ThumbnailCleanupResponse: + """清空所有缩略图缓存。 + + Args: + maibot_session: WebUI 登录会话 Cookie。 + + Returns: + ThumbnailCleanupResponse: 清空结果和删除数量。 + """ + try: + verify_auth_token(maibot_session) + + if not THUMBNAIL_CACHE_DIR.exists(): + return ThumbnailCleanupResponse( + success=True, + message="缓存目录不存在,无需清理", + cleaned_count=0, + kept_count=0, + ) + + cleaned = 0 + for cache_file in THUMBNAIL_CACHE_DIR.glob("*.webp"): + try: + cache_file.unlink() + cleaned += 1 + except Exception as e: + logger.warning(f"删除缓存文件失败 {cache_file.name}: {e}") + + logger.info(f"已清空缩略图缓存: 删除 {cleaned} 个文件") + return ThumbnailCleanupResponse( + success=True, + message=f"已清空所有缩略图缓存:删除 {cleaned} 个文件", + cleaned_count=cleaned, + kept_count=0, + ) + except HTTPException: + raise + except Exception as e: + logger.exception(f"清空缩略图缓存失败: {e}") + raise HTTPException(status_code=500, detail=f"清空失败: {str(e)}") from e diff --git a/src/webui/routers/emoji/schemas.py b/src/webui/routers/emoji/schemas.py new file mode 100644 index 00000000..32402aa9 --- /dev/null +++ b/src/webui/routers/emoji/schemas.py @@ -0,0 +1,167 @@ +from pathlib import Path +from typing import Annotated, List, Optional + +from fastapi import File, Form, UploadFile +from pydantic import BaseModel + +import re + +from src.common.database.database_model import Images + +EmojiFile = Annotated[UploadFile, File(description="表情包上传文件")] +EmojiFiles = Annotated[List[UploadFile], File(description="多个表情包上传文件")] +DescriptionForm = Annotated[str, Form(description="表情包描述")] +EmotionForm = Annotated[str, Form(description="情绪标签,多个使用逗号分隔")] +IsRegisteredForm = Annotated[bool, Form(description="是否直接注册")] + + +class EmojiResponse(BaseModel): + """表情包响应结构""" + + id: int + full_path: str + format: str + emoji_hash: str + description: str + query_count: int + usage_count: int + is_registered: bool + is_banned: bool + emotion: Optional[str] + record_time: float + register_time: Optional[float] + last_used_time: Optional[float] + + +class EmojiListResponse(BaseModel): + """表情包列表响应""" + + success: bool + total: int + page: int + page_size: int + data: List[EmojiResponse] + + +class EmojiDetailResponse(BaseModel): + """表情包详情响应""" + + success: bool + data: EmojiResponse + + +class EmojiUpdateRequest(BaseModel): + """表情包更新请求""" + + description: Optional[str] = None + is_registered: Optional[bool] = None + is_banned: Optional[bool] = None + emotion: Optional[str] = None + + +class EmojiUpdateResponse(BaseModel): + """表情包更新响应""" + + success: bool + message: str + data: Optional[EmojiResponse] = None + + +class EmojiDeleteResponse(BaseModel): + """表情包删除响应""" + + success: bool + message: str + + +class BatchDeleteRequest(BaseModel): + """批量删除请求""" + + emoji_ids: List[int] + + +class BatchDeleteResponse(BaseModel): + """批量删除响应""" + + success: bool + message: str + deleted_count: int + failed_count: int + failed_ids: List[int] = [] + + +class EmojiUploadResponse(BaseModel): + """表情包上传响应""" + + success: bool + message: str + data: Optional[EmojiResponse] = None + + +class ThumbnailCacheStatsResponse(BaseModel): + """缩略图缓存统计响应""" + + success: bool + cache_dir: str + total_count: int + total_size_mb: float + emoji_count: int + coverage_percent: float + + +class ThumbnailCleanupResponse(BaseModel): + """缩略图清理响应""" + + success: bool + message: str + cleaned_count: int + kept_count: int + + +class ThumbnailPreheatResponse(BaseModel): + """缩略图预热响应""" + + success: bool + message: str + generated_count: int + skipped_count: int + failed_count: int + + +def emoji_to_response(image: Images) -> EmojiResponse: + """将表情包模型转换为响应对象。 + + Args: + image: 数据库中的表情包记录。 + + Returns: + EmojiResponse: WebUI 可直接序列化的表情包数据。 + """ + emotions: list[str] = [] + if image.description: + emotions.extend( + item.strip() for item in re.split(r"[,,、;;\s]+", image.description) if item and item.strip() + ) + + deduped_emotions: list[str] = [] + for item in emotions: + if item not in deduped_emotions: + deduped_emotions.append(item) + emotion = ",".join(deduped_emotions) if deduped_emotions else None + image_format = Path(image.full_path).suffix.lower().lstrip(".") or "unknown" + + return EmojiResponse( + id=image.id if image.id is not None else 0, + full_path=image.full_path, + format=image_format, + emoji_hash=image.image_hash, + description=image.description, + query_count=image.query_count, + usage_count=image.query_count, + is_registered=image.is_registered, + is_banned=image.is_banned, + emotion=emotion, + record_time=image.record_time.timestamp() if image.record_time else 0.0, + register_time=image.register_time.timestamp() if image.register_time else None, + last_used_time=image.last_used_time.timestamp() if image.last_used_time else None, + ) diff --git a/src/webui/routers/emoji/support.py b/src/webui/routers/emoji/support.py new file mode 100644 index 00000000..4b715997 --- /dev/null +++ b/src/webui/routers/emoji/support.py @@ -0,0 +1,143 @@ +import os +import threading +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Dict, Set, Tuple + +from PIL import Image +from sqlmodel import col, select + +from src.common.database.database import get_db_session +from src.common.database.database_model import Images, ImageType +from src.common.logger import get_logger + +logger = get_logger("webui.emoji") + +THUMBNAIL_CACHE_DIR = Path("data/emoji_thumbnails") +THUMBNAIL_SIZE = (200, 200) +THUMBNAIL_QUALITY = 80 +EMOJI_REGISTERED_DIR = os.path.join("data", "emoji") +EMOJI_DIR = EMOJI_REGISTERED_DIR + +_thumbnail_locks: Dict[str, threading.Lock] = {} +_locks_lock = threading.Lock() +_thumbnail_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="thumbnail") +_generating_thumbnails: Set[str] = set() +_generating_lock = threading.Lock() + + +def get_thumbnail_executor() -> ThreadPoolExecutor: + """获取缩略图生成线程池。""" + return _thumbnail_executor + + +def get_generating_lock() -> threading.Lock: + """获取缩略图生成状态锁。""" + return _generating_lock + + +def get_generating_thumbnails() -> Set[str]: + """获取正在生成的缩略图哈希集合。""" + return _generating_thumbnails + + +def _get_thumbnail_lock(file_hash: str) -> threading.Lock: + """获取指定文件哈希的锁,用于防止并发生成同一缩略图。""" + with _locks_lock: + if file_hash not in _thumbnail_locks: + _thumbnail_locks[file_hash] = threading.Lock() + return _thumbnail_locks[file_hash] + + +def _background_generate_thumbnail(source_path: str, file_hash: str) -> None: + """在线程池中后台生成缩略图。""" + try: + _generate_thumbnail(source_path, file_hash) + except Exception as e: + logger.warning(f"后台生成缩略图失败 {file_hash}: {e}") + finally: + with _generating_lock: + _generating_thumbnails.discard(file_hash) + + +def ensure_thumbnail_cache_dir() -> Path: + """确保缩略图缓存目录存在。""" + _ = THUMBNAIL_CACHE_DIR.mkdir(parents=True, exist_ok=True) + return THUMBNAIL_CACHE_DIR + + +def get_thumbnail_cache_path(file_hash: str) -> Path: + """获取缩略图缓存路径。""" + return THUMBNAIL_CACHE_DIR / f"{file_hash}.webp" + + +def _generate_thumbnail(source_path: str, file_hash: str) -> Path: + """生成缩略图并保存到缓存目录。""" + ensure_thumbnail_cache_dir() + cache_path = get_thumbnail_cache_path(file_hash) + + lock = _get_thumbnail_lock(file_hash) + with lock: + if cache_path.exists(): + return cache_path + + try: + with Image.open(source_path) as img: + if getattr(img, "n_frames", 1) > 1: + img.seek(0) + + if img.mode in ("P", "PA"): + img = img.convert("RGBA") + elif img.mode == "LA": + img = img.convert("RGBA") + elif img.mode not in ("RGB", "RGBA"): + img = img.convert("RGB") + + img.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS) + img.save(cache_path, "WEBP", quality=THUMBNAIL_QUALITY, method=6) + logger.debug(f"生成缩略图: {file_hash} -> {cache_path}") + except Exception as e: + logger.warning(f"生成缩略图失败 {file_hash}: {e},将返回原图") + raise + + return cache_path + + +def generate_thumbnail(source_path: str, file_hash: str) -> Path: + """暴露给路由层的缩略图生成函数。""" + return _generate_thumbnail(source_path, file_hash) + + +def background_generate_thumbnail(source_path: str, file_hash: str) -> None: + """暴露给路由层的后台缩略图生成函数。""" + _background_generate_thumbnail(source_path, file_hash) + + +def cleanup_orphaned_thumbnails() -> Tuple[int, int]: + """清理孤立的缩略图缓存。""" + if not THUMBNAIL_CACHE_DIR.exists(): + return 0, 0 + + with get_db_session() as session: + statement = select(Images.image_hash).where(col(Images.image_type) == ImageType.EMOJI) + valid_hashes = set(session.exec(statement).all()) + + cleaned = 0 + kept = 0 + + for cache_file in THUMBNAIL_CACHE_DIR.glob("*.webp"): + file_hash = cache_file.stem + if file_hash not in valid_hashes: + try: + cache_file.unlink() + cleaned += 1 + logger.debug(f"清理孤立缩略图: {cache_file.name}") + except Exception as e: + logger.warning(f"清理缩略图失败 {cache_file.name}: {e}") + else: + kept += 1 + + if cleaned > 0: + logger.info(f"清理孤立缩略图: 删除 {cleaned} 个,保留 {kept} 个") + + return cleaned, kept diff --git a/src/webui/routers/expression.py b/src/webui/routers/expression.py new file mode 100644 index 00000000..0b131b4d --- /dev/null +++ b/src/webui/routers/expression.py @@ -0,0 +1,837 @@ +"""表达方式管理 API 路由""" + +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import case, func +from sqlmodel import col, delete, select + +from src.chat.message_receive.chat_manager import chat_manager as _chat_manager +from src.common.database.database import get_db_session +from src.common.database.database_model import ChatSession, Expression, Messages, ModifiedBy +from src.common.logger import get_logger +from src.webui.dependencies import require_auth + +logger = get_logger("webui.expression") +EXCLUDE_IDS_QUERY = Query(None, description="需要排除的表达方式 ID") + +# 创建路由器 +router = APIRouter(prefix="/expression", tags=["Expression"], dependencies=[Depends(require_auth)]) + + +class ExpressionResponse(BaseModel): + """表达方式响应""" + + id: int + situation: str + style: str + last_active_time: float + chat_id: str + chat_name: Optional[str] = None + create_date: Optional[float] + checked: bool + rejected: bool + modified_by: Optional[str] = None # 'ai' 或 'user' 或 None + + +class ExpressionListResponse(BaseModel): + """表达方式列表响应""" + + success: bool + total: int + page: int + page_size: int + data: List[ExpressionResponse] + + +class ExpressionDetailResponse(BaseModel): + """表达方式详情响应""" + + success: bool + data: ExpressionResponse + + +class ExpressionCreateRequest(BaseModel): + """表达方式创建请求""" + + situation: str + style: str + chat_id: str + + +class ExpressionUpdateRequest(BaseModel): + """表达方式更新请求""" + + situation: Optional[str] = None + style: Optional[str] = None + chat_id: Optional[str] = None + + +class ExpressionUpdateResponse(BaseModel): + """表达方式更新响应""" + + success: bool + message: str + data: Optional[ExpressionResponse] = None + + +class ExpressionDeleteResponse(BaseModel): + """表达方式删除响应""" + + success: bool + message: str + + +class ExpressionCreateResponse(BaseModel): + """表达方式创建响应""" + + success: bool + message: str + data: ExpressionResponse + + +def get_chat_name_from_latest_message(chat_id: str, db_session: Any) -> Optional[str]: + """从最近消息中解析聊天显示名称。""" + + statement = ( + select(Messages).where(col(Messages.session_id) == chat_id).order_by(col(Messages.timestamp).desc()).limit(1) + ) + message = db_session.exec(statement).first() + if not message: + return None + if message.group_id: + return message.group_name or f"群聊{message.group_id}" + return message.user_cardname or message.user_nickname or (f"用户{message.user_id}" if message.user_id else None) + + +def get_chat_name_from_session_record(chat_session: ChatSession) -> str: + """从会话记录推断兜底显示名称。""" + + if chat_session.group_id: + return f"群聊{chat_session.group_id}" + if chat_session.user_id: + return f"用户{chat_session.user_id}" + return chat_session.session_id + + +def get_chat_name(chat_id: str, db_session: Optional[Any] = None) -> str: + """根据聊天 ID 获取聊天名称。 + + Args: + chat_id: 聊天会话 ID。 + db_session: 可选数据库会话,用于从历史消息中解析群名或私聊用户名。 + + Returns: + str: 聊天显示名称,获取失败时返回原始聊天 ID。 + """ + + try: + if name := _chat_manager.get_session_name(chat_id): + return name + if db_session and (name := get_chat_name_from_latest_message(chat_id, db_session)): + return name + session = _chat_manager.get_session_by_session_id(chat_id) + if session: + if session.group_id: + return f"群聊{session.group_id}" + if session.user_id: + return f"用户{session.user_id}" + return chat_id + except Exception: + return chat_id + + +def expression_to_response(expression: Expression, db_session: Optional[Any] = None) -> ExpressionResponse: + """将表达方式模型转换为响应对象。 + + Args: + expression: 数据库中的表达方式记录。 + + Returns: + ExpressionResponse: WebUI 可直接序列化的响应对象。 + """ + last_active_time = expression.last_active_time.timestamp() if expression.last_active_time else 0.0 + create_date = expression.create_time.timestamp() if expression.create_time else None + chat_id = expression.session_id or "" + return ExpressionResponse( + id=expression.id if expression.id is not None else 0, + situation=expression.situation, + style=expression.style, + last_active_time=last_active_time, + chat_id=chat_id, + chat_name=get_chat_name(chat_id, db_session) if chat_id else None, + create_date=create_date, + checked=expression.checked, + rejected=expression.rejected, + modified_by=expression.modified_by.value if expression.modified_by else None, + ) + + +def get_chat_names_batch(chat_ids: List[str]) -> Dict[str, str]: + """批量获取聊天名称。 + + Args: + chat_ids: 需要查询的聊天会话 ID 列表。 + + Returns: + Dict[str, str]: 以聊天 ID 为键、显示名称为值的映射。 + """ + result = {cid: cid for cid in chat_ids} # 默认值为原始ID + try: + for chat_id in chat_ids: + result[chat_id] = get_chat_name(chat_id) + except Exception as e: + logger.warning(f"批量获取聊天名称失败: {e}") + return result + + +class ChatInfo(BaseModel): + """聊天信息""" + + chat_id: str + chat_name: str + platform: Optional[str] = None + is_group: bool = False + + +class ChatListResponse(BaseModel): + """聊天列表响应""" + + success: bool + data: List[ChatInfo] + + +@router.get("/chats", response_model=ChatListResponse) +async def get_chat_list() -> ChatListResponse: + """获取所有聊天列表。 + + Returns: + ChatListResponse: 可用于下拉选择的聊天列表。 + """ + try: + chat_by_id: Dict[str, ChatInfo] = {} + for session_id, session in _chat_manager.sessions.items(): + chat_name = _chat_manager.get_session_name(session_id) or session_id + chat_by_id[session_id] = ChatInfo( + chat_id=session_id, + chat_name=chat_name, + platform=session.platform, + is_group=session.is_group_session, + ) + + with get_db_session() as session: + for chat_session in session.exec(select(ChatSession)).all(): + if chat_session.session_id in chat_by_id: + continue + chat_name = get_chat_name_from_latest_message(chat_session.session_id, session) + chat_by_id[chat_session.session_id] = ChatInfo( + chat_id=chat_session.session_id, + chat_name=chat_name or get_chat_name_from_session_record(chat_session), + platform=chat_session.platform, + is_group=bool(chat_session.group_id), + ) + + expression_chat_ids = {chat_id for chat_id in session.exec(select(Expression.session_id)).all() if chat_id} + for session_id in expression_chat_ids: + if session_id in chat_by_id: + continue + chat_by_id[session_id] = ChatInfo( + chat_id=session_id, + chat_name=get_chat_name(session_id, session), + platform=None, + is_group=False, + ) + + # 按名称排序 + chat_list = list(chat_by_id.values()) + chat_list.sort(key=lambda x: x.chat_name) + + return ChatListResponse(success=True, data=chat_list) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取聊天列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取聊天列表失败: {str(e)}") from e + + +@router.get("/list", response_model=ExpressionListResponse) +async def get_expression_list( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + search: Optional[str] = Query(None, description="搜索关键词"), + chat_id: Optional[str] = Query(None, description="聊天ID筛选"), +) -> ExpressionListResponse: + """获取表达方式列表。 + + Args: + page: 页码,从 1 开始。 + page_size: 每页数量,范围为 1-100。 + search: 搜索关键词,用于匹配情景和风格。 + chat_id: 聊天 ID 筛选条件。 + + Returns: + ExpressionListResponse: 分页后的表达方式列表。 + """ + try: + # 构建查询 + statement = select(Expression) + + # 搜索过滤 + if search: + statement = statement.where( + (col(Expression.situation).contains(search)) | (col(Expression.style).contains(search)) + ) + + # 聊天ID过滤 + if chat_id: + statement = statement.where(col(Expression.session_id) == chat_id) + + # 排序:最后活跃时间倒序(NULL 值放在最后) + statement = statement.order_by( + case((col(Expression.last_active_time).is_(None), 1), else_=0), + col(Expression.last_active_time).desc(), + ) + + offset = (page - 1) * page_size + statement = statement.offset(offset).limit(page_size) + + with get_db_session() as session: + expressions = session.exec(statement).all() + + count_statement = select(Expression.id) + if search: + count_statement = count_statement.where( + (col(Expression.situation).contains(search)) | (col(Expression.style).contains(search)) + ) + if chat_id: + count_statement = count_statement.where(col(Expression.session_id) == chat_id) + total = len(session.exec(count_statement).all()) + data = [expression_to_response(expr, session) for expr in expressions] + + return ExpressionListResponse(success=True, total=total, page=page, page_size=page_size, data=data) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取表达方式列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取表达方式列表失败: {str(e)}") from e + + +@router.get("/{expression_id}", response_model=ExpressionDetailResponse) +async def get_expression_detail(expression_id: int) -> ExpressionDetailResponse: + """获取表达方式详细信息。 + + Args: + expression_id: 表达方式 ID。 + + Returns: + ExpressionDetailResponse: 指定表达方式的详细信息。 + """ + try: + with get_db_session() as session: + statement = select(Expression).where(col(Expression.id) == expression_id).limit(1) + expression = session.exec(statement).first() + + if not expression: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式") + + data = expression_to_response(expression, session) + + return ExpressionDetailResponse(success=True, data=data) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取表达方式详情失败: {e}") + raise HTTPException(status_code=500, detail=f"获取表达方式详情失败: {str(e)}") from e + + +@router.post("/", response_model=ExpressionCreateResponse) +async def create_expression( + request: ExpressionCreateRequest, +) -> ExpressionCreateResponse: + """创建新的表达方式。 + + Args: + request: 创建表达方式所需的请求数据。 + + Returns: + ExpressionCreateResponse: 创建结果和新表达方式数据。 + """ + try: + current_time = datetime.now() + + # 创建表达方式 + with get_db_session() as session: + expression = Expression( + situation=request.situation, + style=request.style, + content_list="[]", + count=0, + last_active_time=current_time, + create_time=current_time, + session_id=request.chat_id, + ) + session.add(expression) + session.flush() + expression_id = expression.id + data = expression_to_response(expression, session) + + logger.info(f"表达方式已创建: ID={expression_id}, situation={request.situation}") + + return ExpressionCreateResponse(success=True, message="表达方式创建成功", data=data) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"创建表达方式失败: {e}") + raise HTTPException(status_code=500, detail=f"创建表达方式失败: {str(e)}") from e + + +@router.patch("/{expression_id}", response_model=ExpressionUpdateResponse) +async def update_expression( + expression_id: int, + request: ExpressionUpdateRequest, +) -> ExpressionUpdateResponse: + """增量更新表达方式。 + + Args: + expression_id: 表达方式 ID。 + request: 只包含需要更新字段的请求数据。 + + Returns: + ExpressionUpdateResponse: 更新结果和更新后的表达方式数据。 + """ + try: + # 只更新提供的字段 + update_data = request.model_dump(exclude_unset=True) + + # 映射 API 字段名到数据库字段名 + if "chat_id" in update_data: + update_data["session_id"] = update_data.pop("chat_id") + + if not update_data: + raise HTTPException(status_code=400, detail="未提供任何需要更新的字段") + + # 更新最后活跃时间 + update_data["last_active_time"] = datetime.now() + + # 执行更新 + with get_db_session() as session: + db_expression = session.exec(select(Expression).where(col(Expression.id) == expression_id).limit(1)).first() + if not db_expression: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式") + if "situation" in update_data: + db_expression.situation = update_data["situation"] + if "style" in update_data: + db_expression.style = update_data["style"] + if "session_id" in update_data: + db_expression.session_id = update_data["session_id"] + db_expression.last_active_time = update_data["last_active_time"] + session.add(db_expression) + data = expression_to_response(db_expression, session) + + logger.info(f"表达方式已更新: ID={expression_id}, 字段: {list(update_data.keys())}") + + return ExpressionUpdateResponse(success=True, message=f"成功更新 {len(update_data)} 个字段", data=data) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"更新表达方式失败: {e}") + raise HTTPException(status_code=500, detail=f"更新表达方式失败: {str(e)}") from e + + +@router.delete("/{expression_id}", response_model=ExpressionDeleteResponse) +async def delete_expression(expression_id: int) -> ExpressionDeleteResponse: + """删除表达方式。 + + Args: + expression_id: 表达方式 ID。 + + Returns: + ExpressionDeleteResponse: 删除结果。 + """ + try: + with get_db_session() as session: + statement = select(Expression).where(col(Expression.id) == expression_id).limit(1) + expression = session.exec(statement).first() + + if not expression: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式") + + # 记录删除信息 + situation = expression.situation + + session.exec(delete(Expression).where(col(Expression.id) == expression_id)) + + logger.info(f"表达方式已删除: ID={expression_id}, situation={situation}") + + return ExpressionDeleteResponse(success=True, message=f"成功删除表达方式: {situation}") + + except HTTPException: + raise + except Exception as e: + logger.exception(f"删除表达方式失败: {e}") + raise HTTPException(status_code=500, detail=f"删除表达方式失败: {str(e)}") from e + + +class BatchDeleteRequest(BaseModel): + """批量删除请求""" + + ids: List[int] + + +@router.post("/batch/delete", response_model=ExpressionDeleteResponse) +async def batch_delete_expressions( + request: BatchDeleteRequest, +) -> ExpressionDeleteResponse: + """批量删除表达方式。 + + Args: + request: 包含要删除表达方式 ID 列表的请求。 + + Returns: + ExpressionDeleteResponse: 批量删除结果。 + """ + try: + if not request.ids: + raise HTTPException(status_code=400, detail="未提供要删除的表达方式ID") + + # 查找所有要删除的表达方式 + with get_db_session() as session: + statements = select(Expression.id).where(col(Expression.id).in_(request.ids)) + found_ids = list(session.exec(statements).all()) + + # 检查是否有未找到的ID + if not_found_ids := set(request.ids) - set(found_ids): + logger.warning(f"部分表达方式未找到: {not_found_ids}") + + # 执行批量删除 + with get_db_session() as session: + result = session.exec(delete(Expression).where(col(Expression.id).in_(found_ids))) + deleted_count = result.rowcount or 0 + + logger.info(f"批量删除了 {deleted_count} 个表达方式") + + return ExpressionDeleteResponse(success=True, message=f"成功删除 {deleted_count} 个表达方式") + + except HTTPException: + raise + except Exception as e: + logger.exception(f"批量删除表达方式失败: {e}") + raise HTTPException(status_code=500, detail=f"批量删除表达方式失败: {str(e)}") from e + + +@router.get("/stats/summary") +async def get_expression_stats() -> Dict[str, Any]: + """获取表达方式统计数据。 + + Returns: + Dict[str, Any]: 表达方式数量、近期新增和聊天分布统计。 + """ + try: + with get_db_session() as session: + total = len(session.exec(select(Expression.id)).all()) + + chat_stats = {} + for chat_id in session.exec(select(Expression.session_id)).all(): + if chat_id: + chat_stats[chat_id] = chat_stats.get(chat_id, 0) + 1 + + seven_days_ago = datetime.now() - timedelta(days=7) + recent_statement = ( + select(func.count()) + .select_from(Expression) + .where(col(Expression.create_time).is_not(None), col(Expression.create_time) >= seven_days_ago) + ) + recent = session.exec(recent_statement).one() + + return { + "success": True, + "data": { + "total": total, + "recent_7days": recent, + "chat_count": len(chat_stats), + "top_chats": dict(sorted(chat_stats.items(), key=lambda x: x[1], reverse=True)[:10]), + }, + } + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取统计数据失败: {e}") + raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e + + +# ============ 审核相关接口 ============ + + +class ReviewStatsResponse(BaseModel): + """审核统计响应""" + + total: int + unchecked: int + passed: int + rejected: int + ai_checked: int + user_checked: int + + +def apply_review_filter(statement: Any, filter_type: str) -> Any: + """按审核状态过滤表达方式查询。""" + if filter_type == "unchecked": + return statement.where(col(Expression.checked).is_(False)) + if filter_type == "passed": + return statement.where(col(Expression.checked).is_(True), col(Expression.rejected).is_(False)) + if filter_type == "rejected": + return statement.where(col(Expression.checked).is_(True), col(Expression.rejected).is_(True)) + return statement + + +def count_expressions(session: Any, statement: Any) -> int: + """统计表达方式查询结果数量。""" + return len(session.exec(statement).all()) + + +@router.get("/review/stats", response_model=ReviewStatsResponse) +async def get_review_stats() -> ReviewStatsResponse: + """获取审核统计数据。 + + Returns: + ReviewStatsResponse: 审核统计数据。 + """ + try: + with get_db_session() as session: + total = count_expressions(session, select(Expression.id)) + unchecked = count_expressions(session, apply_review_filter(select(Expression.id), "unchecked")) + passed = count_expressions(session, apply_review_filter(select(Expression.id), "passed")) + rejected = count_expressions(session, apply_review_filter(select(Expression.id), "rejected")) + ai_checked = count_expressions( + session, + select(Expression.id).where( + col(Expression.checked).is_(True), + col(Expression.modified_by) == ModifiedBy.AI, + ), + ) + user_checked = count_expressions( + session, + select(Expression.id).where( + col(Expression.checked).is_(True), + col(Expression.modified_by) == ModifiedBy.USER, + ), + ) + + return ReviewStatsResponse( + total=total, + unchecked=unchecked, + passed=passed, + rejected=rejected, + ai_checked=ai_checked, + user_checked=user_checked, + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取审核统计失败: {e}") + raise HTTPException(status_code=500, detail=f"获取审核统计失败: {str(e)}") from e + + +class ReviewListResponse(BaseModel): + """审核列表响应""" + + success: bool + total: int + page: int + page_size: int + data: List[ExpressionResponse] + + +@router.get("/review/list", response_model=ReviewListResponse) +async def get_review_list( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + filter_type: str = Query("unchecked", description="筛选类型: unchecked/passed/rejected/all"), + order: str = Query("latest", description="排序方式: latest/random"), + search: Optional[str] = Query(None, description="搜索关键词"), + chat_id: Optional[str] = Query(None, description="聊天ID筛选"), + exclude_ids: Optional[List[int]] = EXCLUDE_IDS_QUERY, +) -> ReviewListResponse: + """获取待审核或已审核的表达方式列表。 + + Args: + page: 页码。 + page_size: 每页数量。 + filter_type: 筛选类型,可选 unchecked、passed、rejected 或 all。 + order: 排序方式,可选 latest 或 random。 + search: 搜索关键词。 + chat_id: 聊天 ID 筛选条件。 + exclude_ids: 需要排除的表达方式 ID。 + + Returns: + ReviewListResponse: 审核列表响应。 + """ + try: + statement = apply_review_filter(select(Expression), filter_type) + # all 不需要额外过滤 + + # 搜索过滤 + if search: + statement = statement.where( + (col(Expression.situation).contains(search)) | (col(Expression.style).contains(search)) + ) + + # 聊天ID过滤 + if chat_id: + statement = statement.where(col(Expression.session_id) == chat_id) + + if exclude_ids: + statement = statement.where(~col(Expression.id).in_(exclude_ids)) + + if order == "random": + statement = statement.order_by(func.random()) + else: + # 排序:创建时间倒序 + statement = statement.order_by( + case((col(Expression.create_time).is_(None), 1), else_=0), + col(Expression.create_time).desc(), + ) + + offset = (page - 1) * page_size + statement = statement.offset(offset).limit(page_size) + + with get_db_session() as session: + expressions = session.exec(statement).all() + + count_statement = apply_review_filter(select(Expression.id), filter_type) + if search: + count_statement = count_statement.where( + (col(Expression.situation).contains(search)) | (col(Expression.style).contains(search)) + ) + if chat_id: + count_statement = count_statement.where(col(Expression.session_id) == chat_id) + total = len(session.exec(count_statement).all()) + data = [expression_to_response(expr, session) for expr in expressions] + + return ReviewListResponse( + success=True, + total=total, + page=page, + page_size=page_size, + data=data, + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取审核列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取审核列表失败: {str(e)}") from e + + +class BatchReviewItem(BaseModel): + """批量审核项""" + + id: int + rejected: bool + require_unchecked: bool = True # 前端保留的来源标记,人工审核提交时不再阻断覆盖 + + +class BatchReviewRequest(BaseModel): + """批量审核请求""" + + items: List[BatchReviewItem] + + +class BatchReviewResultItem(BaseModel): + """批量审核结果项""" + + id: int + success: bool + message: str + + +class BatchReviewResponse(BaseModel): + """批量审核响应""" + + success: bool + total: int + succeeded: int + failed: int + results: List[BatchReviewResultItem] + + +@router.post("/review/batch", response_model=BatchReviewResponse) +async def batch_review_expressions( + request: BatchReviewRequest, +) -> BatchReviewResponse: + """批量审核表达方式。 + + Args: + request: 批量审核请求。 + + Returns: + BatchReviewResponse: 每条表达方式的审核结果。 + """ + try: + if not request.items: + raise HTTPException(status_code=400, detail="未提供要审核的表达方式") + + results = [] + succeeded = 0 + failed = 0 + + for item in request.items: + try: + with get_db_session() as session: + expression = session.exec(select(Expression).where(col(Expression.id) == item.id).limit(1)).first() + + if not expression: + results.append( + BatchReviewResultItem(id=item.id, success=False, message=f"未找到 ID 为 {item.id} 的表达方式") + ) + failed += 1 + continue + + # 更新状态 + with get_db_session() as session: + db_expression = session.exec( + select(Expression).where(col(Expression.id) == item.id).limit(1) + ).first() + if not db_expression: + results.append( + BatchReviewResultItem( + id=item.id, success=False, message=f"未找到 ID 为 {item.id} 的表达方式" + ) + ) + failed += 1 + continue + db_expression.checked = True + db_expression.rejected = item.rejected + db_expression.modified_by = ModifiedBy.USER + db_expression.last_active_time = datetime.now() + session.add(db_expression) + + results.append( + BatchReviewResultItem(id=item.id, success=True, message="拒绝" if item.rejected else "通过") + ) + succeeded += 1 + + except Exception as e: + results.append(BatchReviewResultItem(id=item.id, success=False, message=str(e))) + failed += 1 + + logger.info(f"批量审核完成: 成功 {succeeded}, 失败 {failed}") + + return BatchReviewResponse( + success=True, total=len(request.items), succeeded=succeeded, failed=failed, results=results + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"批量审核失败: {e}") + raise HTTPException(status_code=500, detail=f"批量审核失败: {str(e)}") from e diff --git a/src/webui/routers/jargon.py b/src/webui/routers/jargon.py new file mode 100644 index 00000000..c327ecfd --- /dev/null +++ b/src/webui/routers/jargon.py @@ -0,0 +1,668 @@ +"""黑话(俚语)管理路由""" + +from typing import Annotated, Any, Dict, List, Optional, Set + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlmodel import Session, col, delete, select + +import json + +from src.common.database.database import get_db_session +from src.common.database.database_model import ChatSession, Jargon +from src.common.logger import get_logger +from src.webui.dependencies import require_auth + +logger = get_logger("webui.jargon") + +router = APIRouter(prefix="/jargon", tags=["Jargon"], dependencies=[Depends(require_auth)]) + + +# ==================== 辅助函数 ==================== + + +def parse_chat_id_to_stream_ids(chat_id_str: str) -> List[str]: + """解析聊天 ID 字段并提取所有 stream_id。 + + Args: + chat_id_str: JSON 格式或纯字符串格式的聊天 ID。 + + Returns: + List[str]: 解析出的 stream_id 列表。 + """ + if not chat_id_str: + return [] + + try: + # 尝试解析为 JSON + parsed = json.loads(chat_id_str) + if isinstance(parsed, list): + # 格式: [["stream_id", user_id], ...] + return [str(item[0]) for item in parsed if isinstance(item, list) and len(item) >= 1] + + # 其他格式,返回原始字符串 + return [chat_id_str] + except (json.JSONDecodeError, TypeError): + # 不是有效的 JSON,可能是直接的 stream_id + return [chat_id_str] + + +def get_display_name_for_chat_id(chat_id_str: str, session: Session) -> str: + """获取聊天 ID 的显示名称。 + + Args: + chat_id_str: JSON 格式或纯字符串格式的聊天 ID。 + session: 当前数据库会话。 + + Returns: + str: 聊天显示名称,无法查询时返回截断后的 stream_id。 + """ + stream_ids = parse_chat_id_to_stream_ids(chat_id_str) + + if not stream_ids: + return chat_id_str[:20] + + stream_id = stream_ids[0] + if not (chat_session := session.exec(select(ChatSession).where(col(ChatSession.session_id) == stream_id)).first()): + return stream_id[:20] + + if chat_session.group_id: + return str(chat_session.group_id) + + return chat_session.session_id[:20] + + +# ==================== 请求/响应模型 ==================== + + +class JargonResponse(BaseModel): + """黑话信息响应""" + + id: int + content: str + raw_content: Optional[str] = None + meaning: Optional[str] = None + chat_id: str + stream_id: Optional[str] = None # 解析后的 stream_id,用于前端编辑时匹配 + chat_name: Optional[str] = None # 解析后的聊天名称,用于前端显示 + count: int = 0 + is_jargon: Optional[bool] = None + is_complete: bool = False + inference_with_context: Optional[str] = None + inference_content_only: Optional[str] = None + + +class JargonListResponse(BaseModel): + """黑话列表响应""" + + success: bool = True + total: int + page: int + page_size: int + data: List[Dict[str, Any]] + + +class JargonDetailResponse(BaseModel): + """黑话详情响应""" + + success: bool = True + data: JargonResponse + + +class JargonCreateRequest(BaseModel): + """黑话创建请求""" + + content: str = Field(..., description="黑话内容") + raw_content: Optional[str] = Field(None, description="原始内容") + meaning: Optional[str] = Field(None, description="含义") + chat_id: str = Field(..., description="聊天ID") + + +class JargonUpdateRequest(BaseModel): + """黑话更新请求""" + + content: Optional[str] = None + raw_content: Optional[str] = None + meaning: Optional[str] = None + chat_id: Optional[str] = None + is_jargon: Optional[bool] = None + + +class JargonCreateResponse(BaseModel): + """黑话创建响应""" + + success: bool = True + message: str + data: JargonResponse + + +class JargonUpdateResponse(BaseModel): + """黑话更新响应""" + + success: bool = True + message: str + data: Optional[JargonResponse] = None + + +class JargonDeleteResponse(BaseModel): + """黑话删除响应""" + + success: bool = True + message: str + deleted_count: int = 0 + + +class BatchDeleteRequest(BaseModel): + """批量删除请求""" + + ids: List[int] = Field(..., description="要删除的黑话ID列表") + + +class JargonStatsResponse(BaseModel): + """黑话统计响应""" + + success: bool = True + data: Dict[str, Any] + + +class ChatInfoResponse(BaseModel): + """聊天信息响应""" + + chat_id: str + chat_name: str + platform: Optional[str] = None + is_group: bool = False + + +class ChatListResponse(BaseModel): + """聊天列表响应""" + + success: bool = True + data: List[ChatInfoResponse] + + +# ==================== 工具函数 ==================== + + +def parse_session_id_dict(session_id_dict_str: Optional[str]) -> Dict[str, int]: + """解析会话计数字典。 + + Args: + session_id_dict_str: 数据库中保存的会话计数字典 JSON 字符串。 + + Returns: + Dict[str, int]: 解析后的会话计数字典。 + """ + if not session_id_dict_str: + return {} + + try: + parsed = json.loads(session_id_dict_str) + except (json.JSONDecodeError, TypeError): + return {} + + if not isinstance(parsed, dict): + return {} + + session_counts: Dict[str, int] = {} + for session_id, count in parsed.items(): + if not isinstance(session_id, str): + continue + if isinstance(count, int): + session_counts[session_id] = count + else: + try: + session_counts[session_id] = int(count) + except (TypeError, ValueError): + session_counts[session_id] = 0 + return session_counts + + +def dump_session_id_dict(session_counts: Dict[str, int]) -> str: + """序列化会话计数字典。 + + Args: + session_counts: 会话 ID 与出现次数的映射。 + + Returns: + str: 可写入数据库的 JSON 字符串。 + """ + return json.dumps(session_counts, ensure_ascii=False) + + +def get_primary_chat_id(session_id_dict_str: Optional[str]) -> str: + """从会话计数字典中选出主聊天 ID。 + + Args: + session_id_dict_str: 数据库中保存的会话计数字典 JSON 字符串。 + + Returns: + str: 出现次数最多的聊天 ID,没有记录时返回空字符串。 + """ + if not (session_counts := parse_session_id_dict(session_id_dict_str)): + return "" + + return max(session_counts.items(), key=lambda item: item[1])[0] + + +def has_chat_id(session_id_dict_str: Optional[str], chat_id: str) -> bool: + """判断记录是否包含指定聊天 ID。 + + Args: + session_id_dict_str: 数据库中保存的会话计数字典 JSON 字符串。 + chat_id: 需要检查的聊天 ID。 + + Returns: + bool: 记录包含该聊天 ID 时返回 True。 + """ + return chat_id in parse_session_id_dict(session_id_dict_str) + + +def build_session_id_dict_for_chat(chat_id: str, count: int = 1) -> str: + """为单个聊天 ID 构建会话计数字典。 + + Args: + chat_id: 聊天 ID。 + count: 该聊天 ID 的出现次数。 + + Returns: + str: 可写入数据库的会话计数字典 JSON 字符串。 + """ + return dump_session_id_dict({chat_id: count}) + + +def jargon_to_dict(jargon: Jargon, session: Session) -> Dict[str, Any]: + """将黑话模型转换为字典。 + + Args: + jargon: 数据库中的黑话记录。 + session: 当前数据库会话,用于查询聊天显示名称。 + + Returns: + Dict[str, Any]: WebUI 可直接序列化的黑话数据。 + """ + chat_id = get_primary_chat_id(jargon.session_id_dict) + chat_name = get_display_name_for_chat_id(chat_id, session) if chat_id else None + + return { + "id": jargon.id, + "content": jargon.content, + "raw_content": jargon.raw_content, + "meaning": jargon.meaning, + "chat_id": chat_id, + "stream_id": chat_id or None, + "chat_name": chat_name, + "count": jargon.count, + "is_jargon": jargon.is_jargon, + "is_complete": jargon.is_complete, + "inference_with_context": jargon.inference_with_context, + "inference_content_only": jargon.inference_with_content_only, + } + + +# ==================== API 端点 ==================== + + +@router.get("/list", response_model=JargonListResponse) +async def get_jargon_list( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + search: Optional[str] = Query(None, description="搜索关键词"), + chat_id: Optional[str] = Query(None, description="按聊天ID筛选"), + is_jargon: Optional[bool] = Query(None, description="按是否是黑话筛选"), +) -> JargonListResponse: + """获取黑话列表。 + + Args: + page: 页码,从 1 开始。 + page_size: 每页数量,范围为 1-100。 + search: 搜索关键词。 + chat_id: 聊天 ID 筛选条件。 + is_jargon: 是否为黑话的筛选条件。 + + Returns: + JargonListResponse: 分页后的黑话列表。 + """ + try: + statement = select(Jargon) + + if search: + search_filter = ( + (col(Jargon.content).contains(search)) + | (col(Jargon.meaning).contains(search)) + | (col(Jargon.raw_content).contains(search)) + ) + statement = statement.where(search_filter) + + if is_jargon is not None: + statement = statement.where(col(Jargon.is_jargon) == is_jargon) + + statement = statement.order_by(col(Jargon.count).desc(), col(Jargon.id).desc()) + + with get_db_session() as session: + jargons = session.exec(statement).all() + + if chat_id: + stream_ids = parse_chat_id_to_stream_ids(chat_id) + chat_ids = stream_ids or [chat_id] + jargons = [ + jargon + for jargon in jargons + if any(has_chat_id(jargon.session_id_dict, current_chat_id) for current_chat_id in chat_ids) + ] + + total = len(jargons) + offset = (page - 1) * page_size + page_jargons = jargons[offset : offset + page_size] + data = [jargon_to_dict(jargon, session) for jargon in page_jargons] + + return JargonListResponse( + success=True, + total=total, + page=page, + page_size=page_size, + data=data, + ) + + except Exception as e: + logger.error(f"获取黑话列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取黑话列表失败: {str(e)}") from e + + +@router.get("/chats", response_model=ChatListResponse) +async def get_chat_list() -> ChatListResponse: + """获取所有有黑话记录的聊天列表。 + + Returns: + ChatListResponse: 包含黑话记录的聊天列表。 + """ + try: + with get_db_session() as session: + jargons = session.exec(select(Jargon)).all() + + seen_stream_ids: Set[str] = set() + for jargon in jargons: + seen_stream_ids.update(parse_session_id_dict(jargon.session_id_dict).keys()) + + result: List[ChatInfoResponse] = [] + for stream_id in seen_stream_ids: + if chat_session := session.exec( + select(ChatSession).where(col(ChatSession.session_id) == stream_id) + ).first(): + chat_name = str(chat_session.group_id) if chat_session.group_id else stream_id[:20] + result.append( + ChatInfoResponse( + chat_id=stream_id, + chat_name=chat_name, + platform=chat_session.platform, + is_group=bool(chat_session.group_id), + ) + ) + else: + result.append( + ChatInfoResponse( + chat_id=stream_id, + chat_name=stream_id[:20], + platform=None, + is_group=False, + ) + ) + + return ChatListResponse(success=True, data=result) + + except Exception as e: + logger.error(f"获取聊天列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取聊天列表失败: {str(e)}") from e + + +@router.get("/stats/summary", response_model=JargonStatsResponse) +async def get_jargon_stats() -> JargonStatsResponse: + """获取黑话统计数据。 + + Returns: + JargonStatsResponse: 黑话总数、确认状态和聊天分布统计。 + """ + try: + with get_db_session() as session: + jargons = session.exec(select(Jargon)).all() + + total = len(jargons) + confirmed_jargon = sum(jargon.is_jargon is True for jargon in jargons) + confirmed_not_jargon = sum(jargon.is_jargon is False for jargon in jargons) + pending = sum(jargon.is_jargon is None for jargon in jargons) + complete_count = sum(jargon.is_complete for jargon in jargons) + + top_chats_counter: Dict[str, int] = {} + for jargon in jargons: + for session_id in parse_session_id_dict(jargon.session_id_dict): + top_chats_counter[session_id] = top_chats_counter.get(session_id, 0) + 1 + + top_chats_dict = dict(sorted(top_chats_counter.items(), key=lambda item: item[1], reverse=True)[:5]) + chat_count = len(top_chats_counter) + + return JargonStatsResponse( + success=True, + data={ + "total": total, + "confirmed_jargon": confirmed_jargon, + "confirmed_not_jargon": confirmed_not_jargon, + "pending": pending, + "complete_count": complete_count, + "chat_count": chat_count, + "top_chats": top_chats_dict, + }, + ) + + except Exception as e: + logger.error(f"获取黑话统计失败: {e}") + raise HTTPException(status_code=500, detail=f"获取黑话统计失败: {str(e)}") from e + + +@router.get("/{jargon_id}", response_model=JargonDetailResponse) +async def get_jargon_detail(jargon_id: int) -> JargonDetailResponse: + """获取黑话详情。 + + Args: + jargon_id: 黑话记录 ID。 + + Returns: + JargonDetailResponse: 指定黑话记录的详细信息。 + """ + try: + with get_db_session() as session: + if not (jargon := session.exec(select(Jargon).where(col(Jargon.id) == jargon_id)).first()): + raise HTTPException(status_code=404, detail="黑话不存在") + data = JargonResponse(**jargon_to_dict(jargon, session)) + + return JargonDetailResponse(success=True, data=data) + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取黑话详情失败: {e}") + raise HTTPException(status_code=500, detail=f"获取黑话详情失败: {str(e)}") from e + + +@router.post("/", response_model=JargonCreateResponse) +async def create_jargon(request: JargonCreateRequest) -> JargonCreateResponse: + """创建黑话。 + + Args: + request: 创建黑话所需的请求数据。 + + Returns: + JargonCreateResponse: 创建结果和新黑话数据。 + """ + try: + with get_db_session() as session: + same_content_jargons = session.exec(select(Jargon).where(col(Jargon.content) == request.content)).all() + existing = next( + (jargon for jargon in same_content_jargons if has_chat_id(jargon.session_id_dict, request.chat_id)), + None, + ) + if existing is not None: + raise HTTPException(status_code=400, detail="该聊天中已存在相同内容的黑话") + + jargon = Jargon( + content=request.content, + raw_content=request.raw_content, + meaning=request.meaning or "", + session_id_dict=build_session_id_dict_for_chat(request.chat_id), + count=0, + is_jargon=None, + is_complete=False, + ) + session.add(jargon) + session.flush() + + logger.info(f"创建黑话成功: id={jargon.id}, content={request.content}") + data = JargonResponse(**jargon_to_dict(jargon, session)) + + return JargonCreateResponse(success=True, message="创建成功", data=data) + + except HTTPException: + raise + except Exception as e: + logger.error(f"创建黑话失败: {e}") + raise HTTPException(status_code=500, detail=f"创建黑话失败: {str(e)}") from e + + +@router.patch("/{jargon_id}", response_model=JargonUpdateResponse) +async def update_jargon(jargon_id: int, request: JargonUpdateRequest) -> JargonUpdateResponse: + """增量更新黑话。 + + Args: + jargon_id: 黑话记录 ID。 + request: 只包含需要更新字段的请求数据。 + + Returns: + JargonUpdateResponse: 更新结果和更新后的黑话数据。 + """ + try: + with get_db_session() as session: + jargon = session.exec(select(Jargon).where(col(Jargon.id) == jargon_id)).first() + if not jargon: + raise HTTPException(status_code=404, detail="黑话不存在") + + if update_data := request.model_dump(exclude_unset=True): + if "chat_id" in update_data and update_data["chat_id"] is not None: + jargon.session_id_dict = build_session_id_dict_for_chat(update_data["chat_id"], max(jargon.count, 1)) + if "content" in update_data and update_data["content"] is not None: + jargon.content = update_data["content"] + if "raw_content" in update_data: + jargon.raw_content = update_data["raw_content"] + if "meaning" in update_data: + jargon.meaning = update_data["meaning"] or "" + if "is_jargon" in update_data: + jargon.is_jargon = update_data["is_jargon"] + session.add(jargon) + + logger.info(f"更新黑话成功: id={jargon_id}") + data = JargonResponse(**jargon_to_dict(jargon, session)) + + return JargonUpdateResponse(success=True, message="更新成功", data=data) + + except HTTPException: + raise + except Exception as e: + logger.error(f"更新黑话失败: {e}") + raise HTTPException(status_code=500, detail=f"更新黑话失败: {str(e)}") from e + + +@router.delete("/{jargon_id}", response_model=JargonDeleteResponse) +async def delete_jargon(jargon_id: int) -> JargonDeleteResponse: + """删除黑话。 + + Args: + jargon_id: 黑话记录 ID。 + + Returns: + JargonDeleteResponse: 删除结果。 + """ + try: + with get_db_session() as session: + jargon = session.exec(select(Jargon).where(col(Jargon.id) == jargon_id)).first() + if not jargon: + raise HTTPException(status_code=404, detail="黑话不存在") + + content = jargon.content + session.delete(jargon) + + logger.info(f"删除黑话成功: id={jargon_id}, content={content}") + + return JargonDeleteResponse(success=True, message="删除成功", deleted_count=1) + + except HTTPException: + raise + except Exception as e: + logger.error(f"删除黑话失败: {e}") + raise HTTPException(status_code=500, detail=f"删除黑话失败: {str(e)}") from e + + +@router.post("/batch/delete", response_model=JargonDeleteResponse) +async def batch_delete_jargons(request: BatchDeleteRequest) -> JargonDeleteResponse: + """批量删除黑话。 + + Args: + request: 包含要删除黑话 ID 列表的请求。 + + Returns: + JargonDeleteResponse: 批量删除结果。 + """ + try: + if not request.ids: + raise HTTPException(status_code=400, detail="ID列表不能为空") + + with get_db_session() as session: + result = session.exec(delete(Jargon).where(col(Jargon.id).in_(request.ids))) + deleted_count = result.rowcount or 0 + + logger.info(f"批量删除黑话成功: 删除了 {deleted_count} 条记录") + + return JargonDeleteResponse( + success=True, + message=f"成功删除 {deleted_count} 条黑话", + deleted_count=deleted_count, + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"批量删除黑话失败: {e}") + raise HTTPException(status_code=500, detail=f"批量删除黑话失败: {str(e)}") from e + + +@router.post("/batch/set-jargon", response_model=JargonUpdateResponse) +async def batch_set_jargon_status( + ids: Annotated[List[int], Query(description="黑话ID列表")], + is_jargon: Annotated[bool, Query(description="是否是黑话")], +) -> JargonUpdateResponse: + """批量设置黑话状态。 + + Args: + ids: 需要更新状态的黑话 ID 列表。 + is_jargon: 目标黑话状态。 + + Returns: + JargonUpdateResponse: 批量更新结果。 + """ + try: + if not ids: + raise HTTPException(status_code=400, detail="ID列表不能为空") + + with get_db_session() as session: + jargons = session.exec(select(Jargon).where(col(Jargon.id).in_(ids))).all() + for jargon in jargons: + jargon.is_jargon = is_jargon + session.add(jargon) + updated_count = len(jargons) + + logger.info(f"批量更新黑话状态成功: 更新了 {updated_count} 条记录,is_jargon={is_jargon}") + + return JargonUpdateResponse(success=True, message=f"成功更新 {updated_count} 条黑话状态") + + except HTTPException: + raise + except Exception as e: + logger.error(f"批量更新黑话状态失败: {e}") + raise HTTPException(status_code=500, detail=f"批量更新黑话状态失败: {str(e)}") from e diff --git a/src/webui/routers/knowledge.py b/src/webui/routers/knowledge.py new file mode 100644 index 00000000..b6ab500f --- /dev/null +++ b/src/webui/routers/knowledge.py @@ -0,0 +1,397 @@ +"""知识库图谱可视化 API 路由""" + +import logging +from typing import Any, List, Optional, Tuple + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel + +from src.config.config import global_config +from src.webui.dependencies import require_auth + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/webui/knowledge", tags=["knowledge"], dependencies=[Depends(require_auth)]) + +# 延迟初始化的轻量级 embedding store(只读,仅用于获取段落完整文本) +_paragraph_store_cache: Any = None + + +def _get_embedding_dir() -> str: + """获取 embedding 数据目录。""" + import os + + current_dir = os.path.dirname(os.path.abspath(__file__)) + root_path = os.path.abspath(os.path.join(current_dir, "..", "..")) + return os.path.join(root_path, "data/embedding") + + +def _get_paragraph_store(): + """延迟加载段落 embedding store(只读模式,轻量级) + + Returns: + EmbeddingStore | None: 如果配置启用则返回store,否则返回None + """ + # 检查配置是否启用 + if not global_config.webui.enable_paragraph_content: + return None + + global _paragraph_store_cache + if _paragraph_store_cache is not None: + return _paragraph_store_cache + + try: + from src.chat.knowledge.embedding_store import EmbeddingStore + + # 只加载段落 embedding store(轻量级) + paragraph_store = EmbeddingStore( + namespace="paragraph", + dir_path=_get_embedding_dir(), + max_workers=1, # 只读不需要多线程 + chunk_size=100, + ) + paragraph_store.load_from_file() + + _paragraph_store_cache = paragraph_store + logger.info(f"成功加载段落 embedding store,包含 {len(paragraph_store.store)} 个段落") + return paragraph_store + except Exception as e: + logger.warning(f"加载段落 embedding store 失败: {e}") + return None + + +def _get_paragraph_content(node_id: str) -> Tuple[Optional[str], bool]: + """从 embedding store 获取段落完整内容 + + Args: + node_id: 段落节点ID,格式为 'paragraph-{hash}' + + Returns: + tuple[str | None, bool]: (段落完整内容或None, 是否启用了功能) + """ + try: + paragraph_store = _get_paragraph_store() + if paragraph_store is None: + # 功能未启用 + return None, False + + # 从 store 中获取完整内容 + paragraph_item = paragraph_store.store.get(node_id) + if paragraph_item is not None: + # paragraph_item 是 EmbeddingStoreItem,其 str 属性包含完整文本 + if content := getattr(paragraph_item, "str", ""): + return content, True + return None, True + except Exception as e: + logger.debug(f"获取段落内容失败: {e}") + return None, True + + +class KnowledgeNode(BaseModel): + """知识节点""" + + id: str + type: str # 'entity' or 'paragraph' + content: str + create_time: Optional[float] = None + + +class KnowledgeEdge(BaseModel): + """知识边""" + + source: str + target: str + weight: float + create_time: Optional[float] = None + update_time: Optional[float] = None + + +class KnowledgeGraph(BaseModel): + """知识图谱""" + + nodes: List[KnowledgeNode] + edges: List[KnowledgeEdge] + + +class KnowledgeStats(BaseModel): + """知识库统计信息""" + + total_nodes: int + total_edges: int + entity_nodes: int + paragraph_nodes: int + avg_connections: float + + +def _load_kg_manager(): + """延迟加载 KGManager""" + try: + from src.chat.knowledge.kg_manager import KGManager + + kg_manager = KGManager() + kg_manager.load_from_file() + return kg_manager + except Exception as e: + logger.error(f"加载 KGManager 失败: {e}") + return None + + +def _convert_graph_to_json(kg_manager) -> KnowledgeGraph: + """将 DiGraph 转换为 JSON 格式""" + if kg_manager is None or kg_manager.graph is None: + return KnowledgeGraph(nodes=[], edges=[]) + + graph = kg_manager.graph + nodes = [] + edges = [] + + # 转换节点 + node_list = graph.get_node_list() + for node_id in node_list: + try: + node_data = graph[node_id] + # 节点类型: "ent" -> "entity", "pg" -> "paragraph" + node_type = "entity" if ("type" in node_data and node_data["type"] == "ent") else "paragraph" + + # 对于段落节点,尝试从 embedding store 获取完整内容 + if node_type == "paragraph": + full_content, _ = _get_paragraph_content(node_id) + content = ( + full_content + if full_content is not None + else (node_data["content"] if "content" in node_data else node_id) + ) + else: + content = node_data["content"] if "content" in node_data else node_id + + create_time = node_data["create_time"] if "create_time" in node_data else None + + nodes.append(KnowledgeNode(id=node_id, type=node_type, content=content, create_time=create_time)) + except Exception as e: + logger.warning(f"跳过节点 {node_id}: {e}") + continue + + # 转换边 + edge_list = graph.get_edge_list() + for edge_tuple in edge_list: + try: + # edge_tuple 是 (source, target) 元组 + source, target = edge_tuple[0], edge_tuple[1] + # 通过 graph[source, target] 获取边的属性数据 + edge_data = graph[source, target] + + # edge_data 支持 [] 操作符但不支持 .get() + weight = edge_data["weight"] if "weight" in edge_data else 1.0 + create_time = edge_data["create_time"] if "create_time" in edge_data else None + update_time = edge_data["update_time"] if "update_time" in edge_data else None + + edges.append( + KnowledgeEdge( + source=source, target=target, weight=weight, create_time=create_time, update_time=update_time + ) + ) + except Exception as e: + logger.warning(f"跳过边 {edge_tuple}: {e}") + continue + + return KnowledgeGraph(nodes=nodes, edges=edges) + + +@router.get("/graph", response_model=KnowledgeGraph) +async def get_knowledge_graph( + limit: int = Query(100, ge=1, le=10000, description="返回的最大节点数"), + node_type: str = Query("all", description="节点类型过滤: all, entity, paragraph"), +): + """获取知识图谱(限制节点数量) + + Args: + limit: 返回的最大节点数,默认 100,最大 10000 + node_type: 节点类型过滤 - all(全部), entity(实体), paragraph(段落) + + Returns: + KnowledgeGraph: 包含指定数量节点和相关边的知识图谱 + """ + try: + kg_manager = _load_kg_manager() + if kg_manager is None: + logger.warning("KGManager 未初始化,返回空图谱") + return KnowledgeGraph(nodes=[], edges=[]) + + graph = kg_manager.graph + all_node_list = graph.get_node_list() + + # 按类型过滤节点 + if node_type == "entity": + all_node_list = [ + n for n in all_node_list if n in graph and "type" in graph[n] and graph[n]["type"] == "ent" + ] + elif node_type == "paragraph": + all_node_list = [n for n in all_node_list if n in graph and "type" in graph[n] and graph[n]["type"] == "pg"] + + # 限制节点数量 + total_nodes = len(all_node_list) + if len(all_node_list) > limit: + node_list = all_node_list[:limit] + else: + node_list = all_node_list + + logger.info(f"总节点数: {total_nodes}, 返回节点: {len(node_list)} (limit={limit}, type={node_type})") + + # 转换节点 + nodes = [] + node_ids = set() + for node_id in node_list: + try: + node_data = graph[node_id] + node_type_val = "entity" if ("type" in node_data and node_data["type"] == "ent") else "paragraph" + + # 对于段落节点,尝试从 embedding store 获取完整内容 + if node_type_val == "paragraph": + full_content, _ = _get_paragraph_content(node_id) + content = ( + full_content + if full_content is not None + else (node_data["content"] if "content" in node_data else node_id) + ) + else: + content = node_data["content"] if "content" in node_data else node_id + + create_time = node_data["create_time"] if "create_time" in node_data else None + + nodes.append(KnowledgeNode(id=node_id, type=node_type_val, content=content, create_time=create_time)) + node_ids.add(node_id) + except Exception as e: + logger.warning(f"跳过节点 {node_id}: {e}") + continue + + # 只获取涉及当前节点集的边(保证图的完整性) + edges = [] + edge_list = graph.get_edge_list() + for edge_tuple in edge_list: + try: + source, target = edge_tuple[0], edge_tuple[1] + # 只包含两端都在当前节点集中的边 + if source not in node_ids or target not in node_ids: + continue + + edge_data = graph[source, target] + weight = edge_data["weight"] if "weight" in edge_data else 1.0 + create_time = edge_data["create_time"] if "create_time" in edge_data else None + update_time = edge_data["update_time"] if "update_time" in edge_data else None + + edges.append( + KnowledgeEdge( + source=source, target=target, weight=weight, create_time=create_time, update_time=update_time + ) + ) + except Exception as e: + logger.warning(f"跳过边 {edge_tuple}: {e}") + continue + + graph_data = KnowledgeGraph(nodes=nodes, edges=edges) + logger.info(f"返回知识图谱: {len(nodes)} 个节点, {len(edges)} 条边") + return graph_data + + except Exception as e: + logger.error(f"获取知识图谱失败: {e}", exc_info=True) + return KnowledgeGraph(nodes=[], edges=[]) + + +@router.get("/stats", response_model=KnowledgeStats) +async def get_knowledge_stats(): + """获取知识库统计信息 + + Returns: + KnowledgeStats: 统计信息 + """ + try: + kg_manager = _load_kg_manager() + if kg_manager is None or kg_manager.graph is None: + return KnowledgeStats(total_nodes=0, total_edges=0, entity_nodes=0, paragraph_nodes=0, avg_connections=0.0) + + graph = kg_manager.graph + node_list = graph.get_node_list() + edge_list = graph.get_edge_list() + + total_nodes = len(node_list) + total_edges = len(edge_list) + + # 统计节点类型 + entity_nodes = 0 + paragraph_nodes = 0 + for node_id in node_list: + try: + node_data = graph[node_id] + node_type = node_data["type"] if "type" in node_data else "ent" + if node_type == "ent": + entity_nodes += 1 + elif node_type == "pg": + paragraph_nodes += 1 + except Exception: + continue + + # 计算平均连接数 + avg_connections = (total_edges * 2) / total_nodes if total_nodes > 0 else 0.0 + + return KnowledgeStats( + total_nodes=total_nodes, + total_edges=total_edges, + entity_nodes=entity_nodes, + paragraph_nodes=paragraph_nodes, + avg_connections=round(avg_connections, 2), + ) + + except Exception as e: + logger.error(f"获取统计信息失败: {e}", exc_info=True) + return KnowledgeStats(total_nodes=0, total_edges=0, entity_nodes=0, paragraph_nodes=0, avg_connections=0.0) + + +@router.get("/search", response_model=List[KnowledgeNode]) +async def search_knowledge_node(query: str = Query(..., min_length=1)): + """搜索知识节点 + + Args: + query: 搜索关键词 + + Returns: + List[KnowledgeNode]: 匹配的节点列表 + """ + try: + kg_manager = _load_kg_manager() + if kg_manager is None or kg_manager.graph is None: + return [] + + graph = kg_manager.graph + node_list = graph.get_node_list() + results = [] + query_lower = query.lower() + + # 在节点内容中搜索 + for node_id in node_list: + try: + node_data = graph[node_id] + node_type = "entity" if ("type" in node_data and node_data["type"] == "ent") else "paragraph" + + # 对于段落节点,尝试从 embedding store 获取完整内容 + if node_type == "paragraph": + full_content, _ = _get_paragraph_content(node_id) + content = ( + full_content + if full_content is not None + else (node_data["content"] if "content" in node_data else node_id) + ) + else: + content = node_data["content"] if "content" in node_data else node_id + + if query_lower in content.lower() or query_lower in node_id.lower(): + create_time = node_data["create_time"] if "create_time" in node_data else None + results.append(KnowledgeNode(id=node_id, type=node_type, content=content, create_time=create_time)) + except Exception: + continue + + logger.info(f"搜索 '{query}' 找到 {len(results)} 个节点") + return results[:50] # 限制返回数量 + + except Exception as e: + logger.error(f"搜索节点失败: {e}", exc_info=True) + return [] diff --git a/src/webui/routers/logs.py b/src/webui/routers/logs.py new file mode 100644 index 00000000..e69de29b diff --git a/src/webui/routers/memory.py b/src/webui/routers/memory.py new file mode 100644 index 00000000..d1a2bc6c --- /dev/null +++ b/src/webui/routers/memory.py @@ -0,0 +1,1808 @@ +from __future__ import annotations + +import json +import shutil +import uuid +from pathlib import Path +from typing import Any, Optional + +import tomlkit +from fastapi import APIRouter, Body, Depends, File, Form, HTTPException, Query, UploadFile +from pydantic import BaseModel, Field +from sqlmodel import col, select + +from src.A_memorix.host_service import a_memorix_host_service +from src.common.database.database import get_db_session +from src.common.database.database_model import PersonInfo +from src.person_info.person_info import resolve_person_id_for_memory +from src.services.memory_service import MemorySearchResult, memory_service +from src.webui.dependencies import require_auth + + +router = APIRouter(prefix="/memory", tags=["memory"], dependencies=[Depends(require_auth)]) +compat_router = APIRouter(prefix="/api", tags=["memory-compat"], dependencies=[Depends(require_auth)]) +STAGING_ROOT = Path(__file__).resolve().parents[3] / "data" / "memory_upload_staging" + + +class NodeRequest(BaseModel): + name: str = Field(..., min_length=1) + + +class NodeRenameRequest(BaseModel): + old_name: str = Field(..., min_length=1) + new_name: str = Field(..., min_length=1) + + +class EdgeCreateRequest(BaseModel): + subject: str = Field(..., min_length=1) + predicate: str = Field(..., min_length=1) + object: str = Field(..., min_length=1) + confidence: float = Field(1.0, ge=0.0) + + +class EdgeDeleteRequest(BaseModel): + hash: str = "" + subject: str = "" + object: str = "" + + +class EdgeWeightRequest(BaseModel): + hash: str = "" + subject: str = "" + object: str = "" + weight: float = Field(..., ge=0.0) + + +class SourceDeleteRequest(BaseModel): + source: str = Field(..., min_length=1) + + +class SourceBatchDeleteRequest(BaseModel): + sources: list[str] = Field(default_factory=list) + + +class EpisodeRebuildRequest(BaseModel): + source: str = "" + sources: list[str] = Field(default_factory=list) + all: bool = False + + +class EpisodeProcessPendingRequest(BaseModel): + limit: int = Field(20, ge=1, le=200) + max_retry: int = Field(3, ge=1, le=20) + + +class ProfileOverrideRequest(BaseModel): + person_id: str = Field(..., min_length=1) + override_text: str = "" + updated_by: str = "" + source: str = "webui" + + +class MaintainRequest(BaseModel): + target: str = Field(..., min_length=1) + hours: Optional[float] = None + + +class AutoSaveRequest(BaseModel): + enabled: bool + + +class MemoryConfigUpdateRequest(BaseModel): + config: dict[str, Any] = Field(default_factory=dict) + + +class MemoryRawConfigUpdateRequest(BaseModel): + config: str = "" + + +class TuningApplyProfileRequest(BaseModel): + profile: dict[str, Any] = Field(default_factory=dict) + reason: str = "manual" + + +class V5ActionRequest(BaseModel): + target: str = Field(..., min_length=1) + strength: Optional[float] = Field(default=None, ge=0.0) + reason: str = "" + updated_by: str = "webui" + + +class DeleteActionRequest(BaseModel): + mode: str = Field(..., min_length=1) + selector: dict[str, Any] | str = Field(default_factory=dict) + reason: str = "" + requested_by: str = "webui" + + +class DeleteRestoreRequest(BaseModel): + operation_id: str = "" + mode: str = "" + selector: dict[str, Any] | str = Field(default_factory=dict) + reason: str = "" + requested_by: str = "webui" + + +class DeletePurgeRequest(BaseModel): + grace_hours: Optional[float] = Field(default=None, ge=0.0) + limit: int = Field(1000, ge=1, le=5000) + + +class FeedbackRollbackRequest(BaseModel): + requested_by: str = "webui" + reason: str = "" + + +def _build_import_guide_markdown(settings: dict[str, Any]) -> str: + path_aliases_raw = settings.get("path_aliases") + path_aliases = path_aliases_raw if isinstance(path_aliases_raw, dict) else {} + alias_lines = [ + f"- `{name}` -> `{path}`" + for name, path in sorted(path_aliases.items()) + if str(name).strip() and str(path).strip() + ] + if not alias_lines: + alias_lines = ["- 当前未配置路径别名"] + return "\n".join( + [ + "# 长期记忆导入说明", + "", + "支持的导入方式:", + "- 上传文件:适合零散文档、日志、聊天导出文本。", + "- 粘贴文本:适合一次性导入少量整理好的内容。", + "- Raw Scan:扫描白名单目录内的原始文本文件。", + "- LPMM OpenIE / Convert:处理既有 LPMM 数据。", + "- Temporal Backfill:补回已有数据中的时间信息。", + "- MaiBot Migration:从宿主数据库迁移历史聊天记忆。", + "", + "当前路径别名:", + *alias_lines, + "", + "执行建议:", + "- 首次导入先小批量试跑,确认切分和抽取结果正常。", + "- 大批量导入时优先关注任务状态、失败块与重试结果。", + "- 若路径解析失败,请先检查路径别名与相对路径是否仍然有效。", + ] + ) + + +def _unwrap_payload(payload: dict[str, Any] | None) -> dict[str, Any]: + raw = payload if isinstance(payload, dict) else {} + nested = raw.get("payload") + if isinstance(nested, dict): + return dict(nested) + return dict(raw) + + +async def _graph_get(limit: int) -> dict: + return await memory_service.graph_admin(action="get_graph", limit=limit) + + +async def _graph_search(query: str, limit: int) -> dict: + return await memory_service.graph_admin(action="search", query=query, limit=limit) + + +async def _graph_get_node_detail( + node_id: str, + *, + relation_limit: int, + paragraph_limit: int, + evidence_node_limit: int, +) -> dict: + payload = await memory_service.graph_admin( + action="node_detail", + node_id=node_id, + relation_limit=relation_limit, + paragraph_limit=paragraph_limit, + evidence_node_limit=evidence_node_limit, + ) + if not bool(payload.get("success", False)): + raise HTTPException(status_code=404, detail=str(payload.get("error", "未找到节点详情"))) + return payload + + +async def _graph_get_edge_detail( + source: str, + target: str, + *, + paragraph_limit: int, + evidence_node_limit: int, +) -> dict: + payload = await memory_service.graph_admin( + action="edge_detail", + source=source, + target=target, + paragraph_limit=paragraph_limit, + evidence_node_limit=evidence_node_limit, + ) + if not bool(payload.get("success", False)): + raise HTTPException(status_code=404, detail=str(payload.get("error", "未找到边详情"))) + return payload + + +async def _graph_create_node(payload: NodeRequest) -> dict: + return await memory_service.graph_admin(action="create_node", name=payload.name) + + +async def _graph_delete_node(payload: NodeRequest) -> dict: + return await memory_service.graph_admin(action="delete_node", name=payload.name) + + +async def _graph_rename_node(payload: NodeRenameRequest) -> dict: + return await memory_service.graph_admin(action="rename_node", old_name=payload.old_name, new_name=payload.new_name) + + +async def _graph_create_edge(payload: EdgeCreateRequest) -> dict: + return await memory_service.graph_admin( + action="create_edge", + subject=payload.subject, + predicate=payload.predicate, + object=payload.object, + confidence=payload.confidence, + ) + + +async def _graph_delete_edge(payload: EdgeDeleteRequest) -> dict: + return await memory_service.graph_admin( + action="delete_edge", + hash=payload.hash, + subject=payload.subject, + object=payload.object, + ) + + +async def _graph_update_edge_weight(payload: EdgeWeightRequest) -> dict: + return await memory_service.graph_admin( + action="update_edge_weight", + hash=payload.hash, + subject=payload.subject, + object=payload.object, + weight=payload.weight, + ) + + +async def _source_list() -> dict: + return await memory_service.source_admin(action="list") + + +async def _source_delete(payload: SourceDeleteRequest) -> dict: + return await memory_service.source_admin(action="delete", source=payload.source) + + +async def _source_batch_delete(payload: SourceBatchDeleteRequest) -> dict: + return await memory_service.source_admin(action="batch_delete", sources=payload.sources) + + +async def _query_aggregate( + query: str, + *, + limit: int, + chat_id: str, + person_id: str, + time_start: float | None, + time_end: float | None, +) -> dict: + result: MemorySearchResult = await memory_service.search( + query, + limit=limit, + mode="aggregate", + chat_id=chat_id, + person_id=person_id, + time_start=time_start, + time_end=time_end, + respect_filter=False, + ) + return {"success": True, **result.to_dict()} + + +async def _episode_list( + *, + query: str, + limit: int, + source: str, + person_id: str, + platform: str, + user_id: str, + time_start: float | None, + time_end: float | None, +) -> dict: + clean_person_id = str(person_id or "").strip() + if not clean_person_id and str(platform or "").strip() and str(user_id or "").strip(): + clean_person_id = resolve_person_id_for_memory( + platform=str(platform or "").strip(), + user_id=str(user_id or "").strip(), + strict_known=False, + ) + + payload = await memory_service.episode_admin( + action="list", + query=query, + limit=limit, + source=source, + person_id=clean_person_id, + time_start=time_start, + time_end=time_end, + ) + if not isinstance(payload, dict) or not isinstance(payload.get("items"), list): + return payload + + items = [] + for item in payload["items"]: + if not isinstance(item, dict): + items.append(item) + continue + items.append(_enrich_episode_person_name(item)) + + payload = dict(payload) + payload["items"] = items + return payload + + +async def _episode_get(episode_id: str) -> dict: + payload = await memory_service.episode_admin(action="get", episode_id=episode_id) + if isinstance(payload, dict) and isinstance(payload.get("episode"), dict): + payload = dict(payload) + payload["episode"] = _enrich_episode_person_name(payload["episode"]) + return payload + + +async def _episode_rebuild(payload: EpisodeRebuildRequest) -> dict: + return await memory_service.episode_admin( + action="rebuild", + source=payload.source, + sources=payload.sources, + all=payload.all, + ) + + +async def _episode_status(limit: int) -> dict: + return await memory_service.episode_admin(action="status", limit=limit) + + +async def _episode_process_pending(payload: EpisodeProcessPendingRequest) -> dict: + return await memory_service.episode_admin( + action="process_pending", + limit=payload.limit, + max_retry=payload.max_retry, + ) + + +async def _profile_query( + *, + person_id: str, + person_keyword: str, + platform: str, + user_id: str, + limit: int, + force_refresh: bool, +) -> dict: + clean_person_id = str(person_id or "").strip() + if not clean_person_id and str(platform or "").strip() and str(user_id or "").strip(): + clean_person_id = resolve_person_id_for_memory( + platform=str(platform or "").strip(), + user_id=str(user_id or "").strip(), + strict_known=False, + ) + return await memory_service.profile_admin( + action="query", + person_id=clean_person_id, + person_keyword=person_keyword, + limit=limit, + force_refresh=force_refresh, + ) + + +def _get_person_name_for_person_id(person_id: str) -> str: + clean_person_id = str(person_id or "").strip() + if not clean_person_id: + return "" + try: + with get_db_session(auto_commit=False) as session: + statement = select(PersonInfo.person_name).where(col(PersonInfo.person_id) == clean_person_id).limit(1) + person_name = session.exec(statement).first() + return str(person_name or "").strip() + except Exception: + return "" + + +def _enrich_episode_person_name(item: dict) -> dict: + enriched = dict(item) + item_person_id = str(enriched.get("person_id", "") or "").strip() + + participants = enriched.get("participants") + if not item_person_id and isinstance(participants, list): + for participant in participants: + if isinstance(participant, dict): + candidate = str(participant.get("person_id", "") or participant.get("id", "") or "").strip() + else: + candidate = str(participant or "").strip() + if candidate: + item_person_id = candidate + break + + enriched["person_id"] = item_person_id + enriched["person_name"] = _get_person_name_for_person_id(item_person_id) + return enriched + + +async def _profile_list(limit: int) -> dict: + payload = await memory_service.profile_admin(action="list", limit=limit) + if not isinstance(payload, dict) or not isinstance(payload.get("items"), list): + return payload + + items = [] + for item in payload["items"]: + if not isinstance(item, dict): + items.append(item) + continue + enriched = dict(item) + person_id = str(enriched.get("person_id", "") or "").strip() + enriched["person_name"] = _get_person_name_for_person_id(person_id) + items.append(enriched) + + payload = dict(payload) + payload["items"] = items + return payload + + +async def _profile_search( + *, + person_id: str, + person_keyword: str, + platform: str, + user_id: str, + limit: int, +) -> dict: + clean_person_id = str(person_id or "").strip() + if not clean_person_id and str(platform or "").strip() and str(user_id or "").strip(): + clean_person_id = resolve_person_id_for_memory( + platform=str(platform or "").strip(), + user_id=str(user_id or "").strip(), + strict_known=False, + ) + + payload = await _profile_list(max(limit, 200)) + if not isinstance(payload, dict) or not isinstance(payload.get("items"), list): + return payload + + keyword = str(person_keyword or "").strip().lower() + + def _matches(item: dict) -> bool: + if clean_person_id and str(item.get("person_id", "") or "").strip() != clean_person_id: + return False + if not keyword: + return True + + override = item.get("manual_override") + override_text = "" + if isinstance(override, dict): + override_text = str(override.get("override_text", "") or override.get("text", "") or "") + elif isinstance(override, str): + override_text = override + + haystack = "\n".join( + [ + str(item.get("person_id", "") or ""), + str(item.get("person_name", "") or ""), + str(item.get("profile_text", "") or ""), + str(item.get("source_note", "") or ""), + override_text, + ] + ).lower() + return keyword in haystack + + items = [item for item in payload["items"] if isinstance(item, dict) and _matches(item)] + items = items[:limit] + return { + "success": True, + "items": items, + "count": len(items), + "query": { + "person_id": clean_person_id, + "person_keyword": person_keyword, + "platform": platform, + "user_id": user_id, + }, + } + + +async def _profile_set_override(payload: ProfileOverrideRequest) -> dict: + return await memory_service.profile_admin( + action="set_override", + person_id=payload.person_id, + override_text=payload.override_text, + updated_by=payload.updated_by, + source=payload.source, + ) + + +async def _profile_delete_override(person_id: str) -> dict: + return await memory_service.profile_admin(action="delete_override", person_id=person_id) + + +async def _feedback_list(limit: int, status: str, rollback_status: str, query: str) -> dict: + statuses = [item.strip() for item in str(status or "").split(",") if item.strip()] + rollback_statuses = [item.strip() for item in str(rollback_status or "").split(",") if item.strip()] + return await memory_service.feedback_admin( + action="list", + limit=limit, + statuses=statuses, + rollback_statuses=rollback_statuses, + query=query, + ) + + +async def _feedback_get(task_id: int) -> dict: + return await memory_service.feedback_admin(action="get", task_id=task_id) + + +async def _feedback_rollback(task_id: int, payload: FeedbackRollbackRequest) -> dict: + return await memory_service.feedback_admin( + action="rollback", + task_id=task_id, + requested_by=payload.requested_by, + reason=payload.reason, + ) + + +async def _runtime_save() -> dict: + return await memory_service.runtime_admin(action="save") + + +async def _runtime_config() -> dict: + return await memory_service.runtime_admin(action="get_config") + + +async def _runtime_self_check(refresh: bool) -> dict: + return await memory_service.runtime_admin(action="refresh_self_check" if refresh else "self_check") + + +async def _runtime_auto_save(enabled: bool | None = None) -> dict: + if enabled is None: + config = await memory_service.runtime_admin(action="get_config") + return {"success": bool(config.get("success", False)), "auto_save": bool(config.get("auto_save", False))} + return await memory_service.runtime_admin(action="set_auto_save", enabled=enabled) + + +async def _memory_config_schema() -> dict: + return { + "success": True, + "schema": a_memorix_host_service.get_config_schema(), + "path": str(a_memorix_host_service.get_config_path()), + } + + +async def _memory_config_get() -> dict: + return { + "success": True, + "config": a_memorix_host_service.get_config(), + "path": str(a_memorix_host_service.get_config_path()), + } + + +async def _memory_config_get_raw() -> dict: + raw_payload = a_memorix_host_service.get_raw_config_with_meta() + return { + "success": True, + "config": str(raw_payload.get("config", "") or ""), + "exists": bool(raw_payload.get("exists", False)), + "using_default": bool(raw_payload.get("using_default", False)), + "path": str(a_memorix_host_service.get_config_path()), + } + + +async def _memory_config_update(payload: MemoryConfigUpdateRequest) -> dict: + return await a_memorix_host_service.update_config(payload.config) + + +async def _memory_config_update_raw(payload: MemoryRawConfigUpdateRequest) -> dict: + try: + tomlkit.loads(payload.config) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"TOML 格式错误: {exc}") from exc + return await a_memorix_host_service.update_raw_config(payload.config) + + +async def _maintenance_recycle_bin(limit: int) -> dict: + return await memory_service.get_recycle_bin(limit=limit) + + +async def _maintenance_restore(payload: MaintainRequest) -> dict: + return (await memory_service.restore_memory(target=payload.target)).to_dict() + + +async def _maintenance_reinforce(payload: MaintainRequest) -> dict: + return (await memory_service.reinforce_memory(target=payload.target)).to_dict() + + +async def _maintenance_freeze(payload: MaintainRequest) -> dict: + return (await memory_service.freeze_memory(target=payload.target)).to_dict() + + +async def _maintenance_protect(payload: MaintainRequest) -> dict: + return (await memory_service.protect_memory(target=payload.target, hours=payload.hours)).to_dict() + + +async def _v5_status(target: str, limit: int) -> dict: + return await memory_service.v5_admin(action="status", target=target, limit=limit) + + +async def _v5_recycle_bin(limit: int) -> dict: + return await memory_service.v5_admin(action="recycle_bin", limit=limit) + + +async def _v5_action(action: str, payload: V5ActionRequest) -> dict: + kwargs: dict[str, Any] = { + "target": payload.target, + "reason": payload.reason, + "updated_by": payload.updated_by, + } + if payload.strength is not None: + kwargs["strength"] = payload.strength + return await memory_service.v5_admin(action=action, **kwargs) + + +async def _delete_preview(payload: DeleteActionRequest) -> dict: + return await memory_service.delete_admin(action="preview", mode=payload.mode, selector=payload.selector) + + +async def _delete_execute(payload: DeleteActionRequest) -> dict: + return await memory_service.delete_admin( + action="execute", + mode=payload.mode, + selector=payload.selector, + reason=payload.reason, + requested_by=payload.requested_by, + ) + + +async def _delete_restore(payload: DeleteRestoreRequest) -> dict: + return await memory_service.delete_admin( + action="restore", + mode=payload.mode, + selector=payload.selector, + operation_id=payload.operation_id, + reason=payload.reason, + requested_by=payload.requested_by, + ) + + +async def _delete_list(limit: int, mode: str) -> dict: + return await memory_service.delete_admin(action="list_operations", limit=limit, mode=mode) + + +async def _delete_get(operation_id: str) -> dict: + return await memory_service.delete_admin(action="get_operation", operation_id=operation_id) + + +async def _delete_purge(payload: DeletePurgeRequest) -> dict: + return await memory_service.delete_admin( + action="purge", + grace_hours=payload.grace_hours, + limit=payload.limit, + ) + + +async def _import_settings() -> dict: + return await memory_service.import_admin(action="get_settings") + + +async def _import_path_aliases() -> dict: + return await memory_service.import_admin(action="get_path_aliases") + + +async def _import_guide() -> dict: + payload = await memory_service.import_admin(action="get_guide") + if not isinstance(payload, dict): + payload = {"success": False, "error": "invalid_payload"} + if isinstance(payload.get("content"), str): + return payload + + settings = payload.get("settings") if isinstance(payload.get("settings"), dict) else None + if settings is None: + settings_payload = await memory_service.import_admin(action="get_settings") + settings = settings_payload.get("settings") if isinstance(settings_payload.get("settings"), dict) else {} + + return { + "success": True, + "source": "local", + "path": "generated://memory_import_guide", + "content": _build_import_guide_markdown(settings or {}), + "settings": settings or {}, + } + + +async def _import_resolve_path(payload: dict[str, Any]) -> dict: + return await memory_service.import_admin(action="resolve_path", **_unwrap_payload(payload)) + + +async def _import_create(action: str, payload: dict[str, Any]) -> dict: + return await memory_service.import_admin(action=action, **_unwrap_payload(payload)) + + +async def _import_list(limit: int) -> dict: + listing = await memory_service.import_admin(action="list", limit=limit) + if not isinstance(listing, dict): + listing = {"success": False, "items": []} + settings_payload = await memory_service.import_admin(action="get_settings") + settings = settings_payload.get("settings") if isinstance(settings_payload.get("settings"), dict) else {} + listing.setdefault("success", True) + listing.setdefault("items", []) + listing["settings"] = settings + return listing + + +async def _import_get(task_id: str, include_chunks: bool) -> dict: + return await memory_service.import_admin(action="get", task_id=task_id, include_chunks=include_chunks) + + +async def _import_chunks(task_id: str, file_id: str, offset: int, limit: int) -> dict: + return await memory_service.import_admin( + action="get_chunks", + task_id=task_id, + file_id=file_id, + offset=offset, + limit=limit, + ) + + +async def _import_cancel(task_id: str) -> dict: + return await memory_service.import_admin(action="cancel", task_id=task_id) + + +async def _import_retry(task_id: str, payload: dict[str, Any]) -> dict: + raw = _unwrap_payload(payload) + overrides = raw.get("overrides") if isinstance(raw.get("overrides"), dict) else raw + return await memory_service.import_admin(action="retry_failed", task_id=task_id, overrides=overrides) + + +async def _tuning_settings() -> dict: + return await memory_service.tuning_admin(action="get_settings") + + +async def _tuning_profile() -> dict: + profile = await memory_service.tuning_admin(action="get_profile") + if not isinstance(profile, dict): + profile = {"success": False, "profile": {}} + if not isinstance(profile.get("settings"), dict): + settings = await memory_service.tuning_admin(action="get_settings") + profile["settings"] = settings.get("settings") if isinstance(settings.get("settings"), dict) else {} + return profile + + +async def _tuning_apply_profile(payload: TuningApplyProfileRequest) -> dict: + return await memory_service.tuning_admin(action="apply_profile", profile=payload.profile, reason=payload.reason) + + +async def _tuning_rollback_profile() -> dict: + return await memory_service.tuning_admin(action="rollback_profile") + + +async def _tuning_export_profile() -> dict: + return await memory_service.tuning_admin(action="export_profile") + + +async def _tuning_create_task(payload: dict[str, Any]) -> dict: + return await memory_service.tuning_admin(action="create_task", payload=_unwrap_payload(payload)) + + +async def _tuning_list_tasks(limit: int) -> dict: + return await memory_service.tuning_admin(action="list_tasks", limit=limit) + + +async def _tuning_get_task(task_id: str, include_rounds: bool) -> dict: + return await memory_service.tuning_admin(action="get_task", task_id=task_id, include_rounds=include_rounds) + + +async def _tuning_get_rounds(task_id: str, offset: int, limit: int) -> dict: + return await memory_service.tuning_admin(action="get_rounds", task_id=task_id, offset=offset, limit=limit) + + +async def _tuning_cancel(task_id: str) -> dict: + return await memory_service.tuning_admin(action="cancel", task_id=task_id) + + +async def _tuning_apply_best(task_id: str) -> dict: + return await memory_service.tuning_admin(action="apply_best", task_id=task_id) + + +async def _tuning_report(task_id: str, fmt: str) -> dict: + payload_raw = await memory_service.tuning_admin(action="get_report", task_id=task_id, format=fmt) + payload = payload_raw if isinstance(payload_raw, dict) else {} + report_raw = payload.get("report") + report = report_raw if isinstance(report_raw, dict) else {} + return { + "success": bool(payload.get("success", False)), + "format": report.get("format", fmt), + "content": report.get("content", ""), + "path": report.get("path", ""), + "error": payload.get("error", ""), + } + + +async def _stage_upload_files(files: list[UploadFile]) -> tuple[Path, list[dict[str, Any]]]: + STAGING_ROOT.mkdir(parents=True, exist_ok=True) + staging_dir = STAGING_ROOT / uuid.uuid4().hex + staging_dir.mkdir(parents=True, exist_ok=True) + staged_files: list[dict[str, Any]] = [] + for index, upload in enumerate(files): + filename = Path(upload.filename or f"upload_{index}.txt").name + target = staging_dir / f"{index:03d}_{filename}" + content = await upload.read() + target.write_bytes(content) + staged_files.append( + { + "filename": filename, + "staged_path": str(target.resolve()), + "size": len(content), + } + ) + return staging_dir, staged_files + + +@router.get("/graph") +async def get_memory_graph(limit: int = Query(200, ge=1, le=5000)): + return await _graph_get(limit) + + +@router.get("/graph/search") +async def search_memory_graph( + query: str = Query(..., min_length=1), + limit: int = Query(50, ge=1, le=200), +): + return await _graph_search(query, limit) + + +@router.get("/graph/node-detail") +async def get_memory_graph_node_detail( + node_id: str = Query(..., min_length=1), + relation_limit: int = Query(20, ge=1, le=100), + paragraph_limit: int = Query(20, ge=1, le=100), + evidence_node_limit: int = Query(80, ge=12, le=200), +): + return await _graph_get_node_detail( + node_id, + relation_limit=relation_limit, + paragraph_limit=paragraph_limit, + evidence_node_limit=evidence_node_limit, + ) + + +@router.get("/graph/edge-detail") +async def get_memory_graph_edge_detail( + source: str = Query(..., min_length=1), + target: str = Query(..., min_length=1), + paragraph_limit: int = Query(20, ge=1, le=100), + evidence_node_limit: int = Query(80, ge=12, le=200), +): + return await _graph_get_edge_detail( + source, + target, + paragraph_limit=paragraph_limit, + evidence_node_limit=evidence_node_limit, + ) + + +@router.post("/graph/node") +async def create_memory_node(payload: NodeRequest): + return await _graph_create_node(payload) + + +@router.delete("/graph/node") +async def delete_memory_node(payload: NodeRequest): + return await _graph_delete_node(payload) + + +@router.post("/graph/node/rename") +async def rename_memory_node(payload: NodeRenameRequest): + return await _graph_rename_node(payload) + + +@router.post("/graph/edge") +async def create_memory_edge(payload: EdgeCreateRequest): + return await _graph_create_edge(payload) + + +@router.delete("/graph/edge") +async def delete_memory_edge(payload: EdgeDeleteRequest): + return await _graph_delete_edge(payload) + + +@router.post("/graph/edge/weight") +async def update_memory_edge_weight(payload: EdgeWeightRequest): + return await _graph_update_edge_weight(payload) + + +@router.get("/sources") +async def list_memory_sources(): + return await _source_list() + + +@router.post("/sources/delete") +async def delete_memory_source(payload: SourceDeleteRequest): + return await _source_delete(payload) + + +@router.post("/sources/batch-delete") +async def batch_delete_memory_sources(payload: SourceBatchDeleteRequest): + return await _source_batch_delete(payload) + + +@router.get("/query/aggregate") +async def query_memory_aggregate( + query: str = Query(""), + limit: int = Query(20, ge=1, le=200), + chat_id: str = Query(""), + person_id: str = Query(""), + time_start: float | None = Query(None), + time_end: float | None = Query(None), +): + return await _query_aggregate( + query, + limit=limit, + chat_id=chat_id, + person_id=person_id, + time_start=time_start, + time_end=time_end, + ) + + +@router.get("/episodes") +async def list_memory_episodes( + query: str = Query(""), + limit: int = Query(20, ge=1, le=200), + source: str = Query(""), + person_id: str = Query(""), + platform: str = Query(""), + user_id: str = Query(""), + time_start: float | None = Query(None), + time_end: float | None = Query(None), +): + return await _episode_list( + query=query, + limit=limit, + source=source, + person_id=person_id, + platform=platform, + user_id=user_id, + time_start=time_start, + time_end=time_end, + ) + + +@router.get("/episodes/status") +async def get_memory_episode_status(limit: int = Query(20, ge=1, le=200)): + return await _episode_status(limit) + + +@router.get("/episodes/{episode_id}") +async def get_memory_episode(episode_id: str): + return await _episode_get(episode_id) + + +@router.post("/episodes/rebuild") +async def rebuild_memory_episodes(payload: EpisodeRebuildRequest): + return await _episode_rebuild(payload) + + +@router.post("/episodes/process-pending") +async def process_memory_episode_pending(payload: EpisodeProcessPendingRequest): + return await _episode_process_pending(payload) + + +@router.get("/profiles/query") +async def query_memory_profile( + person_id: str = Query(""), + person_keyword: str = Query(""), + platform: str = Query(""), + user_id: str = Query(""), + limit: int = Query(12, ge=1, le=100), + force_refresh: bool = Query(False), +): + return await _profile_query( + person_id=person_id, + person_keyword=person_keyword, + platform=platform, + user_id=user_id, + limit=limit, + force_refresh=force_refresh, + ) + + +@router.get("/profiles") +async def list_memory_profiles(limit: int = Query(50, ge=1, le=200)): + return await _profile_list(limit) + + +@router.get("/profiles/search") +async def search_memory_profiles( + person_id: str = Query(""), + person_keyword: str = Query(""), + platform: str = Query(""), + user_id: str = Query(""), + limit: int = Query(50, ge=1, le=200), +): + return await _profile_search( + person_id=person_id, + person_keyword=person_keyword, + platform=platform, + user_id=user_id, + limit=limit, + ) + + +@router.post("/profiles/override") +async def set_memory_profile_override(payload: ProfileOverrideRequest): + return await _profile_set_override(payload) + + +@router.delete("/profiles/override/{person_id}") +async def delete_memory_profile_override(person_id: str): + return await _profile_delete_override(person_id) + + +@router.get("/feedback-corrections") +async def list_memory_feedback_corrections( + limit: int = Query(50, ge=1, le=200), + status: str = Query(""), + rollback_status: str = Query(""), + query: str = Query(""), +): + return await _feedback_list(limit, status, rollback_status, query) + + +@router.get("/feedback-corrections/{task_id}") +async def get_memory_feedback_correction(task_id: int): + return await _feedback_get(task_id) + + +@router.post("/feedback-corrections/{task_id}/rollback") +async def rollback_memory_feedback_correction(task_id: int, payload: FeedbackRollbackRequest): + return await _feedback_rollback(task_id, payload) + + +@router.post("/runtime/save") +async def save_memory_runtime(): + return await _runtime_save() + + +@router.get("/config/schema") +async def get_memory_config_schema(): + return await _memory_config_schema() + + +@router.get("/config") +async def get_memory_config(): + return await _memory_config_get() + + +@router.put("/config") +async def update_memory_config(payload: MemoryConfigUpdateRequest): + return await _memory_config_update(payload) + + +@router.get("/config/raw") +async def get_memory_config_raw(): + return await _memory_config_get_raw() + + +@router.put("/config/raw") +async def update_memory_config_raw(payload: MemoryRawConfigUpdateRequest): + return await _memory_config_update_raw(payload) + + +@router.get("/runtime/config") +async def get_memory_runtime_config(): + return await _runtime_config() + + +@router.get("/runtime/self-check") +async def get_memory_runtime_self_check(): + return await _runtime_self_check(False) + + +@router.post("/runtime/self-check/refresh") +async def refresh_memory_runtime_self_check(): + return await _runtime_self_check(True) + + +@router.get("/runtime/auto-save") +async def get_memory_runtime_auto_save(): + return await _runtime_auto_save(None) + + +@router.post("/runtime/auto-save") +async def set_memory_runtime_auto_save(payload: AutoSaveRequest): + return await _runtime_auto_save(payload.enabled) + + +@router.get("/maintenance/recycle-bin") +async def get_memory_recycle_bin(limit: int = Query(50, ge=1, le=200)): + return await _maintenance_recycle_bin(limit) + + +@router.post("/maintenance/restore") +async def restore_memory_relation(payload: MaintainRequest): + return await _maintenance_restore(payload) + + +@router.post("/maintenance/reinforce") +async def reinforce_memory_relation(payload: MaintainRequest): + return await _maintenance_reinforce(payload) + + +@router.post("/maintenance/freeze") +async def freeze_memory_relation(payload: MaintainRequest): + return await _maintenance_freeze(payload) + + +@router.post("/maintenance/protect") +async def protect_memory_relation(payload: MaintainRequest): + return await _maintenance_protect(payload) + + +@router.get("/v5/status") +async def get_memory_v5_status( + target: str = Query(""), + limit: int = Query(50, ge=1, le=200), +): + return await _v5_status(target, limit) + + +@router.get("/v5/recycle-bin") +async def get_memory_v5_recycle_bin(limit: int = Query(50, ge=1, le=200)): + return await _v5_recycle_bin(limit) + + +@router.post("/v5/reinforce") +async def reinforce_memory_v5(payload: V5ActionRequest): + return await _v5_action("reinforce", payload) + + +@router.post("/v5/weaken") +async def weaken_memory_v5(payload: V5ActionRequest): + return await _v5_action("weaken", payload) + + +@router.post("/v5/remember-forever") +async def remember_forever_memory_v5(payload: V5ActionRequest): + return await _v5_action("remember_forever", payload) + + +@router.post("/v5/forget") +async def forget_memory_v5(payload: V5ActionRequest): + return await _v5_action("forget", payload) + + +@router.post("/v5/restore") +async def restore_memory_v5(payload: V5ActionRequest): + return await _v5_action("restore", payload) + + +@router.post("/delete/preview") +async def preview_memory_delete(payload: DeleteActionRequest): + return await _delete_preview(payload) + + +@router.post("/delete/execute") +async def execute_memory_delete(payload: DeleteActionRequest): + return await _delete_execute(payload) + + +@router.post("/delete/restore") +async def restore_memory_delete(payload: DeleteRestoreRequest): + return await _delete_restore(payload) + + +@router.get("/delete/operations") +async def list_memory_delete_operations( + limit: int = Query(50, ge=1, le=200), + mode: str = Query(""), +): + return await _delete_list(limit, mode) + + +@router.get("/delete/operations/{operation_id}") +async def get_memory_delete_operation(operation_id: str): + return await _delete_get(operation_id) + + +@router.post("/delete/purge") +async def purge_memory_delete(payload: DeletePurgeRequest): + return await _delete_purge(payload) + + +@router.get("/import/settings") +async def get_memory_import_settings(): + return await _import_settings() + + +@router.get("/import/path-aliases") +async def get_memory_import_path_aliases(): + return await _import_path_aliases() + + +@router.get("/import/guide") +async def get_memory_import_guide(): + return await _import_guide() + + +@router.post("/import/resolve-path") +async def resolve_memory_import_path(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_resolve_path(payload) + + +@router.post("/import/upload") +async def create_memory_import_upload( + files: list[UploadFile] = File(...), + payload_json: str = Form("{}"), +): + staging_dir, staged_files = await _stage_upload_files(files) + try: + try: + payload = json.loads(payload_json or "{}") + except Exception: + payload = {} + if not isinstance(payload, dict): + payload = {} + payload["staged_files"] = staged_files + return await _import_create("create_upload", payload) + finally: + shutil.rmtree(staging_dir, ignore_errors=True) + + +@router.post("/import/paste") +async def create_memory_import_paste(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_paste", payload) + + +@router.post("/import/raw-scan") +async def create_memory_import_raw_scan(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_raw_scan", payload) + + +@router.post("/import/lpmm-openie") +async def create_memory_import_lpmm_openie(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_lpmm_openie", payload) + + +@router.post("/import/lpmm-convert") +async def create_memory_import_lpmm_convert(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_lpmm_convert", payload) + + +@router.post("/import/temporal-backfill") +async def create_memory_import_temporal_backfill(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_temporal_backfill", payload) + + +@router.post("/import/maibot-migration") +async def create_memory_import_maibot_migration(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_maibot_migration", payload) + + +@router.get("/import/tasks") +async def list_memory_import_tasks(limit: int = Query(50, ge=1, le=200)): + return await _import_list(limit) + + +@router.get("/import/tasks/{task_id}") +async def get_memory_import_task(task_id: str, include_chunks: bool = Query(False)): + return await _import_get(task_id, include_chunks) + + +@router.get("/import/tasks/{task_id}/chunks/{file_id}") +async def get_memory_import_chunks( + task_id: str, + file_id: str, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), +): + return await _import_chunks(task_id, file_id, offset, limit) + + +@router.post("/import/tasks/{task_id}/cancel") +async def cancel_memory_import_task(task_id: str): + return await _import_cancel(task_id) + + +@router.post("/import/tasks/{task_id}/retry") +async def retry_memory_import_task(task_id: str, payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_retry(task_id, payload) + + +@router.get("/retrieval_tuning/settings") +async def get_memory_tuning_settings(): + return await _tuning_settings() + + +@router.get("/retrieval_tuning/profile") +async def get_memory_tuning_profile(): + return await _tuning_profile() + + +@router.post("/retrieval_tuning/profile/apply") +async def apply_memory_tuning_profile(payload: TuningApplyProfileRequest): + return await _tuning_apply_profile(payload) + + +@router.post("/retrieval_tuning/profile/rollback") +async def rollback_memory_tuning_profile(): + return await _tuning_rollback_profile() + + +@router.get("/retrieval_tuning/profile/export") +async def export_memory_tuning_profile(): + return await _tuning_export_profile() + + +@router.post("/retrieval_tuning/tasks") +async def create_memory_tuning_task(payload: dict[str, Any] = Body(default_factory=dict)): + return await _tuning_create_task(payload) + + +@router.get("/retrieval_tuning/tasks") +async def list_memory_tuning_tasks(limit: int = Query(50, ge=1, le=200)): + return await _tuning_list_tasks(limit) + + +@router.get("/retrieval_tuning/tasks/{task_id}") +async def get_memory_tuning_task(task_id: str, include_rounds: bool = Query(False)): + return await _tuning_get_task(task_id, include_rounds) + + +@router.get("/retrieval_tuning/tasks/{task_id}/rounds") +async def get_memory_tuning_rounds( + task_id: str, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), +): + return await _tuning_get_rounds(task_id, offset, limit) + + +@router.post("/retrieval_tuning/tasks/{task_id}/cancel") +async def cancel_memory_tuning_task(task_id: str): + return await _tuning_cancel(task_id) + + +@router.post("/retrieval_tuning/tasks/{task_id}/apply-best") +async def apply_best_memory_tuning_profile(task_id: str): + return await _tuning_apply_best(task_id) + + +@router.get("/retrieval_tuning/tasks/{task_id}/report") +async def get_memory_tuning_report(task_id: str, format: str = Query("md")): + return await _tuning_report(task_id, format) + + +@compat_router.get("/graph") +async def compat_get_graph(limit: int = Query(200, ge=1, le=5000)): + return await _graph_get(limit) + + +@compat_router.post("/node") +async def compat_create_node(payload: NodeRequest): + return await _graph_create_node(payload) + + +@compat_router.delete("/node") +async def compat_delete_node(payload: NodeRequest): + return await _graph_delete_node(payload) + + +@compat_router.post("/node/rename") +async def compat_rename_node(payload: NodeRenameRequest): + return await _graph_rename_node(payload) + + +@compat_router.post("/edge") +async def compat_create_edge(payload: EdgeCreateRequest): + return await _graph_create_edge(payload) + + +@compat_router.delete("/edge") +async def compat_delete_edge(payload: EdgeDeleteRequest): + return await _graph_delete_edge(payload) + + +@compat_router.post("/edge/weight") +async def compat_update_edge_weight(payload: EdgeWeightRequest): + return await _graph_update_edge_weight(payload) + + +@compat_router.get("/source/list") +async def compat_list_sources(): + return await _source_list() + + +@compat_router.post("/source/delete") +async def compat_delete_source(payload: SourceDeleteRequest): + return await _source_delete(payload) + + +@compat_router.post("/source/batch_delete") +async def compat_batch_delete_sources(payload: SourceBatchDeleteRequest): + return await _source_batch_delete(payload) + + +@compat_router.get("/query/aggregate") +async def compat_query_aggregate( + query: str = Query(""), + limit: int = Query(20, ge=1, le=200), + chat_id: str = Query(""), + person_id: str = Query(""), + time_start: float | None = Query(None), + time_end: float | None = Query(None), +): + return await _query_aggregate( + query, + limit=limit, + chat_id=chat_id, + person_id=person_id, + time_start=time_start, + time_end=time_end, + ) + + +@compat_router.get("/episodes") +async def compat_list_episodes( + query: str = Query(""), + limit: int = Query(20, ge=1, le=200), + source: str = Query(""), + person_id: str = Query(""), + platform: str = Query(""), + user_id: str = Query(""), + time_start: float | None = Query(None), + time_end: float | None = Query(None), +): + return await _episode_list( + query=query, + limit=limit, + source=source, + person_id=person_id, + platform=platform, + user_id=user_id, + time_start=time_start, + time_end=time_end, + ) + + +@compat_router.get("/episodes/status") +async def compat_episode_status(limit: int = Query(20, ge=1, le=200)): + return await _episode_status(limit) + + +@compat_router.get("/episodes/{episode_id}") +async def compat_get_episode(episode_id: str): + return await _episode_get(episode_id) + + +@compat_router.post("/episodes/rebuild") +async def compat_rebuild_episodes(payload: EpisodeRebuildRequest): + return await _episode_rebuild(payload) + + +@compat_router.post("/episodes/process_pending") +async def compat_process_episode_pending(payload: EpisodeProcessPendingRequest): + return await _episode_process_pending(payload) + + +@compat_router.get("/person_profile/query") +async def compat_profile_query( + person_id: str = Query(""), + person_keyword: str = Query(""), + platform: str = Query(""), + user_id: str = Query(""), + limit: int = Query(12, ge=1, le=100), + force_refresh: bool = Query(False), +): + return await _profile_query( + person_id=person_id, + person_keyword=person_keyword, + platform=platform, + user_id=user_id, + limit=limit, + force_refresh=force_refresh, + ) + + +@compat_router.get("/person_profile/list") +async def compat_profile_list(limit: int = Query(50, ge=1, le=200)): + return await _profile_list(limit) + + +@compat_router.get("/person_profile/search") +async def compat_profile_search( + person_id: str = Query(""), + person_keyword: str = Query(""), + platform: str = Query(""), + user_id: str = Query(""), + limit: int = Query(50, ge=1, le=200), +): + return await _profile_search( + person_id=person_id, + person_keyword=person_keyword, + platform=platform, + user_id=user_id, + limit=limit, + ) + + +@compat_router.post("/person_profile/override") +async def compat_set_profile_override(payload: ProfileOverrideRequest): + return await _profile_set_override(payload) + + +@compat_router.delete("/person_profile/override/{person_id}") +async def compat_delete_profile_override(person_id: str): + return await _profile_delete_override(person_id) + + +@compat_router.post("/save") +async def compat_runtime_save(): + return await _runtime_save() + + +@compat_router.get("/config") +async def compat_runtime_config(): + return await _runtime_config() + + +@compat_router.get("/runtime/self_check") +async def compat_runtime_self_check(): + return await _runtime_self_check(False) + + +@compat_router.post("/runtime/self_check/refresh") +async def compat_refresh_runtime_self_check(): + return await _runtime_self_check(True) + + +@compat_router.get("/config/auto_save") +async def compat_runtime_auto_save(): + return await _runtime_auto_save(None) + + +@compat_router.post("/config/auto_save") +async def compat_set_runtime_auto_save(payload: AutoSaveRequest): + return await _runtime_auto_save(payload.enabled) + + +@compat_router.get("/memory/recycle_bin") +async def compat_get_recycle_bin(limit: int = Query(50, ge=1, le=200)): + return await _maintenance_recycle_bin(limit) + + +@compat_router.post("/memory/restore") +async def compat_restore_memory(payload: MaintainRequest): + return await _maintenance_restore(payload) + + +@compat_router.post("/memory/reinforce") +async def compat_reinforce_memory(payload: MaintainRequest): + return await _maintenance_reinforce(payload) + + +@compat_router.post("/memory/freeze") +async def compat_freeze_memory(payload: MaintainRequest): + return await _maintenance_freeze(payload) + + +@compat_router.post("/memory/protect") +async def compat_protect_memory(payload: MaintainRequest): + return await _maintenance_protect(payload) + + +@compat_router.get("/import/settings") +async def compat_import_settings(): + return await _import_settings() + + +@compat_router.get("/import/path_aliases") +async def compat_import_path_aliases(): + return await _import_path_aliases() + + +@compat_router.get("/import/guide") +async def compat_import_guide(): + return await _import_guide() + + +@compat_router.post("/import/resolve_path") +async def compat_import_resolve_path(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_resolve_path(payload) + + +@compat_router.post("/import/upload") +async def compat_import_upload( + files: list[UploadFile] = File(...), + payload_json: str = Form("{}"), +): + return await create_memory_import_upload(files=files, payload_json=payload_json) + + +@compat_router.post("/import/tasks/upload") +async def compat_import_upload_task( + files: list[UploadFile] = File(...), + payload_json: str = Form("{}"), +): + return await create_memory_import_upload(files=files, payload_json=payload_json) + + +@compat_router.post("/import/paste") +async def compat_import_paste(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_paste", payload) + + +@compat_router.post("/import/tasks/paste") +async def compat_import_paste_task(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_paste", payload) + + +@compat_router.post("/import/raw_scan") +async def compat_import_raw_scan(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_raw_scan", payload) + + +@compat_router.post("/import/tasks/raw_scan") +async def compat_import_raw_scan_task(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_raw_scan", payload) + + +@compat_router.post("/import/lpmm_openie") +async def compat_import_lpmm_openie(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_lpmm_openie", payload) + + +@compat_router.post("/import/tasks/lpmm_openie") +async def compat_import_lpmm_openie_task(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_lpmm_openie", payload) + + +@compat_router.post("/import/lpmm_convert") +async def compat_import_lpmm_convert(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_lpmm_convert", payload) + + +@compat_router.post("/import/tasks/lpmm_convert") +async def compat_import_lpmm_convert_task(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_lpmm_convert", payload) + + +@compat_router.post("/import/temporal_backfill") +async def compat_import_temporal_backfill(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_temporal_backfill", payload) + + +@compat_router.post("/import/tasks/temporal_backfill") +async def compat_import_temporal_backfill_task(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_temporal_backfill", payload) + + +@compat_router.post("/import/maibot_migration") +async def compat_import_maibot_migration(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_maibot_migration", payload) + + +@compat_router.post("/import/tasks/maibot_migration") +async def compat_import_maibot_migration_task(payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_create("create_maibot_migration", payload) + + +@compat_router.get("/import/tasks") +async def compat_import_list(limit: int = Query(50, ge=1, le=200)): + return await _import_list(limit) + + +@compat_router.get("/import/tasks/{task_id}") +async def compat_import_get(task_id: str, include_chunks: bool = Query(False)): + return await _import_get(task_id, include_chunks) + + +@compat_router.get("/import/tasks/{task_id}/chunks/{file_id}") +async def compat_import_chunks( + task_id: str, + file_id: str, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), +): + return await _import_chunks(task_id, file_id, offset, limit) + + +@compat_router.get("/import/tasks/{task_id}/files/{file_id}/chunks") +async def compat_import_file_chunks( + task_id: str, + file_id: str, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), +): + return await _import_chunks(task_id, file_id, offset, limit) + + +@compat_router.post("/import/tasks/{task_id}/cancel") +async def compat_import_cancel(task_id: str): + return await _import_cancel(task_id) + + +@compat_router.post("/import/tasks/{task_id}/retry") +async def compat_import_retry(task_id: str, payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_retry(task_id, payload) + + +@compat_router.post("/import/tasks/{task_id}/retry_failed") +async def compat_import_retry_failed(task_id: str, payload: dict[str, Any] = Body(default_factory=dict)): + return await _import_retry(task_id, payload) + + +@compat_router.get("/retrieval_tuning/settings") +async def compat_tuning_settings(): + return await _tuning_settings() + + +@compat_router.get("/retrieval_tuning/profile") +async def compat_tuning_profile(): + return await _tuning_profile() + + +@compat_router.post("/retrieval_tuning/profile/apply") +async def compat_apply_tuning_profile(payload: TuningApplyProfileRequest): + return await _tuning_apply_profile(payload) + + +@compat_router.post("/retrieval_tuning/profile/rollback") +async def compat_rollback_tuning_profile(): + return await _tuning_rollback_profile() + + +@compat_router.get("/retrieval_tuning/profile/export") +async def compat_export_tuning_profile(): + return await _tuning_export_profile() + + +@compat_router.get("/retrieval_tuning/profile/export_toml") +async def compat_export_tuning_profile_toml(): + return await _tuning_export_profile() + + +@compat_router.post("/retrieval_tuning/tasks") +async def compat_create_tuning_task(payload: dict[str, Any] = Body(default_factory=dict)): + return await _tuning_create_task(payload) + + +@compat_router.get("/retrieval_tuning/tasks") +async def compat_list_tuning_tasks(limit: int = Query(50, ge=1, le=200)): + return await _tuning_list_tasks(limit) + + +@compat_router.get("/retrieval_tuning/tasks/{task_id}") +async def compat_get_tuning_task(task_id: str, include_rounds: bool = Query(False)): + return await _tuning_get_task(task_id, include_rounds) + + +@compat_router.get("/retrieval_tuning/tasks/{task_id}/rounds") +async def compat_get_tuning_rounds( + task_id: str, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), +): + return await _tuning_get_rounds(task_id, offset, limit) + + +@compat_router.post("/retrieval_tuning/tasks/{task_id}/cancel") +async def compat_cancel_tuning_task(task_id: str): + return await _tuning_cancel(task_id) + + +@compat_router.post("/retrieval_tuning/tasks/{task_id}/apply_best") +async def compat_apply_best_tuning_profile(task_id: str): + return await _tuning_apply_best(task_id) + + +@compat_router.post("/retrieval_tuning/tasks/{task_id}/apply-best") +async def compat_apply_best_tuning_profile_kebab(task_id: str): + return await _tuning_apply_best(task_id) + + +@compat_router.get("/retrieval_tuning/tasks/{task_id}/report") +async def compat_get_tuning_report(task_id: str, format: str = Query("md")): + return await _tuning_report(task_id, format) diff --git a/src/webui/routers/model.py b/src/webui/routers/model.py new file mode 100644 index 00000000..cf2ad1b0 --- /dev/null +++ b/src/webui/routers/model.py @@ -0,0 +1,428 @@ +""" +模型列表获取API路由 + +提供从各个 AI 厂商 API 获取可用模型列表的代理接口 +""" + +import os +from typing import Dict, List, Optional + +import httpx +import tomlkit +from fastapi import APIRouter, Depends, HTTPException, Query + +from src.common.logger import get_logger +from src.config.config import CONFIG_DIR +from src.config.model_configs import APIProvider +from src.llm_models.openai_compat import build_openai_compatible_client_config, normalize_openai_base_url +from src.webui.dependencies import require_auth +from src.webui.utils.network_security import validate_public_url + +logger = get_logger("webui") + +router = APIRouter(prefix="/models", tags=["models"], dependencies=[Depends(require_auth)]) +# 模型获取器配置 +MODEL_FETCHER_CONFIG = { + # OpenAI 兼容格式的提供商 + "openai": { + "endpoint": "/models", + "parser": "openai", + }, + # Gemini 格式 + "gemini": { + "endpoint": "/models", + "parser": "gemini", + }, +} + + +def _normalize_url(url: str) -> str: + """规范化 URL(去掉尾部斜杠)。""" + return normalize_openai_base_url(url) if url else "" + + +def _parse_openai_response(data: Dict) -> List[Dict]: + """ + 解析 OpenAI 格式的模型列表响应 + + 格式: { "data": [{ "id": "gpt-4", "object": "model", ... }] } + """ + if "data" not in data or not isinstance(data["data"], list): + return [] + + return [ + { + "id": model["id"], + "name": model.get("name") or model["id"], + "owned_by": model.get("owned_by", ""), + } + for model in data["data"] + if isinstance(model, dict) and "id" in model + ] + + +def _parse_gemini_response(data: Dict) -> List[Dict]: + """ + 解析 Gemini 格式的模型列表响应 + + 格式: { "models": [{ "name": "models/gemini-pro", "displayName": "Gemini Pro", ... }] } + """ + models = [] + if "models" in data and isinstance(data["models"], list): + for model in data["models"]: + if isinstance(model, dict) and "name" in model: + # Gemini 的 name 格式是 "models/gemini-pro",我们只取后面部分 + model_id = model["name"] + if model_id.startswith("models/"): + model_id = model_id[7:] # 去掉 "models/" 前缀 + models.append( + { + "id": model_id, + "name": model.get("displayName") or model_id, + "owned_by": "google", + } + ) + return models + + +async def _fetch_models_from_provider( + base_url: str, + api_key: str, + endpoint: str, + parser: str, + client_type: str = "openai", + auth_type: str = "bearer", + auth_header_name: str = "Authorization", + auth_header_prefix: str = "Bearer", + auth_query_name: str = "api_key", + default_headers: Optional[Dict[str, str]] = None, + default_query: Optional[Dict[str, str]] = None, +) -> List[Dict]: + """从提供商 API 获取模型列表。 + + Args: + base_url: 提供商的基础 URL。 + api_key: API 密钥。 + endpoint: 获取模型列表的端点。 + parser: 响应解析器类型。 + client_type: 客户端类型。 + auth_type: OpenAI 兼容接口的鉴权方式。 + auth_header_name: Header 鉴权时使用的请求头名称。 + auth_header_prefix: Header 鉴权时使用的请求头前缀。 + auth_query_name: Query 鉴权时使用的查询参数名称。 + default_headers: 默认附带的请求头。 + default_query: 默认附带的查询参数。 + + Returns: + List[Dict]: 解析后的模型列表。 + """ + try: + base_url = validate_public_url(_normalize_url(base_url)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + url = f"{base_url}{endpoint}" + + # 根据客户端类型设置请求头 + headers = {} + params = {} + + if client_type == "gemini": + # Gemini 使用 URL 参数传递 API Key + params["key"] = api_key + else: + provider = APIProvider( + name="webui-openai-compatible-fetcher", + base_url=base_url, + api_key=api_key, + client_type="openai", + auth_type=auth_type, + auth_header_name=auth_header_name, + auth_header_prefix=auth_header_prefix, + auth_query_name=auth_query_name, + default_headers=default_headers or {}, + default_query=default_query or {}, + ) + client_config = build_openai_compatible_client_config(provider) + headers.update(client_config.default_headers) + params.update(client_config.default_query) + # build_openai_compatible_client_config 在“默认 Bearer”场景下, + # 会把 api_key 留在 client_config.api_key 中交给 OpenAI SDK 自行注入 Authorization 头, + # 而不会写入 default_headers。这里我们用 httpx 直接发请求,需要手动补上鉴权头/参数。 + if client_config.api_key and "Authorization" not in headers: + headers["Authorization"] = f"Bearer {client_config.api_key}" + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + data = response.json() + except httpx.TimeoutException as e: + raise HTTPException(status_code=504, detail="请求超时,请稍后重试") from e + except httpx.HTTPStatusError as e: + # 注意:使用 502 Bad Gateway 而不是原始的 401/403, + # 因为前端的 fetchWithAuth 会把 401 当作 WebUI 认证失败处理 + if e.response.status_code == 401: + raise HTTPException(status_code=502, detail="API Key 无效或已过期") from e + elif e.response.status_code == 403: + raise HTTPException(status_code=502, detail="没有权限访问模型列表,请检查 API Key 权限") from e + elif e.response.status_code == 404: + raise HTTPException(status_code=502, detail="该提供商不支持获取模型列表") from e + else: + raise HTTPException( + status_code=502, detail=f"上游服务请求失败 ({e.response.status_code}): {e.response.text[:200]}" + ) from e + except Exception as e: + logger.error(f"获取模型列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取模型列表失败: {str(e)}") from e + + # 根据解析器类型解析响应 + if parser == "openai": + return _parse_openai_response(data) + elif parser == "gemini": + return _parse_gemini_response(data) + else: + raise HTTPException(status_code=400, detail=f"不支持的解析器类型: {parser}") + + +def _get_provider_config(provider_name: str) -> Optional[Dict]: + """ + 从 model_config.toml 获取指定提供商的配置 + + Args: + provider_name: 提供商名称 + + Returns: + 提供商配置,如果未找到则返回 None + """ + config_path = os.path.join(CONFIG_DIR, "model_config.toml") + if not os.path.exists(config_path): + return None + + try: + with open(config_path, "r", encoding="utf-8") as f: + config_data = tomlkit.load(f) + + providers = config_data.get("api_providers", []) + provider = next((provider for provider in providers if provider.get("name") == provider_name), None) + return dict(provider) if provider is not None else None + except Exception as e: + logger.error(f"读取提供商配置失败: {e}") + return None + + +@router.get("/list") +async def get_provider_models( + provider_name: str = Query(..., description="提供商名称"), + parser: str = Query("openai", description="响应解析器类型 (openai | gemini)"), + endpoint: str = Query("/models", description="获取模型列表的端点"), +): + """获取指定提供商的可用模型列表。 + + 通过提供商名称查找配置,然后请求对应的模型列表端点。 + """ + # 获取提供商配置 + provider_config = _get_provider_config(provider_name) + if not provider_config: + raise HTTPException(status_code=404, detail=f"未找到提供商: {provider_name}") + + base_url = provider_config.get("base_url") + api_key = provider_config.get("api_key") + client_type = provider_config.get("client_type", "openai") + + if not base_url: + raise HTTPException(status_code=400, detail="提供商配置缺少 base_url") + if not api_key: + raise HTTPException(status_code=400, detail="提供商配置缺少 api_key") + + resolved_endpoint = provider_config.get("model_list_endpoint", endpoint) if endpoint == "/models" else endpoint + + # 获取模型列表 + models = await _fetch_models_from_provider( + base_url=base_url, + api_key=api_key, + endpoint=resolved_endpoint, + parser=parser, + client_type=client_type, + auth_type=provider_config.get("auth_type", "bearer"), + auth_header_name=provider_config.get("auth_header_name", "Authorization"), + auth_header_prefix=provider_config.get("auth_header_prefix", "Bearer"), + auth_query_name=provider_config.get("auth_query_name", "api_key"), + default_headers=provider_config.get("default_headers", {}), + default_query=provider_config.get("default_query", {}), + ) + + return { + "success": True, + "models": models, + "provider": provider_name, + "count": len(models), + } + + +@router.get("/list-by-url") +async def get_models_by_url( + base_url: str = Query(..., description="提供商的基础 URL"), + api_key: str = Query(..., description="API Key"), + parser: str = Query("openai", description="响应解析器类型 (openai | gemini)"), + endpoint: str = Query("/models", description="获取模型列表的端点"), + client_type: str = Query("openai", description="客户端类型 (openai | gemini)"), + auth_type: str = Query("bearer", description="鉴权方式 (bearer | header | query | none)"), + auth_header_name: str = Query("Authorization", description="Header 鉴权名称"), + auth_header_prefix: str = Query("Bearer", description="Header 鉴权前缀"), + auth_query_name: str = Query("api_key", description="Query 鉴权参数名"), +): + """通过 URL 直接获取模型列表。""" + models = await _fetch_models_from_provider( + base_url=base_url, + api_key=api_key, + endpoint=endpoint, + parser=parser, + client_type=client_type, + auth_type=auth_type, + auth_header_name=auth_header_name, + auth_header_prefix=auth_header_prefix, + auth_query_name=auth_query_name, + ) + + return { + "success": True, + "models": models, + "count": len(models), + } + + +@router.get("/test-connection") +async def test_provider_connection( + base_url: str = Query(..., description="提供商的基础 URL"), + api_key: Optional[str] = Query(None, description="API Key(可选,用于验证 Key 有效性)"), + client_type: str = Query("openai", description="客户端类型 (openai | gemini)"), +): + """ + 测试提供商连接状态 + + 分两步测试: + 1. 网络连通性测试:向 base_url 发送请求,检查是否能连接 + 2. API Key 验证(可选):如果提供了 api_key,尝试获取模型列表验证 Key 是否有效 + + 返回: + - network_ok: 网络是否连通 + - api_key_valid: API Key 是否有效(仅在提供 api_key 时返回) + - latency_ms: 响应延迟(毫秒) + - error: 错误信息(如果有) + """ + import time + + base_url = _normalize_url(base_url) + if not base_url: + raise HTTPException(status_code=400, detail="base_url 不能为空") + + try: + base_url = validate_public_url(base_url) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + result = { + "network_ok": False, + "api_key_valid": None, + "latency_ms": None, + "error": None, + "http_status": None, + } + + # 第一步:测试网络连通性 + try: + start_time = time.time() + async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: + # 尝试 GET 请求 base_url(不需要 API Key) + response = await client.get(base_url) + latency = (time.time() - start_time) * 1000 + + result["network_ok"] = True + result["latency_ms"] = round(latency, 2) + result["http_status"] = response.status_code + + except httpx.ConnectError as e: + result["error"] = f"连接失败:无法连接到服务器 ({str(e)})" + return result + except httpx.TimeoutException: + result["error"] = "连接超时:服务器响应时间过长" + return result + except httpx.RequestError as e: + result["error"] = f"请求错误:{str(e)}" + return result + except Exception as e: + result["error"] = f"未知错误:{str(e)}" + return result + + # 第二步:如果提供了 API Key,验证其有效性 + if api_key: + try: + start_time = time.time() + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: + headers = {"Content-Type": "application/json"} + params = {} + + if client_type == "gemini": + # Gemini 使用 URL 参数传递 API Key + params["key"] = api_key + else: + # OpenAI 兼容格式使用 Authorization 头 + headers["Authorization"] = f"Bearer {api_key}" + + # 尝试获取模型列表 + models_url = f"{base_url}/models" + response = await client.get(models_url, headers=headers, params=params) + + if response.status_code == 200: + result["api_key_valid"] = True + elif response.status_code in (401, 403): + result["api_key_valid"] = False + result["error"] = "API Key 无效或已过期" + else: + # 其他状态码,可能是端点不支持,但 Key 可能是有效的 + result["api_key_valid"] = None + + except Exception as e: + # API Key 验证失败不影响网络连通性结果 + logger.warning(f"API Key 验证失败: {e}") + result["api_key_valid"] = None + + return result + + +@router.post("/test-connection-by-name") +async def test_provider_connection_by_name( + provider_name: str = Query(..., description="提供商名称"), +): + """ + 通过提供商名称测试连接(从配置文件读取信息) + """ + # 读取配置文件 + model_config_path = os.path.join(CONFIG_DIR, "model_config.toml") + if not os.path.exists(model_config_path): + raise HTTPException(status_code=404, detail="配置文件不存在") + + with open(model_config_path, "r", encoding="utf-8") as f: + config = tomlkit.load(f) + + # 查找提供商 + providers = config.get("api_providers", []) + provider = next((item for item in providers if item.get("name") == provider_name), None) + + if not provider: + raise HTTPException(status_code=404, detail=f"未找到提供商: {provider_name}") + + base_url = provider.get("base_url", "") + api_key = provider.get("api_key", "") + client_type = provider.get("client_type", "openai") + + if not base_url: + raise HTTPException(status_code=400, detail="提供商配置缺少 base_url") + + # 调用测试接口 + return await test_provider_connection( + base_url=base_url, + api_key=api_key if api_key else None, + client_type=client_type, + ) diff --git a/src/webui/routers/person.py b/src/webui/routers/person.py new file mode 100644 index 00000000..5a5396c0 --- /dev/null +++ b/src/webui/routers/person.py @@ -0,0 +1,421 @@ +"""人物信息管理 API 路由""" + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import case +from sqlmodel import col, delete, select + +import json + +from src.common.database.database import get_db_session +from src.common.database.database_model import PersonInfo +from src.common.logger import get_logger +from src.webui.dependencies import require_auth + +logger = get_logger("webui.person") + +# 创建路由器 +router = APIRouter(prefix="/person", tags=["Person"], dependencies=[Depends(require_auth)]) + + +class PersonInfoResponse(BaseModel): + """人物信息响应""" + + id: int + is_known: bool + person_id: str + person_name: Optional[str] + name_reason: Optional[str] + platform: str + user_id: str + nickname: Optional[str] + group_nick_name: Optional[List[Dict[str, str]]] # 解析后的 JSON + memory_points: Optional[str] + know_times: Optional[int] + know_since: Optional[float] + last_know: Optional[float] + + +class PersonListResponse(BaseModel): + """人物列表响应""" + + success: bool + total: int + page: int + page_size: int + data: List[PersonInfoResponse] + + +class PersonDetailResponse(BaseModel): + """人物详情响应""" + + success: bool + data: PersonInfoResponse + + +class PersonUpdateRequest(BaseModel): + """人物信息更新请求""" + + person_name: Optional[str] = None + name_reason: Optional[str] = None + nickname: Optional[str] = None + memory_points: Optional[str] = None + is_known: Optional[bool] = None + + +class PersonUpdateResponse(BaseModel): + """人物信息更新响应""" + + success: bool + message: str + data: Optional[PersonInfoResponse] = None + + +class PersonDeleteResponse(BaseModel): + """人物删除响应""" + + success: bool + message: str + + +class BatchDeleteRequest(BaseModel): + """批量删除请求""" + + person_ids: List[str] + + +class BatchDeleteResponse(BaseModel): + """批量删除响应""" + + success: bool + message: str + deleted_count: int + failed_count: int + failed_ids: List[str] = [] + + +def parse_group_nick_name(group_nick_name_str: Optional[str]) -> Optional[List[Dict[str, str]]]: + """解析群昵称 JSON 字符串。 + + Args: + group_nick_name_str: 数据库中保存的群昵称 JSON 字符串。 + + Returns: + Optional[List[Dict[str, str]]]: 解析后的群昵称列表,解析失败时返回 None。 + """ + if not group_nick_name_str: + return None + try: + return json.loads(group_nick_name_str) + except (json.JSONDecodeError, TypeError): + return None + + +def person_to_response(person: PersonInfo) -> PersonInfoResponse: + """将人物信息模型转换为响应对象。 + + Args: + person: 数据库中的人物信息记录。 + + Returns: + PersonInfoResponse: WebUI 可直接序列化的人物信息。 + """ + know_since = person.first_known_time.timestamp() if person.first_known_time else None + last_know = person.last_known_time.timestamp() if person.last_known_time else None + return PersonInfoResponse( + id=person.id or 0, + is_known=person.is_known, + person_id=person.person_id, + person_name=person.person_name, + name_reason=person.name_reason, + platform=person.platform, + user_id=person.user_id, + nickname=person.user_nickname, + group_nick_name=parse_group_nick_name(person.group_cardname), + memory_points=person.memory_points, + know_times=person.know_counts, + know_since=know_since, + last_know=last_know, + ) + + +@router.get("/list", response_model=PersonListResponse) +async def get_person_list( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + search: Optional[str] = Query(None, description="搜索关键词"), + is_known: Optional[bool] = Query(None, description="是否已认识筛选"), + platform: Optional[str] = Query(None, description="平台筛选"), +) -> PersonListResponse: + """获取人物信息列表。 + + Args: + page: 页码,从 1 开始。 + page_size: 每页数量,范围为 1-100。 + search: 搜索关键词,用于匹配人物名称、昵称和用户 ID。 + is_known: 是否已认识筛选条件。 + platform: 平台筛选条件。 + + Returns: + PersonListResponse: 分页后的人物信息列表。 + """ + try: + # 构建查询 + statement = select(PersonInfo) + + # 搜索过滤 + if search: + statement = statement.where( + (col(PersonInfo.person_name).contains(search)) + | (col(PersonInfo.user_nickname).contains(search)) + | (col(PersonInfo.user_id).contains(search)) + ) + + # 已认识状态过滤 + if is_known is not None: + statement = statement.where(col(PersonInfo.is_known) == is_known) + + # 平台过滤 + if platform: + statement = statement.where(col(PersonInfo.platform) == platform) + + # 排序:最后更新时间倒序(NULL 值放在最后) + # Peewee 不支持 nulls_last,使用 CASE WHEN 来实现 + statement = statement.order_by( + case((col(PersonInfo.last_known_time).is_(None), 1), else_=0), + col(PersonInfo.last_known_time).desc(), + ) + + offset = (page - 1) * page_size + statement = statement.offset(offset).limit(page_size) + + with get_db_session() as session: + persons = session.exec(statement).all() + + count_statement = select(PersonInfo.id) + if search: + count_statement = count_statement.where( + (col(PersonInfo.person_name).contains(search)) + | (col(PersonInfo.user_nickname).contains(search)) + | (col(PersonInfo.user_id).contains(search)) + ) + if is_known is not None: + count_statement = count_statement.where(col(PersonInfo.is_known) == is_known) + if platform: + count_statement = count_statement.where(col(PersonInfo.platform) == platform) + total = len(session.exec(count_statement).all()) + data = [person_to_response(person) for person in persons] + + return PersonListResponse(success=True, total=total, page=page, page_size=page_size, data=data) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取人物列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取人物列表失败: {str(e)}") from e + + +@router.get("/{person_id}", response_model=PersonDetailResponse) +async def get_person_detail(person_id: str) -> PersonDetailResponse: + """获取人物详细信息。 + + Args: + person_id: 人物唯一 ID。 + + Returns: + PersonDetailResponse: 指定人物的详细信息。 + """ + try: + with get_db_session() as session: + statement = select(PersonInfo).where(col(PersonInfo.person_id) == person_id).limit(1) + person = session.exec(statement).first() + + if not person: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {person_id} 的人物信息") + + data = person_to_response(person) + + return PersonDetailResponse(success=True, data=data) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取人物详情失败: {e}") + raise HTTPException(status_code=500, detail=f"获取人物详情失败: {str(e)}") from e + + +@router.patch("/{person_id}", response_model=PersonUpdateResponse) +async def update_person( + person_id: str, + request: PersonUpdateRequest, +) -> PersonUpdateResponse: + """增量更新人物信息。 + + Args: + person_id: 人物唯一 ID。 + request: 只包含需要更新字段的请求数据。 + + Returns: + PersonUpdateResponse: 更新结果和更新后的人物信息。 + """ + try: + # 只更新提供的字段 + update_data = request.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException(status_code=400, detail="未提供任何需要更新的字段") + + # 更新最后修改时间 + update_data["last_known_time"] = datetime.now() + + # 执行更新 + with get_db_session() as session: + db_person = session.exec(select(PersonInfo).where(col(PersonInfo.person_id) == person_id).limit(1)).first() + if not db_person: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {person_id} 的人物信息") + if "person_name" in update_data: + db_person.person_name = update_data["person_name"] + if "name_reason" in update_data: + db_person.name_reason = update_data["name_reason"] + if "nickname" in update_data: + db_person.user_nickname = update_data["nickname"] + if "memory_points" in update_data: + db_person.memory_points = update_data["memory_points"] + if "is_known" in update_data: + db_person.is_known = update_data["is_known"] + db_person.last_known_time = update_data["last_known_time"] + session.add(db_person) + data = person_to_response(db_person) + + logger.info(f"人物信息已更新: {person_id}, 字段: {list(update_data.keys())}") + + return PersonUpdateResponse(success=True, message=f"成功更新 {len(update_data)} 个字段", data=data) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"更新人物信息失败: {e}") + raise HTTPException(status_code=500, detail=f"更新人物信息失败: {str(e)}") from e + + +@router.delete("/{person_id}", response_model=PersonDeleteResponse) +async def delete_person(person_id: str) -> PersonDeleteResponse: + """删除人物信息。 + + Args: + person_id: 人物唯一 ID。 + + Returns: + PersonDeleteResponse: 删除结果。 + """ + try: + with get_db_session() as session: + statement = select(PersonInfo).where(col(PersonInfo.person_id) == person_id).limit(1) + person = session.exec(statement).first() + + if not person: + raise HTTPException(status_code=404, detail=f"未找到 ID 为 {person_id} 的人物信息") + + # 记录删除信息 + person_name = person.person_name or person.user_nickname or person.user_id + + session.exec(delete(PersonInfo).where(col(PersonInfo.person_id) == person_id)) + + logger.info(f"人物信息已删除: {person_id} ({person_name})") + + return PersonDeleteResponse(success=True, message=f"成功删除人物信息: {person_name}") + + except HTTPException: + raise + except Exception as e: + logger.exception(f"删除人物信息失败: {e}") + raise HTTPException(status_code=500, detail=f"删除人物信息失败: {str(e)}") from e + + +@router.get("/stats/summary") +async def get_person_stats() -> Dict[str, Any]: + """获取人物信息统计数据。 + + Returns: + Dict[str, Any]: 人物总数、已认识数量和平台分布统计。 + """ + try: + with get_db_session() as session: + total = len(session.exec(select(PersonInfo.id)).all()) + known = len(session.exec(select(PersonInfo.id).where(col(PersonInfo.is_known))).all()) + unknown = total - known + + # 按平台统计 + platforms = {} + with get_db_session() as session: + for platform in session.exec(select(PersonInfo.platform)).all(): + if platform: + platforms[platform] = platforms.get(platform, 0) + 1 + + return {"success": True, "data": {"total": total, "known": known, "unknown": unknown, "platforms": platforms}} + + except HTTPException: + raise + except Exception as e: + logger.exception(f"获取统计数据失败: {e}") + raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e + + +@router.post("/batch/delete", response_model=BatchDeleteResponse) +async def batch_delete_persons( + request: BatchDeleteRequest, +) -> BatchDeleteResponse: + """批量删除人物信息。 + + Args: + request: 包含人物 ID 列表的请求。 + + Returns: + BatchDeleteResponse: 批量删除结果。 + """ + try: + if not request.person_ids: + raise HTTPException(status_code=400, detail="未提供要删除的人物ID") + + deleted_count = 0 + failed_count = 0 + failed_ids = [] + + for person_id in request.person_ids: + try: + with get_db_session() as session: + person = session.exec( + select(PersonInfo).where(col(PersonInfo.person_id) == person_id).limit(1) + ).first() + if person: + session.exec(delete(PersonInfo).where(col(PersonInfo.person_id) == person_id)) + deleted_count += 1 + logger.info(f"批量删除: {person_id}") + else: + failed_count += 1 + failed_ids.append(person_id) + except Exception as e: + logger.error(f"删除 {person_id} 失败: {e}") + failed_count += 1 + failed_ids.append(person_id) + + message = f"成功删除 {deleted_count} 个人物" + if failed_count > 0: + message += f",{failed_count} 个失败" + + return BatchDeleteResponse( + success=True, + message=message, + deleted_count=deleted_count, + failed_count=failed_count, + failed_ids=failed_ids, + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"批量删除人物信息失败: {e}") + raise HTTPException(status_code=500, detail=f"批量删除失败: {str(e)}") from e diff --git a/src/webui/routers/plugin/__init__.py b/src/webui/routers/plugin/__init__.py new file mode 100644 index 00000000..deaa2eac --- /dev/null +++ b/src/webui/routers/plugin/__init__.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter + +from src.webui.services.git_mirror_service import set_update_progress_callback + +from .catalog import router as catalog_router +from .config_routes import router as config_router +from .management import router as management_router +from .progress import get_progress_router, update_progress +from .runtime_routes import router as runtime_router + +router = APIRouter(prefix="/plugins", tags=["插件管理"]) +router.include_router(catalog_router) +router.include_router(management_router) +router.include_router(config_router) +router.include_router(runtime_router) + +set_update_progress_callback(update_progress) + +__all__ = ["get_progress_router", "router"] diff --git a/src/webui/routers/plugin/catalog.py b/src/webui/routers/plugin/catalog.py new file mode 100644 index 00000000..80077bad --- /dev/null +++ b/src/webui/routers/plugin/catalog.py @@ -0,0 +1,210 @@ +import json +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Cookie, HTTPException + +from src.common.logger import get_logger +from src.config.config import MMC_VERSION +from src.webui.services.git_mirror_service import get_git_mirror_service + +from .progress import update_progress +from .schemas import ( + AddMirrorRequest, + AvailableMirrorsResponse, + CloneRepositoryRequest, + CloneRepositoryResponse, + FetchRawFileRequest, + FetchRawFileResponse, + GitStatusResponse, + MirrorConfigResponse, + UpdateMirrorRequest, + VersionResponse, +) +from .support import get_plugins_dir, parse_version, require_plugin_token, validate_safe_path + +logger = get_logger("webui.plugin_routes") + +router = APIRouter() + + +def _mirror_to_response(mirror: Dict[str, Any]) -> MirrorConfigResponse: + return MirrorConfigResponse( + id=mirror["id"], + name=mirror["name"], + raw_prefix=mirror["raw_prefix"], + clone_prefix=mirror["clone_prefix"], + enabled=mirror["enabled"], + priority=mirror["priority"], + ) + + +@router.get("/version", response_model=VersionResponse) +async def get_maimai_version() -> VersionResponse: + major, minor, patch = parse_version(MMC_VERSION) + return VersionResponse(version=MMC_VERSION, version_major=major, version_minor=minor, version_patch=patch) + + +@router.get("/git-status", response_model=GitStatusResponse) +async def check_git_status() -> GitStatusResponse: + service = get_git_mirror_service() + return GitStatusResponse(**service.check_git_installed()) + + +@router.get("/mirrors", response_model=AvailableMirrorsResponse) +async def get_available_mirrors(maibot_session: Optional[str] = Cookie(None)) -> AvailableMirrorsResponse: + require_plugin_token(maibot_session) + + service = get_git_mirror_service() + config = service.get_mirror_config() + mirrors = [_mirror_to_response(mirror) for mirror in config.get_all_mirrors()] + return AvailableMirrorsResponse(mirrors=mirrors, default_priority=config.get_default_priority_list()) + + +@router.post("/mirrors", response_model=MirrorConfigResponse) +async def add_mirror(request: AddMirrorRequest, maibot_session: Optional[str] = Cookie(None)) -> MirrorConfigResponse: + require_plugin_token(maibot_session) + + try: + service = get_git_mirror_service() + config = service.get_mirror_config() + mirror = config.add_mirror( + mirror_id=request.id, + name=request.name, + raw_prefix=request.raw_prefix, + clone_prefix=request.clone_prefix, + enabled=request.enabled, + priority=request.priority, + ) + return _mirror_to_response(mirror) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + logger.error(f"添加镜像源失败: {e}") + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.put("/mirrors/{mirror_id}", response_model=MirrorConfigResponse) +async def update_mirror( + mirror_id: str, + request: UpdateMirrorRequest, + maibot_session: Optional[str] = Cookie(None), +) -> MirrorConfigResponse: + require_plugin_token(maibot_session) + + try: + service = get_git_mirror_service() + config = service.get_mirror_config() + mirror = config.update_mirror( + mirror_id=mirror_id, + name=request.name, + raw_prefix=request.raw_prefix, + clone_prefix=request.clone_prefix, + enabled=request.enabled, + priority=request.priority, + ) + if mirror is None: + raise HTTPException(status_code=404, detail=f"未找到镜像源: {mirror_id}") + return _mirror_to_response(mirror) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except HTTPException: + raise + except Exception as e: + logger.error(f"更新镜像源失败: {e}") + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.delete("/mirrors/{mirror_id}") +async def delete_mirror(mirror_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + require_plugin_token(maibot_session) + + service = get_git_mirror_service() + config = service.get_mirror_config() + if not config.delete_mirror(mirror_id): + raise HTTPException(status_code=404, detail=f"未找到镜像源: {mirror_id}") + return {"success": True, "message": f"已删除镜像源: {mirror_id}"} + + +@router.post("/fetch-raw", response_model=FetchRawFileResponse) +async def fetch_raw_file( + request: FetchRawFileRequest, + maibot_session: Optional[str] = Cookie(None), +) -> FetchRawFileResponse: + require_plugin_token(maibot_session) + logger.info(f"收到获取 Raw 文件请求: {request.owner}/{request.repo}/{request.branch}/{request.file_path}") + + await update_progress( + stage="loading", + progress=10, + message=f"正在获取插件列表: {request.file_path}", + total_plugins=0, + loaded_plugins=0, + ) + + try: + service = get_git_mirror_service() + result = await service.fetch_raw_file( + owner=request.owner, + repo=request.repo, + branch=request.branch, + file_path=request.file_path, + mirror_id=request.mirror_id, + custom_url=request.custom_url, + ) + + if result.get("success"): + await update_progress( + stage="loading", + progress=70, + message="正在解析插件数据...", + total_plugins=0, + loaded_plugins=0, + ) + try: + data = json.loads(result.get("data", "[]")) + total = len(data) if isinstance(data, list) else 0 + await update_progress( + stage="success", + progress=100, + message=f"成功加载 {total} 个插件", + total_plugins=total, + loaded_plugins=total, + ) + except Exception: + await update_progress( + stage="success", progress=100, message="加载完成", total_plugins=0, loaded_plugins=0 + ) + + return FetchRawFileResponse(**result) + except Exception as e: + logger.error(f"获取 Raw 文件失败: {e}") + await update_progress( + stage="error", progress=0, message="加载失败", error=str(e), total_plugins=0, loaded_plugins=0 + ) + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.post("/clone", response_model=CloneRepositoryResponse) +async def clone_repository( + request: CloneRepositoryRequest, + maibot_session: Optional[str] = Cookie(None), +) -> CloneRepositoryResponse: + require_plugin_token(maibot_session) + logger.info(f"收到克隆仓库请求: {request.owner}/{request.repo} -> {request.target_path}") + + try: + target_path = validate_safe_path(request.target_path, get_plugins_dir()) + service = get_git_mirror_service() + result = await service.clone_repository( + owner=request.owner, + repo=request.repo, + target_path=target_path, + branch=request.branch, + mirror_id=request.mirror_id, + custom_url=request.custom_url, + depth=request.depth, + ) + return CloneRepositoryResponse(**result) + except Exception as e: + logger.error(f"克隆仓库失败: {e}") + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e diff --git a/src/webui/routers/plugin/config_routes.py b/src/webui/routers/plugin/config_routes.py new file mode 100644 index 00000000..66a89250 --- /dev/null +++ b/src/webui/routers/plugin/config_routes.py @@ -0,0 +1,656 @@ +"""插件配置相关 WebUI 路由。""" + +from pathlib import Path +from typing import Any, Dict, Optional, cast + +from fastapi import APIRouter, Cookie, HTTPException +import tomlkit + +from src.common.logger import get_logger +from src.plugin_runtime.protocol.envelope import InspectPluginConfigResultPayload +from src.webui.utils.toml_utils import save_toml_with_format + +from .schemas import UpdatePluginConfigRequest, UpdatePluginRawConfigRequest +from .support import ( + backup_file, + deep_merge, + find_plugin_path_by_id, + get_plugin_config_path, + normalize_dotted_keys, + require_plugin_token, + resolve_plugin_file_path, +) + +logger = get_logger("webui.plugin_routes") + +router = APIRouter() + + +def _to_builtin_data(obj: Any) -> Any: + """将 TOML 对象递归转换为内建 Python 数据。 + + Args: + obj: 原始对象。 + + Returns: + Any: 转换后的内建数据结构。 + """ + + if hasattr(obj, "unwrap"): + try: + obj = obj.unwrap() + except Exception: + pass + + if isinstance(obj, dict): + return {str(key): _to_builtin_data(value) for key, value in obj.items()} + if isinstance(obj, list): + return [_to_builtin_data(value) for value in obj] + return obj + + +def _merge_plugin_config_patch(base_config: Dict[str, Any], patch_config: Dict[str, Any]) -> Dict[str, Any]: + """以现有配置为基线合并本次插件配置改动。 + + Args: + base_config: 当前完整配置。 + patch_config: 本次提交的局部配置改动。 + + Returns: + Dict[str, Any]: 合并后的完整配置。 + """ + + merged_config = cast(Dict[str, Any], _to_builtin_data(base_config)) + deep_merge(merged_config, patch_config) + return merged_config + + +def _build_schema_from_current_config(plugin_id: str, current_config: Any) -> Dict[str, Any]: + """根据当前配置内容自动推断一个兜底 Schema。 + + Args: + plugin_id: 插件 ID。 + current_config: 当前配置对象。 + + Returns: + Dict[str, Any]: 可供前端渲染的兜底 Schema。 + """ + + schema: Dict[str, Any] = { + "plugin_id": plugin_id, + "plugin_info": { + "name": plugin_id, + "version": "", + "description": "", + "author": "", + }, + "sections": {}, + "layout": {"type": "auto", "tabs": []}, + "_note": "插件未加载,仅返回当前配置结构", + } + + for section_name, section_data in current_config.items(): + if not isinstance(section_data, dict): + continue + section_fields: Dict[str, Any] = {} + for field_name, field_value in section_data.items(): + field_type = type(field_value).__name__ + ui_type = "text" + item_type = None + item_fields = None + + if isinstance(field_value, bool): + ui_type = "switch" + elif isinstance(field_value, (int, float)): + ui_type = "number" + elif isinstance(field_value, list): + ui_type = "list" + if field_value: + first_item = field_value[0] + if isinstance(first_item, dict): + item_type = "object" + item_fields = { + key: { + "type": "number" if isinstance(value, (int, float)) else "string", + "label": key, + "default": "" if isinstance(value, str) else 0, + } + for key, value in first_item.items() + } + elif isinstance(first_item, (int, float)): + item_type = "number" + else: + item_type = "string" + else: + item_type = "string" + elif isinstance(field_value, dict): + ui_type = "json" + + section_fields[field_name] = { + "name": field_name, + "type": field_type, + "default": field_value, + "description": field_name, + "label": field_name, + "ui_type": ui_type, + "required": False, + "hidden": False, + "disabled": False, + "order": 0, + "item_type": item_type, + "item_fields": item_fields, + "min_items": None, + "max_items": None, + "placeholder": None, + "hint": None, + "icon": None, + "example": None, + "choices": None, + "min": None, + "max": None, + "step": None, + "pattern": None, + "max_length": None, + "input_type": None, + "rows": 3, + "group": None, + "depends_on": None, + "depends_value": None, + } + + schema["sections"][section_name] = { + "name": section_name, + "title": section_name, + "description": None, + "icon": None, + "collapsed": False, + "order": 0, + "fields": section_fields, + } + + return schema + + +def _coerce_scalar_value(field_schema: Dict[str, Any], value: Any) -> Any: + """根据字段 Schema 规范化单个字段值。 + + Args: + field_schema: 单个字段 Schema。 + value: 当前字段值。 + + Returns: + Any: 规范化后的字段值。 + """ + + field_type = str(field_schema.get("type", "") or "").lower() + if field_type == "boolean" and isinstance(value, str): + normalized_value = value.strip().lower() + if normalized_value in {"1", "true", "yes", "on"}: + return True + if normalized_value in {"0", "false", "no", "off"}: + return False + if field_type == "integer" and isinstance(value, str): + try: + return int(value) + except ValueError: + return value + if field_type == "number" and isinstance(value, str): + try: + return float(value) + except ValueError: + return value + if field_type == "array" and isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + return value + + +def _coerce_config_by_plugin_schema(schema: Dict[str, Any], config_data: Dict[str, Any]) -> None: + """根据插件配置 Schema 就地规范化配置值类型。 + + Args: + schema: 插件配置 Schema。 + config_data: 待规范化的配置字典。 + """ + + sections = schema.get("sections") + if not isinstance(sections, dict): + return + + for section_name, section_schema in sections.items(): + if not isinstance(section_schema, dict): + continue + if section_name not in config_data or not isinstance(config_data[section_name], dict): + continue + + section_fields = section_schema.get("fields") + if not isinstance(section_fields, dict): + continue + + section_config = cast(Dict[str, Any], config_data[section_name]) + for field_name, field_schema in section_fields.items(): + if field_name not in section_config or not isinstance(field_schema, dict): + continue + section_config[field_name] = _coerce_scalar_value(field_schema, section_config[field_name]) + + +def _build_toml_document(config_data: Dict[str, Any]) -> tomlkit.TOMLDocument: + """将普通字典转换为 TOML 文档对象。 + + Args: + config_data: 原始配置字典。 + + Returns: + tomlkit.TOMLDocument: 解析后的 TOML 文档。 + """ + + if not config_data: + return tomlkit.document() + return tomlkit.parse(tomlkit.dumps(config_data)) + + +def _load_plugin_config_from_disk(plugin_path: Path) -> Dict[str, Any]: + """从磁盘读取插件配置。 + + Args: + plugin_path: 插件目录。 + + Returns: + Dict[str, Any]: 当前配置字典;文件不存在时返回空字典。 + """ + + config_path = resolve_plugin_file_path(plugin_path, "config.toml") + if not config_path.exists(): + return {} + + with open(config_path, "r", encoding="utf-8") as file_obj: + loaded_config = tomlkit.load(file_obj).unwrap() + return loaded_config if isinstance(loaded_config, dict) else {} + + +async def _inspect_plugin_config_via_runtime( + plugin_id: str, + config_data: Optional[Dict[str, Any]] = None, + *, + use_provided_config: bool = False, +) -> InspectPluginConfigResultPayload | None: + """通过插件运行时解析配置元数据。 + + Args: + plugin_id: 插件 ID。 + config_data: 可选的配置内容。 + use_provided_config: 是否优先使用传入配置而不是磁盘配置。 + + Returns: + InspectPluginConfigResultPayload | None: 运行时可用时返回解析结果,否则返回 ``None``。 + + Raises: + ValueError: 插件运行时明确拒绝解析请求时抛出。 + """ + + from src.plugin_runtime.integration import get_plugin_runtime_manager + + runtime_manager = get_plugin_runtime_manager() + return await runtime_manager.inspect_plugin_config( + plugin_id, + config_data, + use_provided_config=use_provided_config, + ) + + +async def _validate_plugin_config_via_runtime(plugin_id: str, config_data: Dict[str, Any]) -> Dict[str, Any] | None: + """通过插件运行时对配置进行校验。 + + Args: + plugin_id: 插件 ID。 + config_data: 待校验的配置内容。 + + Returns: + Dict[str, Any] | None: 校验成功时返回规范化后的配置;若运行时不可用则返回 + ``None``,由调用方自行回退到静态 Schema 方案。 + + Raises: + ValueError: 插件运行时明确判定配置非法时抛出。 + """ + + from src.plugin_runtime.integration import get_plugin_runtime_manager + + runtime_manager = get_plugin_runtime_manager() + return await runtime_manager.validate_plugin_config(plugin_id, config_data) + + +@router.get("/config/{plugin_id}/schema") +async def get_plugin_config_schema(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """按插件 ID 返回配置 Schema。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 包含 Schema 的响应字典。 + """ + + require_plugin_token(maibot_session) + logger.info(f"获取插件配置 Schema: {plugin_id}") + + try: + plugin_path = find_plugin_path_by_id(plugin_id) + if plugin_path is None: + raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") + + try: + runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id) + except ValueError as exc: + logger.warning(f"插件 {plugin_id} 配置 Schema 解析失败,将回退到弱推断: {exc}") + runtime_snapshot = None + + if runtime_snapshot is not None and runtime_snapshot.config_schema: + return {"success": True, "schema": dict(runtime_snapshot.config_schema)} + + current_config: Any = ( + dict(runtime_snapshot.normalized_config) + if runtime_snapshot is not None + else _load_plugin_config_from_disk(plugin_path) + ) + + return {"success": True, "schema": _build_schema_from_current_config(plugin_id, current_config)} + except HTTPException: + raise + except Exception as e: + logger.error(f"获取插件配置 Schema 失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.get("/config/{plugin_id}/raw") +async def get_plugin_config_raw(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """获取插件原始 TOML 配置内容。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 包含原始配置文本的响应字典。 + """ + + require_plugin_token(maibot_session) + logger.info(f"获取插件原始配置: {plugin_id}") + + try: + plugin_path = find_plugin_path_by_id(plugin_id) + if plugin_path is None: + raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") + + config_path = get_plugin_config_path(plugin_id, plugin_path) + if not config_path.exists(): + return {"success": True, "config": "", "message": "配置文件不存在"} + + with open(config_path, "r", encoding="utf-8") as file_obj: + return {"success": True, "config": file_obj.read()} + except HTTPException: + raise + except Exception as e: + logger.error(f"获取插件原始配置失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.put("/config/{plugin_id}/raw") +async def update_plugin_config_raw( + plugin_id: str, + request: UpdatePluginRawConfigRequest, + maibot_session: Optional[str] = Cookie(None), +) -> Dict[str, Any]: + """更新插件原始 TOML 配置内容。 + + Args: + plugin_id: 插件 ID。 + request: 原始配置更新请求。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 更新结果。 + """ + + require_plugin_token(maibot_session) + logger.info(f"更新插件原始配置: {plugin_id}") + + try: + plugin_path = find_plugin_path_by_id(plugin_id) + if plugin_path is None: + raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") + + config_path = get_plugin_config_path(plugin_id, plugin_path) + try: + tomlkit.loads(request.config) + except Exception as e: + raise HTTPException(status_code=400, detail=f"TOML 格式错误: {str(e)}") from e + + backup_path = backup_file(config_path, "backup") + if backup_path is not None: + logger.info(f"已备份配置文件: {backup_path}") + + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as file_obj: + file_obj.write(request.config) + + logger.info(f"已更新插件原始配置: {plugin_id}") + return {"success": True, "message": "配置已保存", "note": "配置更改将自动热更新到对应插件"} + except HTTPException: + raise + except Exception as e: + logger.error(f"更新插件原始配置失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.get("/config/{plugin_id}") +async def get_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """获取插件配置字典。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 当前配置响应。 + """ + + require_plugin_token(maibot_session) + logger.info(f"获取插件配置: {plugin_id}") + + try: + plugin_path = find_plugin_path_by_id(plugin_id) + if plugin_path is None: + raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") + + config_path = get_plugin_config_path(plugin_id, plugin_path) + try: + runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id) + except ValueError as exc: + logger.warning(f"插件 {plugin_id} 配置读取失败,将回退到磁盘内容: {exc}") + runtime_snapshot = None + + if runtime_snapshot is not None: + message = "配置文件不存在,已返回默认配置" if not config_path.exists() else "" + return { + "success": True, + "config": dict(runtime_snapshot.normalized_config), + "message": message, + } + + if not config_path.exists(): + return {"success": True, "config": {}, "message": "配置文件不存在"} + + return {"success": True, "config": _load_plugin_config_from_disk(plugin_path)} + except HTTPException: + raise + except Exception as e: + logger.error(f"获取插件配置失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.put("/config/{plugin_id}") +async def update_plugin_config( + plugin_id: str, + request: UpdatePluginConfigRequest, + maibot_session: Optional[str] = Cookie(None), +) -> Dict[str, Any]: + """更新插件结构化配置。 + + Args: + plugin_id: 插件 ID。 + request: 结构化配置更新请求。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 更新结果。 + """ + + require_plugin_token(maibot_session) + logger.info(f"更新插件配置: {plugin_id}") + + try: + plugin_path = find_plugin_path_by_id(plugin_id) + if plugin_path is None: + raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") + + config_data = request.config or {} + if isinstance(config_data, dict): + config_patch = normalize_dotted_keys(config_data) + runtime_snapshot = None + try: + runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id) + except ValueError as exc: + logger.warning(f"插件 {plugin_id} 保存前配置检查失败,将回退到磁盘内容: {exc}") + + base_config = ( + dict(runtime_snapshot.normalized_config) + if runtime_snapshot is not None + else _load_plugin_config_from_disk(plugin_path) + ) + config_data = _merge_plugin_config_patch(base_config, config_patch) + runtime_validated_config = await _validate_plugin_config_via_runtime(plugin_id, config_data) + if isinstance(runtime_validated_config, dict): + config_data = runtime_validated_config + else: + runtime_snapshot = await _inspect_plugin_config_via_runtime( + plugin_id, + config_data, + use_provided_config=True, + ) + if runtime_snapshot is not None and runtime_snapshot.config_schema: + _coerce_config_by_plugin_schema(dict(runtime_snapshot.config_schema), config_data) + + config_path = get_plugin_config_path(plugin_id, plugin_path) + backup_path = backup_file(config_path, "backup") + if backup_path is not None: + logger.info(f"已备份配置文件: {backup_path}") + + config_path.parent.mkdir(parents=True, exist_ok=True) + save_toml_with_format(config_data, str(config_path)) + logger.info(f"已更新插件配置: {plugin_id}") + return {"success": True, "message": "配置已保存", "note": "配置更改将自动热更新到对应插件"} + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except HTTPException: + raise + except Exception as e: + logger.error(f"更新插件配置失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.post("/config/{plugin_id}/reset") +async def reset_plugin_config(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """重置插件配置文件。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 重置结果。 + """ + + require_plugin_token(maibot_session) + logger.info(f"重置插件配置: {plugin_id}") + + try: + plugin_path = find_plugin_path_by_id(plugin_id) + if plugin_path is None: + raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") + + config_path = get_plugin_config_path(plugin_id, plugin_path) + if not config_path.exists(): + return {"success": True, "message": "配置文件不存在,无需重置"} + + backup_path = backup_file(config_path, "reset", move_file=True) + logger.info(f"已重置插件配置: {plugin_id},备份: {backup_path}") + return {"success": True, "message": "配置已重置,运行时将自动刷新为默认配置", "backup": str(backup_path)} + except HTTPException: + raise + except Exception as e: + logger.error(f"重置插件配置失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.post("/config/{plugin_id}/toggle") +async def toggle_plugin(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + """切换插件启用状态。 + + Args: + plugin_id: 插件 ID。 + maibot_session: 当前会话令牌。 + + Returns: + Dict[str, Any]: 切换结果。 + """ + + require_plugin_token(maibot_session) + logger.info(f"切换插件状态: {plugin_id}") + + try: + plugin_path = find_plugin_path_by_id(plugin_id) + if plugin_path is None: + raise HTTPException(status_code=404, detail=f"未找到插件: {plugin_id}") + + config_path = get_plugin_config_path(plugin_id, plugin_path) + try: + runtime_snapshot = await _inspect_plugin_config_via_runtime(plugin_id) + except ValueError as exc: + logger.warning(f"插件 {plugin_id} 状态切换前配置解析失败,将回退到磁盘内容: {exc}") + runtime_snapshot = None + + current_config = ( + dict(runtime_snapshot.normalized_config) + if runtime_snapshot is not None + else _load_plugin_config_from_disk(plugin_path) + ) + config = _build_toml_document(current_config) + + plugin_section = config.get("plugin") + if plugin_section is None or not hasattr(plugin_section, "get"): + config["plugin"] = tomlkit.table() + + plugin_config = cast(Any, config["plugin"]) + current_enabled = ( + bool(runtime_snapshot.enabled) + if runtime_snapshot is not None + else bool(plugin_config.get("enabled", True)) + ) + new_enabled = not current_enabled + plugin_config["enabled"] = new_enabled + config_path.parent.mkdir(parents=True, exist_ok=True) + save_toml_with_format(config, str(config_path)) + + status = "启用" if new_enabled else "禁用" + logger.info(f"已{status}插件: {plugin_id}") + return { + "success": True, + "enabled": new_enabled, + "message": f"插件已{status}", + "note": "状态更改将自动热更新到对应插件", + } + except HTTPException: + raise + except Exception as e: + logger.error(f"切换插件状态失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e diff --git a/src/webui/routers/plugin/management.py b/src/webui/routers/plugin/management.py new file mode 100644 index 00000000..7884831c --- /dev/null +++ b/src/webui/routers/plugin/management.py @@ -0,0 +1,525 @@ +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Cookie, HTTPException +import json +import tomlkit + +from src.common.logger import get_logger +from src.webui.services.git_mirror_service import get_git_mirror_service + +from .progress import update_progress +from .schemas import InstallPluginRequest, UninstallPluginRequest, UpdatePluginRequest +from .support import ( + find_plugin_path_by_id, + get_plugin_candidate_paths, + get_plugin_config_path, + iter_plugin_directories, + load_manifest_json, + parse_repository_url, + remove_tree, + require_plugin_token, + resolve_installed_plugin_path, + resolve_plugin_file_path, + validate_plugin_id, +) + +logger = get_logger("webui.plugin_routes") + +router = APIRouter() + + +def _infer_plugin_id(folder_name: str, manifest: Dict[str, Any], manifest_path: Path) -> str: + if "id" in manifest: + return str(manifest["id"]) + + author_name: Optional[str] = None + repo_name: Optional[str] = None + if "author" in manifest: + author_data = manifest["author"] + if isinstance(author_data, dict) and "name" in author_data: + author_name = str(author_data["name"]) + elif isinstance(author_data, str): + author_name = author_data + + if "repository_url" in manifest: + repo_url = str(manifest["repository_url"]).rstrip("/").removesuffix(".git") + repo_name = repo_url.split("/")[-1] + + if author_name and repo_name: + plugin_id = f"{author_name}.{repo_name}" + elif author_name: + plugin_id = f"{author_name}.{folder_name}" + elif "_" in folder_name and "." not in folder_name: + plugin_id = folder_name.replace("_", ".", 1) + else: + plugin_id = folder_name + + logger.info(f"为插件 {folder_name} 自动生成 ID: {plugin_id}") + manifest["id"] = plugin_id + try: + safe_manifest_path = resolve_plugin_file_path(manifest_path.parent, "_manifest.json") + with open(safe_manifest_path, "w", encoding="utf-8") as file_obj: + json.dump(manifest, file_obj, ensure_ascii=False, indent=2) + except Exception as write_error: + logger.warning(f"无法写入 ID 到 manifest: {write_error}") + return plugin_id + + +def _coerce_enabled_value(value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() not in {"false", "0", "no", "off", "disabled"} + return bool(value) + + +def _read_plugin_enabled(plugin_id: str, plugin_path: Path) -> bool: + try: + config_path = get_plugin_config_path(plugin_id, plugin_path) + if not config_path.exists(): + return True + with open(config_path, "r", encoding="utf-8") as file_obj: + config = tomlkit.load(file_obj).unwrap() + except Exception as exc: + logger.warning(f"读取插件 {plugin_id} 启用状态失败,将按启用处理: {exc}") + return True + + plugin_config = config.get("plugin") if isinstance(config, dict) else None + if not isinstance(plugin_config, dict): + return True + return _coerce_enabled_value(plugin_config.get("enabled", True)) + + +def _get_runtime_plugin_load_statuses() -> Dict[str, str]: + try: + from src.plugin_runtime.integration import get_plugin_runtime_manager + + return get_plugin_runtime_manager().get_plugin_load_statuses() + except Exception as exc: + logger.warning(f"获取插件运行时加载状态失败: {exc}") + return {} + + +@router.post("/install") +async def install_plugin(request: InstallPluginRequest, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + require_plugin_token(maibot_session) + logger.info(f"收到安装插件请求: {request.plugin_id}") + plugin_id = request.plugin_id + + try: + plugin_id = validate_plugin_id(request.plugin_id) + await update_progress( + stage="loading", progress=5, message=f"开始安装插件: {plugin_id}", operation="install", plugin_id=plugin_id + ) + + repo_url, owner, repo = parse_repository_url(request.repository_url) + await update_progress( + stage="loading", + progress=10, + message=f"解析仓库信息: {owner}/{repo}", + operation="install", + plugin_id=plugin_id, + ) + + target_path, old_format_path = get_plugin_candidate_paths(plugin_id) + if target_path.exists() or old_format_path.exists(): + await update_progress( + stage="error", + progress=0, + message="插件已存在", + operation="install", + plugin_id=plugin_id, + error="插件已安装,请先卸载", + ) + raise HTTPException(status_code=400, detail="插件已安装") + + await update_progress( + stage="loading", progress=15, message=f"准备克隆到: {target_path}", operation="install", plugin_id=plugin_id + ) + service = get_git_mirror_service() + if "github.com" in repo_url: + result = await service.clone_repository( + owner=owner, + repo=repo, + target_path=target_path, + branch=request.branch, + mirror_id=request.mirror_id, + depth=1, + ) + else: + result = await service.clone_repository( + owner=owner, repo=repo, target_path=target_path, branch=request.branch, custom_url=repo_url, depth=1 + ) + + if not result.get("success"): + error_msg = str(result.get("error", "克隆失败")) + await update_progress( + stage="error", + progress=0, + message="克隆仓库失败", + operation="install", + plugin_id=plugin_id, + error=error_msg, + ) + raise HTTPException(status_code=int(result.get("status_code", 500)), detail=error_msg) + + await update_progress( + stage="loading", progress=85, message="验证插件文件...", operation="install", plugin_id=plugin_id + ) + manifest_path = resolve_plugin_file_path(target_path, "_manifest.json") + if not manifest_path.exists(): + remove_tree(target_path) + await update_progress( + stage="error", + progress=0, + message="插件缺少 _manifest.json", + operation="install", + plugin_id=plugin_id, + error="无效的插件格式", + ) + raise HTTPException(status_code=400, detail="无效的插件:缺少 _manifest.json") + + await update_progress( + stage="loading", progress=90, message="读取插件配置...", operation="install", plugin_id=plugin_id + ) + try: + with open(manifest_path, "r", encoding="utf-8") as file_obj: + manifest = json.load(file_obj) + for field in ["manifest_version", "name", "version", "author"]: + if field not in manifest: + raise ValueError(f"缺少必需字段: {field}") + if not str(manifest.get("id", "")).strip(): + manifest["id"] = plugin_id + with open(manifest_path, "w", encoding="utf-8") as file_obj: + json.dump(manifest, file_obj, ensure_ascii=False, indent=2) + except Exception as e: + remove_tree(target_path) + await update_progress( + stage="error", + progress=0, + message="_manifest.json 无效", + operation="install", + plugin_id=plugin_id, + error=str(e), + ) + raise HTTPException(status_code=400, detail=f"无效的 _manifest.json: {e}") from e + + await update_progress( + stage="success", + progress=100, + message=f"成功安装插件: {manifest['name']} v{manifest['version']}", + operation="install", + plugin_id=plugin_id, + ) + return { + "success": True, + "message": "插件安装成功", + "plugin_id": plugin_id, + "plugin_name": manifest["name"], + "version": manifest["version"], + "path": str(target_path), + } + except HTTPException: + raise + except Exception as e: + logger.error(f"安装插件失败: {e}", exc_info=True) + await update_progress( + stage="error", progress=0, message="安装失败", operation="install", plugin_id=plugin_id, error=str(e) + ) + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.post("/uninstall") +async def uninstall_plugin( + request: UninstallPluginRequest, maibot_session: Optional[str] = Cookie(None) +) -> Dict[str, Any]: + require_plugin_token(maibot_session) + logger.info(f"收到卸载插件请求: {request.plugin_id}") + plugin_id = request.plugin_id + + try: + plugin_id = validate_plugin_id(request.plugin_id) + await update_progress( + stage="loading", + progress=10, + message=f"开始卸载插件: {plugin_id}", + operation="uninstall", + plugin_id=plugin_id, + ) + plugin_path = resolve_installed_plugin_path(plugin_id) + if plugin_path is None: + await update_progress( + stage="error", + progress=0, + message="插件不存在", + operation="uninstall", + plugin_id=plugin_id, + error="插件未安装或已被删除", + ) + raise HTTPException(status_code=404, detail="插件未安装") + + await update_progress( + stage="loading", + progress=30, + message=f"正在删除插件文件: {plugin_path}", + operation="uninstall", + plugin_id=plugin_id, + ) + manifest = load_manifest_json(resolve_plugin_file_path(plugin_path, "_manifest.json")) + plugin_name = str(manifest.get("name", plugin_id)) if manifest is not None else plugin_id + await update_progress( + stage="loading", + progress=50, + message=f"正在删除 {plugin_name}...", + operation="uninstall", + plugin_id=plugin_id, + ) + remove_tree(plugin_path) + logger.info(f"成功卸载插件: {plugin_id} ({plugin_name})") + await update_progress( + stage="success", + progress=100, + message=f"成功卸载插件: {plugin_name}", + operation="uninstall", + plugin_id=plugin_id, + ) + return {"success": True, "message": "插件卸载成功", "plugin_id": plugin_id, "plugin_name": plugin_name} + except HTTPException: + raise + except PermissionError as e: + logger.error(f"卸载插件失败(权限错误): {e}") + await update_progress( + stage="error", + progress=0, + message="卸载失败", + operation="uninstall", + plugin_id=plugin_id, + error="权限不足,无法删除插件文件", + ) + raise HTTPException(status_code=500, detail="权限不足,无法删除插件文件") from e + except Exception as e: + logger.error(f"卸载插件失败: {e}", exc_info=True) + await update_progress( + stage="error", progress=0, message="卸载失败", operation="uninstall", plugin_id=plugin_id, error=str(e) + ) + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.post("/update") +async def update_plugin(request: UpdatePluginRequest, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + require_plugin_token(maibot_session) + logger.info(f"收到更新插件请求: {request.plugin_id}") + plugin_id = request.plugin_id + + try: + plugin_id = validate_plugin_id(request.plugin_id) + await update_progress( + stage="loading", progress=5, message=f"开始更新插件: {plugin_id}", operation="update", plugin_id=plugin_id + ) + plugin_path = resolve_installed_plugin_path(plugin_id) + if plugin_path is None: + await update_progress( + stage="error", + progress=0, + message="插件不存在", + operation="update", + plugin_id=plugin_id, + error="插件未安装,请先安装", + ) + raise HTTPException(status_code=404, detail="插件未安装") + + manifest = load_manifest_json(resolve_plugin_file_path(plugin_path, "_manifest.json")) + old_version = str(manifest.get("version", "unknown")) if manifest is not None else "unknown" + await update_progress( + stage="loading", + progress=10, + message=f"当前版本: {old_version},准备更新...", + operation="update", + plugin_id=plugin_id, + ) + await update_progress( + stage="loading", progress=20, message="正在删除旧版本...", operation="update", plugin_id=plugin_id + ) + remove_tree(plugin_path) + + await update_progress( + stage="loading", progress=30, message="正在准备下载新版本...", operation="update", plugin_id=plugin_id + ) + repo_url, owner, repo = parse_repository_url(request.repository_url) + service = get_git_mirror_service() + if "github.com" in repo_url: + result = await service.clone_repository( + owner=owner, + repo=repo, + target_path=plugin_path, + branch=request.branch, + mirror_id=request.mirror_id, + depth=1, + ) + else: + result = await service.clone_repository( + owner=owner, repo=repo, target_path=plugin_path, branch=request.branch, custom_url=repo_url, depth=1 + ) + + if not result.get("success"): + error_msg = str(result.get("error", "克隆失败")) + await update_progress( + stage="error", + progress=0, + message="下载新版本失败", + operation="update", + plugin_id=plugin_id, + error=error_msg, + ) + raise HTTPException(status_code=int(result.get("status_code", 500)), detail=error_msg) + + await update_progress( + stage="loading", progress=90, message="验证新版本...", operation="update", plugin_id=plugin_id + ) + new_manifest_path = resolve_plugin_file_path(plugin_path, "_manifest.json") + if not new_manifest_path.exists(): + remove_tree(plugin_path) + await update_progress( + stage="error", + progress=0, + message="新版本缺少 _manifest.json", + operation="update", + plugin_id=plugin_id, + error="无效的插件格式", + ) + raise HTTPException(status_code=400, detail="无效的插件:缺少 _manifest.json") + + try: + with open(new_manifest_path, "r", encoding="utf-8") as file_obj: + new_manifest = json.load(file_obj) + new_version = str(new_manifest.get("version", "unknown")) + new_name = str(new_manifest.get("name", plugin_id)) + logger.info(f"成功更新插件: {plugin_id} {old_version} → {new_version}") + await update_progress( + stage="success", + progress=100, + message=f"成功更新 {new_name}: {old_version} → {new_version}", + operation="update", + plugin_id=plugin_id, + ) + return { + "success": True, + "message": "插件更新成功", + "plugin_id": plugin_id, + "plugin_name": new_name, + "old_version": old_version, + "new_version": new_version, + } + except Exception as e: + remove_tree(plugin_path) + await update_progress( + stage="error", + progress=0, + message="_manifest.json 无效", + operation="update", + plugin_id=plugin_id, + error=str(e), + ) + raise HTTPException(status_code=400, detail=f"无效的 _manifest.json: {e}") from e + except HTTPException: + raise + except Exception as e: + logger.error(f"更新插件失败: {e}", exc_info=True) + await update_progress( + stage="error", progress=0, message="更新失败", operation="update", plugin_id=plugin_id, error=str(e) + ) + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.get("/installed") +async def get_installed_plugins(maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + require_plugin_token(maibot_session) + logger.info("收到获取已安装插件列表请求") + + try: + installed_plugins: List[Dict[str, Any]] = [] + runtime_statuses = _get_runtime_plugin_load_statuses() + for plugin_path in iter_plugin_directories(): + folder_name = plugin_path.name + if folder_name.startswith(".") or folder_name.startswith("__"): + continue + + manifest_path = resolve_plugin_file_path(plugin_path, "_manifest.json") + if not manifest_path.exists(): + logger.warning(f"插件文件夹 {folder_name} 缺少 _manifest.json,跳过") + continue + + try: + manifest = load_manifest_json(manifest_path) + if manifest is None: + logger.warning(f"插件文件夹 {folder_name} 的 _manifest.json 不安全或无效,跳过") + continue + if "name" not in manifest or "version" not in manifest: + logger.warning(f"插件文件夹 {folder_name} 的 _manifest.json 格式无效,跳过") + continue + plugin_id = _infer_plugin_id(folder_name, manifest, manifest_path) + enabled = _read_plugin_enabled(plugin_id, plugin_path) + load_status = runtime_statuses.get(plugin_id, "unknown") + installed_plugins.append( + { + "id": plugin_id, + "manifest": manifest, + "path": str(plugin_path.absolute()), + "enabled": enabled, + "disabled": not enabled, + "loaded": load_status == "success", + "load_status": "disabled" if not enabled else load_status, + } + ) + except json.JSONDecodeError as e: + logger.warning(f"插件 {folder_name} 的 _manifest.json 解析失败: {e}") + except Exception as e: + logger.error(f"读取插件 {folder_name} 信息时出错: {e}") + + seen_ids: Dict[str, str] = {} + unique_plugins: List[Dict[str, Any]] = [] + duplicates: List[Dict[str, Any]] = [] + for plugin in installed_plugins: + plugin_id = str(plugin["id"]) + plugin_path = str(plugin["path"]) + if plugin_id not in seen_ids: + seen_ids[plugin_id] = plugin_path + unique_plugins.append(plugin) + else: + duplicates.append(plugin) + logger.warning(f"重复插件 {plugin_id}: 保留 {seen_ids[plugin_id]}, 跳过 {plugin_path}") + + if duplicates: + logger.warning(f"共检测到 {len(duplicates)} 个重复插件已去重") + + logger.info(f"找到 {len(unique_plugins)} 个已安装插件") + return {"success": True, "plugins": unique_plugins, "total": len(unique_plugins)} + except Exception as e: + logger.error(f"获取已安装插件列表失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") from e + + +@router.get("/local-readme/{plugin_id}") +async def get_local_plugin_readme(plugin_id: str, maibot_session: Optional[str] = Cookie(None)) -> Dict[str, Any]: + require_plugin_token(maibot_session) + logger.info(f"获取本地插件 README: {plugin_id}") + + try: + plugin_path = find_plugin_path_by_id(plugin_id) + if plugin_path is None: + return {"success": False, "error": "插件未安装"} + + for readme_name in ["README.md", "readme.md", "Readme.md", "README.MD"]: + readme_path = resolve_plugin_file_path(plugin_path, readme_name) + if readme_path.exists(): + try: + with open(readme_path, "r", encoding="utf-8") as file_obj: + readme_content = file_obj.read() + logger.info(f"成功读取本地 README: {readme_path}") + return {"success": True, "data": readme_content} + except Exception as e: + logger.warning(f"读取 {readme_path} 失败: {e}") + + return {"success": False, "error": "本地未找到 README 文件"} + except Exception as e: + logger.error(f"获取本地 README 失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} diff --git a/src/webui/routers/plugin/progress.py b/src/webui/routers/plugin/progress.py new file mode 100644 index 00000000..0edd7451 --- /dev/null +++ b/src/webui/routers/plugin/progress.py @@ -0,0 +1,151 @@ +"""插件进度实时推送支持。""" + +from typing import Any, Dict, Optional, Set +import asyncio +import json + +from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect + +from src.common.logger import get_logger +from src.webui.core import get_token_manager +from src.webui.routers.websocket.auth import verify_ws_token +from src.webui.routers.websocket.manager import websocket_manager + +logger = get_logger("webui.plugin_progress") + +router = APIRouter() + +active_connections: Set[WebSocket] = set() +current_progress: Dict[str, Any] = { + "operation": "idle", + "stage": "idle", + "progress": 0, + "message": "", + "error": None, + "plugin_id": None, + "total_plugins": 0, + "loaded_plugins": 0, +} + + +def get_current_progress() -> Dict[str, Any]: + """获取当前插件进度快照。 + + Returns: + Dict[str, Any]: 当前插件进度数据副本。 + """ + return current_progress.copy() + + +async def broadcast_progress(progress_data: Dict[str, Any]) -> None: + """向统一连接层广播插件进度更新。 + + Args: + progress_data: 插件进度数据。 + """ + global current_progress + current_progress = progress_data.copy() + await websocket_manager.broadcast_to_topic( + domain="plugin_progress", + topic="main", + event="update", + data={"progress": progress_data}, + ) + + +async def update_progress( + stage: str, + progress: int, + message: str, + operation: str = "fetch", + error: Optional[str] = None, + plugin_id: Optional[str] = None, + total_plugins: int = 0, + loaded_plugins: int = 0, +) -> None: + """更新当前插件进度并广播。 + + Args: + stage: 当前阶段。 + progress: 当前进度百分比。 + message: 进度说明消息。 + operation: 当前操作类型。 + error: 可选的错误信息。 + plugin_id: 当前处理的插件 ID。 + total_plugins: 总插件数量。 + loaded_plugins: 已处理插件数量。 + """ + progress_data = { + "operation": operation, + "stage": stage, + "progress": progress, + "message": message, + "error": error, + "plugin_id": plugin_id, + "total_plugins": total_plugins, + "loaded_plugins": loaded_plugins, + "timestamp": asyncio.get_event_loop().time(), + } + + await broadcast_progress(progress_data) + logger.debug(f"进度更新: [{operation}] {stage} - {progress}% - {message}") + + +@router.websocket("/ws/plugin-progress") +async def websocket_plugin_progress(websocket: WebSocket, token: Optional[str] = Query(None)) -> None: + """旧版插件进度 WebSocket 入口。 + + Args: + websocket: FastAPI WebSocket 对象。 + token: 可选的一次性握手 Token。 + """ + is_authenticated = False + + if token and verify_ws_token(token): + is_authenticated = True + logger.debug("插件进度 WebSocket 使用临时 token 认证成功") + + if not is_authenticated: + cookie_token = websocket.cookies.get("maibot_session") + if cookie_token: + token_manager = get_token_manager() + if token_manager.verify_token(cookie_token): + is_authenticated = True + logger.debug("插件进度 WebSocket 使用 Cookie 认证成功") + + if not is_authenticated: + logger.warning("插件进度 WebSocket 连接被拒绝:认证失败") + await websocket.close(code=4001, reason="认证失败,请重新登录") + return + + await websocket.accept() + active_connections.add(websocket) + logger.info(f"📡 插件进度 WebSocket 客户端已连接(已认证),当前连接数: {len(active_connections)}") + + try: + await websocket.send_text(json.dumps(current_progress, ensure_ascii=False)) + + while True: + try: + data = await websocket.receive_text() + if data == "ping": + await websocket.send_text("pong") + except Exception as exc: + logger.error(f"处理客户端消息时出错: {exc}") + break + + except WebSocketDisconnect: + active_connections.discard(websocket) + logger.info(f"📡 插件进度 WebSocket 客户端已断开,当前连接数: {len(active_connections)}") + except Exception as exc: + logger.error(f"❌ WebSocket 错误: {exc}") + active_connections.discard(websocket) + + +def get_progress_router() -> APIRouter: + """获取旧版插件进度路由对象。 + + Returns: + APIRouter: 插件进度路由对象。 + """ + return router diff --git a/src/webui/routers/plugin/runtime_routes.py b/src/webui/routers/plugin/runtime_routes.py new file mode 100644 index 00000000..fe1773a4 --- /dev/null +++ b/src/webui/routers/plugin/runtime_routes.py @@ -0,0 +1,28 @@ +"""插件运行时相关 WebUI 路由。""" + +from typing import Optional + +from fastapi import APIRouter, Cookie + +from src.plugin_runtime.component_query import component_query_service + +from .schemas import HookSpecListResponse, HookSpecResponse +from .support import require_plugin_token + +router = APIRouter() + + +@router.get("/runtime/hooks", response_model=HookSpecListResponse) +async def list_runtime_hook_specs(maibot_session: Optional[str] = Cookie(None)) -> HookSpecListResponse: + """返回当前插件运行时公开的 Hook 规格清单。 + + Args: + maibot_session: 当前 WebUI 会话令牌。 + + Returns: + HookSpecListResponse: Hook 规格列表响应。 + """ + + require_plugin_token(maibot_session) + hooks = [HookSpecResponse(**hook_data) for hook_data in component_query_service.list_hook_specs()] + return HookSpecListResponse(success=True, hooks=hooks) diff --git a/src/webui/routers/plugin/schemas.py b/src/webui/routers/plugin/schemas.py new file mode 100644 index 00000000..eda21038 --- /dev/null +++ b/src/webui/routers/plugin/schemas.py @@ -0,0 +1,129 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class FetchRawFileRequest(BaseModel): + owner: str = Field(..., description="仓库所有者", examples=["MaiM-with-u"]) + repo: str = Field(..., description="仓库名称", examples=["plugin-repo"]) + branch: str = Field(..., description="分支名称", examples=["main"]) + file_path: str = Field(..., description="文件路径", examples=["plugin_details.json"]) + mirror_id: Optional[str] = Field(None, description="指定镜像源 ID") + custom_url: Optional[str] = Field(None, description="自定义完整 URL") + + +class FetchRawFileResponse(BaseModel): + success: bool = Field(..., description="是否成功") + data: Optional[str] = Field(None, description="文件内容") + error: Optional[str] = Field(None, description="错误信息") + mirror_used: Optional[str] = Field(None, description="使用的镜像源") + attempts: int = Field(..., description="尝试次数") + url: Optional[str] = Field(None, description="实际请求的 URL") + + +class CloneRepositoryRequest(BaseModel): + owner: str = Field(..., description="仓库所有者", examples=["MaiM-with-u"]) + repo: str = Field(..., description="仓库名称", examples=["plugin-repo"]) + target_path: str = Field(..., description="目标路径(相对于插件目录)") + branch: Optional[str] = Field(None, description="分支名称", examples=["main"]) + mirror_id: Optional[str] = Field(None, description="指定镜像源 ID") + custom_url: Optional[str] = Field(None, description="自定义克隆 URL") + depth: Optional[int] = Field(None, description="克隆深度(浅克隆)", ge=1) + + +class CloneRepositoryResponse(BaseModel): + success: bool = Field(..., description="是否成功") + path: Optional[str] = Field(None, description="克隆路径") + error: Optional[str] = Field(None, description="错误信息") + mirror_used: Optional[str] = Field(None, description="使用的镜像源") + attempts: int = Field(..., description="尝试次数") + url: Optional[str] = Field(None, description="实际克隆的 URL") + message: Optional[str] = Field(None, description="附加信息") + + +class MirrorConfigResponse(BaseModel): + id: str = Field(..., description="镜像源 ID") + name: str = Field(..., description="镜像源名称") + raw_prefix: str = Field(..., description="Raw 文件前缀") + clone_prefix: str = Field(..., description="克隆前缀") + enabled: bool = Field(..., description="是否启用") + priority: int = Field(..., description="优先级(数字越小优先级越高)") + + +class AvailableMirrorsResponse(BaseModel): + mirrors: List[MirrorConfigResponse] = Field(..., description="镜像源列表") + default_priority: List[str] = Field(..., description="默认优先级顺序(ID 列表)") + + +class AddMirrorRequest(BaseModel): + id: str = Field(..., description="镜像源 ID", examples=["custom-mirror"]) + name: str = Field(..., description="镜像源名称", examples=["自定义镜像源"]) + raw_prefix: str = Field(..., description="Raw 文件前缀", examples=["https://example.com/raw"]) + clone_prefix: str = Field(..., description="克隆前缀", examples=["https://example.com/clone"]) + enabled: bool = Field(True, description="是否启用") + priority: Optional[int] = Field(None, description="优先级") + + +class UpdateMirrorRequest(BaseModel): + name: Optional[str] = Field(None, description="镜像源名称") + raw_prefix: Optional[str] = Field(None, description="Raw 文件前缀") + clone_prefix: Optional[str] = Field(None, description="克隆前缀") + enabled: Optional[bool] = Field(None, description="是否启用") + priority: Optional[int] = Field(None, description="优先级") + + +class GitStatusResponse(BaseModel): + installed: bool = Field(..., description="是否已安装 Git") + version: Optional[str] = Field(None, description="Git 版本号") + path: Optional[str] = Field(None, description="Git 可执行文件路径") + error: Optional[str] = Field(None, description="错误信息") + + +class InstallPluginRequest(BaseModel): + plugin_id: str = Field(..., description="插件 ID") + repository_url: str = Field(..., description="插件仓库 URL") + branch: Optional[str] = Field("main", description="分支名称") + mirror_id: Optional[str] = Field(None, description="指定镜像源 ID") + + +class VersionResponse(BaseModel): + version: str = Field(..., description="麦麦版本号") + version_major: int = Field(..., description="主版本号") + version_minor: int = Field(..., description="次版本号") + version_patch: int = Field(..., description="补丁版本号") + + +class UninstallPluginRequest(BaseModel): + plugin_id: str = Field(..., description="插件 ID") + + +class UpdatePluginRequest(BaseModel): + plugin_id: str = Field(..., description="插件 ID") + repository_url: str = Field(..., description="插件仓库 URL") + branch: Optional[str] = Field("main", description="分支名称") + mirror_id: Optional[str] = Field(None, description="指定镜像源 ID") + + +class UpdatePluginConfigRequest(BaseModel): + enabled: Optional[bool] = None + config: Optional[Dict[str, Any]] = None + + +class UpdatePluginRawConfigRequest(BaseModel): + config: str = Field(..., description="原始 TOML 配置内容") + + +class HookSpecResponse(BaseModel): + name: str = Field(..., description="Hook 名称") + description: str = Field("", description="Hook 描述") + parameters_schema: Dict[str, Any] = Field(default_factory=dict, description="Hook 参数模型") + default_timeout_ms: int = Field(..., description="默认超时毫秒数") + allow_blocking: bool = Field(..., description="是否允许 blocking 处理器") + allow_observe: bool = Field(..., description="是否允许 observe 处理器") + allow_abort: bool = Field(..., description="是否允许 abort") + allow_kwargs_mutation: bool = Field(..., description="是否允许修改 kwargs") + + +class HookSpecListResponse(BaseModel): + success: bool = Field(..., description="是否成功") + hooks: List[HookSpecResponse] = Field(default_factory=list, description="Hook 规格列表") diff --git a/src/webui/routers/plugin/support.py b/src/webui/routers/plugin/support.py new file mode 100644 index 00000000..f7a3c827 --- /dev/null +++ b/src/webui/routers/plugin/support.py @@ -0,0 +1,308 @@ +import json +import os +import re +import shutil +import stat +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, cast, get_origin + +from fastapi import HTTPException + +from src.common.logger import get_logger +from src.core.config_types import ConfigField +from src.webui.core import get_token_manager + +logger = get_logger("webui.plugin_routes") + + +def require_plugin_token(maibot_session: Optional[str]) -> str: + token_manager = get_token_manager() + if not maibot_session or not token_manager.verify_token(maibot_session): + raise HTTPException(status_code=401, detail="未授权:无效的访问令牌") + return maibot_session + + +def validate_safe_path(user_path: str, base_path: Path) -> Path: + base_resolved = base_path.resolve() + if any(pattern in user_path for pattern in ["..", "\x00"]): + logger.warning(f"检测到可疑路径: {user_path}") + raise HTTPException(status_code=400, detail="路径包含非法字符") + + if user_path.startswith("/") or user_path.startswith("\\") or (len(user_path) > 1 and user_path[1] == ":"): + logger.warning(f"检测到绝对路径: {user_path}") + raise HTTPException(status_code=400, detail="不允许使用绝对路径") + + target_path = (base_path / user_path).resolve() + try: + target_path.relative_to(base_resolved) + except ValueError as e: + logger.warning(f"路径遍历攻击检测: {user_path} -> {target_path}") + raise HTTPException(status_code=400, detail="路径超出允许范围") from e + + return target_path + + +def _resolve_safe_plugin_directory(plugin_path: Path, plugins_dir: Path, strict: bool) -> Optional[Path]: + try: + if plugin_path.is_symlink(): + raise HTTPException(status_code=400, detail="插件目录不能是符号链接") + + resolved_plugins_dir = plugins_dir.resolve() + resolved_plugin_path = plugin_path.resolve() + resolved_plugin_path.relative_to(resolved_plugins_dir) + + return resolved_plugin_path if resolved_plugin_path.is_dir() else None + except HTTPException: + if strict: + raise + logger.warning(f"已跳过不安全的插件目录: {plugin_path}") + return None + except (OSError, RuntimeError, ValueError): + if strict: + raise HTTPException(status_code=400, detail="插件目录超出允许范围") from None + logger.warning(f"已跳过越界的插件目录: {plugin_path}") + return None + + +def resolve_plugin_file_path(plugin_path: Path, relative_path: str, allow_missing: bool = True) -> Path: + plugin_root = plugin_path.resolve() + target_path = plugin_root / relative_path + + if target_path.exists() and target_path.is_symlink(): + raise HTTPException(status_code=400, detail=f"插件文件不能是符号链接: {relative_path}") + + try: + resolved_target_path = target_path.resolve() + resolved_target_path.relative_to(plugin_root) + except (OSError, RuntimeError, ValueError) as e: + raise HTTPException(status_code=400, detail=f"插件文件超出允许范围: {relative_path}") from e + + if not allow_missing and not resolved_target_path.exists(): + raise HTTPException(status_code=404, detail=f"插件文件不存在: {relative_path}") + + return resolved_target_path + + +def validate_plugin_id(plugin_id: str) -> str: + if not plugin_id or not plugin_id.strip(): + logger.warning("非法插件 ID: 空字符串") + raise HTTPException(status_code=400, detail="插件 ID 不能为空") + + for pattern in ["/", "\\", "\x00", "..", "\n", "\r", "\t"]: + if pattern in plugin_id: + logger.warning(f"非法插件 ID 格式: {plugin_id} (包含危险字符)") + raise HTTPException(status_code=400, detail="插件 ID 包含非法字符") + + if plugin_id.startswith(".") or plugin_id.endswith("."): + logger.warning(f"非法插件 ID: {plugin_id}") + raise HTTPException(status_code=400, detail="插件 ID 不能以点开头或结尾") + + if plugin_id in {".", ".."}: + logger.warning(f"非法插件 ID: {plugin_id}") + raise HTTPException(status_code=400, detail="插件 ID 不能为特殊目录名") + + return plugin_id + + +def parse_version(version_str: str) -> Tuple[int, int, int]: + base_version = re.split(r"[-.](?:snapshot|dev|pre|alpha|beta|rc)", version_str, flags=re.IGNORECASE)[0] + parts = base_version.split(".") + if len(parts) < 3: + parts.extend(["0"] * (3 - len(parts))) + + try: + return int(parts[0]), int(parts[1]), int(parts[2]) + except (ValueError, IndexError): + logger.warning(f"无法解析版本号: {version_str},返回默认值 (0, 0, 0)") + return 0, 0, 0 + + +def deep_merge(dst: Dict[str, Any], src: Dict[str, Any]) -> None: + for key, value in src.items(): + if key in dst and isinstance(dst[key], dict) and isinstance(value, dict): + deep_merge(dst[key], value) + else: + dst[key] = value + + +def normalize_dotted_keys(obj: Dict[str, Any]) -> Dict[str, Any]: + result: Dict[str, Any] = {} + dotted_items: List[Tuple[str, Any]] = [] + + for key, value in obj.items(): + if "." in key: + dotted_items.append((key, value)) + else: + result[key] = normalize_dotted_keys(value) if isinstance(value, dict) else value + + for dotted_key, value in dotted_items: + normalized_value = normalize_dotted_keys(value) if isinstance(value, dict) else value + parts = dotted_key.split(".") + if "" in parts: + logger.warning(f"键路径包含空段: '{dotted_key}'") + parts = [part for part in parts if part] + if not parts: + logger.warning(f"忽略空键路径: '{dotted_key}'") + continue + + current = result + for index, part in enumerate(parts[:-1]): + if part in current and not isinstance(current[part], dict): + path_ctx = ".".join(parts[: index + 1]) + logger.warning(f"键冲突:{part} 已存在且非字典,覆盖为字典以展开 {dotted_key} (路径 {path_ctx})") + current[part] = {} + current = current.setdefault(part, {}) + + last_part = parts[-1] + if last_part in current and isinstance(current[last_part], dict) and isinstance(normalized_value, dict): + deep_merge(current[last_part], normalized_value) + else: + current[last_part] = normalized_value + + return result + + +def coerce_types(schema_part: Dict[str, Any], config_part: Dict[str, Any]) -> None: + def is_list_type(tp: Any) -> bool: + origin = get_origin(tp) + return tp is list or origin is list + + for key, schema_val in schema_part.items(): + if key not in config_part: + continue + value = config_part[key] + if isinstance(schema_val, ConfigField): + if is_list_type(schema_val.type) and isinstance(value, str): + config_part[key] = [item.strip() for item in value.split(",") if item.strip()] + elif isinstance(schema_val, dict) and isinstance(value, dict): + coerce_types(schema_val, value) + + +def find_plugin_instance(plugin_id: str) -> Optional[Any]: + from src.plugin_runtime.integration import get_plugin_runtime_manager + + manager = get_plugin_runtime_manager() + for supervisor in manager.supervisors: + registered = supervisor._registered_plugins.get(plugin_id) + if registered is not None: + return registered + return None + + +def get_plugins_dir() -> Path: + plugins_dir = Path("plugins").resolve() + plugins_dir.mkdir(exist_ok=True) + return plugins_dir + +def get_plugin_config_path(plugin_id: str, plugin_path: Path) -> Path: + return resolve_plugin_file_path(plugin_path, "config.toml") + + +def get_plugin_candidate_paths(plugin_id: str) -> Tuple[Path, Path]: + plugins_dir = get_plugins_dir() + folder_name = plugin_id.replace(".", "_") + return validate_safe_path(folder_name, plugins_dir), validate_safe_path(plugin_id, plugins_dir) + + +def resolve_installed_plugin_path(plugin_id: str) -> Optional[Path]: + new_format_path, old_format_path = get_plugin_candidate_paths(plugin_id) + plugins_dir = get_plugins_dir() + + if new_format_path.exists(): + return _resolve_safe_plugin_directory(new_format_path, plugins_dir, strict=True) + if old_format_path.exists(): + return _resolve_safe_plugin_directory(old_format_path, plugins_dir, strict=True) + return find_plugin_path_by_id(plugin_id) + + +def parse_repository_url(repository_url: str) -> Tuple[str, str, str]: + repo_url = repository_url.rstrip("/").removesuffix(".git") + parts = repo_url.split("/") + if len(parts) < 2: + raise HTTPException(status_code=400, detail="无效的仓库 URL") + return repo_url, parts[-2], parts[-1] + + +def load_manifest_json(manifest_path: Path) -> Optional[Dict[str, Any]]: + if not manifest_path.exists(): + return None + + if manifest_path.is_symlink(): + logger.warning(f"已拒绝读取符号链接 manifest: {manifest_path}") + return None + + try: + manifest_path.resolve().relative_to(manifest_path.parent.resolve()) + except (OSError, RuntimeError, ValueError): + logger.warning(f"已拒绝读取越界 manifest: {manifest_path}") + return None + + try: + with open(manifest_path, "r", encoding="utf-8") as file_obj: + return cast(dict[str, Any], json.load(file_obj)) + except Exception: + return None + + +def iter_plugin_directories() -> List[Path]: + plugins_dir = get_plugins_dir() + plugin_directories: List[Path] = [] + for path in plugins_dir.iterdir(): + safe_path = _resolve_safe_plugin_directory(path, plugins_dir, strict=False) + if safe_path is not None: + plugin_directories.append(safe_path) + return plugin_directories + + +def find_plugin_path_by_id(plugin_id: str) -> Optional[Path]: + casefold_matched_path: Optional[Path] = None + normalized_plugin_id = plugin_id.casefold() + + for plugin_path in iter_plugin_directories(): + manifest_path = resolve_plugin_file_path(plugin_path, "_manifest.json") + manifest = load_manifest_json(manifest_path) + if manifest is None: + continue + + manifest_id = str(manifest.get("id", "")) + if manifest_id == plugin_id or plugin_path.name == plugin_id: + return plugin_path + + if ( + casefold_matched_path is None + and (manifest_id.casefold() == normalized_plugin_id or plugin_path.name.casefold() == normalized_plugin_id) + ): + casefold_matched_path = plugin_path + + if casefold_matched_path is not None: + logger.warning(f"插件 ID 大小写不一致,已按大小写不敏感匹配: {plugin_id} -> {casefold_matched_path}") + return casefold_matched_path + + return None + + +def backup_file(file_path: Path, action: str, move_file: bool = False) -> Optional[Path]: + if not file_path.exists(): + return None + + backup_name = f"{file_path.name}.{action}.{datetime.now().strftime('%Y%m%d%H%M%S')}" + backup_dir = file_path.parent / "config_back" + backup_dir.mkdir(parents=True, exist_ok=True) + backup_path = backup_dir / backup_name + if move_file: + shutil.move(file_path, backup_path) + else: + shutil.copy(file_path, backup_path) + return backup_path + + +def remove_tree(path: Path) -> None: + if path.is_symlink(): + raise ValueError(f"拒绝删除符号链接路径: {path}") + + def remove_readonly(func: Any, target_path: str, _: Any) -> None: + os.chmod(target_path, stat.S_IWRITE) + func(target_path) + + shutil.rmtree(path, onerror=remove_readonly) diff --git a/src/webui/routers/reasoning_process.py b/src/webui/routers/reasoning_process.py new file mode 100644 index 00000000..08023c2f --- /dev/null +++ b/src/webui/routers/reasoning_process.py @@ -0,0 +1,236 @@ +"""推理过程日志浏览接口。""" + +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field + +from src.webui.dependencies import require_auth + +router = APIRouter(prefix="/reasoning-process", tags=["reasoning-process"], dependencies=[Depends(require_auth)]) + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +PROMPT_LOG_ROOT = (PROJECT_ROOT / "logs" / "maisaka_prompt").resolve() +ALLOWED_SUFFIXES = {".txt", ".html"} + + +class ReasoningPromptFile(BaseModel): + """推理过程日志条目。""" + + stage: str + session_id: str + stem: str + timestamp: int | None = None + text_path: str | None = None + html_path: str | None = None + size: int = 0 + modified_at: float = 0 + + +class ReasoningPromptListResponse(BaseModel): + """推理过程日志列表响应。""" + + items: list[ReasoningPromptFile] + total: int + page: int + page_size: int + stages: list[str] = Field(default_factory=list) + sessions: list[str] = Field(default_factory=list) + selected_session: str = "" + + +class ReasoningPromptContentResponse(BaseModel): + """推理过程文本内容响应。""" + + path: str + content: str + size: int + modified_at: float + + +def _to_safe_relative_path(relative_path: str) -> Path: + safe_path = Path(relative_path) + if safe_path.is_absolute() or ".." in safe_path.parts: + raise HTTPException(status_code=400, detail="路径不合法") + return safe_path + + +def _resolve_prompt_log_path(relative_path: str, allowed_suffixes: set[str]) -> Path: + safe_path = _to_safe_relative_path(relative_path) + resolved_path = (PROMPT_LOG_ROOT / safe_path).resolve() + + try: + resolved_path.relative_to(PROMPT_LOG_ROOT) + except ValueError as exc: + raise HTTPException(status_code=400, detail="路径不合法") from exc + + if resolved_path.suffix.lower() not in allowed_suffixes: + raise HTTPException(status_code=400, detail="不支持的文件类型") + if not resolved_path.is_file(): + raise HTTPException(status_code=404, detail="文件不存在") + + return resolved_path + + +def _relative_posix_path(path: Path) -> str: + return path.relative_to(PROMPT_LOG_ROOT).as_posix() + + +def _is_safe_name(name: str) -> bool: + path = Path(name) + return bool(name) and not path.is_absolute() and ".." not in path.parts and len(path.parts) == 1 + + +def _list_stage_names() -> list[str]: + if not PROMPT_LOG_ROOT.is_dir(): + return [] + + return sorted(path.name for path in PROMPT_LOG_ROOT.iterdir() if path.is_dir() and _is_safe_name(path.name)) + + +def _resolve_stage_name(stage: str) -> str: + normalized_stage = str(stage or "").strip() + if not normalized_stage or normalized_stage == "all": + return "planner" + if not _is_safe_name(normalized_stage): + raise HTTPException(status_code=400, detail="阶段名称不合法") + return normalized_stage + + +def _list_session_names(stage: str) -> list[str]: + stage_dir = PROMPT_LOG_ROOT / stage + if not stage_dir.is_dir(): + return [] + + session_dirs = [path for path in stage_dir.iterdir() if path.is_dir() and _is_safe_name(path.name)] + session_dirs.sort(key=lambda path: path.stat().st_mtime, reverse=True) + return [path.name for path in session_dirs] + + +def _resolve_session_name(session: str, sessions: list[str]) -> str: + normalized_session = str(session or "").strip() + if not normalized_session or normalized_session in {"all", "auto"}: + return sessions[0] if sessions else "" + if not _is_safe_name(normalized_session): + raise HTTPException(status_code=400, detail="会话名称不合法") + return normalized_session if normalized_session in sessions else "" + + +def _collect_prompt_files(stage: str, session: str) -> list[ReasoningPromptFile]: + session_dir = PROMPT_LOG_ROOT / stage / session + if not session or not session_dir.is_dir(): + return [] + + records: dict[tuple[str, str, str], dict[str, object]] = {} + + for file_path in session_dir.iterdir(): + if not file_path.is_file() or file_path.suffix.lower() not in ALLOWED_SUFFIXES: + continue + + try: + relative_path = file_path.relative_to(PROMPT_LOG_ROOT) + except ValueError: + continue + + parts = relative_path.parts + if len(parts) < 3: + continue + + stage_name, session_id = parts[0], parts[1] + stem = file_path.stem + key = (stage_name, session_id, stem) + stat = file_path.stat() + + record = records.setdefault( + key, + { + "stage": stage_name, + "session_id": session_id, + "stem": stem, + "timestamp": int(stem) if stem.isdigit() else None, + "text_path": None, + "html_path": None, + "size": 0, + "modified_at": 0.0, + }, + ) + record["size"] = int(record["size"]) + stat.st_size + record["modified_at"] = max(float(record["modified_at"]), stat.st_mtime) + + if file_path.suffix.lower() == ".txt": + record["text_path"] = _relative_posix_path(file_path) + elif file_path.suffix.lower() == ".html": + record["html_path"] = _relative_posix_path(file_path) + + items = [ReasoningPromptFile(**record) for record in records.values()] + items.sort(key=lambda item: (item.modified_at, item.timestamp or 0), reverse=True) + return items + + +@router.get("/files", response_model=ReasoningPromptListResponse) +async def list_reasoning_prompt_files( + stage: str = Query("planner"), + session: str = Query("auto"), + search: str = Query(""), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=10, le=200), +): + """列出 logs/maisaka_prompt 下的推理过程日志。""" + + stages = _list_stage_names() + selected_stage = _resolve_stage_name(stage) + sessions = _list_session_names(selected_stage) + selected_session = _resolve_session_name(session, sessions) + items = _collect_prompt_files(selected_stage, selected_session) + normalized_search = search.strip().lower() + + if normalized_search: + items = [ + item + for item in items + if normalized_search in item.stage.lower() + or normalized_search in item.session_id.lower() + or normalized_search in item.stem.lower() + ] + + total = len(items) + start = (page - 1) * page_size + end = start + page_size + + return ReasoningPromptListResponse( + items=items[start:end], + total=total, + page=page, + page_size=page_size, + stages=stages, + sessions=sessions, + selected_session=selected_session, + ) + + +@router.get("/file", response_model=ReasoningPromptContentResponse) +async def get_reasoning_prompt_file(path: str = Query(...)): + """读取推理过程 txt 日志内容。""" + + file_path = _resolve_prompt_log_path(path, {".txt"}) + stat = file_path.stat() + + return ReasoningPromptContentResponse( + path=_relative_posix_path(file_path), + content=file_path.read_text(encoding="utf-8", errors="replace"), + size=stat.st_size, + modified_at=stat.st_mtime, + ) + + +@router.get("/html") +async def get_reasoning_prompt_html(path: str = Query(...)): + """预览推理过程 html 日志内容。""" + + file_path = _resolve_prompt_log_path(path, {".html"}) + return FileResponse( + file_path, + media_type="text/html; charset=utf-8", + headers={"X-Robots-Tag": "noindex, nofollow"}, + ) diff --git a/src/webui/routers/statistics.py b/src/webui/routers/statistics.py new file mode 100644 index 00000000..3c6b8034 --- /dev/null +++ b/src/webui/routers/statistics.py @@ -0,0 +1,45 @@ +from datetime import datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException + +from src.common.logger import get_logger +from src.services.statistics_service import get_dashboard_statistics, get_model_statistics, get_summary_statistics +from src.webui.dependencies import require_auth +from src.webui.schemas.statistics import DashboardData + +logger = get_logger("webui.statistics") + +router = APIRouter(prefix="/statistics", tags=["statistics"], dependencies=[Depends(require_auth)]) + + +@router.get("/dashboard", response_model=DashboardData) +async def get_dashboard_data(hours: int = 24) -> DashboardData: + """获取仪表盘统计数据。""" + try: + return await get_dashboard_statistics(hours=hours) + except Exception as e: + logger.error(f"获取仪表盘数据失败: {e}") + raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e + + +@router.get("/summary") +async def get_summary(hours: int = 24): + """获取统计摘要。""" + try: + now = datetime.now() + start_time = now - timedelta(hours=hours) + return await get_summary_statistics(start_time, now) + except Exception as e: + logger.error(f"获取统计摘要失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/models") +async def get_model_stats(hours: int = 24): + """获取模型统计。""" + try: + start_time = datetime.now() - timedelta(hours=hours) + return await get_model_statistics(start_time) + except Exception as e: + logger.error(f"获取模型统计失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/src/webui/routers/system.py b/src/webui/routers/system.py new file mode 100644 index 00000000..5c9af8ed --- /dev/null +++ b/src/webui/routers/system.py @@ -0,0 +1,429 @@ +""" +系统控制路由 + +提供系统重启、状态查询等功能 +""" + +from datetime import datetime +from pathlib import Path +from typing import Literal, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import func, inspect, text +from sqlmodel import col, select + +import os +import time + +from src.common.database.database import engine, get_db_session +from src.common.database.database_model import Images, ImageType +from src.common.logger import get_logger +from src.config.config import MMC_VERSION +from src.webui.dashboard_update import ( + DASHBOARD_PACKAGE_NAME, + PYPI_PROJECT_URL, + detect_package_runner, + get_dashboard_version_info, +) +from src.webui.dependencies import require_auth + +router = APIRouter(prefix="/system", tags=["system"], dependencies=[Depends(require_auth)]) +logger = get_logger("webui_system") + +_start_time = time.time() +_PROJECT_ROOT = Path(__file__).resolve().parents[3] +_DATA_DIR = _PROJECT_ROOT / "data" +_IMAGE_DIR = _DATA_DIR / "images" +_EMOJI_DIR = _DATA_DIR / "emoji" +_EMOJI_THUMBNAIL_DIR = _DATA_DIR / "emoji_thumbnails" +_LOG_DIR = _PROJECT_ROOT / "logs" +_DATABASE_FILE = _DATA_DIR / "MaiBot.db" +_DATABASE_AUXILIARY_SUFFIXES = ("-wal", "-shm") + + +class RestartResponse(BaseModel): + """重启响应""" + + success: bool + message: str + + +class StatusResponse(BaseModel): + """状态响应""" + + running: bool + uptime: float + version: str + start_time: str + + +class DashboardVersionResponse(BaseModel): + """WebUI 版本检查响应""" + + current_version: str + latest_version: Optional[str] = None + has_update: bool = False + runner: str = "unknown" + package_name: str = DASHBOARD_PACKAGE_NAME + pypi_url: str = PYPI_PROJECT_URL + + +class CacheDirectoryStats(BaseModel): + """本地缓存目录统计。""" + + key: str + label: str + path: str + exists: bool + file_count: int + total_size: int + db_records: int = 0 + + +class DatabaseFileStats(BaseModel): + """数据库文件统计。""" + + path: str + exists: bool + size: int + + +class DatabaseTableStats(BaseModel): + """数据库表统计。""" + + name: str + rows: int + + +class DatabaseStorageStats(BaseModel): + """数据库存储统计。""" + + files: list[DatabaseFileStats] + tables: list[DatabaseTableStats] + total_size: int + + +class LocalCacheStatsResponse(BaseModel): + """本地缓存统计响应。""" + + directories: list[CacheDirectoryStats] + database: DatabaseStorageStats + + +class LocalCacheCleanupRequest(BaseModel): + """本地缓存清理请求。""" + + target: Literal["images", "emoji", "log_files", "database_logs"] + tables: list[Literal["llm_usage", "tool_records", "mai_messages"]] = Field(default_factory=list) + + +class LocalCacheCleanupResponse(BaseModel): + """本地缓存清理响应。""" + + success: bool + message: str + target: str + removed_files: int = 0 + removed_bytes: int = 0 + removed_records: int = 0 + + +def _parse_version_parts(version: str | None) -> Optional[list[int]]: + """将版本号转换为可比较的整数列表。""" + if not version: + return None + parts: list[int] = [] + for raw_part in version.split("."): + if not raw_part.isdigit(): + return None + parts.append(int(raw_part)) + return parts + + +def _is_newer_version(latest: str | None, current: str | None) -> bool: + """判断 latest 是否新于 current。""" + latest_parts = _parse_version_parts(latest) + current_parts = _parse_version_parts(current) + if latest_parts is None or current_parts is None: + return False + + max_len = max(len(latest_parts), len(current_parts)) + latest_parts.extend([0] * (max_len - len(latest_parts))) + current_parts.extend([0] * (max_len - len(current_parts))) + return latest_parts > current_parts + + +def _iter_files(directory: Path) -> list[Path]: + if not directory.exists() or not directory.is_dir(): + return [] + return [path for path in directory.rglob("*") if path.is_file()] + + +def _get_directory_size(directory: Path) -> tuple[int, int]: + files = _iter_files(directory) + total_size = 0 + for file_path in files: + try: + total_size += file_path.stat().st_size + except OSError: + logger.warning(f"读取缓存文件大小失败: {file_path}") + return len(files), total_size + + +def _get_image_record_count(image_type: ImageType) -> int: + with get_db_session() as session: + statement = select(func.count()).select_from(Images).where(col(Images.image_type) == image_type) + return int(session.exec(statement).one()) + + +def _build_directory_stats(key: str, label: str, path: Path, image_type: ImageType | None = None) -> CacheDirectoryStats: + file_count, total_size = _get_directory_size(path) + return CacheDirectoryStats( + key=key, + label=label, + path=str(path), + exists=path.exists(), + file_count=file_count, + total_size=total_size, + db_records=_get_image_record_count(image_type) if image_type is not None else 0, + ) + + +def _get_database_files() -> list[DatabaseFileStats]: + db_paths = [_DATABASE_FILE, *[Path(f"{_DATABASE_FILE}{suffix}") for suffix in _DATABASE_AUXILIARY_SUFFIXES]] + result: list[DatabaseFileStats] = [] + for db_path in db_paths: + exists = db_path.exists() + size = 0 + if exists: + try: + size = db_path.stat().st_size + except OSError: + logger.warning(f"读取数据库文件大小失败: {db_path}") + result.append(DatabaseFileStats(path=str(db_path), exists=exists, size=size)) + return result + + +def _get_database_table_stats() -> list[DatabaseTableStats]: + inspector = inspect(engine) + table_stats: list[DatabaseTableStats] = [] + with engine.connect() as connection: + for table_name in inspector.get_table_names(): + quoted_table_name = table_name.replace('"', '""') + rows = connection.execute(text(f'SELECT COUNT(*) FROM "{quoted_table_name}"')).scalar_one() + table_stats.append(DatabaseTableStats(name=table_name, rows=int(rows))) + return sorted(table_stats, key=lambda item: item.name) + + +def _build_database_stats() -> DatabaseStorageStats: + files = _get_database_files() + return DatabaseStorageStats( + files=files, + tables=_get_database_table_stats(), + total_size=sum(file.size for file in files), + ) + + +def _remove_directory_contents(directory: Path) -> tuple[int, int]: + if not directory.exists() or not directory.is_dir(): + return 0, 0 + + removed_files = 0 + removed_bytes = 0 + for file_path in _iter_files(directory): + try: + file_size = file_path.stat().st_size + file_path.unlink() + removed_files += 1 + removed_bytes += file_size + except OSError as exc: + logger.warning(f"删除缓存文件失败: {file_path}, error={exc}") + + for child in sorted(directory.rglob("*"), key=lambda item: len(item.parts), reverse=True): + if child.is_dir(): + try: + child.rmdir() + except OSError: + pass + return removed_files, removed_bytes + + +def _delete_image_records(image_type: ImageType) -> int: + removed_records = 0 + with get_db_session() as session: + statement = select(Images).where(col(Images.image_type) == image_type) + for record in session.exec(statement).all(): + session.delete(record) + removed_records += 1 + return removed_records + + +def _delete_log_records(table_names: list[str]) -> int: + allowed_tables = {"llm_usage", "tool_records", "mai_messages"} + invalid_tables = set(table_names) - allowed_tables + if invalid_tables: + raise ValueError(f"不支持清理这些表: {', '.join(sorted(invalid_tables))}") + + removed_records = 0 + with engine.begin() as connection: + for table_name in table_names: + quoted_table_name = table_name.replace('"', '""') + result = connection.execute(text(f'DELETE FROM "{quoted_table_name}"')) + removed_records += int(result.rowcount or 0) + return removed_records + + +@router.post("/restart", response_model=RestartResponse) +async def restart_maibot(): + """ + 重启麦麦主程序 + + 请求重启当前进程,配置更改将在重启后生效。 + 注意:此操作会使麦麦暂时离线。 + """ + import asyncio + + try: + # 记录重启操作 + logger.info("WebUI 触发重启操作") + + # 定义延迟重启的异步任务 + async def delayed_restart(): + await asyncio.sleep(0.5) # 延迟0.5秒,确保响应已发送 + # 使用 os._exit(42) 退出当前进程,配合外部 runner 脚本进行重启 + # 42 是约定的重启状态码 + logger.info("WebUI 请求重启,退出代码 42") + os._exit(42) + + # 创建后台任务执行重启 + asyncio.create_task(delayed_restart()) + + # 立即返回成功响应 + return RestartResponse(success=True, message="麦麦正在重启中...") + except Exception as e: + raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}") from e + + +@router.get("/status", response_model=StatusResponse) +async def get_maibot_status(): + """ + 获取麦麦运行状态 + + 返回麦麦的运行状态、运行时长和版本信息。 + """ + try: + uptime = time.time() - _start_time + + # 尝试获取版本信息(需要根据实际情况调整) + version = MMC_VERSION # 可以从配置或常量中读取 + + return StatusResponse( + running=True, uptime=uptime, version=version, start_time=datetime.fromtimestamp(_start_time).isoformat() + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"获取状态失败: {str(e)}") from e + + +@router.get("/dashboard-version", response_model=DashboardVersionResponse) +async def get_dashboard_version(current_version: Optional[str] = None): + """获取 WebUI 当前版本和 PyPI 最新版本。""" + version_info = await get_dashboard_version_info(current_version) + + return DashboardVersionResponse( + current_version=version_info.current_version, + latest_version=version_info.latest_version, + has_update=version_info.has_update, + runner=detect_package_runner(), + ) + + +@router.get("/local-cache", response_model=LocalCacheStatsResponse) +async def get_local_cache_stats(): + """获取 data 目录下图片、表情包和数据库的本地存储情况。""" + try: + return LocalCacheStatsResponse( + directories=[ + _build_directory_stats("images", "图片缓存", _IMAGE_DIR, ImageType.IMAGE), + _build_directory_stats("emoji", "表情包缓存", _EMOJI_DIR, ImageType.EMOJI), + _build_directory_stats("emoji_thumbnails", "表情包缩略图缓存", _EMOJI_THUMBNAIL_DIR), + _build_directory_stats("logs", "日志文件", _LOG_DIR), + ], + database=_build_database_stats(), + ) + except Exception as e: + logger.exception(f"获取本地缓存统计失败: {e}") + raise HTTPException(status_code=500, detail=f"获取本地缓存统计失败: {str(e)}") from e + + +@router.post("/local-cache/cleanup", response_model=LocalCacheCleanupResponse) +async def cleanup_local_cache(request: LocalCacheCleanupRequest): + """清理指定的本地缓存区域。""" + try: + if request.target == "images": + removed_files, removed_bytes = _remove_directory_contents(_IMAGE_DIR) + removed_records = _delete_image_records(ImageType.IMAGE) + return LocalCacheCleanupResponse( + success=True, + message="图片缓存已清理", + target=request.target, + removed_files=removed_files, + removed_bytes=removed_bytes, + removed_records=removed_records, + ) + + if request.target == "emoji": + emoji_files, emoji_bytes = _remove_directory_contents(_EMOJI_DIR) + thumbnail_files, thumbnail_bytes = _remove_directory_contents(_EMOJI_THUMBNAIL_DIR) + removed_records = _delete_image_records(ImageType.EMOJI) + return LocalCacheCleanupResponse( + success=True, + message="表情包缓存已清理", + target=request.target, + removed_files=emoji_files + thumbnail_files, + removed_bytes=emoji_bytes + thumbnail_bytes, + removed_records=removed_records, + ) + + if request.target == "log_files": + removed_files, removed_bytes = _remove_directory_contents(_LOG_DIR) + return LocalCacheCleanupResponse( + success=True, + message="日志文件已清理", + target=request.target, + removed_files=removed_files, + removed_bytes=removed_bytes, + ) + + if not request.tables: + raise HTTPException(status_code=400, detail="请至少选择一个要清理的数据库表") + + removed_records = _delete_log_records(list(request.tables)) + return LocalCacheCleanupResponse( + success=True, + message="数据库日志记录已清理", + target=request.target, + removed_records=removed_records, + ) + except HTTPException: + raise + except Exception as e: + logger.exception(f"清理本地缓存失败: {e}") + raise HTTPException(status_code=500, detail=f"清理本地缓存失败: {str(e)}") from e + + +# 可选:添加更多系统控制功能 + + +@router.post("/reload-config") +async def reload_config(): + """ + 热重载配置(不重启进程) + + 仅重新加载配置文件,某些配置可能需要重启才能生效。 + 此功能需要在主程序中实现配置热重载逻辑。 + """ + # 这里需要调用主程序的配置重载函数 + # 示例:await app_instance.reload_config() + + return {"success": True, "message": "配置重载功能待实现"} diff --git a/src/webui/routers/websocket/__init__.py b/src/webui/routers/websocket/__init__.py new file mode 100644 index 00000000..af4ffdb5 --- /dev/null +++ b/src/webui/routers/websocket/__init__.py @@ -0,0 +1,7 @@ +"""WebSocket 路由包。""" + +__all__ = [ + "auth", + "manager", + "unified", +] diff --git a/src/webui/routers/websocket/auth.py b/src/webui/routers/websocket/auth.py new file mode 100644 index 00000000..1cb24f6c --- /dev/null +++ b/src/webui/routers/websocket/auth.py @@ -0,0 +1,104 @@ +"""WebSocket 认证模块。""" + +import secrets +import time +from typing import Dict, Optional, Tuple + +from fastapi import APIRouter, Cookie + +from src.common.logger import get_logger +from src.webui.core import get_token_manager + +logger = get_logger("webui.ws_auth") +router = APIRouter() + +# WebSocket 临时 token 存储 {token: (expire_time, session_token)} +# 临时 token 有效期 60 秒,仅用于 WebSocket 握手 +_ws_temp_tokens: Dict[str, Tuple[float, str]] = {} +_WS_TOKEN_EXPIRE_SECONDS = 60 + + +def _cleanup_expired_ws_tokens(): + """清理过期的临时 token""" + now = time.time() + expired = [t for t, (exp, _) in _ws_temp_tokens.items() if now > exp] + for t in expired: + del _ws_temp_tokens[t] + + +def generate_ws_token(session_token: str) -> str: + """生成 WebSocket 临时 token + + Args: + session_token: 原始的 session token + + Returns: + 临时 token 字符串 + """ + _cleanup_expired_ws_tokens() + temp_token = secrets.token_urlsafe(32) + _ws_temp_tokens[temp_token] = (time.time() + _WS_TOKEN_EXPIRE_SECONDS, session_token) + logger.debug(f"生成 WS 临时 token: {temp_token[:8]}... 有效期 {_WS_TOKEN_EXPIRE_SECONDS}s") + return temp_token + + +def verify_ws_token(temp_token: str) -> bool: + """验证并消费 WebSocket 临时 token(一次性使用) + + Args: + temp_token: 临时 token + + Returns: + 验证是否通过 + """ + _cleanup_expired_ws_tokens() + if temp_token not in _ws_temp_tokens: + logger.warning(f"WS token 不存在: {temp_token[:8]}...") + return False + expire_time, session_token = _ws_temp_tokens[temp_token] + if time.time() > expire_time: + del _ws_temp_tokens[temp_token] + logger.warning(f"WS token 已过期: {temp_token[:8]}...") + return False + # 验证原始 session token 仍然有效 + token_manager = get_token_manager() + if not token_manager.verify_token(session_token): + del _ws_temp_tokens[temp_token] + logger.warning(f"WS token 关联的 session 已失效: {temp_token[:8]}...") + return False + # 消费 token(一次性使用) + del _ws_temp_tokens[temp_token] + logger.debug(f"WS token 验证成功: {temp_token[:8]}...") + return True + + +@router.get("/ws-token") +async def get_ws_token( + maibot_session: Optional[str] = Cookie(None), +): + """ + 获取 WebSocket 连接用的临时 token + + 此端点验证当前会话 Cookie, + 然后返回一个临时 token 用于 WebSocket 握手认证。 + 临时 token 有效期 60 秒,且只能使用一次。 + + 注意:在未认证时返回 200 状态码但 success=False,避免前端因 401 刷新页面。 + """ + if not maibot_session: + # 返回 200 但 success=False,避免前端因 401 刷新页面 + # 这在登录页面是正常情况,不应该触发错误处理 + logger.debug("ws-token 请求:未提供认证信息(可能在登录页面)") + return {"success": False, "message": "未提供认证信息,请先登录", "token": None, "expires_in": 0} + + # 验证 session token + token_manager = get_token_manager() + if not token_manager.verify_token(maibot_session): + # 同样返回 200 但 success=False,避免前端刷新 + logger.debug("ws-token 请求:认证已过期") + return {"success": False, "message": "认证已过期,请重新登录", "token": None, "expires_in": 0} + + # 生成临时 WebSocket token + ws_token = generate_ws_token(maibot_session) + + return {"success": True, "token": ws_token, "expires_in": _WS_TOKEN_EXPIRE_SECONDS} diff --git a/src/webui/routers/websocket/manager.py b/src/webui/routers/websocket/manager.py new file mode 100644 index 00000000..54d8c72d --- /dev/null +++ b/src/webui/routers/websocket/manager.py @@ -0,0 +1,322 @@ +"""统一 WebSocket 连接管理器。""" + +from dataclasses import dataclass, field +from typing import Any, Dict, Optional, Set + +import asyncio + +from fastapi import WebSocket +from starlette.websockets import WebSocketState + +from src.common.logger import get_logger + +logger = get_logger("webui.websocket") + + +@dataclass +class WebSocketConnection: + """统一 WebSocket 连接上下文。""" + + connection_id: str + websocket: WebSocket + subscriptions: Set[str] = field(default_factory=set) + chat_sessions: Dict[str, str] = field(default_factory=dict) + send_queue: "asyncio.Queue[Optional[Dict[str, Any]]]" = field(default_factory=asyncio.Queue) + sender_task: Optional["asyncio.Task[None]"] = None + + +class UnifiedWebSocketManager: + """统一 WebSocket 连接管理器。""" + + def __init__(self) -> None: + """初始化统一 WebSocket 连接管理器。""" + self.connections: Dict[str, WebSocketConnection] = {} + + def _build_subscription_key(self, domain: str, topic: str) -> str: + """构建订阅索引键。 + + Args: + domain: 业务域名称。 + topic: 主题名称。 + + Returns: + str: 订阅索引键。 + """ + return f"{domain}:{topic}" + + async def _close_websocket(self, connection: WebSocketConnection) -> None: + """显式关闭底层 WebSocket 连接。 + + 某些异常退出路径只会执行清理逻辑,但不会自动向客户端发送关闭帧。 + 这里主动关闭底层连接,确保浏览器能够及时感知断线并触发重连。 + + Args: + connection: 目标连接上下文。 + """ + websocket = connection.websocket + if ( + websocket.client_state == WebSocketState.DISCONNECTED + or websocket.application_state == WebSocketState.DISCONNECTED + ): + return + + await websocket.close() + + async def _sender_loop(self, connection: WebSocketConnection) -> None: + """串行发送指定连接的出站消息。 + + Args: + connection: 目标连接上下文。 + """ + try: + while True: + message = await connection.send_queue.get() + if message is None: + return + await connection.websocket.send_json(message) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.error(f"统一 WebSocket 发送失败: connection={connection.connection_id}, error={exc}") + + async def connect(self, connection_id: str, websocket: WebSocket) -> WebSocketConnection: + """注册一个新的物理 WebSocket 连接。 + + Args: + connection_id: 连接 ID。 + websocket: FastAPI WebSocket 对象。 + + Returns: + WebSocketConnection: 新建的连接上下文。 + """ + await websocket.accept() + connection = WebSocketConnection(connection_id=connection_id, websocket=websocket) + connection.sender_task = asyncio.create_task(self._sender_loop(connection)) + self.connections[connection_id] = connection + return connection + + async def disconnect(self, connection_id: str) -> None: + """断开并清理指定连接。 + + Args: + connection_id: 连接 ID。 + """ + connection = self.connections.pop(connection_id, None) + if connection is None: + return + + try: + await self._close_websocket(connection) + except Exception as exc: + logger.debug(f"关闭统一 WebSocket 底层连接时出现异常: connection={connection_id}, error={exc}") + + await connection.send_queue.put(None) + if connection.sender_task is not None: + try: + await connection.sender_task + except asyncio.CancelledError: + pass + except Exception as exc: + logger.debug(f"等待发送协程退出时出现异常: connection={connection_id}, error={exc}") + + def get_connection(self, connection_id: str) -> Optional[WebSocketConnection]: + """获取指定连接上下文。 + + Args: + connection_id: 连接 ID。 + + Returns: + Optional[WebSocketConnection]: 找到时返回连接上下文。 + """ + return self.connections.get(connection_id) + + def register_chat_session(self, connection_id: str, client_session_id: str, session_id: str) -> None: + """登记连接下的逻辑聊天会话。 + + Args: + connection_id: 连接 ID。 + client_session_id: 前端会话 ID。 + session_id: 内部会话 ID。 + """ + connection = self.connections.get(connection_id) + if connection is None: + return + connection.chat_sessions[client_session_id] = session_id + + def unregister_chat_session(self, connection_id: str, client_session_id: str) -> None: + """移除连接下的逻辑聊天会话登记。 + + Args: + connection_id: 连接 ID。 + client_session_id: 前端会话 ID。 + """ + connection = self.connections.get(connection_id) + if connection is None: + return + connection.chat_sessions.pop(client_session_id, None) + + def get_chat_session_id(self, connection_id: str, client_session_id: str) -> Optional[str]: + """查询连接下的内部聊天会话 ID。 + + Args: + connection_id: 连接 ID。 + client_session_id: 前端会话 ID。 + + Returns: + Optional[str]: 找到时返回内部会话 ID。 + """ + connection = self.connections.get(connection_id) + if connection is None: + return None + return connection.chat_sessions.get(client_session_id) + + def subscribe(self, connection_id: str, domain: str, topic: str) -> None: + """登记连接的主题订阅。 + + Args: + connection_id: 连接 ID。 + domain: 业务域名称。 + topic: 主题名称。 + """ + connection = self.connections.get(connection_id) + if connection is None: + return + connection.subscriptions.add(self._build_subscription_key(domain, topic)) + + def unsubscribe(self, connection_id: str, domain: str, topic: str) -> None: + """移除连接的主题订阅。 + + Args: + connection_id: 连接 ID。 + domain: 业务域名称。 + topic: 主题名称。 + """ + connection = self.connections.get(connection_id) + if connection is None: + return + connection.subscriptions.discard(self._build_subscription_key(domain, topic)) + + def is_subscribed(self, connection_id: str, domain: str, topic: str) -> bool: + """判断连接是否订阅了指定主题。 + + Args: + connection_id: 连接 ID。 + domain: 业务域名称。 + topic: 主题名称。 + + Returns: + bool: 已订阅时返回 ``True``。 + """ + connection = self.connections.get(connection_id) + if connection is None: + return False + return self._build_subscription_key(domain, topic) in connection.subscriptions + + async def enqueue(self, connection_id: str, message: Dict[str, Any]) -> None: + """向指定连接的发送队列压入消息。 + + Args: + connection_id: 连接 ID。 + message: 待发送的消息。 + """ + connection = self.connections.get(connection_id) + if connection is None: + return + await connection.send_queue.put(message) + + async def send_response( + self, + connection_id: str, + request_id: Optional[str], + ok: bool, + data: Optional[Dict[str, Any]] = None, + error: Optional[Dict[str, Any]] = None, + ) -> None: + """发送统一响应消息。 + + Args: + connection_id: 连接 ID。 + request_id: 请求 ID。 + ok: 请求是否成功。 + data: 成功响应数据。 + error: 失败响应数据。 + """ + response_message: Dict[str, Any] = { + "op": "response", + "id": request_id, + "ok": ok, + } + if data is not None: + response_message["data"] = data + if error is not None: + response_message["error"] = error + await self.enqueue(connection_id, response_message) + + async def send_event( + self, + connection_id: str, + domain: str, + event: str, + data: Dict[str, Any], + session: Optional[str] = None, + topic: Optional[str] = None, + ) -> None: + """发送统一事件消息。 + + Args: + connection_id: 连接 ID。 + domain: 业务域名称。 + event: 事件名称。 + data: 事件数据。 + session: 可选的逻辑会话 ID。 + topic: 可选的主题名称。 + """ + event_message: Dict[str, Any] = { + "op": "event", + "domain": domain, + "event": event, + "data": data, + } + if session is not None: + event_message["session"] = session + if topic is not None: + event_message["topic"] = topic + await self.enqueue(connection_id, event_message) + + async def send_pong(self, connection_id: str, timestamp: float) -> None: + """发送心跳响应。 + + Args: + connection_id: 连接 ID。 + timestamp: 当前时间戳。 + """ + await self.enqueue( + connection_id, + { + "op": "pong", + "ts": timestamp, + }, + ) + + async def broadcast_to_topic(self, domain: str, topic: str, event: str, data: Dict[str, Any]) -> None: + """向订阅指定主题的全部连接广播事件。 + + Args: + domain: 业务域名称。 + topic: 主题名称。 + event: 事件名称。 + data: 事件数据。 + """ + subscription_key = self._build_subscription_key(domain, topic) + for connection in list(self.connections.values()): + if subscription_key in connection.subscriptions: + await self.send_event( + connection.connection_id, + domain=domain, + event=event, + data=data, + topic=topic, + ) + + +websocket_manager = UnifiedWebSocketManager() diff --git a/src/webui/routers/websocket/unified.py b/src/webui/routers/websocket/unified.py new file mode 100644 index 00000000..26c63177 --- /dev/null +++ b/src/webui/routers/websocket/unified.py @@ -0,0 +1,588 @@ +"""统一 WebSocket 路由。""" + +from typing import Any, Dict, Optional, Set, cast + +import asyncio +import time +import uuid + +from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect + +from src.common.logger import get_logger +from src.webui.core import get_token_manager +from src.webui.logs_ws import load_recent_logs +from src.webui.routers.chat.service import ( + chat_manager, + dispatch_chat_event, + normalize_webui_user_id, + resolve_initial_virtual_identity, + send_initial_chat_state, +) +from src.webui.routers.plugin.progress import get_current_progress +from src.webui.routers.websocket.auth import verify_ws_token +from src.webui.routers.websocket.manager import websocket_manager + +logger = get_logger("webui.unified_ws") +router = APIRouter() +_background_tasks: Set["asyncio.Task[None]"] = set() + + +def _build_error(code: str, message: str) -> Dict[str, Any]: + """构建统一错误响应体。 + + Args: + code: 错误码。 + message: 错误描述。 + + Returns: + Dict[str, Any]: 统一错误对象。 + """ + return { + "code": code, + "message": message, + } + + +def _get_request_data(message: Dict[str, Any]) -> Dict[str, Any]: + """从客户端消息中提取数据字段。 + + Args: + message: 客户端消息。 + + Returns: + Dict[str, Any]: 标准化后的数据字典。 + """ + data = message.get("data", {}) + if isinstance(data, dict): + return cast(Dict[str, Any], data) + return {} + + +def _track_background_task(task: "asyncio.Task[None]") -> None: + """登记后台任务并在完成后自动清理。 + + Args: + task: 后台协程任务。 + """ + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) + + +async def authenticate_websocket_connection(websocket: WebSocket, token: Optional[str]) -> bool: + """校验统一 WebSocket 连接的认证状态。 + + Args: + websocket: FastAPI WebSocket 对象。 + token: 可选的一次性握手 Token。 + + Returns: + bool: 认证通过时返回 ``True``。 + """ + if token and verify_ws_token(token): + logger.debug("统一 WebSocket 使用临时 token 认证成功") + return True + + cookie_token = websocket.cookies.get("maibot_session") + if cookie_token: + token_manager = get_token_manager() + if token_manager.verify_token(cookie_token): + logger.debug("统一 WebSocket 使用 Cookie 认证成功") + return True + + return False + + +async def _handle_logs_subscribe(connection_id: str, request_id: Optional[str], data: Dict[str, Any]) -> None: + """处理日志域订阅请求。 + + Args: + connection_id: 连接 ID。 + request_id: 请求 ID。 + data: 订阅参数。 + """ + replay_limit = int(data.get("replay", 100) or 100) + replay_limit = max(0, min(replay_limit, 500)) + websocket_manager.subscribe(connection_id, domain="logs", topic="main") + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=True, + data={"domain": "logs", "topic": "main"}, + ) + await websocket_manager.send_event( + connection_id, + domain="logs", + event="snapshot", + topic="main", + data={"entries": load_recent_logs(limit=replay_limit)}, + ) + + +async def _handle_plugin_progress_subscribe(connection_id: str, request_id: Optional[str]) -> None: + """处理插件进度域订阅请求。 + + Args: + connection_id: 连接 ID。 + request_id: 请求 ID。 + """ + websocket_manager.subscribe(connection_id, domain="plugin_progress", topic="main") + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=True, + data={"domain": "plugin_progress", "topic": "main"}, + ) + await websocket_manager.send_event( + connection_id, + domain="plugin_progress", + event="snapshot", + topic="main", + data={"progress": get_current_progress()}, + ) + + +async def _handle_maisaka_monitor_subscribe(connection_id: str, request_id: Optional[str]) -> None: + """处理 MaiSaka 监控域订阅请求。 + + Args: + connection_id: 连接 ID。 + request_id: 请求 ID。 + """ + logger.info( + f"MaiSaka 监控订阅请求: connection_id={connection_id} " + f"manager_id={id(websocket_manager)}" + ) + websocket_manager.subscribe(connection_id, domain="maisaka_monitor", topic="main") + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=True, + data={"domain": "maisaka_monitor", "topic": "main"}, + ) + from src.maisaka.display.stage_status_board import get_stage_status_snapshot + + await websocket_manager.send_event( + connection_id, + domain="maisaka_monitor", + event="stage.snapshot", + topic="main", + data={"entries": get_stage_status_snapshot(), "timestamp": time.time()}, + ) + + +async def _handle_subscribe(connection_id: str, message: Dict[str, Any]) -> None: + """处理主题订阅请求。 + + Args: + connection_id: 连接 ID。 + message: 客户端消息。 + """ + request_id = cast(Optional[str], message.get("id")) + domain = str(message.get("domain") or "").strip() + topic = str(message.get("topic") or "").strip() + data = _get_request_data(message) + + if domain == "logs" and topic == "main": + await _handle_logs_subscribe(connection_id, request_id, data) + return + + if domain == "plugin_progress" and topic == "main": + await _handle_plugin_progress_subscribe(connection_id, request_id) + return + + if domain == "maisaka_monitor" and topic == "main": + await _handle_maisaka_monitor_subscribe(connection_id, request_id) + return + + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=False, + error=_build_error("unsupported_subscription", f"不支持的订阅目标: {domain}:{topic}"), + ) + + +async def _handle_unsubscribe(connection_id: str, message: Dict[str, Any]) -> None: + """处理主题退订请求。 + + Args: + connection_id: 连接 ID。 + message: 客户端消息。 + """ + request_id = cast(Optional[str], message.get("id")) + domain = str(message.get("domain") or "").strip() + topic = str(message.get("topic") or "").strip() + + if not domain or not topic: + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=False, + error=_build_error("invalid_unsubscribe", "退订请求缺少 domain 或 topic"), + ) + return + + websocket_manager.unsubscribe(connection_id, domain=domain, topic=topic) + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=True, + data={"domain": domain, "topic": topic}, + ) + + +async def _open_chat_session(connection_id: str, message: Dict[str, Any]) -> None: + """打开一个逻辑聊天会话。 + + Args: + connection_id: 连接 ID。 + message: 客户端消息。 + """ + request_id = cast(Optional[str], message.get("id")) + client_session_id = str(message.get("session") or "").strip() + if not client_session_id: + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=False, + error=_build_error("missing_session", "聊天会话打开请求缺少 session"), + ) + return + + data = _get_request_data(message) + normalized_user_id = normalize_webui_user_id(cast(Optional[str], data.get("user_id"))) + current_user_name = str(data.get("user_name") or "WebUI用户") + current_virtual_config = resolve_initial_virtual_identity( + platform=cast(Optional[str], data.get("platform")), + person_id=cast(Optional[str], data.get("person_id")), + group_name=cast(Optional[str], data.get("group_name")), + group_id=cast(Optional[str], data.get("group_id")), + ) + restore = bool(data.get("restore")) + session_id = f"{connection_id}:{client_session_id}" + + async def send_chat_event(chat_message: Dict[str, Any]) -> None: + """将聊天消息封装为统一事件并发送。 + + Args: + chat_message: 聊天消息体。 + """ + event_name = str(chat_message.get("type") or "message") + await websocket_manager.send_event( + connection_id, + domain="chat", + event=event_name, + session=client_session_id, + data=chat_message, + ) + + await chat_manager.connect( + session_id=session_id, + connection_id=connection_id, + client_session_id=client_session_id, + user_id=normalized_user_id, + user_name=current_user_name, + virtual_config=current_virtual_config, + sender=send_chat_event, + ) + websocket_manager.register_chat_session(connection_id, client_session_id, session_id) + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=True, + data={"session": client_session_id, "session_id": session_id}, + ) + await send_initial_chat_state( + session_id=session_id, + user_id=normalized_user_id, + user_name=current_user_name, + virtual_config=current_virtual_config, + include_welcome=not restore, + ) + + +async def _close_chat_session(connection_id: str, message: Dict[str, Any]) -> None: + """关闭一个逻辑聊天会话。 + + Args: + connection_id: 连接 ID。 + message: 客户端消息。 + """ + request_id = cast(Optional[str], message.get("id")) + client_session_id = str(message.get("session") or "").strip() + session_id = websocket_manager.get_chat_session_id(connection_id, client_session_id) + if session_id is None: + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=False, + error=_build_error("session_not_found", f"找不到聊天会话: {client_session_id}"), + ) + return + + chat_manager.disconnect(session_id) + websocket_manager.unregister_chat_session(connection_id, client_session_id) + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=True, + data={"session": client_session_id}, + ) + + +async def _process_chat_message(connection_id: str, client_session_id: str, data: Dict[str, Any]) -> None: + """在后台处理聊天消息事件。 + + Args: + connection_id: 连接 ID。 + client_session_id: 前端会话 ID。 + data: 客户端提交的消息数据。 + """ + session_id = websocket_manager.get_chat_session_id(connection_id, client_session_id) + if session_id is None: + return + + session_state = chat_manager.get_session(session_id) + if session_state is None: + return + + next_user_name, next_virtual_config = await dispatch_chat_event( + session_id=session_id, + session_id_prefix=session_id[:8], + data=data, + current_user_name=session_state.user_name, + normalized_user_id=session_state.user_id, + current_virtual_config=session_state.virtual_config, + ) + chat_manager.update_session_context( + session_id=session_id, + user_name=next_user_name, + virtual_config=next_virtual_config, + ) + + +async def _handle_chat_message_send(connection_id: str, message: Dict[str, Any]) -> None: + """处理聊天消息发送请求。 + + Args: + connection_id: 连接 ID。 + message: 客户端消息。 + """ + request_id = cast(Optional[str], message.get("id")) + client_session_id = str(message.get("session") or "").strip() + session_id = websocket_manager.get_chat_session_id(connection_id, client_session_id) + if session_id is None: + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=False, + error=_build_error("session_not_found", f"找不到聊天会话: {client_session_id}"), + ) + return + + data = _get_request_data(message) + payload = { + "type": "message", + "content": data.get("content", ""), + "user_name": data.get("user_name", ""), + } + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=True, + data={"accepted": True, "session": client_session_id}, + ) + _track_background_task(asyncio.create_task(_process_chat_message(connection_id, client_session_id, payload))) + + +async def _handle_chat_nickname_update(connection_id: str, message: Dict[str, Any]) -> None: + """处理聊天昵称更新请求。 + + Args: + connection_id: 连接 ID。 + message: 客户端消息。 + """ + request_id = cast(Optional[str], message.get("id")) + client_session_id = str(message.get("session") or "").strip() + session_id = websocket_manager.get_chat_session_id(connection_id, client_session_id) + if session_id is None: + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=False, + error=_build_error("session_not_found", f"找不到聊天会话: {client_session_id}"), + ) + return + + data = _get_request_data(message) + session_state = chat_manager.get_session(session_id) + if session_state is None: + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=False, + error=_build_error("session_not_found", f"找不到聊天会话: {client_session_id}"), + ) + return + + next_user_name, next_virtual_config = await dispatch_chat_event( + session_id=session_id, + session_id_prefix=session_id[:8], + data={ + "type": "update_nickname", + "user_name": data.get("user_name", ""), + }, + current_user_name=session_state.user_name, + normalized_user_id=session_state.user_id, + current_virtual_config=session_state.virtual_config, + ) + chat_manager.update_session_context( + session_id=session_id, + user_name=next_user_name, + virtual_config=next_virtual_config, + ) + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=True, + data={"session": client_session_id, "user_name": next_user_name}, + ) + + +async def _handle_chat_call(connection_id: str, message: Dict[str, Any]) -> None: + """处理聊天域调用请求。 + + Args: + connection_id: 连接 ID。 + message: 客户端消息。 + """ + request_id = cast(Optional[str], message.get("id")) + method = str(message.get("method") or "").strip() + + if method == "session.open": + await _open_chat_session(connection_id, message) + return + + if method == "session.close": + await _close_chat_session(connection_id, message) + return + + if method == "message.send": + await _handle_chat_message_send(connection_id, message) + return + + if method == "session.update_nickname": + await _handle_chat_nickname_update(connection_id, message) + return + + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=False, + error=_build_error("unsupported_method", f"不支持的聊天方法: {method}"), + ) + + +async def _handle_call(connection_id: str, message: Dict[str, Any]) -> None: + """处理统一调用请求。 + + Args: + connection_id: 连接 ID。 + message: 客户端消息。 + """ + request_id = cast(Optional[str], message.get("id")) + domain = str(message.get("domain") or "").strip() + if domain == "chat": + await _handle_chat_call(connection_id, message) + return + + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=False, + error=_build_error("unsupported_domain", f"不支持的调用域: {domain}"), + ) + + +async def handle_client_message(connection_id: str, message: Dict[str, Any]) -> None: + """处理统一 WebSocket 客户端消息。 + + Args: + connection_id: 连接 ID。 + message: 客户端消息。 + """ + operation = str(message.get("op") or "").strip() + request_id = cast(Optional[str], message.get("id")) + + if operation == "ping": + await websocket_manager.send_pong(connection_id, time.time()) + return + + if operation == "subscribe": + await _handle_subscribe(connection_id, message) + return + + if operation == "unsubscribe": + await _handle_unsubscribe(connection_id, message) + return + + if operation == "call": + await _handle_call(connection_id, message) + return + + await websocket_manager.send_response( + connection_id, + request_id=request_id, + ok=False, + error=_build_error("unsupported_operation", f"不支持的操作: {operation}"), + ) + + +@router.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket, token: Optional[str] = Query(None)) -> None: + """统一 WebSocket 入口。 + + Args: + websocket: FastAPI WebSocket 对象。 + token: 可选的一次性握手 Token。 + """ + if not await authenticate_websocket_connection(websocket, token): + logger.warning("统一 WebSocket 连接被拒绝:认证失败") + await websocket.close(code=4001, reason="认证失败,请重新登录") + return + + connection_id = uuid.uuid4().hex + await websocket_manager.connect(connection_id=connection_id, websocket=websocket) + logger.info(f"统一 WebSocket 客户端已连接: connection={connection_id}") + await websocket_manager.send_event( + connection_id, + domain="system", + event="ready", + data={"connection_id": connection_id, "timestamp": time.time()}, + ) + + try: + while True: + raw_message = await websocket.receive_json() + if not isinstance(raw_message, dict): + await websocket_manager.send_response( + connection_id, + request_id=None, + ok=False, + error=_build_error("invalid_message", "消息必须是 JSON 对象"), + ) + continue + await handle_client_message(connection_id, cast(Dict[str, Any], raw_message)) + except WebSocketDisconnect: + logger.info(f"统一 WebSocket 客户端已断开: connection={connection_id}") + except asyncio.CancelledError: + logger.warning(f"统一 WebSocket 连接处理被取消: connection={connection_id}") + raise + except Exception as exc: + logger.error(f"统一 WebSocket 处理失败: connection={connection_id}, error={exc}", exc_info=True) + finally: + chat_manager.disconnect_connection(connection_id) + await websocket_manager.disconnect(connection_id) + logger.info( + f"统一 WebSocket 连接清理完成: connection={connection_id}, 剩余连接={len(websocket_manager.connections)}", + ) diff --git a/src/webui/routes.py b/src/webui/routes.py new file mode 100644 index 00000000..7c9b002c --- /dev/null +++ b/src/webui/routes.py @@ -0,0 +1,338 @@ +"""WebUI API 路由""" + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel, Field + +from src.common.logger import get_logger +from src.webui.core import ( + check_auth_rate_limit, + clear_auth_cookie, + get_rate_limiter, + get_token_manager, + set_auth_cookie, +) +from src.webui.dependencies import require_auth, verify_token_optional +from src.webui.routers.config import router as config_router +from src.webui.routers.emoji import router as emoji_router +from src.webui.routers.expression import router as expression_router +from src.webui.routers.jargon import router as jargon_router +from src.webui.routers.memory import router as memory_router +from src.webui.routers.model import router as model_router +from src.webui.routers.person import router as person_router +from src.webui.routers.plugin import router as plugin_router +from src.webui.routers.reasoning_process import router as reasoning_process_router +from src.webui.routers.statistics import router as statistics_router +from src.webui.routers.system import router as system_router +from src.webui.routers.websocket.auth import router as ws_auth_router +from src.webui.routers.websocket.unified import router as unified_ws_router + +logger = get_logger("webui.api") + +# 创建路由器 +router = APIRouter(prefix="/api/webui", tags=["WebUI"]) + +# 注册配置管理路由 +router.include_router(config_router) +# 注册统计数据路由 +router.include_router(statistics_router) +# 注册人物信息管理路由 +router.include_router(person_router) +# 注册表达方式管理路由 +router.include_router(expression_router) +# 注册黑话管理路由 +router.include_router(jargon_router) +# 注册表情包管理路由 +router.include_router(emoji_router) +# 注册插件管理路由 +router.include_router(plugin_router) +# 注册系统控制路由 +router.include_router(system_router) +router.include_router(reasoning_process_router) +# 注册模型列表获取路由 +router.include_router(model_router) +# 注册长期记忆管理路由 +router.include_router(memory_router) +# 注册 WebSocket 认证路由 +router.include_router(ws_auth_router) +# 注册统一 WebSocket 路由 +router.include_router(unified_ws_router) + + +class TokenVerifyRequest(BaseModel): + """Token 验证请求""" + + token: str = Field(..., description="访问令牌") + + +class TokenVerifyResponse(BaseModel): + """Token 验证响应""" + + valid: bool = Field(..., description="Token 是否有效") + message: str = Field(..., description="验证结果消息") + is_first_setup: bool = Field(False, description="是否为首次设置") + + +class TokenUpdateRequest(BaseModel): + """Token 更新请求""" + + new_token: str = Field(..., description="新的访问令牌", min_length=10) + + +class TokenUpdateResponse(BaseModel): + """Token 更新响应""" + + success: bool = Field(..., description="是否更新成功") + message: str = Field(..., description="更新结果消息") + + +class TokenRegenerateResponse(BaseModel): + """Token 重新生成响应""" + + success: bool = Field(..., description="是否生成成功") + token: str = Field(..., description="新生成的令牌") + message: str = Field(..., description="生成结果消息") + + +class FirstSetupStatusResponse(BaseModel): + """首次配置状态响应""" + + is_first_setup: bool = Field(..., description="是否为首次配置") + message: str = Field(..., description="状态消息") + + +class CompleteSetupResponse(BaseModel): + """完成配置响应""" + + success: bool = Field(..., description="是否成功") + message: str = Field(..., description="结果消息") + + +class ResetSetupResponse(BaseModel): + """重置配置响应""" + + success: bool = Field(..., description="是否成功") + message: str = Field(..., description="结果消息") + + +@router.get("/health") +async def health_check(): + """健康检查""" + return {"status": "healthy", "service": "MaiBot WebUI"} + + +@router.post("/auth/verify", response_model=TokenVerifyResponse) +async def verify_token( + request_body: TokenVerifyRequest, + request: Request, + response: Response, + _rate_limit: None = Depends(check_auth_rate_limit), +): + """ + 验证访问令牌,验证成功后设置 HttpOnly Cookie + + Args: + request_body: 包含 token 的验证请求 + request: FastAPI Request 对象(用于获取客户端 IP) + response: FastAPI Response 对象 + + Returns: + 验证结果(包含首次配置状态) + """ + try: + token_manager = get_token_manager() + rate_limiter = get_rate_limiter() + + is_valid = token_manager.verify_token(request_body.token) + + if is_valid: + # 认证成功,重置失败计数 + rate_limiter.reset_failures(request) + # 设置 HttpOnly Cookie(传入 request 以检测协议) + set_auth_cookie(response, request_body.token, request) + # 同时返回首次配置状态,避免额外请求 + is_first_setup = token_manager.is_first_setup() + return TokenVerifyResponse(valid=True, message="Token 验证成功", is_first_setup=is_first_setup) + else: + # 记录失败尝试 + blocked, remaining = rate_limiter.record_failed_attempt( + request, + max_failures=5, # 5 次失败 + window_seconds=300, # 5 分钟窗口 + block_duration=600, # 封禁 10 分钟 + ) + + if blocked: + raise HTTPException(status_code=429, detail="认证失败次数过多,您的 IP 已被临时封禁 10 分钟") + + message = "Token 无效或已过期" + if remaining <= 2: + message += f"(剩余 {remaining} 次尝试机会)" + + return TokenVerifyResponse(valid=False, message=message) + except HTTPException: + raise + except Exception as e: + logger.error(f"Token 验证失败: {e}") + raise HTTPException(status_code=500, detail="Token 验证失败") from e + + +@router.post("/auth/logout") +async def logout(response: Response): + """ + 登出并清除认证 Cookie + + Args: + response: FastAPI Response 对象 + + Returns: + 登出结果 + """ + clear_auth_cookie(response) + return {"success": True, "message": "已成功登出"} + + +@router.get("/auth/check") +async def check_auth_status( + authenticated: bool = Depends(verify_token_optional), +): + """ + 检查当前认证状态(用于前端判断是否已登录) + + Returns: + 认证状态 + """ + try: + logger.debug(f"检查认证状态,结果: {authenticated}") + return {"authenticated": authenticated} + except Exception as e: + logger.error(f"认证检查失败: {e}", exc_info=True) + return {"authenticated": False} + + +@router.post("/auth/update", response_model=TokenUpdateResponse, dependencies=[Depends(require_auth)]) +async def update_token( + request: TokenUpdateRequest, + response: Response, +): + """ + 更新访问令牌(需要当前有效的 token) + + Args: + request: 包含新 token 的更新请求 + response: FastAPI Response 对象 + + Returns: + 更新结果 + """ + try: + token_manager = get_token_manager() + + # 更新 token + success, message = token_manager.update_token(request.new_token) + + # 如果更新成功,清除 Cookie,要求用户重新登录 + if success: + clear_auth_cookie(response) + + return TokenUpdateResponse(success=success, message=message) + except HTTPException: + raise + except Exception as e: + logger.error(f"Token 更新失败: {e}") + raise HTTPException(status_code=500, detail="Token 更新失败") from e + + +@router.post("/auth/regenerate", response_model=TokenRegenerateResponse, dependencies=[Depends(require_auth)]) +async def regenerate_token( + response: Response, +): + """ + 重新生成访问令牌(需要当前有效的 token) + + Args: + response: FastAPI Response 对象 + + Returns: + 新生成的 token + """ + try: + token_manager = get_token_manager() + + # 重新生成 token + new_token = token_manager.regenerate_token() + + # 清除 Cookie,要求用户重新登录 + clear_auth_cookie(response) + + return TokenRegenerateResponse(success=True, token=new_token, message="Token 已重新生成") + except HTTPException: + raise + except Exception as e: + logger.error(f"Token 重新生成失败: {e}") + raise HTTPException(status_code=500, detail="Token 重新生成失败") from e + + +@router.get("/setup/status", response_model=FirstSetupStatusResponse, dependencies=[Depends(require_auth)]) +async def get_setup_status(): + """ + 获取首次配置状态 + + Returns: + 首次配置状态 + """ + try: + token_manager = get_token_manager() + + # 检查是否为首次配置 + is_first = token_manager.is_first_setup() + + return FirstSetupStatusResponse(is_first_setup=is_first, message="首次配置" if is_first else "已完成配置") + except HTTPException: + raise + except Exception as e: + logger.error(f"获取配置状态失败: {e}") + raise HTTPException(status_code=500, detail="获取配置状态失败") from e + + +@router.post("/setup/complete", response_model=CompleteSetupResponse, dependencies=[Depends(require_auth)]) +async def complete_setup(): + """ + 标记首次配置完成 + + Returns: + 完成结果 + """ + try: + token_manager = get_token_manager() + + # 标记配置完成 + success = token_manager.mark_setup_completed() + + return CompleteSetupResponse(success=success, message="配置已完成" if success else "标记失败") + except HTTPException: + raise + except Exception as e: + logger.error(f"标记配置完成失败: {e}") + raise HTTPException(status_code=500, detail="标记配置完成失败") from e + + +@router.post("/setup/reset", response_model=ResetSetupResponse, dependencies=[Depends(require_auth)]) +async def reset_setup(): + """ + 重置首次配置状态,允许重新进入配置向导 + + Returns: + 重置结果 + """ + try: + token_manager = get_token_manager() + + # 重置配置状态 + success = token_manager.reset_setup_status() + + return ResetSetupResponse(success=success, message="配置状态已重置" if success else "重置失败") + except HTTPException: + raise + except Exception as e: + logger.error(f"重置配置状态失败: {e}") + raise HTTPException(status_code=500, detail="重置配置状态失败") from e diff --git a/src/webui/schemas/__init__.py b/src/webui/schemas/__init__.py new file mode 100644 index 00000000..8eb337a1 --- /dev/null +++ b/src/webui/schemas/__init__.py @@ -0,0 +1,109 @@ +"""WebUI Schemas - Pydantic models for API requests and responses.""" + +# Auth schemas +from .auth import ( + CompleteSetupResponse, + FirstSetupStatusResponse, + ResetSetupResponse, + TokenRegenerateResponse, + TokenUpdateRequest, + TokenUpdateResponse, + TokenVerifyRequest, + TokenVerifyResponse, +) + +# Chat schemas +from .chat import ( + ChatHistoryMessage, + VirtualIdentityConfig, +) + +# Emoji schemas +from .emoji import ( + BatchDeleteRequest, + BatchDeleteResponse, + EmojiDeleteResponse, + EmojiDetailResponse, + EmojiListResponse, + EmojiResponse, + EmojiUpdateRequest, + EmojiUpdateResponse, + EmojiUploadResponse, + ThumbnailCacheStatsResponse, + ThumbnailCleanupResponse, + ThumbnailPreheatResponse, +) + +# Plugin schemas +from .plugin import ( + AddMirrorRequest, + AvailableMirrorsResponse, + CloneRepositoryRequest, + CloneRepositoryResponse, + FetchRawFileRequest, + FetchRawFileResponse, + GitStatusResponse, + InstallPluginRequest, + MirrorConfigResponse, + UninstallPluginRequest, + UpdateMirrorRequest, + UpdatePluginConfigRequest, + UpdatePluginRequest, + VersionResponse, +) + +# Statistics schemas +from .statistics import ( + DashboardData, + ModelStatistics, + StatisticsSummary, + TimeSeriesData, +) + +__all__ = [ + # Auth + "TokenVerifyRequest", + "TokenVerifyResponse", + "TokenUpdateRequest", + "TokenUpdateResponse", + "TokenRegenerateResponse", + "FirstSetupStatusResponse", + "CompleteSetupResponse", + "ResetSetupResponse", + # Statistics + "StatisticsSummary", + "ModelStatistics", + "TimeSeriesData", + "DashboardData", + # Emoji + "EmojiResponse", + "EmojiListResponse", + "EmojiDetailResponse", + "EmojiUpdateRequest", + "EmojiUpdateResponse", + "EmojiDeleteResponse", + "BatchDeleteRequest", + "BatchDeleteResponse", + "EmojiUploadResponse", + "ThumbnailCacheStatsResponse", + "ThumbnailCleanupResponse", + "ThumbnailPreheatResponse", + # Chat + "VirtualIdentityConfig", + "ChatHistoryMessage", + # Plugin + "FetchRawFileRequest", + "FetchRawFileResponse", + "CloneRepositoryRequest", + "CloneRepositoryResponse", + "MirrorConfigResponse", + "AvailableMirrorsResponse", + "AddMirrorRequest", + "UpdateMirrorRequest", + "GitStatusResponse", + "InstallPluginRequest", + "VersionResponse", + "UninstallPluginRequest", + "UpdatePluginRequest", + "UpdatePluginConfigRequest", +] diff --git a/src/webui/schemas/auth.py b/src/webui/schemas/auth.py new file mode 100644 index 00000000..51e8d662 --- /dev/null +++ b/src/webui/schemas/auth.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel, Field + + +class TokenVerifyRequest(BaseModel): + token: str = Field(..., description="访问令牌") + + +class TokenVerifyResponse(BaseModel): + valid: bool = Field(..., description="Token 是否有效") + message: str = Field(..., description="验证结果消息") + is_first_setup: bool = Field(False, description="是否为首次设置") + + +class TokenUpdateRequest(BaseModel): + new_token: str = Field(..., description="新的访问令牌", min_length=10) + + +class TokenUpdateResponse(BaseModel): + success: bool = Field(..., description="是否更新成功") + message: str = Field(..., description="更新结果消息") + + +class TokenRegenerateResponse(BaseModel): + success: bool = Field(..., description="是否生成成功") + token: str = Field(..., description="新生成的令牌") + message: str = Field(..., description="生成结果消息") + + +class FirstSetupStatusResponse(BaseModel): + is_first_setup: bool = Field(..., description="是否为首次配置") + message: str = Field(..., description="状态消息") + + +class CompleteSetupResponse(BaseModel): + success: bool = Field(..., description="是否成功") + message: str = Field(..., description="结果消息") + + +class ResetSetupResponse(BaseModel): + success: bool = Field(..., description="是否成功") + message: str = Field(..., description="结果消息") diff --git a/src/webui/schemas/chat.py b/src/webui/schemas/chat.py new file mode 100644 index 00000000..899dfe22 --- /dev/null +++ b/src/webui/schemas/chat.py @@ -0,0 +1,27 @@ +from typing import Optional + +from pydantic import BaseModel + + +class VirtualIdentityConfig(BaseModel): + """虚拟身份配置""" + + enabled: bool = False + platform: Optional[str] = None + person_id: Optional[str] = None + user_id: Optional[str] = None + user_nickname: Optional[str] = None + group_id: Optional[str] = None + group_name: Optional[str] = None + + +class ChatHistoryMessage(BaseModel): + """聊天历史消息""" + + id: str + type: str # 'user' | 'bot' | 'system' + content: str + timestamp: float + sender_name: str + sender_id: Optional[str] = None + is_bot: bool = False diff --git a/src/webui/schemas/emoji.py b/src/webui/schemas/emoji.py new file mode 100644 index 00000000..2962e385 --- /dev/null +++ b/src/webui/schemas/emoji.py @@ -0,0 +1,116 @@ +from typing import List, Optional + +from pydantic import BaseModel + + +class EmojiResponse(BaseModel): + """表情包响应""" + + id: int + full_path: str + format: str + emoji_hash: str + description: str + query_count: int + usage_count: int + is_registered: bool + is_banned: bool + emotion: Optional[str] + record_time: float + register_time: Optional[float] + last_used_time: Optional[float] + + +class EmojiListResponse(BaseModel): + """表情包列表响应""" + + success: bool + total: int + page: int + page_size: int + data: List[EmojiResponse] + + +class EmojiDetailResponse(BaseModel): + """表情包详情响应""" + + success: bool + data: EmojiResponse + + +class EmojiUpdateRequest(BaseModel): + """表情包更新请求""" + + description: Optional[str] = None + is_registered: Optional[bool] = None + is_banned: Optional[bool] = None + emotion: Optional[str] = None + + +class EmojiUpdateResponse(BaseModel): + """表情包更新响应""" + + success: bool + message: str + data: Optional[EmojiResponse] = None + + +class EmojiDeleteResponse(BaseModel): + """表情包删除响应""" + + success: bool + message: str + + +class BatchDeleteRequest(BaseModel): + """批量删除请求""" + + emoji_ids: List[int] + + +class BatchDeleteResponse(BaseModel): + """批量删除响应""" + + success: bool + message: str + deleted_count: int + failed_count: int + failed_ids: List[int] = [] + + +class EmojiUploadResponse(BaseModel): + """表情包上传响应""" + + success: bool + message: str + data: Optional[EmojiResponse] = None + + +class ThumbnailCacheStatsResponse(BaseModel): + """缩略图缓存统计响应""" + + success: bool + cache_dir: str + total_count: int + total_size_mb: float + emoji_count: int + coverage_percent: float + + +class ThumbnailCleanupResponse(BaseModel): + """缩略图清理响应""" + + success: bool + message: str + cleaned_count: int + kept_count: int + + +class ThumbnailPreheatResponse(BaseModel): + """缩略图预热响应""" + + success: bool + message: str + generated_count: int + skipped_count: int + failed_count: int diff --git a/src/webui/schemas/plugin.py b/src/webui/schemas/plugin.py new file mode 100644 index 00000000..e2fea32d --- /dev/null +++ b/src/webui/schemas/plugin.py @@ -0,0 +1,136 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class FetchRawFileRequest(BaseModel): + """获取 Raw 文件请求""" + + owner: str = Field(..., description="仓库所有者", example="MaiM-with-u") + repo: str = Field(..., description="仓库名称", example="plugin-repo") + branch: str = Field(..., description="分支名称", example="main") + file_path: str = Field(..., description="文件路径", example="plugin_details.json") + mirror_id: Optional[str] = Field(None, description="指定镜像源 ID") + custom_url: Optional[str] = Field(None, description="自定义完整 URL") + + +class FetchRawFileResponse(BaseModel): + """获取 Raw 文件响应""" + + success: bool = Field(..., description="是否成功") + data: Optional[str] = Field(None, description="文件内容") + error: Optional[str] = Field(None, description="错误信息") + mirror_used: Optional[str] = Field(None, description="使用的镜像源") + attempts: int = Field(..., description="尝试次数") + url: Optional[str] = Field(None, description="实际请求的 URL") + + +class CloneRepositoryRequest(BaseModel): + """克隆仓库请求""" + + owner: str = Field(..., description="仓库所有者", example="MaiM-with-u") + repo: str = Field(..., description="仓库名称", example="plugin-repo") + target_path: str = Field(..., description="目标路径(相对于插件目录)") + branch: Optional[str] = Field(None, description="分支名称", example="main") + mirror_id: Optional[str] = Field(None, description="指定镜像源 ID") + custom_url: Optional[str] = Field(None, description="自定义克隆 URL") + depth: Optional[int] = Field(None, description="克隆深度(浅克隆)", ge=1) + + +class CloneRepositoryResponse(BaseModel): + """克隆仓库响应""" + + success: bool = Field(..., description="是否成功") + path: Optional[str] = Field(None, description="克隆路径") + error: Optional[str] = Field(None, description="错误信息") + mirror_used: Optional[str] = Field(None, description="使用的镜像源") + attempts: int = Field(..., description="尝试次数") + url: Optional[str] = Field(None, description="实际克隆的 URL") + message: Optional[str] = Field(None, description="附加信息") + + +class MirrorConfigResponse(BaseModel): + """镜像源配置响应""" + + id: str = Field(..., description="镜像源 ID") + name: str = Field(..., description="镜像源名称") + raw_prefix: str = Field(..., description="Raw 文件前缀") + clone_prefix: str = Field(..., description="克隆前缀") + enabled: bool = Field(..., description="是否启用") + priority: int = Field(..., description="优先级(数字越小优先级越高)") + + +class AvailableMirrorsResponse(BaseModel): + """可用镜像源列表响应""" + + mirrors: List[MirrorConfigResponse] = Field(..., description="镜像源列表") + default_priority: List[str] = Field(..., description="默认优先级顺序(ID 列表)") + + +class AddMirrorRequest(BaseModel): + """添加镜像源请求""" + + id: str = Field(..., description="镜像源 ID", example="custom-mirror") + name: str = Field(..., description="镜像源名称", example="自定义镜像源") + raw_prefix: str = Field(..., description="Raw 文件前缀", example="https://example.com/raw") + clone_prefix: str = Field(..., description="克隆前缀", example="https://example.com/clone") + enabled: bool = Field(True, description="是否启用") + priority: Optional[int] = Field(None, description="优先级") + + +class UpdateMirrorRequest(BaseModel): + """更新镜像源请求""" + + name: Optional[str] = Field(None, description="镜像源名称") + raw_prefix: Optional[str] = Field(None, description="Raw 文件前缀") + clone_prefix: Optional[str] = Field(None, description="克隆前缀") + enabled: Optional[bool] = Field(None, description="是否启用") + priority: Optional[int] = Field(None, description="优先级") + + +class GitStatusResponse(BaseModel): + """Git 安装状态响应""" + + installed: bool = Field(..., description="是否已安装 Git") + version: Optional[str] = Field(None, description="Git 版本号") + path: Optional[str] = Field(None, description="Git 可执行文件路径") + error: Optional[str] = Field(None, description="错误信息") + + +class InstallPluginRequest(BaseModel): + """安装插件请求""" + + plugin_id: str = Field(..., description="插件 ID") + repository_url: str = Field(..., description="插件仓库 URL") + branch: Optional[str] = Field("main", description="分支名称") + mirror_id: Optional[str] = Field(None, description="指定镜像源 ID") + + +class VersionResponse(BaseModel): + """麦麦版本响应""" + + version: str = Field(..., description="麦麦版本号") + version_major: int = Field(..., description="主版本号") + version_minor: int = Field(..., description="次版本号") + version_patch: int = Field(..., description="补丁版本号") + + +class UninstallPluginRequest(BaseModel): + """卸载插件请求""" + + plugin_id: str = Field(..., description="插件 ID") + + +class UpdatePluginRequest(BaseModel): + """更新插件请求""" + + plugin_id: str = Field(..., description="插件 ID") + repository_url: str = Field(..., description="插件仓库 URL") + branch: Optional[str] = Field("main", description="分支名称") + mirror_id: Optional[str] = Field(None, description="指定镜像源 ID") + + +class UpdatePluginConfigRequest(BaseModel): + """更新插件配置请求""" + + config: Dict[str, Any] = Field(..., description="配置数据") diff --git a/src/webui/schemas/statistics.py b/src/webui/schemas/statistics.py new file mode 100644 index 00000000..36ce36b6 --- /dev/null +++ b/src/webui/schemas/statistics.py @@ -0,0 +1,46 @@ +from typing import Any, Dict, List + +from pydantic import BaseModel, Field + + +class StatisticsSummary(BaseModel): + """统计数据摘要""" + + total_requests: int = Field(0, description="总请求数") + total_cost: float = Field(0.0, description="总花费") + total_tokens: int = Field(0, description="总token数") + online_time: float = Field(0.0, description="在线时间(秒)") + total_messages: int = Field(0, description="总消息数") + total_replies: int = Field(0, description="总回复数") + avg_response_time: float = Field(0.0, description="平均响应时间") + cost_per_hour: float = Field(0.0, description="每小时花费") + tokens_per_hour: float = Field(0.0, description="每小时token数") + + +class ModelStatistics(BaseModel): + """模型统计""" + + model_name: str + request_count: int + total_cost: float + total_tokens: int + avg_response_time: float + + +class TimeSeriesData(BaseModel): + """时间序列数据""" + + timestamp: str + requests: int = 0 + cost: float = 0.0 + tokens: int = 0 + + +class DashboardData(BaseModel): + """仪表盘数据""" + + summary: StatisticsSummary + model_stats: List[ModelStatistics] + hourly_data: List[TimeSeriesData] + daily_data: List[TimeSeriesData] + recent_activity: List[Dict[str, Any]] diff --git a/src/webui/services/__init__.py b/src/webui/services/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/webui/services/__init__.py @@ -0,0 +1 @@ + diff --git a/src/webui/services/git_mirror_service.py b/src/webui/services/git_mirror_service.py new file mode 100644 index 00000000..b40e1c3f --- /dev/null +++ b/src/webui/services/git_mirror_service.py @@ -0,0 +1,720 @@ +"""Git 镜像源服务 - 支持多镜像源、错误重试、Git 克隆和 Raw 文件获取""" + +import asyncio +import json +import shutil +import subprocess +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any, Dict, List, Optional + +import httpx + +from src.common.logger import get_logger +from src.webui.utils.network_security import validate_public_url + +logger = get_logger("webui.git_mirror") + +# 导入进度更新函数(避免循环导入) +_update_progress = None + + +def _validate_mirror_prefix(url: str, field_name: str) -> str: + try: + return validate_public_url(url) + except ValueError as e: + raise ValueError(f"{field_name} 非法: {e}") from e + + +def _validate_custom_outbound_url(url: str) -> str: + try: + return validate_public_url(url) + except ValueError as e: + raise ValueError(f"目标 URL 非法: {e}") from e + + +def set_update_progress_callback(callback): + """设置进度更新回调函数""" + global _update_progress + _update_progress = callback + + +class MirrorType(str, Enum): + """镜像源类型""" + + GH_PROXY = "gh-proxy" # gh-proxy 主节点 + HK_GH_PROXY = "hk-gh-proxy" # gh-proxy 香港节点 + CDN_GH_PROXY = "cdn-gh-proxy" # gh-proxy CDN 节点 + EDGEONE_GH_PROXY = "edgeone-gh-proxy" # gh-proxy EdgeOne 节点 + MEYZH_GITHUB = "meyzh-github" # Meyzh GitHub 镜像 + GITHUB = "github" # GitHub 官方源(兜底) + CUSTOM = "custom" # 自定义镜像源 + + +class GitMirrorConfig: + """Git 镜像源配置管理""" + + # 配置文件路径 + CONFIG_FILE = Path("data/webui.json") + + # 默认镜像源配置 + DEFAULT_MIRRORS = [ + { + "id": "gh-proxy", + "name": "gh-proxy 镜像", + "raw_prefix": "https://gh-proxy.org/https://raw.githubusercontent.com", + "clone_prefix": "https://gh-proxy.org/https://github.com", + "enabled": True, + "priority": 1, + "created_at": None, + }, + { + "id": "hk-gh-proxy", + "name": "gh-proxy 香港节点", + "raw_prefix": "https://hk.gh-proxy.org/https://raw.githubusercontent.com", + "clone_prefix": "https://hk.gh-proxy.org/https://github.com", + "enabled": True, + "priority": 2, + "created_at": None, + }, + { + "id": "cdn-gh-proxy", + "name": "gh-proxy CDN 节点", + "raw_prefix": "https://cdn.gh-proxy.org/https://raw.githubusercontent.com", + "clone_prefix": "https://cdn.gh-proxy.org/https://github.com", + "enabled": True, + "priority": 3, + "created_at": None, + }, + { + "id": "edgeone-gh-proxy", + "name": "gh-proxy EdgeOne 节点", + "raw_prefix": "https://edgeone.gh-proxy.org/https://raw.githubusercontent.com", + "clone_prefix": "https://edgeone.gh-proxy.org/https://github.com", + "enabled": True, + "priority": 4, + "created_at": None, + }, + { + "id": "meyzh-github", + "name": "Meyzh GitHub 镜像", + "raw_prefix": "https://meyzh.github.io/https://raw.githubusercontent.com", + "clone_prefix": "https://meyzh.github.io/https://github.com", + "enabled": True, + "priority": 5, + "created_at": None, + }, + { + "id": "github", + "name": "GitHub 官方源(兜底)", + "raw_prefix": "https://raw.githubusercontent.com", + "clone_prefix": "https://github.com", + "enabled": True, + "priority": 999, + "created_at": None, + }, + ] + + def __init__(self): + """初始化配置管理器""" + self.config_file = self.CONFIG_FILE + self.mirrors: List[Dict[str, Any]] = [] + self._load_config() + + def _load_config(self) -> None: + """加载配置文件""" + try: + if self.config_file.exists(): + with open(self.config_file, "r", encoding="utf-8") as f: + data = json.load(f) + + # 检查是否有镜像源配置 + if "git_mirrors" not in data or not data["git_mirrors"]: + logger.info("配置文件中未找到镜像源配置,使用默认配置") + self._init_default_mirrors() + else: + self.mirrors = data["git_mirrors"] + logger.info(f"已加载 {len(self.mirrors)} 个镜像源配置") + else: + logger.info("配置文件不存在,创建默认配置") + self._init_default_mirrors() + except Exception as e: + logger.error(f"加载配置文件失败: {e}") + self._init_default_mirrors() + + def _init_default_mirrors(self) -> None: + """初始化默认镜像源""" + current_time = datetime.now().isoformat() + self.mirrors = [] + + for mirror in self.DEFAULT_MIRRORS: + mirror_copy = mirror.copy() + mirror_copy["created_at"] = current_time + self.mirrors.append(mirror_copy) + + self._save_config() + logger.info(f"已初始化 {len(self.mirrors)} 个默认镜像源") + + def _save_config(self) -> None: + """保存配置到文件""" + try: + # 确保目录存在 + self.config_file.parent.mkdir(parents=True, exist_ok=True) + + # 读取现有配置 + existing_data = {} + if self.config_file.exists(): + with open(self.config_file, "r", encoding="utf-8") as f: + existing_data = json.load(f) + + # 更新镜像源配置 + existing_data["git_mirrors"] = self.mirrors + + # 写入文件 + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump(existing_data, f, indent=2, ensure_ascii=False) + + logger.debug(f"配置已保存到 {self.config_file}") + except Exception as e: + logger.error(f"保存配置文件失败: {e}") + + def get_all_mirrors(self) -> List[Dict[str, Any]]: + """获取所有镜像源""" + return self.mirrors.copy() + + def get_enabled_mirrors(self) -> List[Dict[str, Any]]: + """获取所有启用的镜像源,按优先级排序""" + enabled = [m for m in self.mirrors if m.get("enabled", False)] + return sorted(enabled, key=lambda x: x.get("priority", 999)) + + def get_mirror_by_id(self, mirror_id: str) -> Optional[Dict[str, Any]]: + """根据 ID 获取镜像源""" + matched_mirror = next((mirror for mirror in self.mirrors if mirror.get("id") == mirror_id), None) + return matched_mirror.copy() if matched_mirror is not None else None + + def add_mirror( + self, + mirror_id: str, + name: str, + raw_prefix: str, + clone_prefix: str, + enabled: bool = True, + priority: Optional[int] = None, + ) -> Dict[str, Any]: + """ + 添加新的镜像源 + + Returns: + 添加的镜像源配置 + + Raises: + ValueError: 如果镜像源 ID 已存在 + """ + # 检查 ID 是否已存在 + if self.get_mirror_by_id(mirror_id): + raise ValueError(f"镜像源 ID 已存在: {mirror_id}") + + raw_prefix = _validate_mirror_prefix(raw_prefix, "Raw 前缀") + clone_prefix = _validate_mirror_prefix(clone_prefix, "克隆前缀") + + # 如果未指定优先级,使用最大优先级 + 1 + if priority is None: + max_priority = max((m.get("priority", 0) for m in self.mirrors), default=0) + priority = max_priority + 1 + + new_mirror = { + "id": mirror_id, + "name": name, + "raw_prefix": raw_prefix, + "clone_prefix": clone_prefix, + "enabled": enabled, + "priority": priority, + "created_at": datetime.now().isoformat(), + } + + self.mirrors.append(new_mirror) + self._save_config() + + logger.info(f"已添加镜像源: {mirror_id} - {name}") + return new_mirror.copy() + + def update_mirror( + self, + mirror_id: str, + name: Optional[str] = None, + raw_prefix: Optional[str] = None, + clone_prefix: Optional[str] = None, + enabled: Optional[bool] = None, + priority: Optional[int] = None, + ) -> Optional[Dict[str, Any]]: + """ + 更新镜像源配置 + + Returns: + 更新后的镜像源配置,如果不存在则返回 None + """ + for mirror in self.mirrors: + if mirror.get("id") == mirror_id: + if name is not None: + mirror["name"] = name + if raw_prefix is not None: + raw_prefix = _validate_mirror_prefix(raw_prefix, "Raw 前缀") + mirror["raw_prefix"] = raw_prefix + if clone_prefix is not None: + clone_prefix = _validate_mirror_prefix(clone_prefix, "克隆前缀") + mirror["clone_prefix"] = clone_prefix + if enabled is not None: + mirror["enabled"] = enabled + if priority is not None: + mirror["priority"] = priority + + mirror["updated_at"] = datetime.now().isoformat() + self._save_config() + + logger.info(f"已更新镜像源: {mirror_id}") + return mirror.copy() + + return None + + def delete_mirror(self, mirror_id: str) -> bool: + """ + 删除镜像源 + + Returns: + True 如果删除成功,False 如果镜像源不存在 + """ + for i, mirror in enumerate(self.mirrors): + if mirror.get("id") == mirror_id: + self.mirrors.pop(i) + self._save_config() + logger.info(f"已删除镜像源: {mirror_id}") + return True + + return False + + def get_default_priority_list(self) -> List[str]: + """获取默认优先级列表(仅启用的镜像源 ID)""" + enabled = self.get_enabled_mirrors() + return [m["id"] for m in enabled] + + +class GitMirrorService: + """Git 镜像源服务""" + + def __init__(self, max_retries: int = 3, timeout: int = 30, config: Optional[GitMirrorConfig] = None): + """ + 初始化 Git 镜像源服务 + + Args: + max_retries: 最大重试次数 + timeout: 请求超时时间(秒) + config: 镜像源配置管理器(可选,默认创建新实例) + """ + self.max_retries = max_retries + self.timeout = timeout + self.config = config or GitMirrorConfig() + logger.info(f"Git镜像源服务初始化完成,已加载 {len(self.config.get_enabled_mirrors())} 个启用的镜像源") + + def get_mirror_config(self) -> GitMirrorConfig: + """获取镜像源配置管理器""" + return self.config + + @staticmethod + def check_git_installed() -> Dict[str, Any]: + """ + 检查本机是否安装了 Git + + Returns: + Dict 包含: + - installed: bool - 是否已安装 Git + - version: str - Git 版本号(如果已安装) + - path: str - Git 可执行文件路径(如果已安装) + - error: str - 错误信息(如果未安装或检测失败) + """ + import shutil + import subprocess + + try: + # 查找 git 可执行文件路径 + git_path = shutil.which("git") + + if not git_path: + logger.warning("未找到 Git 可执行文件") + return {"installed": False, "error": "系统中未找到 Git,请先安装 Git"} + + # 获取 Git 版本 + result = subprocess.run(["git", "--version"], capture_output=True, text=True, timeout=5) + + if result.returncode == 0: + version = result.stdout.strip() + logger.info(f"检测到 Git: {version} at {git_path}") + return {"installed": True, "version": version, "path": git_path} + else: + logger.warning(f"Git 命令执行失败: {result.stderr}") + return {"installed": False, "error": f"Git 命令执行失败: {result.stderr}"} + + except subprocess.TimeoutExpired: + logger.error("Git 版本检测超时") + return {"installed": False, "error": "Git 版本检测超时"} + except Exception as e: + logger.error(f"检测 Git 时发生错误: {e}") + return {"installed": False, "error": f"检测 Git 时发生错误: {str(e)}"} + + async def fetch_raw_file( + self, + owner: str, + repo: str, + branch: str, + file_path: str, + mirror_id: Optional[str] = None, + custom_url: Optional[str] = None, + ) -> Dict[str, Any]: + """ + 获取 GitHub 仓库的 Raw 文件内容 + + Args: + owner: 仓库所有者 + repo: 仓库名称 + branch: 分支名称 + file_path: 文件路径 + mirror_id: 指定的镜像源 ID + custom_url: 自定义完整 URL(如果提供,将忽略其他参数) + + Returns: + Dict 包含: + - success: bool - 是否成功 + - data: str - 文件内容(成功时) + - error: str - 错误信息(失败时) + - mirror_used: str - 使用的镜像源 + - attempts: int - 尝试次数 + """ + logger.info(f"开始获取 Raw 文件: {owner}/{repo}/{branch}/{file_path}") + + if custom_url: + try: + custom_url = _validate_custom_outbound_url(custom_url) + except ValueError as e: + return { + "success": False, + "error": str(e), + "mirror_used": "custom", + "attempts": 0, + "url": custom_url, + "status_code": 400, + } + + return await self._fetch_with_url(custom_url, "custom") + + # 确定要使用的镜像源列表 + if mirror_id: + # 使用指定的镜像源 + if (mirror := self.config.get_mirror_by_id(mirror_id)) is None: + return {"success": False, "error": f"未找到镜像源: {mirror_id}", "mirror_used": None, "attempts": 0} + mirrors_to_try = [mirror] + else: + # 使用所有启用的镜像源 + mirrors_to_try = self.config.get_enabled_mirrors() + + total_mirrors = len(mirrors_to_try) + + # 依次尝试每个镜像源 + for index, mirror in enumerate(mirrors_to_try, 1): + # 推送进度:正在尝试第 N 个镜像源 + if _update_progress: + try: + progress = 30 + int((index - 1) / total_mirrors * 40) # 30% - 70% + await _update_progress( + stage="loading", + progress=progress, + message=f"正在尝试镜像源 {index}/{total_mirrors}: {mirror['name']}", + total_plugins=0, + loaded_plugins=0, + ) + except Exception as e: + logger.warning(f"推送进度失败: {e}") + + result = await self._fetch_raw_from_mirror(owner, repo, branch, file_path, mirror) + + if result["success"]: + # 成功,推送进度 + if _update_progress: + try: + await _update_progress( + stage="loading", + progress=70, + message=f"成功从 {mirror['name']} 获取数据", + total_plugins=0, + loaded_plugins=0, + ) + except Exception as e: + logger.warning(f"推送进度失败: {e}") + return result + + # 失败,记录日志并推送失败信息 + logger.warning(f"镜像源 {mirror['id']} 失败: {result.get('error')}") + + if _update_progress and index < total_mirrors: + try: + await _update_progress( + stage="loading", + progress=30 + int(index / total_mirrors * 40), + message=f"镜像源 {mirror['name']} 失败,尝试下一个...", + total_plugins=0, + loaded_plugins=0, + ) + except Exception as e: + logger.warning(f"推送进度失败: {e}") + + # 所有镜像源都失败 + return {"success": False, "error": "所有镜像源均失败", "mirror_used": None, "attempts": len(mirrors_to_try)} + + async def _fetch_raw_from_mirror( + self, owner: str, repo: str, branch: str, file_path: str, mirror: Dict[str, Any] + ) -> Dict[str, Any]: + """从指定镜像源获取文件""" + try: + raw_prefix = _validate_mirror_prefix(mirror["raw_prefix"], "镜像 Raw 前缀") + except ValueError as e: + return { + "success": False, + "error": str(e), + "mirror_used": mirror.get("id"), + "attempts": 0, + "status_code": 400, + } + + url = f"{raw_prefix}/{owner}/{repo}/{branch}/{file_path}" + + return await self._fetch_with_url(url, mirror["id"]) + + async def _fetch_with_url(self, url: str, mirror_type: str) -> Dict[str, Any]: + """使用指定 URL 获取文件,支持重试""" + attempts = 0 + last_error = None + + for attempt in range(self.max_retries): + attempts += 1 + try: + logger.debug(f"尝试 #{attempt + 1}: {url}") + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get(url) + response.raise_for_status() + + logger.info(f"成功获取文件: {url}") + return { + "success": True, + "data": response.text, + "mirror_used": mirror_type, + "attempts": attempts, + "url": url, + } + except httpx.HTTPStatusError as e: + last_error = f"HTTP {e.response.status_code}: {e}" + logger.warning(f"HTTP 错误 (尝试 {attempt + 1}/{self.max_retries}): {last_error}") + except httpx.TimeoutException as e: + last_error = f"请求超时: {e}" + logger.warning(f"超时 (尝试 {attempt + 1}/{self.max_retries}): {last_error}") + except Exception as e: + last_error = f"未知错误: {e}" + logger.error(f"错误 (尝试 {attempt + 1}/{self.max_retries}): {last_error}") + + return {"success": False, "error": last_error, "mirror_used": mirror_type, "attempts": attempts, "url": url} + + async def clone_repository( + self, + owner: str, + repo: str, + target_path: Path, + branch: Optional[str] = None, + mirror_id: Optional[str] = None, + custom_url: Optional[str] = None, + depth: Optional[int] = None, + ) -> Dict[str, Any]: + """ + 克隆 GitHub 仓库 + + Args: + owner: 仓库所有者 + repo: 仓库名称 + target_path: 目标路径 + branch: 分支名称(可选) + mirror_id: 指定的镜像源 ID + custom_url: 自定义克隆 URL + depth: 克隆深度(浅克隆) + + Returns: + Dict 包含: + - success: bool - 是否成功 + - path: str - 克隆路径(成功时) + - error: str - 错误信息(失败时) + - mirror_used: str - 使用的镜像源 + - attempts: int - 尝试次数 + """ + logger.info(f"开始克隆仓库: {owner}/{repo} 到 {target_path}") + + if custom_url: + try: + custom_url = _validate_custom_outbound_url(custom_url) + except ValueError as e: + return { + "success": False, + "error": str(e), + "mirror_used": "custom", + "attempts": 0, + "url": custom_url, + "status_code": 400, + } + + return await self._clone_with_url(custom_url, target_path, branch, depth, "custom") + + # 确定要使用的镜像源列表 + if mirror_id: + # 使用指定的镜像源 + if (mirror := self.config.get_mirror_by_id(mirror_id)) is None: + return {"success": False, "error": f"未找到镜像源: {mirror_id}", "mirror_used": None, "attempts": 0} + mirrors_to_try = [mirror] + else: + # 使用所有启用的镜像源 + mirrors_to_try = self.config.get_enabled_mirrors() + + # 依次尝试每个镜像源 + for mirror in mirrors_to_try: + result = await self._clone_from_mirror(owner, repo, target_path, branch, depth, mirror) + if result["success"]: + return result + logger.warning(f"镜像源 {mirror['id']} 克隆失败: {result.get('error')}") + + # 所有镜像源都失败 + return {"success": False, "error": "所有镜像源克隆均失败", "mirror_used": None, "attempts": len(mirrors_to_try)} + + async def _clone_from_mirror( + self, + owner: str, + repo: str, + target_path: Path, + branch: Optional[str], + depth: Optional[int], + mirror: Dict[str, Any], + ) -> Dict[str, Any]: + """从指定镜像源克隆仓库""" + try: + clone_prefix = _validate_mirror_prefix(mirror["clone_prefix"], "镜像克隆前缀") + except ValueError as e: + return { + "success": False, + "error": str(e), + "mirror_used": mirror.get("id"), + "attempts": 0, + "status_code": 400, + } + + url = f"{clone_prefix}/{owner}/{repo}.git" + + return await self._clone_with_url(url, target_path, branch, depth, mirror["id"]) + + async def _clone_with_url( + self, url: str, target_path: Path, branch: Optional[str], depth: Optional[int], mirror_type: str + ) -> Dict[str, Any]: + """使用指定 URL 克隆仓库,支持重试""" + attempts = 0 + last_error = None + + for attempt in range(self.max_retries): + attempts += 1 + + try: + # 确保目标路径不存在 + if target_path.exists(): + logger.warning(f"目标路径已存在,删除: {target_path}") + shutil.rmtree(target_path, ignore_errors=True) + + # 构建 git clone 命令 + cmd = ["git", "clone"] + + # 添加分支参数 + if branch: + cmd.extend(["-b", branch]) + + # 添加深度参数(浅克隆) + if depth: + cmd.extend(["--depth", str(depth)]) + + # 添加 URL 和目标路径 + cmd.extend([url, str(target_path)]) + + logger.info(f"尝试克隆 #{attempt + 1}: {' '.join(cmd)}") + + # 推送进度 + if _update_progress: + try: + await _update_progress( + stage="loading", + progress=20 + attempt * 10, + message=f"正在克隆仓库 (尝试 {attempt + 1}/{self.max_retries})...", + operation="install", + ) + except Exception as e: + logger.warning(f"推送进度失败: {e}") + + # 执行 git clone(在线程池中运行以避免阻塞) + loop = asyncio.get_event_loop() + + def run_git_clone(clone_cmd=cmd): + return subprocess.run( + clone_cmd, + capture_output=True, + text=True, + timeout=300, # 5分钟超时 + ) + + process = await loop.run_in_executor(None, run_git_clone) + + if process.returncode == 0: + logger.info(f"成功克隆仓库: {url} -> {target_path}") + return { + "success": True, + "path": str(target_path), + "mirror_used": mirror_type, + "attempts": attempts, + "url": url, + "branch": branch or "default", + } + else: + last_error = f"Git 克隆失败: {process.stderr}" + logger.warning(f"克隆失败 (尝试 {attempt + 1}/{self.max_retries}): {last_error}") + + except subprocess.TimeoutExpired: + last_error = "克隆超时(超过 5 分钟)" + logger.warning(f"克隆超时 (尝试 {attempt + 1}/{self.max_retries})") + + # 清理可能的部分克隆 + if target_path.exists(): + shutil.rmtree(target_path, ignore_errors=True) + + except FileNotFoundError: + last_error = "Git 未安装或不在 PATH 中" + logger.error(f"Git 未找到: {last_error}") + break # Git 不存在,不需要重试 + + except Exception as e: + last_error = f"未知错误: {e}" + logger.error(f"克隆错误 (尝试 {attempt + 1}/{self.max_retries}): {last_error}") + + # 清理可能的部分克隆 + if target_path.exists(): + shutil.rmtree(target_path, ignore_errors=True) + + return {"success": False, "error": last_error, "mirror_used": mirror_type, "attempts": attempts, "url": url} + + +# 全局服务实例 +_git_mirror_service: Optional[GitMirrorService] = None + + +def get_git_mirror_service() -> GitMirrorService: + """获取 Git 镜像源服务实例(单例)""" + global _git_mirror_service + if _git_mirror_service is None: + _git_mirror_service = GitMirrorService() + return _git_mirror_service diff --git a/src/webui/utils/__init__.py b/src/webui/utils/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/webui/utils/__init__.py @@ -0,0 +1 @@ + diff --git a/src/webui/utils/network_security.py b/src/webui/utils/network_security.py new file mode 100644 index 00000000..9d1577d1 --- /dev/null +++ b/src/webui/utils/network_security.py @@ -0,0 +1,74 @@ +import ipaddress +import socket +from typing import Iterable, Set +from urllib.parse import urlparse + + +def _resolve_ip_addresses(hostname: str, port: int) -> Set[ipaddress.IPv4Address | ipaddress.IPv6Address]: + try: + address_infos = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM) + except socket.gaierror as exc: + raise ValueError(f"无法解析主机名: {hostname}") from exc + + resolved_addresses: Set[ipaddress.IPv4Address | ipaddress.IPv6Address] = set() + for _, _, _, _, sockaddr in address_infos: + host_address = sockaddr[0] + if not isinstance(host_address, str): + continue + + raw_ip = host_address.split("%", 1)[0] + resolved_addresses.add(ipaddress.ip_address(raw_ip)) + + if not resolved_addresses: + raise ValueError(f"无法解析主机名: {hostname}") + + return resolved_addresses + + +def _is_forbidden_ip_address(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + return any( + ( + address.is_loopback, + address.is_link_local, + address.is_multicast, + address.is_reserved, + address.is_unspecified, + ) + ) + + +def validate_public_url(url: str, allowed_schemes: Iterable[str] = ("http", "https")) -> str: + normalized_url = url.strip() + if not normalized_url: + raise ValueError("URL 不能为空") + + if "://" not in normalized_url: + normalized_url = "http://" + normalized_url + parsed = urlparse(normalized_url) + allowed_scheme_set = {scheme.lower() for scheme in allowed_schemes} + if parsed.scheme.lower() not in allowed_scheme_set: + allowed = ", ".join(sorted(allowed_scheme_set)) + raise ValueError(f"仅允许以下协议: {allowed}") + + if not parsed.hostname or not parsed.netloc: + raise ValueError("URL 缺少有效的主机名") + + if parsed.username or parsed.password: + raise ValueError("URL 不允许内嵌认证信息") + + if parsed.fragment: + raise ValueError("URL 不允许包含片段") + + if parsed.hostname.lower() in {"localhost", "localhost.localdomain"}: + raise ValueError("不允许访问本地主机") + + try: + port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80) + except ValueError as exc: + raise ValueError("URL 端口非法") from exc + + for address in _resolve_ip_addresses(parsed.hostname, port): + if _is_forbidden_ip_address(address): + raise ValueError(f"禁止访问非公网地址: {address}") + + return normalized_url diff --git a/src/webui/utils/toml_utils.py b/src/webui/utils/toml_utils.py new file mode 100644 index 00000000..ecabdf0b --- /dev/null +++ b/src/webui/utils/toml_utils.py @@ -0,0 +1,105 @@ +""" +TOML 工具函数 + +提供 TOML 文件的格式化保存功能,确保数组等元素以美观的多行格式输出。 +""" + +import re +from typing import Any + +import tomlkit +from tomlkit.items import AoT, Array, Table + + +def _format_toml_value(obj: Any, threshold: int, depth: int = 0) -> Any: + """递归格式化 TOML 值,将数组转换为多行格式""" + # 处理 AoT (Array of Tables) - 保持原样,递归处理内部 + if isinstance(obj, AoT): + for item in obj: + _format_toml_value(item, threshold, depth) + return obj + + # 处理字典类型 (dict 或 Table) + if isinstance(obj, (dict, Table)): + for k, v in obj.items(): + obj[k] = _format_toml_value(v, threshold, depth) + return obj + + # 处理列表类型 (list 或 Array) + if isinstance(obj, (list, Array)): + if isinstance(obj, list) and not isinstance(obj, Array) and obj and isinstance(obj[0], (dict, Table)): + for i, item in enumerate(obj): + obj[i] = _format_toml_value(item, threshold, depth) + return obj + + should_multiline = depth == 0 and len(obj) > threshold + + if isinstance(obj, Array): + obj.multiline(should_multiline) + for i, item in enumerate(obj): + obj[i] = _format_toml_value(item, threshold, depth + 1) + return obj + + arr = tomlkit.array() + arr.multiline(should_multiline) + + for item in obj: + arr.append(_format_toml_value(item, threshold, depth + 1)) + return arr + + return obj + + +def _update_toml_doc(target: Any, source: Any) -> None: + """ + 递归合并字典,将 source 的值更新到 target 中,保留 target 的注释和格式。 + """ + if isinstance(source, list) or not isinstance(source, dict) or not isinstance(target, dict): + return + + for key, value in source.items(): + if key == "version": + continue + if key in target: + target_value = target[key] + if isinstance(value, dict) and isinstance(target_value, dict): + _update_toml_doc(target_value, value) + else: + try: + target[key] = tomlkit.item(value) + except (TypeError, ValueError): + target[key] = value + else: + try: + target[key] = tomlkit.item(value) + except (TypeError, ValueError): + target[key] = value + + +def save_toml_with_format( + data: Any, file_path: str, multiline_threshold: int = 1, preserve_comments: bool = True +) -> None: + """ + 格式化 TOML 数据并保存到文件。 + + Args: + data: 要保存的数据(dict 或 tomlkit 文档) + file_path: 保存路径 + multiline_threshold: 数组多行格式化阈值,-1 表示不格式化 + preserve_comments: 是否保留原文件的注释和格式 + """ + import os + + from tomlkit import TOMLDocument + + if preserve_comments and os.path.exists(file_path) and not isinstance(data, TOMLDocument): + with open(file_path, "r", encoding="utf-8") as f: + doc = tomlkit.load(f) + _update_toml_doc(doc, data) + data = doc + + formatted = _format_toml_value(data, multiline_threshold) if multiline_threshold >= 0 else data + output = tomlkit.dumps(formatted) + output = re.sub(r"\n{3,}", "\n\n", output) + with open(file_path, "w", encoding="utf-8") as f: + f.write(output) diff --git a/src/webui/webui_server.py b/src/webui/webui_server.py new file mode 100644 index 00000000..155ef2b7 --- /dev/null +++ b/src/webui/webui_server.py @@ -0,0 +1,162 @@ +"""独立的 WebUI 服务器。""" + +from typing import Any, Optional + +import asyncio +import sys + +from uvicorn import Config +from uvicorn import Server as UvicornServer + +from src.common.logger import get_logger +from src.common.utils.port_checker import assert_port_available, is_port_conflict_error, log_port_conflict +from src.config.startup_bindings import resolve_webui_bind_address +from src.webui.app import create_app, show_access_token + +logger = get_logger("webui_server") + + +def _get_loaded_config_manager() -> Optional[Any]: + config_module = sys.modules.get("src.config.config") + if config_module is None: + return None + return getattr(config_module, "config_manager", None) + + +class _ASGIProxy: + def __init__(self, app): + self._app = app + + def set_app(self, app) -> None: + self._app = app + + async def __call__(self, scope, receive, send): + await self._app(scope, receive, send) + + +class WebUIServer: + """独立的 WebUI 服务器""" + + def __init__(self, host: str = "0.0.0.0", port: int = 8001): + self.host = host + self.port = port + self._app = create_app(host=host, port=port, enable_static=True) + self.app = _ASGIProxy(self._app) + self._server: Optional[UvicornServer] = None + self._reload_callback_registered = False + + show_access_token() + self._maybe_register_reload_callback() + + def _maybe_register_reload_callback(self) -> None: + if self._reload_callback_registered: + return + + config_manager = _get_loaded_config_manager() + if config_manager is None: + return + + config_manager.register_reload_callback(self.reload_app) + self._reload_callback_registered = True + + def _maybe_unregister_reload_callback(self) -> None: + if not self._reload_callback_registered: + return + + config_manager = _get_loaded_config_manager() + if config_manager is None: + return + + config_manager.unregister_reload_callback(self.reload_app) + self._reload_callback_registered = False + + async def reload_app(self) -> None: + self._app = create_app(host=self.host, port=self.port, enable_static=True) + self.app.set_app(self._app) + logger.info("WebUI 应用已热重载") + + async def start(self): + """启动服务器""" + self._maybe_register_reload_callback() + assert_port_available( + host=self.host, + port=self.port, + service_name="WebUI 服务器", + logger=logger, + config_hint="webui.port (config/bot_config.toml)", + allow_reuse_addr=True, + ) + + config = Config( + app=self.app, + host=self.host, + port=self.port, + log_config=None, + access_log=False, + ) + self._server = UvicornServer(config=config) + + logger.info("🌐 WebUI 服务器启动中...") + + # 根据地址类型显示正确的访问地址 + if ":" in self.host: + # IPv6 地址需要用方括号包裹 + logger.info(f"🌐 访问地址: http://[{self.host}]:{self.port}") + if self.host == "::": + logger.info(f"💡 IPv6 本机访问: http://[::1]:{self.port}") + logger.info(f"💡 IPv4 本机访问: http://127.0.0.1:{self.port}") + elif self.host == "::1": + logger.info("💡 仅支持 IPv6 本地访问") + else: + # IPv4 地址 + logger.info(f"🌐 访问地址: http://{self.host}:{self.port}") + if self.host == "0.0.0.0": + logger.info(f"💡 本机访问: http://localhost:{self.port} 或 http://127.0.0.1:{self.port}") + + try: + await self._server.serve() + except OSError as e: + if is_port_conflict_error(e): + log_port_conflict( + logger, + service_name="WebUI 服务器", + host=self.host, + port=self.port, + config_hint="webui.port (config/bot_config.toml)", + ) + else: + logger.error(f"❌ WebUI 服务器启动失败 (网络错误): {e}") + raise + except Exception as e: + logger.error(f"❌ WebUI 服务器运行错误: {e}", exc_info=True) + raise + finally: + self._maybe_unregister_reload_callback() + + async def shutdown(self): + """关闭服务器""" + if self._server: + logger.info("正在关闭 WebUI 服务器...") + self._server.should_exit = True + try: + await asyncio.wait_for(self._server.shutdown(), timeout=3.0) + logger.info("✅ WebUI 服务器已关闭") + except asyncio.TimeoutError: + logger.warning("⚠️ WebUI 服务器关闭超时") + except Exception as e: + logger.error(f"❌ WebUI 服务器关闭失败: {e}") + finally: + self._server = None + + +# 全局 WebUI 服务器实例 +_webui_server = None + + +def get_webui_server() -> WebUIServer: + """获取全局 WebUI 服务器实例""" + global _webui_server + if _webui_server is None: + bind_address = resolve_webui_bind_address() + _webui_server = WebUIServer(host=bind_address.host, port=bind_address.port) + return _webui_server diff --git a/tests/test_config_upgrade_hooks.py b/tests/test_config_upgrade_hooks.py new file mode 100644 index 00000000..44bafaf6 --- /dev/null +++ b/tests/test_config_upgrade_hooks.py @@ -0,0 +1,76 @@ +from pathlib import Path + +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from src.config.config_upgrade_hooks import ( + BOT_CONFIG_UPGRADE_HOOKS, + ConfigUpgradeHook, + apply_config_upgrade_hooks, + set_nested_config_value, +) +from src.config.official_configs import ChatConfig + +import src.config.config_upgrade_hooks as hooks + + +def test_apply_config_upgrade_hooks_runs_when_target_version_is_crossed(monkeypatch): + def migrate(data): + changed = set_nested_config_value(data, ("chat", "enable"), False) + return ["chat.enable"] if changed else [] + + monkeypatch.setattr( + hooks, + "BOT_CONFIG_UPGRADE_HOOKS", + (ConfigUpgradeHook(target_version="8.10.11", config_names=("bot_config.toml",), migrate=migrate),), + ) + + data = {"chat": {"enable": True}} + result = apply_config_upgrade_hooks(data, "bot_config.toml", "8.10.10", "8.10.11") + + assert result.migrated is True + assert result.reason == "8.10.11:chat.enable" + assert result.data["chat"]["enable"] is False + + +def test_apply_config_upgrade_hooks_skips_versions_outside_upgrade_range(monkeypatch): + def migrate(data): + set_nested_config_value(data, ("chat", "enable"), False) + return ["chat.enable"] + + monkeypatch.setattr( + hooks, + "BOT_CONFIG_UPGRADE_HOOKS", + (ConfigUpgradeHook(target_version="8.10.11", config_names=("bot_config.toml",), migrate=migrate),), + ) + + data = {"chat": {"enable": True}} + result = apply_config_upgrade_hooks(data, "bot_config.toml", "8.10.11", "8.10.12") + + assert result.migrated is False + assert result.data["chat"]["enable"] is True + + +def test_set_nested_config_value_can_keep_existing_value(): + data = {"webui": {"port": 8001}} + + changed = set_nested_config_value(data, ("webui", "port"), 8080, force=False) + + assert changed is False + assert data["webui"]["port"] == 8001 + + +def test_builtin_hook_resets_group_chat_prompt_when_upgrading_from_8_10_10(): + data = {"chat": {"group_chat_prompt": "自定义旧提示词"}} + + result = apply_config_upgrade_hooks(data, "bot_config.toml", "8.10.10", "8.10.11") + + assert result.migrated is True + assert result.reason == "8.10.11:chat.group_chat_prompt" + assert result.data["chat"]["group_chat_prompt"] == ChatConfig().group_chat_prompt + + +def test_bot_config_upgrade_hooks_register_group_chat_prompt_reset(): + assert len(BOT_CONFIG_UPGRADE_HOOKS) == 1 + assert BOT_CONFIG_UPGRADE_HOOKS[0].target_version == "8.10.11" diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..c43dc239 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2826 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.5" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.7" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "faiss-cpu" +version = "1.13.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, +] +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/c9/671f66f6b31ec48e5825d36435f0cb91189fa8bb6b50724029dbff4ca83c/faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_arm64.whl", hash = "sha256:a9064eb34f8f64438dd5b95c8f03a780b1a3f0b99c46eeacb1f0b5d15fc02dc1", size = 3452776, upload-time = "2025-12-24T10:27:01.419Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/4a/97150aa1582fb9c2bca95bd8fc37f27d3b470acec6f0a6833844b21e4b40/faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_x86_64.whl", hash = "sha256:c8d097884521e1ecaea6467aeebbf1aa56ee4a36350b48b2ca6b39366565c317", size = 7896434, upload-time = "2025-12-24T10:27:03.592Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/d0/0940575f059591ca31b63a881058adb16a387020af1709dcb7669460115c/faiss_cpu-1.13.2-cp310-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ee330a284042c2480f2e90450a10378fd95655d62220159b1408f59ee83ebf1", size = 11485825, upload-time = "2025-12-24T10:27:05.681Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/e1/a5acac02aa593809f0123539afe7b4aff61d1db149e7093239888c9053e1/faiss_cpu-1.13.2-cp310-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab88ee287c25a119213153d033f7dd64c3ccec466ace267395872f554b648cd7", size = 23845772, upload-time = "2025-12-24T10:27:08.194Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/7b/49dcaf354834ec457e85ca769d50bc9b5f3003fab7c94a9dcf08cf742793/faiss_cpu-1.13.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:85511129b34f890d19c98b82a0cd5ffb27d89d1cec2ee41d2621ee9f9ef8cf3f", size = 13477567, upload-time = "2025-12-24T10:27:10.822Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/6b/12bb4037921c38bb2c0b4cfc213ca7e04bbbebbfea89b0b5746248ce446e/faiss_cpu-1.13.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b32eb4065bac352b52a9f5ae07223567fab0a976c7d05017c01c45a1c24264f", size = 25102239, upload-time = "2025-12-24T10:27:13.476Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/ff/35ed875423200c17bdd594ce921abfc1812ddd21e09355290b9a94e170ab/faiss_cpu-1.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:b82c01d30430dd7b1fa442001b9099735d1a82f6bb72033acdc9206d5ac66a64", size = 18890300, upload-time = "2025-12-24T10:27:24.194Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/3a/bbdf5deaf6feb34b46b469c0a0acd40216c3d3c6ecf5aeb71d56b8a650e3/faiss_cpu-1.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2c4f696ae76e7c97cbc12311db83aaf1e7f4f7be06a3ffea7e5b0e8ec1fd805b", size = 8553157, upload-time = "2025-12-24T10:27:26.38Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/4b/903d85bf3a8264d49964ec799e45c7ffc91098606b8bc9ef2c904c1a56cb/faiss_cpu-1.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:cb4b5ee184816a4b099162ac93c0d7f0033d81a88e7c1291d0a9cc41ec348984", size = 18891330, upload-time = "2025-12-24T10:27:28.806Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/52/5d10642da628f63544aab27e48416be4a7ea25c6b81d8bd65016d8538b00/faiss_cpu-1.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:1243967eeb2298791ff7f3683a4abd2100d7e6ec7542ca05c3b75d47a7f621e5", size = 8553088, upload-time = "2025-12-24T10:27:31.325Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/b1/daaab8046f56c60079648bd83774f61b283b59a9930a2f60790ee4cdedfe/faiss_cpu-1.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:c8b645e7d56591aa35dc75415bb53a62e4a494dba010e16f4b67daeffd830bd7", size = 18892621, upload-time = "2025-12-24T10:27:33.923Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/6f/5eaf3e249c636e616ebb52e369a4a2f1d32b1caf9a611b4f917b3dd21423/faiss_cpu-1.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:8113a2a80b59fe5653cf66f5c0f18be0a691825601a52a614c30beb1fca9bc7c", size = 8556374, upload-time = "2025-12-24T10:27:36.653Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/d9/e66315807e41e69e7f6a1b42a162dada2f249c5f06ad3f1a95f84ab336ef/fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e", size = 396607, upload-time = "2026-04-16T11:47:13.623Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", size = 117556, upload-time = "2026-04-16T11:47:11.922Z" }, +] + +[[package]] +name = "fonttools" +version = "4.62.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "google-auth" +version = "2.49.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/fc/e925290a1ad95c975c459e2df070fac2b90954e13a0370ac505dff78cb99/google_auth-2.49.2.tar.gz", hash = "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", size = 333958, upload-time = "2026-04-10T00:41:21.888Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/76/d241a5c927433420507215df6cac1b1fa4ac0ba7a794df42a84326c68da8/google_auth-2.49.2-py3-none-any.whl", hash = "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5", size = 240638, upload-time = "2026-04-10T00:41:14.501Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "1.73.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/d8/40f5f107e5a2976bbac52d421f04d14fc221b55a8f05e66be44b2f739fe6/google_genai-1.73.1.tar.gz", hash = "sha256:b637e3a3b9e2eccc46f27136d470165803de84eca52abfed2e7352081a4d5a15", size = 530998, upload-time = "2026-04-14T21:06:19.153Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/af/508e0528015240d710c6763f7c89ff44fab9a94a80b4377e265d692cbfd6/google_genai-1.73.1-py3-none-any.whl", hash = "sha256:af2d2287d25e42a187de19811ef33beb2e347c7e2bdb4dc8c467d78254e43a2c", size = 783595, upload-time = "2026-04-14T21:06:17.464Z" }, +] + +[[package]] +name = "greenlet" +version = "3.4.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/57/eae2cac10421feae6c0987e3dc106c6d86262b1cb379e171b017aba893a6/greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d", size = 624901, upload-time = "2026-04-08T16:40:38.981Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/8a/0e73c9b94f31d1cc257fe79a0eff621674141cdae7d6d00f40de378a1e42/greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077", size = 423927, upload-time = "2026-04-08T16:43:05.293Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/93/c8c508d68ba93232784bbc1b5474d92371f2897dfc6bc281b419f2e0d492/greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f", size = 628455, upload-time = "2026-04-08T16:40:40.698Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/46/cfaaa0ade435a60550fd83d07dfd5c41f873a01da17ede5c4cade0b9bab8/greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55", size = 426238, upload-time = "2026-04-08T16:43:06.865Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "socksio" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.12" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/12/2948fbe5513d062169bd91f7d7b1cd97bc8894f32946b71fa39f6e63ca0c/idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254", size = 194350, upload-time = "2026-04-21T13:32:48.916Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jieba" +version = "0.42.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/cb/18eeb235f833b726522d7ebed54f2278ce28ba9438e3135ab0278d9792a2/jieba-0.42.1.tar.gz", hash = "sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2", size = 19214172, upload-time = "2020-01-20T14:27:23.5Z" } + +[[package]] +name = "jiter" +version = "0.14.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, +] + +[[package]] +name = "json-repair" +version = "0.59.4" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/41/4ae9c6e711647a41b4e0c04bce815113ce9c0286eff6dc6fb86979b2fb9f/json_repair-0.59.4.tar.gz", hash = "sha256:559ca1828f6f566530663cd96d64bee29f8282b9d2ff0e661e05fa87b4171ab3", size = 47624, upload-time = "2026-04-15T06:48:40.776Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/c4/ec3068436d2275731539b7a43fbc947f502bc3fe149856a5d00368c7b087/json_repair-0.59.4-py3-none-any.whl", hash = "sha256:46052e646bc0b0c39db672ebbf732f774f3c1a5bde81a54f0b0e19d3af4f45cd", size = 46697, upload-time = "2026-04-15T06:48:39.61Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, +] + +[[package]] +name = "levenshtein" +version = "0.27.3" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "rapidfuzz" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/56/dcf68853b062e3b94bdc3d011cc4198779abc5b9dc134146a062920ce2e2/levenshtein-0.27.3.tar.gz", hash = "sha256:1ac326b2c84215795163d8a5af471188918b8797b4953ec87aaba22c9c1f9fc0", size = 393269, upload-time = "2025-11-01T12:14:31.04Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/8e/3be9d8e0245704e3af5258fb6cb157c3d59902e1351e95edf6ed8a8c0434/levenshtein-0.27.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2de7f095b0ca8e44de9de986ccba661cd0dec3511c751b499e76b60da46805e9", size = 169622, upload-time = "2025-11-01T12:13:10.026Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a6/42/a2b2fda5e8caf6ecd5aac142f946a77574a3961e65da62c12fd7e48e5cb1/levenshtein-0.27.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9b8b29e5d5145a3c958664c85151b1bb4b26e4ca764380b947e6a96a321217c", size = 159183, upload-time = "2025-11-01T12:13:11.197Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/c4/f083fabbd61c449752df1746533538f4a8629e8811931b52f66e6c4290ad/levenshtein-0.27.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc975465a51b1c5889eadee1a583b81fba46372b4b22df28973e49e8ddb8f54a", size = 133120, upload-time = "2025-11-01T12:13:12.363Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/e5/b6421e04cb0629615b8efd6d4d167dd2b1afb5097b87bb83cd992004dcca/levenshtein-0.27.3-cp312-cp312-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:57573ed885118554770979fdee584071b66103f6d50beddeabb54607a1213d81", size = 114988, upload-time = "2025-11-01T12:13:13.486Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/77/39ee0e8d3028e90178e1031530ccc98563f8f2f0d905ec784669dcf0fa90/levenshtein-0.27.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23aff800a6dd5d91bb3754a6092085aa7ad46b28e497682c155c74f681cfaa2d", size = 153346, upload-time = "2025-11-01T12:13:14.744Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/0d/c0f367bbd260dbd7a4e134fd21f459e0f5eac43deac507952b46a1d8a93a/levenshtein-0.27.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c08a952432b8ad9dccb145f812176db94c52cda732311ddc08d29fd3bf185b0a", size = 1114538, upload-time = "2025-11-01T12:13:15.851Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/ef/ae71433f7b4db0bd2af7974785e36cdec899919203fb82e647c5a6109c07/levenshtein-0.27.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3bfcb2d78ab9cc06a1e75da8fcfb7a430fe513d66cfe54c07e50f32805e5e6db", size = 1009734, upload-time = "2025-11-01T12:13:17.212Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/dc/62c28b812dcb0953fc32ab7adf3d0e814e43c8560bb28d9269a44d874adf/levenshtein-0.27.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba7235f6dcb31a217247468295e2dd4c6c1d3ac81629dc5d355d93e1a5f4c185", size = 1185581, upload-time = "2025-11-01T12:13:18.661Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/e8/2e7ab9c565793220edb8e5432f9a846386a157075bdd032a90e9585bce38/levenshtein-0.27.3-cp312-cp312-win32.whl", hash = "sha256:ea80d70f1d18c161a209be556b9094968627cbaae620e102459ef9c320a98cbb", size = 84660, upload-time = "2025-11-01T12:13:19.87Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/a6/907a1fc8587dc91c40156973e09d106ab064c06eb28dc4700ba0fe54d654/levenshtein-0.27.3-cp312-cp312-win_amd64.whl", hash = "sha256:fbaa1219d9b2d955339a37e684256a861e9274a3fe3a6ee1b8ea8724c3231ed9", size = 94909, upload-time = "2025-11-01T12:13:21.323Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/d6/e04f0ddf6a71df3cdd1817b71703490ac874601ed460b2af172d3752c321/levenshtein-0.27.3-cp312-cp312-win_arm64.whl", hash = "sha256:2edbaa84f887ea1d9d8e4440af3fdda44769a7855d581c6248d7ee51518402a8", size = 87358, upload-time = "2025-11-01T12:13:22.393Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/f2/162e9ea7490b36bbf05776c8e3a8114c75aa78546ddda8e8f36731db3da6/levenshtein-0.27.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e55aa9f9453fd89d4a9ff1f3c4a650b307d5f61a7eed0568a52fbd2ff2eba107", size = 169230, upload-time = "2025-11-01T12:13:23.735Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/2d/7316ba7f94e3d60e89bd120526bc71e4812866bb7162767a2a10f73f72c5/levenshtein-0.27.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae4d484453c48939ecd01c5c213530c68dd5cd6e5090f0091ef69799ec7a8a9f", size = 158643, upload-time = "2025-11-01T12:13:25.549Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/87/85433cb1e51c45016f061d96fea3106b6969f700e2cbb56c15de82d0deeb/levenshtein-0.27.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d18659832567ee387b266be390da0de356a3aa6cf0e8bc009b6042d8188e131f", size = 132881, upload-time = "2025-11-01T12:13:26.822Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/1c/3ce66c9a7da169a43dd89146d69df9dec935e6f86c70c6404f48d1291d2c/levenshtein-0.27.3-cp313-cp313-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027b3d142cc8ea2ab4e60444d7175f65a94dde22a54382b2f7b47cc24936eb53", size = 114650, upload-time = "2025-11-01T12:13:28.382Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/60/7138e98884ca105c76ef192f5b43165d6eac6f32b432853ebe9f09ee50c9/levenshtein-0.27.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdca6989368cc64f347f0423c528520f12775b812e170a0eb0c10e4c9b0f3ff", size = 153127, upload-time = "2025-11-01T12:13:29.781Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/8f/664ac8b83026d7d1382866b68babae17e92b7b6ff8dc3c6205c0066b8ce1/levenshtein-0.27.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fa00ab389386032b02a1c9050ec3c6aa824d2bbcc692548fdc44a46b71c058c6", size = 1114602, upload-time = "2025-11-01T12:13:31.651Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/c8/8905d96cf2d7ed6af7eb39a8be0925ef335729473c1e9d1f56230ecaffc5/levenshtein-0.27.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:691c9003c6c481b899a5c2f72e8ce05a6d956a9668dc75f2a3ce9f4381a76dc6", size = 1008036, upload-time = "2025-11-01T12:13:33.006Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/57/01c37608121380a6357a297625562adad1c1fc8058d4f62279b735108927/levenshtein-0.27.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:12f7fc8bf0c24492fe97905348e020b55b9fc6dbaab7cd452566d1a466cb5e15", size = 1185338, upload-time = "2025-11-01T12:13:34.452Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/57/bceab41d40b58dee7927a8d1d18ed3bff7c95c5e530fb60093ce741a8c26/levenshtein-0.27.3-cp313-cp313-win32.whl", hash = "sha256:9f4872e4e19ee48eed39f214eea4eca42e5ef303f8a4a488d8312370674dbf3a", size = 84562, upload-time = "2025-11-01T12:13:35.858Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/1d/74f1ff589bb687d0cad2bbdceef208dc070f56d1e38a3831da8c00bf13bb/levenshtein-0.27.3-cp313-cp313-win_amd64.whl", hash = "sha256:83aa2422e9a9af2c9d3e56a53e3e8de6bae58d1793628cae48c4282577c5c2c6", size = 94658, upload-time = "2025-11-01T12:13:36.963Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/3c/22c86d3c8f254141096fd6089d2e9fdf98b1472c7a5d79d36d3557ec2d83/levenshtein-0.27.3-cp313-cp313-win_arm64.whl", hash = "sha256:d4adaf1edbcf38c3f2e290b52f4dcb5c6deff20308c26ef1127a106bc2d23e9f", size = 86929, upload-time = "2025-11-01T12:13:37.997Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/bc/9b7cf1b5fa098b86844d42de22549304699deff309c5c9e28b9a3fc4076a/levenshtein-0.27.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:272e24764b8210337b65a1cfd69ce40df5d2de1a3baf1234e7f06d2826ba2e7a", size = 170360, upload-time = "2025-11-01T12:13:39.019Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/95/997f2c83bd4712426bf0de8143b5e4403c7ebbafb5d1271983e774de3ae7/levenshtein-0.27.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:329a8e748a4e14d56daaa11f07bce3fde53385d05bad6b3f6dd9ee7802cdc915", size = 159098, upload-time = "2025-11-01T12:13:40.17Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/96/123c3316ae2f72c73be4fba9756924af015da4c0e5b12804f5753c0ee511/levenshtein-0.27.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5fea1a9c6b9cc8729e467e2174b4359ff6bac27356bb5f31898e596b4ce133a", size = 136655, upload-time = "2025-11-01T12:13:41.262Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/72/a3180d437736b1b9eacc3100be655a756deafb91de47c762d40eb45a9d91/levenshtein-0.27.3-cp313-cp313t-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3a61aa825819b6356555091d8a575d1235bd9c3753a68316a261af4856c3b487", size = 117511, upload-time = "2025-11-01T12:13:42.647Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/61/f9/ba7c546a4b99347938e6661104064ab6a3651c601d59f241ffdc37510ecc/levenshtein-0.27.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51de7a514e8183f0a82f2947d01b014d2391426543b1c076bf5a26328cec4e4", size = 155656, upload-time = "2025-11-01T12:13:44.208Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/cd/5edd6e1e02c3e47c8121761756dd0f85f816b636f25509118b687e6b0f96/levenshtein-0.27.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53cbf726d6e92040c9be7e594d959d496bd62597ea48eba9d96105898acbeafe", size = 1116689, upload-time = "2025-11-01T12:13:45.485Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/67/25ca0119e0c6ec17226c72638f48ef8887124597ac48ad5da111c0b3a825/levenshtein-0.27.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:191b358afead8561c4fcfed22f83c13bb6c8da5f5789e277f0c5aa1c45ca612f", size = 1003166, upload-time = "2025-11-01T12:13:47.126Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/64/ab216f3fb3cef1ee7e222665537f9340d828ef84c99409ba31f2ef2a3947/levenshtein-0.27.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ba1318d0635b834b8f0397014a7c43f007e65fce396a47614780c881bdff828b", size = 1189362, upload-time = "2025-11-01T12:13:48.627Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/58/b150034858de0899a5a222974b6710618ebc0779a0695df070f7ab559a0b/levenshtein-0.27.3-cp313-cp313t-win32.whl", hash = "sha256:8dd9e1db6c3b35567043e155a686e4827c4aa28a594bd81e3eea84d3a1bd5875", size = 86149, upload-time = "2025-11-01T12:13:50.588Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/c4/bbe46a11073641450200e6a604b3b62d311166e8061c492612a40e560e85/levenshtein-0.27.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7813ecdac7a6223264ebfea0c8d69959c43d21a99694ef28018d22c4265c2af6", size = 96685, upload-time = "2025-11-01T12:13:51.641Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/65/30b362ad9bfc1085741776a08b6ddee3f434e9daac2920daaee2e26271bf/levenshtein-0.27.3-cp313-cp313t-win_arm64.whl", hash = "sha256:8f05a0d23d13a6f802c7af595d0e43f5b9b98b6ed390cec7a35cb5d6693b882b", size = 88538, upload-time = "2025-11-01T12:13:52.757Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/e1/2f705da403f865a5fa3449b155738dc9c53021698fd6926253a9af03180b/levenshtein-0.27.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a6728bfae9a86002f0223576675fc7e2a6e7735da47185a1d13d1eaaa73dd4be", size = 169457, upload-time = "2025-11-01T12:13:53.778Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/2c/bb6ef359e007fe7b6b3195b68a94f4dd3ecd1885ee337ee8fbd4df55996f/levenshtein-0.27.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8e5037c4a6f97a238e24aad6f98a1e984348b7931b1b04b6bd02bd4f8238150d", size = 158680, upload-time = "2025-11-01T12:13:55.005Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/7b/de1999f4cf1cfebc3fbbf03a6d58498952d6560d9798af4b0a566e6b6f30/levenshtein-0.27.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6cf5ecf9026bf24cf66ad019c6583f50058fae3e1b3c20e8812455b55d597f1", size = 133167, upload-time = "2025-11-01T12:13:56.426Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/da/aaa7f3a0a8ae8744b284043653652db3d7d93595517f9ed8158c03287692/levenshtein-0.27.3-cp314-cp314-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9285084bd2fc19adb47dab54ed4a71f57f78fe0d754e4a01e3c75409a25aed24", size = 114530, upload-time = "2025-11-01T12:13:57.883Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/ce/ed422816fb30ffa3bc11597b30d5deca06b4a1388707a04215da73c65b53/levenshtein-0.27.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce3bbbe92172a08b599d79956182c6b7ab6ec8d4adbe7237417a363b968ad87b", size = 153325, upload-time = "2025-11-01T12:13:59.318Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/5a/a225477a0bda154f19f1c07a5e35500d631ae25dfd620b479027d79f0d4c/levenshtein-0.27.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9dac48fab9d166ca90e12fb6cf6c7c8eb9c41aacf7136584411e20f7f136f745", size = 1114956, upload-time = "2025-11-01T12:14:00.543Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/c4/a1be1040f3cce516a5e2be68453fd0c32ac63b2e9d31f476723fd8002c09/levenshtein-0.27.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d37a83722dc5326c93d17078e926c4732dc4f3488dc017c6839e34cd16af92b7", size = 1007610, upload-time = "2025-11-01T12:14:02.036Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/d7/6f50e8a307e0c2befd819b481eb3a4c2eacab3dd8101982423003fac8ea3/levenshtein-0.27.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3466cb8294ce586e49dd467560a153ab8d296015c538223f149f9aefd3d9f955", size = 1185379, upload-time = "2025-11-01T12:14:03.385Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/e5/5d8fb1b3ebd5735f53221bf95c923066bcfc132234925820128f7eee5b47/levenshtein-0.27.3-cp314-cp314-win32.whl", hash = "sha256:c848bf2457b268672b7e9e73b44f18f49856420ac50b2564cf115a6e4ef82688", size = 86328, upload-time = "2025-11-01T12:14:04.74Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/82/8a9ccbdb4e38bd4d516f2804999dccb8cb4bcb4e33f52851735da0c73ea7/levenshtein-0.27.3-cp314-cp314-win_amd64.whl", hash = "sha256:742633f024362a4ed6ef9d7e75d68f74b041ae738985fcf55a0e6d1d4cade438", size = 96640, upload-time = "2025-11-01T12:14:06.24Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/86/f9d15919f59f5d92c6baa500315e1fa0143a39d811427b83c54f038267ca/levenshtein-0.27.3-cp314-cp314-win_arm64.whl", hash = "sha256:9eed6851224b19e8d588ddb8eb8a4ae3c2dcabf3d1213985f0b94a67e517b1df", size = 89689, upload-time = "2025-11-01T12:14:07.379Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/f6/10f44975ae6dc3047b2cd260e3d4c3a5258b8d10690a42904115de24fc51/levenshtein-0.27.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77de69a345c76227b51a4521cd85442eb3da54c7eb6a06663a20c058fc49e683", size = 170518, upload-time = "2025-11-01T12:14:09.196Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/07/fa294a145a0c99a814a9a807614962c1ee0f5749ca691645980462027d5d/levenshtein-0.27.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eba2756dc1f5b962b0ff80e49abb2153d5e809cc5e7fa5e85be9410ce474795d", size = 159097, upload-time = "2025-11-01T12:14:10.404Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/50/24bdf37813fc30f293e53b46022b091144f4737a6a66663d2235b311bb98/levenshtein-0.27.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c8fcb498287e971d84260f67808ff1a06b3f6212d80fea75cf5155db80606ff", size = 136650, upload-time = "2025-11-01T12:14:11.579Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/a9/0399c7a190b277cdea3acc801129d9d30da57c3fa79519e7b8c3f080d86c/levenshtein-0.27.3-cp314-cp314t-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f067092c67464faab13e00a5c1a80da93baca8955d4d49579861400762e35591", size = 117515, upload-time = "2025-11-01T12:14:12.877Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/a4/1c27533e97578b385a4b8079abe8d1ce2e514717c761efbe4bf7bbd0ac2e/levenshtein-0.27.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92415f32c68491203f2855d05eef3277d376182d014cf0859c013c89f277fbbf", size = 155711, upload-time = "2025-11-01T12:14:13.985Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/35/bbc26638394a72b1e31a685ec251c995ee66a630c7e5c86f98770928b632/levenshtein-0.27.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ef61eeaf1e0a42d7d947978d981fe4b9426b98b3dd8c1582c535f10dee044c3f", size = 1116692, upload-time = "2025-11-01T12:14:15.359Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/83/32fcf28b388f8dc6c36b54552b9bae289dab07d43df104893158c834cbcc/levenshtein-0.27.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:103bb2e9049d1aa0d1216dd09c1c9106ecfe7541bbdc1a0490b9357d42eec8f2", size = 1003167, upload-time = "2025-11-01T12:14:17.469Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/79/1fbf2877ec4b819f373a32ebe3c48a61ee810693593a6015108b0be97b78/levenshtein-0.27.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a64ddd1986b2a4c468b09544382287315c53585eb067f6e200c337741e057ee", size = 1189417, upload-time = "2025-11-01T12:14:19.081Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/ac/dad4e09f1f7459c64172e48e40ed2baf3aa92d38205bcbd1b4ff00853701/levenshtein-0.27.3-cp314-cp314t-win32.whl", hash = "sha256:957244f27dc284ccb030a8b77b8a00deb7eefdcd70052a4b1d96f375780ae9dc", size = 88144, upload-time = "2025-11-01T12:14:20.667Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/61/cd51dc8b8a382e17c559a9812734c3a9afc2dab7d36253516335ee16ae50/levenshtein-0.27.3-cp314-cp314t-win_amd64.whl", hash = "sha256:ccd7eaa6d8048c3ec07c93cfbcdefd4a3ae8c6aca3a370f2023ee69341e5f076", size = 98516, upload-time = "2025-11-01T12:14:21.786Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/5e/3fb67e882c1fee01ebb7abc1c0a6669e5ff8acd060e93bfe7229e9ce6e4f/levenshtein-0.27.3-cp314-cp314t-win_arm64.whl", hash = "sha256:1d8520b89b7a27bb5aadbcc156715619bcbf556a8ac46ad932470945dca6e1bd", size = 91020, upload-time = "2025-11-01T12:14:22.944Z" }, +] + +[[package]] +name = "maibot" +version = "1.0.0rc16" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "babel" }, + { name = "certifi" }, + { name = "colorama" }, + { name = "faiss-cpu" }, + { name = "fastapi" }, + { name = "google-genai" }, + { name = "httpx", extra = ["socks"] }, + { name = "jieba" }, + { name = "json-repair" }, + { name = "maibot-dashboard" }, + { name = "maibot-plugin-sdk" }, + { name = "maim-message" }, + { name = "matplotlib" }, + { name = "mcp" }, + { name = "msgpack" }, + { name = "numpy" }, + { name = "openai" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "playwright" }, + { name = "pyarrow" }, + { name = "pydantic" }, + { name = "pypinyin" }, + { name = "python-dotenv" }, + { name = "python-levenshtein" }, + { name = "python-multipart" }, + { name = "rich" }, + { name = "scipy" }, + { name = "sqlalchemy" }, + { name = "sqlmodel" }, + { name = "structlog" }, + { name = "tomlkit" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "watchfiles" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "zstandard" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.12.14" }, + { name = "babel", specifier = ">=2.17.0" }, + { name = "certifi" }, + { name = "colorama", specifier = ">=0.4.6" }, + { name = "faiss-cpu", specifier = ">=1.11.0" }, + { name = "fastapi", specifier = ">=0.116.0" }, + { name = "google-genai", specifier = ">=1.39.1" }, + { name = "httpx", extras = ["socks"] }, + { name = "jieba", specifier = ">=0.42.1" }, + { name = "json-repair", specifier = ">=0.47.6" }, + { name = "maibot-dashboard", specifier = ">=1.0.10" }, + { name = "maibot-plugin-sdk", specifier = ">=2.4.0" }, + { name = "maim-message", specifier = ">=0.6.2" }, + { name = "matplotlib", specifier = ">=3.10.5" }, + { name = "mcp" }, + { name = "msgpack", specifier = ">=1.1.2" }, + { name = "numpy", specifier = ">=2.2.6" }, + { name = "openai", specifier = ">=1.95.0" }, + { name = "pandas", specifier = ">=2.3.1" }, + { name = "pillow", specifier = ">=11.3.0" }, + { name = "playwright", specifier = ">=1.54.0" }, + { name = "pyarrow", specifier = ">=20.0.0" }, + { name = "pydantic", specifier = ">=2.11.7" }, + { name = "pypinyin", specifier = ">=0.54.0" }, + { name = "python-dotenv", specifier = ">=1.1.1" }, + { name = "python-levenshtein" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "rich", specifier = ">=14.0.0" }, + { name = "scipy", specifier = ">=1.7.0" }, + { name = "sqlalchemy", specifier = ">=2.0.40" }, + { name = "sqlmodel", specifier = ">=0.0.24" }, + { name = "structlog", specifier = ">=25.4.0" }, + { name = "tomlkit", specifier = ">=0.13.3" }, + { name = "typing-extensions" }, + { name = "uvicorn", specifier = ">=0.35.0" }, + { name = "watchfiles", specifier = ">=1.1.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff", specifier = ">=0.12.2" }, + { name = "zstandard" }, +] + +[[package]] +name = "maibot-dashboard" +version = "1.0.10" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/b7/3301c49d96bbfde0c61405b7e5afe1c75d625420fdd83d1f4814b9f09eec/maibot_dashboard-1.0.10.tar.gz", hash = "sha256:38be2833f6a17c1f347262eccf5967813327948ad29900b955bb91631844a27c", size = 2496436, upload-time = "2026-05-08T13:15:48.204Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ab/1e/c86f8a98c9de4eef831f855994744ec0512ccfb9322aaef385ca0919f950/maibot_dashboard-1.0.10-py3-none-any.whl", hash = "sha256:749390d6e4c340598e048d72f4c733558163153c0ad4443ff81b5a1c28799629", size = 2563613, upload-time = "2026-05-08T13:15:46.562Z" }, +] + +[[package]] +name = "maibot-plugin-sdk" +version = "2.4.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "pydantic" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/ce/92427976b24d569bf40b57e4f2cf65b33b2c3f6a3e3b5bb1c2ee30ff4c59/maibot_plugin_sdk-2.4.0.tar.gz", hash = "sha256:b1bb7b74b8d87d2bc321ff4c46864dc802cd80c24ef16de80b5a64558cb6f06e", size = 81612, upload-time = "2026-04-27T08:51:15.336Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/73/0f8a01f2349a5c9f1a485f565ab7a66e1cf960afa8c96b31ee5e62fbc3c1/maibot_plugin_sdk-2.4.0-py3-none-any.whl", hash = "sha256:c805d9c0f2d9dd6f98323b7e8e05558d0804ca1c44b69f884f93d5fc262e72d5", size = 101637, upload-time = "2026-04-27T08:51:14.066Z" }, +] + +[[package]] +name = "maim-message" +version = "0.6.8" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "cryptography" }, + { name = "fastapi" }, + { name = "pydantic" }, + { name = "uvicorn" }, + { name = "websockets" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/81/5908fc8c37fb283e0cbdf35c6026fcd1aa8b69883875a601aa02fe834566/maim_message-0.6.8.tar.gz", hash = "sha256:0aa4c6a3643fd4daa581ff1b2834dd5247ac74f42d3ca65c68f0e6fece047c35", size = 767058, upload-time = "2026-01-26T06:27:33.73Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/18/d79fff71b7926069d3c4765498a9207a32ba7ede7349ac9f543fbc413167/maim_message-0.6.8-py3-none-any.whl", hash = "sha256:74cf581294bcb06cb350e0c8eb9fe64e28a188ff20cf8e2aa5321a915158a4aa", size = 85711, upload-time = "2026-01-26T06:27:31.338Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, +] + +[[package]] +name = "mcp" +version = "1.27.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +] + +[[package]] +name = "openai" +version = "2.32.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286, upload-time = "2026-04-15T22:28:19.434Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" }, +] + +[[package]] +name = "packaging" +version = "26.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/b0/c20bd4d6d3f736e6bd6b55794e9cd0a617b858eaad27c8f410ea05d953b7/pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", size = 10347921, upload-time = "2026-03-31T06:46:33.36Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/d0/4831af68ce30cc2d03c697bea8450e3225a835ef497d0d70f31b8cdde965/pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", size = 9888127, upload-time = "2026-03-31T06:46:36.253Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/61/a9/16ea9346e1fc4a96e2896242d9bc674764fb9049b0044c0132502f7a771e/pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", size = 10399577, upload-time = "2026-03-31T06:46:39.224Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/a8/3a61a721472959ab0ce865ef05d10b0d6bfe27ce8801c99f33d4fa996e65/pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", size = 10880030, upload-time = "2026-03-31T06:46:42.412Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/65/7225c0ea4d6ce9cb2160a7fb7f39804871049f016e74782e5dade4d14109/pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab", size = 11409468, upload-time = "2026-03-31T06:46:45.2Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/5b/46e7c76032639f2132359b5cf4c785dd8cf9aea5ea64699eac752f02b9db/pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", size = 11936381, upload-time = "2026-03-31T06:46:48.293Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/8b/721a9cff6fa6a91b162eb51019c6243b82b3226c71bb6c8ef4a9bd65cbc6/pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", size = 9744993, upload-time = "2026-03-31T06:46:51.488Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/18/7f0bd34ae27b28159aa80f2a6799f47fda34f7fb938a76e20c7b7fe3b200/pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", size = 9056118, upload-time = "2026-03-31T06:46:54.548Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "playwright" +version = "1.58.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.3" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pypinyin" +version = "0.55.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/a4/784cf98c09e0dc22776b0d7d8a4a5b761218bcae4608c2416ce1e167c8af/pypinyin-0.55.0.tar.gz", hash = "sha256:b5711b3a0c6f76e67408ec6b2e3c4987a3a806b7c528076e7c7b86fcf0eaa66b", size = 839836, upload-time = "2025-07-20T12:01:50.657Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/7b/4cabc76fcc21c3c7d5c671d8783984d30ac9d3bb387c4ba784fca3cdfa3a/pypinyin-0.55.0-py2.py3-none-any.whl", hash = "sha256:d53b1e8ad2cdb815fb2cb604ed3123372f5a28c6f447571244aca36fc62a286f", size = 840203, upload-time = "2025-07-20T12:01:48.535Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-levenshtein" +version = "0.27.3" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "levenshtein" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b4/36eda4188dd19d3cb53d8a8749d7520bd23dfe1c1f44e56ea9dcd0232274/python_levenshtein-0.27.3.tar.gz", hash = "sha256:27dc2d65aeb62a7d6852388f197073296370779286c0860b087357f3b8129a62", size = 12446, upload-time = "2025-11-01T12:54:59.712Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/5b/26e3cca2589252ceabf964ba81514e6f48556553c9c2766e1a0fdceec696/python_levenshtein-0.27.3-py3-none-any.whl", hash = "sha256:5d6168a8e8befb25abf04d2952368a446722be10e8ced218d0dc4fd3703a43a1", size = 9504, upload-time = "2025-11-01T12:54:58.933Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.26" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "rapidfuzz" +version = "3.14.5" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.11" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.49" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, +] + +[[package]] +name = "sqlmodel" +version = "0.0.38" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/0d/26ec1329960ea9430131fe63f63a95ea4cb8971d49c891ff7e1f3255421c/sqlmodel-0.0.38.tar.gz", hash = "sha256:d583ec237b14103809f74e8630032bc40ab68cd6b754a610f0813c56911a547b", size = 86710, upload-time = "2026-04-02T21:03:55.571Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/c7/10c60af0607ab6fa136264f7f39d205932218516226d38585324ffda705d/sqlmodel-0.0.38-py3-none-any.whl", hash = "sha256:84e3fa990a77395461ded72a6c73173438ce8449d5c1c4d97fbff1b1df692649", size = 27294, upload-time = "2026-04-02T21:03:56.406Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.3.4" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.45.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/2e/62b0d9a2cfc8b4de6771322dae30f2db76c66dae9ec32e94e176a44ad563/uvicorn-0.45.0.tar.gz", hash = "sha256:3fe650df136c5bd2b9b06efc5980636344a2fbb840e9ddd86437d53144fa335d", size = 87818, upload-time = "2026-04-21T10:43:46.815Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/88/d0f7512465b166a4e931ccf7e77792be60fb88466a43964c7566cbaff752/uvicorn-0.45.0-py3-none-any.whl", hash = "sha256:2db26f588131aeec7439de00f2dd52d5f210710c1f01e407a52c90b880d1fd4f", size = 69838, upload-time = "2026-04-21T10:43:45.029Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +]