From 59cd4ecfbd104287238c1f7635b658c3906b8d38 Mon Sep 17 00:00:00 2001 From: coso Date: Thu, 1 Jan 2026 03:59:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.27.0=20-=20=E9=87=8D=E6=9E=84=20Agen?= =?UTF-8?q?t=20=E6=9E=B6=E6=9E=84=E5=B9=B6=E6=96=B0=E5=A2=9E=20API=20Key?= =?UTF-8?q?=20Provider=20=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要变更: - 引入基于 Protocol 的 Agent 架构(支持 OpenAI/Anthropic/Kiro/Gemini) - 新增 API Key Provider 管理系统,支持多种第三方 AI 服务商 - 重构流式响应处理,新增统一的 Stream 处理模块 - 新增协议转换器(Translator)模块,支持 Kiro 到 OpenAI/Anthropic 的转换 - 前端新增 API Key Provider 配置界面和导入/导出功能 - 新增 50+ AI 服务商图标支持 - 优化 Agent 工具调用显示和流式渲染 - 完善数据库 schema,新增 api_key_providers 表 技术细节: - 重构 native_agent.rs,引入策略模式的 Protocol trait - 新增 parsers 和 generators 模块处理不同协议的 SSE 流 - 新增完整的 API Key 验证和连接测试功能 - 改进前端 TypeScript 类型定义 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- eslint.config.js | 43 +- jimeng-2025-12-25-7978-removebg-preview.png | Bin 148489 -> 0 bytes src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/agent/mod.rs | 13 +- src-tauri/src/agent/native_agent.rs | 1511 +++++------------ src-tauri/src/agent/parsers/anthropic_sse.rs | 209 +++ src-tauri/src/agent/parsers/mod.rs | 9 + src-tauri/src/agent/parsers/openai_sse.rs | 262 +++ src-tauri/src/agent/protocols/anthropic.rs | 491 ++++++ src-tauri/src/agent/protocols/mod.rs | 70 + src-tauri/src/agent/protocols/openai.rs | 380 +++++ src-tauri/src/agent/tool_loop.rs | 8 +- src-tauri/src/agent/tools/mod.rs | 41 + src-tauri/src/agent/tools/prompt.rs | 146 +- src-tauri/src/agent/tools/registry.rs | 9 + src-tauri/src/agent/tools/types.rs | 11 + src-tauri/src/agent/types.rs | 78 +- src-tauri/src/backends/mod.rs | 43 + src-tauri/src/backends/traits.rs | 194 +++ src-tauri/src/commands/agent_cmd.rs | 20 +- .../src/commands/api_key_provider_cmd.rs | 398 +++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/native_agent_cmd.rs | 108 +- src-tauri/src/config/export.rs | 1 + src-tauri/src/config/mod.rs | 2 + .../src/database/dao/api_key_provider.rs | 609 +++++++ src-tauri/src/database/dao/mod.rs | 1 + src-tauri/src/database/migration.rs | 225 ++- src-tauri/src/database/mod.rs | 13 + src-tauri/src/database/schema.rs | 62 + src-tauri/src/database/system_providers.rs | 632 +++++++ src-tauri/src/lib.rs | 33 +- src-tauri/src/providers/claude_custom.rs | 121 +- src-tauri/src/providers/kiro.rs | 92 +- .../src/server/handlers/provider_calls.rs | 233 ++- .../src/services/api_key_provider_service.rs | 650 +++++++ src-tauri/src/services/mod.rs | 1 + src-tauri/src/stream/events.rs | 317 ++++ .../src/stream/generators/anthropic_sse.rs | 465 +++++ src-tauri/src/stream/generators/mod.rs | 15 + src-tauri/src/stream/generators/openai_sse.rs | 402 +++++ src-tauri/src/stream/mod.rs | 36 + .../src/stream/parsers/aws_event_stream.rs | 500 ++++++ src-tauri/src/stream/parsers/mod.rs | 13 + src-tauri/src/stream/pipeline.rs | 327 ++++ .../src/translator/kiro/anthropic/mod.rs | 13 + .../src/translator/kiro/anthropic/request.rs | 677 ++++++++ .../src/translator/kiro/anthropic/response.rs | 189 +++ src-tauri/src/translator/kiro/mod.rs | 43 + src-tauri/src/translator/kiro/openai/mod.rs | 9 + .../src/translator/kiro/openai/request.rs | 620 +++++++ .../src/translator/kiro/openai/response.rs | 130 ++ src-tauri/src/translator/mod.rs | 53 + src-tauri/src/translator/traits.rs | 215 +++ src-tauri/tauri.conf.json | 2 +- src-tauri/tests/api_key_provider_tests.rs | 813 +++++++++ .../agent/chat/components/MessageList.tsx | 42 +- .../agent/chat/components/README.md | 53 + .../chat/components/StreamingRenderer.tsx | 261 ++- .../agent/chat/components/ToolCallDisplay.tsx | 677 +++++--- .../agent/chat/hooks/useAgentChat.ts | 166 +- src/components/agent/chat/types.ts | 20 +- .../provider-pool/CredentialCard.test.ts | 404 +++++ .../provider-pool/ProviderPoolPage.tsx | 462 ++--- src/components/provider-pool/README.md | 59 + .../api-key/AddCustomProviderModal.test.ts | 375 ++++ .../api-key/AddCustomProviderModal.tsx | 501 ++++++ .../provider-pool/api-key/ApiKeyItem.tsx | 287 ++++ .../provider-pool/api-key/ApiKeyList.tsx | 306 ++++ .../api-key/ApiKeyProviderSection.test.ts | 275 +++ .../api-key/ApiKeyProviderSection.tsx | 258 +++ .../api-key/ConnectionTestButton.tsx | 260 +++ .../api-key/DeleteProviderDialog.test.ts | 296 ++++ .../api-key/DeleteProviderDialog.tsx | 206 +++ .../api-key/ImportExportDialog.tsx | 378 +++++ .../api-key/ProviderConfigForm.test.ts | 226 +++ .../api-key/ProviderConfigForm.tsx | 384 +++++ .../provider-pool/api-key/ProviderGroup.tsx | 173 ++ .../api-key/ProviderList.test.ts | 528 ++++++ .../provider-pool/api-key/ProviderList.tsx | 288 ++++ .../api-key/ProviderListItem.test.ts | 248 +++ .../api-key/ProviderListItem.tsx | 162 ++ .../api-key/ProviderSetting.test.ts | 315 ++++ .../provider-pool/api-key/ProviderSetting.tsx | 281 +++ .../provider-pool/api-key/README.md | 98 ++ src/components/provider-pool/api-key/index.ts | 47 + src/components/ui/checkbox.tsx | 44 + src/components/ui/dialog.tsx | 23 +- src/hooks/README.md | 1 + src/hooks/useApiKeyProvider.ts | 456 +++++ src/icons/providers/302ai.svg | 1 + src/icons/providers/README.md | 65 + src/icons/providers/aihubmix.svg | 1 + src/icons/providers/aionly.svg | 1 + src/icons/providers/alayanew.svg | 1 + src/icons/providers/baichuan.svg | 1 + src/icons/providers/baidu.svg | 1 + src/icons/providers/bedrock.svg | 1 + src/icons/providers/burncloud.svg | 1 + src/icons/providers/cephalon.svg | 1 + src/icons/providers/cerebras.svg | 1 + src/icons/providers/cherryin.svg | 1 + src/icons/providers/cohere.svg | 1 + src/icons/providers/custom.svg | 1 + src/icons/providers/dashscope.svg | 1 + src/icons/providers/dmxapi.svg | 1 + src/icons/providers/fireworks.svg | 1 + src/icons/providers/github.svg | 1 + src/icons/providers/gpustack.svg | 1 + src/icons/providers/grok.svg | 1 + src/icons/providers/groq.svg | 1 + src/icons/providers/huggingface.svg | 1 + src/icons/providers/hunyuan.svg | 1 + src/icons/providers/hyperbolic.svg | 1 + src/icons/providers/index.tsx | 191 ++- src/icons/providers/infini.svg | 1 + src/icons/providers/jina.svg | 1 + src/icons/providers/lanyun.svg | 1 + src/icons/providers/lmstudio.svg | 1 + src/icons/providers/longcat.svg | 1 + src/icons/providers/mimo.svg | 1 + src/icons/providers/mistral.svg | 1 + src/icons/providers/modelscope.svg | 1 + src/icons/providers/moonshot.svg | 1 + src/icons/providers/newapi.svg | 1 + src/icons/providers/nvidia.svg | 1 + src/icons/providers/ocoolai.svg | 1 + src/icons/providers/ollama.svg | 1 + src/icons/providers/openrouter.svg | 1 + src/icons/providers/ovms.svg | 1 + src/icons/providers/perplexity.svg | 1 + src/icons/providers/ph8.svg | 1 + src/icons/providers/poe.svg | 1 + src/icons/providers/ppio.svg | 1 + src/icons/providers/providers-icons.test.ts | 120 ++ src/icons/providers/qiniu.svg | 1 + src/icons/providers/silicon.svg | 1 + src/icons/providers/sophnet.svg | 1 + src/icons/providers/stepfun.svg | 1 + src/icons/providers/tencent.svg | 1 + src/icons/providers/together.svg | 1 + src/icons/providers/tokenflux.svg | 1 + src/icons/providers/utils.ts | 200 ++- src/icons/providers/vercel.svg | 1 + src/icons/providers/vertexai.svg | 1 + src/icons/providers/voyageai.svg | 1 + src/icons/providers/xirang.svg | 1 + src/icons/providers/yi.svg | 1 + src/icons/providers/zhinao.svg | 1 + src/lib/README.md | 7 + src/lib/api/agent.ts | 29 +- src/lib/api/apiKeyProvider.ts | 246 +++ src/lib/api/importExport.test.ts | 416 +++++ src/lib/config/README.md | 23 + src/lib/config/providers.test.ts | 231 +++ src/lib/config/providers.ts | 763 +++++++++ src/lib/types/README.md | 21 + src/lib/types/provider.ts | 272 +++ src/lib/utils/apiKeyValidation.test.ts | 248 +++ src/lib/utils/apiKeyValidation.ts | 668 ++++++++ 161 files changed, 21741 insertions(+), 1937 deletions(-) delete mode 100644 jimeng-2025-12-25-7978-removebg-preview.png create mode 100644 src-tauri/src/agent/parsers/anthropic_sse.rs create mode 100644 src-tauri/src/agent/parsers/mod.rs create mode 100644 src-tauri/src/agent/parsers/openai_sse.rs create mode 100644 src-tauri/src/agent/protocols/anthropic.rs create mode 100644 src-tauri/src/agent/protocols/mod.rs create mode 100644 src-tauri/src/agent/protocols/openai.rs create mode 100644 src-tauri/src/backends/mod.rs create mode 100644 src-tauri/src/backends/traits.rs create mode 100644 src-tauri/src/commands/api_key_provider_cmd.rs create mode 100644 src-tauri/src/database/dao/api_key_provider.rs create mode 100644 src-tauri/src/database/system_providers.rs create mode 100644 src-tauri/src/services/api_key_provider_service.rs create mode 100644 src-tauri/src/stream/events.rs create mode 100644 src-tauri/src/stream/generators/anthropic_sse.rs create mode 100644 src-tauri/src/stream/generators/mod.rs create mode 100644 src-tauri/src/stream/generators/openai_sse.rs create mode 100644 src-tauri/src/stream/mod.rs create mode 100644 src-tauri/src/stream/parsers/aws_event_stream.rs create mode 100644 src-tauri/src/stream/parsers/mod.rs create mode 100644 src-tauri/src/stream/pipeline.rs create mode 100644 src-tauri/src/translator/kiro/anthropic/mod.rs create mode 100644 src-tauri/src/translator/kiro/anthropic/request.rs create mode 100644 src-tauri/src/translator/kiro/anthropic/response.rs create mode 100644 src-tauri/src/translator/kiro/mod.rs create mode 100644 src-tauri/src/translator/kiro/openai/mod.rs create mode 100644 src-tauri/src/translator/kiro/openai/request.rs create mode 100644 src-tauri/src/translator/kiro/openai/response.rs create mode 100644 src-tauri/src/translator/mod.rs create mode 100644 src-tauri/src/translator/traits.rs create mode 100644 src-tauri/tests/api_key_provider_tests.rs create mode 100644 src/components/agent/chat/components/README.md create mode 100644 src/components/provider-pool/CredentialCard.test.ts create mode 100644 src/components/provider-pool/README.md create mode 100644 src/components/provider-pool/api-key/AddCustomProviderModal.test.ts create mode 100644 src/components/provider-pool/api-key/AddCustomProviderModal.tsx create mode 100644 src/components/provider-pool/api-key/ApiKeyItem.tsx create mode 100644 src/components/provider-pool/api-key/ApiKeyList.tsx create mode 100644 src/components/provider-pool/api-key/ApiKeyProviderSection.test.ts create mode 100644 src/components/provider-pool/api-key/ApiKeyProviderSection.tsx create mode 100644 src/components/provider-pool/api-key/ConnectionTestButton.tsx create mode 100644 src/components/provider-pool/api-key/DeleteProviderDialog.test.ts create mode 100644 src/components/provider-pool/api-key/DeleteProviderDialog.tsx create mode 100644 src/components/provider-pool/api-key/ImportExportDialog.tsx create mode 100644 src/components/provider-pool/api-key/ProviderConfigForm.test.ts create mode 100644 src/components/provider-pool/api-key/ProviderConfigForm.tsx create mode 100644 src/components/provider-pool/api-key/ProviderGroup.tsx create mode 100644 src/components/provider-pool/api-key/ProviderList.test.ts create mode 100644 src/components/provider-pool/api-key/ProviderList.tsx create mode 100644 src/components/provider-pool/api-key/ProviderListItem.test.ts create mode 100644 src/components/provider-pool/api-key/ProviderListItem.tsx create mode 100644 src/components/provider-pool/api-key/ProviderSetting.test.ts create mode 100644 src/components/provider-pool/api-key/ProviderSetting.tsx create mode 100644 src/components/provider-pool/api-key/README.md create mode 100644 src/components/provider-pool/api-key/index.ts create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/hooks/useApiKeyProvider.ts create mode 100644 src/icons/providers/302ai.svg create mode 100644 src/icons/providers/README.md create mode 100644 src/icons/providers/aihubmix.svg create mode 100644 src/icons/providers/aionly.svg create mode 100644 src/icons/providers/alayanew.svg create mode 100644 src/icons/providers/baichuan.svg create mode 100644 src/icons/providers/baidu.svg create mode 100644 src/icons/providers/bedrock.svg create mode 100644 src/icons/providers/burncloud.svg create mode 100644 src/icons/providers/cephalon.svg create mode 100644 src/icons/providers/cerebras.svg create mode 100644 src/icons/providers/cherryin.svg create mode 100644 src/icons/providers/cohere.svg create mode 100644 src/icons/providers/custom.svg create mode 100644 src/icons/providers/dashscope.svg create mode 100644 src/icons/providers/dmxapi.svg create mode 100644 src/icons/providers/fireworks.svg create mode 100644 src/icons/providers/github.svg create mode 100644 src/icons/providers/gpustack.svg create mode 100644 src/icons/providers/grok.svg create mode 100644 src/icons/providers/groq.svg create mode 100644 src/icons/providers/huggingface.svg create mode 100644 src/icons/providers/hunyuan.svg create mode 100644 src/icons/providers/hyperbolic.svg create mode 100644 src/icons/providers/infini.svg create mode 100644 src/icons/providers/jina.svg create mode 100644 src/icons/providers/lanyun.svg create mode 100644 src/icons/providers/lmstudio.svg create mode 100644 src/icons/providers/longcat.svg create mode 100644 src/icons/providers/mimo.svg create mode 100644 src/icons/providers/mistral.svg create mode 100644 src/icons/providers/modelscope.svg create mode 100644 src/icons/providers/moonshot.svg create mode 100644 src/icons/providers/newapi.svg create mode 100644 src/icons/providers/nvidia.svg create mode 100644 src/icons/providers/ocoolai.svg create mode 100644 src/icons/providers/ollama.svg create mode 100644 src/icons/providers/openrouter.svg create mode 100644 src/icons/providers/ovms.svg create mode 100644 src/icons/providers/perplexity.svg create mode 100644 src/icons/providers/ph8.svg create mode 100644 src/icons/providers/poe.svg create mode 100644 src/icons/providers/ppio.svg create mode 100644 src/icons/providers/providers-icons.test.ts create mode 100644 src/icons/providers/qiniu.svg create mode 100644 src/icons/providers/silicon.svg create mode 100644 src/icons/providers/sophnet.svg create mode 100644 src/icons/providers/stepfun.svg create mode 100644 src/icons/providers/tencent.svg create mode 100644 src/icons/providers/together.svg create mode 100644 src/icons/providers/tokenflux.svg create mode 100644 src/icons/providers/vercel.svg create mode 100644 src/icons/providers/vertexai.svg create mode 100644 src/icons/providers/voyageai.svg create mode 100644 src/icons/providers/xirang.svg create mode 100644 src/icons/providers/yi.svg create mode 100644 src/icons/providers/zhinao.svg create mode 100644 src/lib/api/apiKeyProvider.ts create mode 100644 src/lib/api/importExport.test.ts create mode 100644 src/lib/config/README.md create mode 100644 src/lib/config/providers.test.ts create mode 100644 src/lib/config/providers.ts create mode 100644 src/lib/types/README.md create mode 100644 src/lib/types/provider.ts create mode 100644 src/lib/utils/apiKeyValidation.test.ts create mode 100644 src/lib/utils/apiKeyValidation.ts diff --git a/eslint.config.js b/eslint.config.js index a5d2dcb99..4e6c69b1c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -26,7 +26,48 @@ export default [ ...js.configs.recommended.rules, ...tseslint.configs.recommended.rules, ...reactHooks.configs.recommended.rules, - "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "react-refresh/only-export-components": [ + "warn", + { + allowConstantExport: true, + allowExportNames: [ + // AddCustomProviderModal.tsx + "validateCustomProviderForm", + "isFormValid", + "hasRequiredFields", + // ApiKeyItem.tsx + "extractApiKeyDisplayInfo", + // ApiKeyList.tsx + "getApiKeyListStats", + // ApiKeyProviderSection.tsx + "verifyProviderSelectionSync", + "extractSelectionState", + // ConnectionTestButton.tsx + "getConnectionTestStatusInfo", + // DeleteProviderDialog.tsx + "canDeleteProvider", + "isSystemProvider", + // ProviderConfigForm.tsx + "getFieldsForProviderType", + "providerTypeRequiresField", + // ProviderGroup.tsx + "getGroupLabel", + "isProviderInGroup", + "getGroupOrder", + // ProviderList.tsx + "filterProviders", + "groupProviders", + "matchesSearchQuery", + // ProviderListItem.tsx + "extractListItemDisplayInfo", + "getApiKeyCount", + // ProviderSetting.tsx + "extractProviderSettingInfo", + // icons/providers/index.tsx + "iconComponents", + ], + }, + ], "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }], "@typescript-eslint/no-explicit-any": "off", }, diff --git a/jimeng-2025-12-25-7978-removebg-preview.png b/jimeng-2025-12-25-7978-removebg-preview.png deleted file mode 100644 index 9c0162490eaa32aa0f062ddade699a11eb084b11..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 148489 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%0D&t1erkg6b+I^|AsE0X4-tSZGe3kQNuv~GUQDC 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 P^YHp2^kuRP^VJpUKAx88Z6Ja7DizvDOQ z?kl%+|H0cjI~z64m-yTg?_7~Kw`Hb!Axxyg_0K5^v-|_{bVezk&h<)Sm!d z!?&7@9?v`gPTJeWM@4ii6cRuMKGIglytbjcy*w@bh+X`Jt})>!NblG3@R9YgVW?^` zm*@i9tOscrHg()=%eEdapGXUQNre?`uS$okZ57nPj4FSaQgxK*&GNP`#bbcMHD}^l zBYh4EzZbD|6|ih&8ksLAJ-ENsg9qn)PQTp}_vtNNkigG2XZrM~-_pOOO*C5o01yC4 zL_t*kum3Chy07~>edaTt(cQauYv1DEcI|5mA?r-F%=@`=*iCOA(zUMhQmdbGhwbXG zmqTsSeQXcbcr@=G>C3yX{r=SC!I6%?KW#m)R+zQt<@$Z~d-I+5zJ_tNvFE>#Z|-{E z9Lw%TKAdj*aQ$}stGy5&X5gx-eac1!DlfAgCPA6R0gpVJH)N3)%@ktWA?j3~MnjWi zLK|E+ZgPY!*$l~X60HXVoBGMgDSGLh2Spc`Bd*=x1UTn^}QQZ0x0AYeL|J<(k4M@mr{{LjX?z zFzE-R`VU<%VVKh+P_pBV{jUDVK$Bq}8E+ zOZ!dwWLK)-e_!|5S14`F z@T!ZEBT}ja;R6$VAkQgYi^QvyGpf{h*2Xn17HFlD1k0Osqj^Y7QorQ4^MUtsvtBM9 zY_;(%J-j!`d};d3r{9L2>a9;d(7*M6`nUC2ANw52mRVOM7^v!DBLhNS!Q2X%zBf9` zHqW(<+OB2Qqpi)#QsiH+O2Fi`a#Ba5ZHSxtY`W? zy82+Mb0ZZn+uq%5xe?YAlpC=}x0ZL=v!b?FH__r18A8sz&ThQzYGiw-Pkd`sgXPlY zORt)@TDA|^l4XW^2GUH%-?^{~<0Xi~XRt_7AedlM2mFMla@ZE63Zbyr-@CkVjg%i zH)ZH-cnr6ld4a_i5^Tv_bzA6r_vXF`N#ByU-}WfbNQ>9Hy1je&Xt&l z@k}Z>En#N(5KLueu!}?jL*z~6iY<|*syVfa-wcmuxYwM zGkp)zpBd|$kom-|3Vypy_&^>Ne~Dp@$fBET9JGaT*GE@D`vuIhI#MpwN!`#O^+E&D z?~XjwMX3k5&4}CX*;#0u*A2OEIYxZVN08!7FC5dOAL(|U+XHvio=!DioU#&M(f0nJ&3sF5-1&(9&;Q21t?k96S9wd<_G@G0GS?L_0Ld82=;ZF2bAlKjukZr5^) zTlGLyWtCbgvo#ZGlyfei#X%rDUc^c-5k*%MC2@ooeZ=hGjPEX>mSVTYb+Q9J{d}Yi zF(>Xd+L+EuoNdh4K5ycCoS_FcX=B&NXdjK<=K~+q&)7$n+M#`nQw|~v!Z!>`;;pb$ z=N*M@*?>8qcxBYnen8kNyZd7?P6c3_>;AM|=SDkw)i$&oiJiPxOLV2$Cf0c_@T>{) zapFsvDO1^66HgX}Pf7WWeWHARZ`KLP`TkqynlFof>u>q3`se@W|GeJ*%v=1{e#WEa zhUbVuch2|?ezU2`{N&_JbOK+Ae}D%>;dGFmn};4j5utM>?b7TT->$Z0yA_VE>C0MV zUvIaz`8CGBbltf+!!KPIYQRt25Bud@l+WVkvfo?ZdkizXQ}`gG9~y)|Mq!hwiKS%o zVSia@w_E!PAH)|;)Z~-$F(!7&pxWRsl$QoJIO+U+Qr_b4-aXUjectQ(s;~Z1oo_#* zd*`3j+1**&a$gTG&t*Q_Z`oXD-jT%SR}v?wyJ#ypTlJPrxy7u!h4ST~fN17y@O;WI zF`shKb>WW@-5Oo58k>kk zv{x#6f3!N1Kxf7&2%m8Xk|0Afh8-Ep`XE<|X2vyGw z7Ia#Bc2R z^B|R*D@a|LVW|uj^~S`nQt2x7ut@sl&*yo<`Dp>W#yuwl{dJ zbbYa1i?(Y&2Dv^E*sYwkZ2V|_Frd~TLA4yE^2nC*vmzO?jTfSozW$ zdaeD-B7F8U_CEJ~$al5(xy=uvIs5vBc^u2pLOoVrUuLtoULo05dQDVVprx3$=rHCQqLcn%f9u6UvgVt z_=%6}-kJG14#xL{$CR74BkS*o-oZv&R(?@ni z7r`}%?HUtFV+Lzks-+zII8c>`wM)iNvgw3cKOpstpY;wM`>No=vf{cwZSQ71&|sd$S}jTf&Y16$c#~B*Ux~_g-~+%&(zWir^?)SK{k66J5C8jr zLmQI($OGr-6HVBfwt6xb37F5svR#q2W}BE)%Oi{5*xOB}<*-}Xwp{h2Y%kiIiQR1M zMsKz})MP9UB`;(toeJLH;GRQk!*lTYLb|kH&iS&vyWwo_#x^%fU?<_UZ7b!S!3~VP z_fbub5=@m{dl_Ul5e2KAG35h5J|d_tV>PkMTKAsyZ?;c%vjCX&cs^=oD($-at_30P^x62PPMV{kE|3t8iWFT zOcm&;Ju?<-OsSu`05vfbVUF)Z8rd4NO0`+j=u^IqMpi+5X=mm#AqZyXIS4QKI*%6p zVZBkVM?QqR;4YJm$aDS+LdBu0Tk-;!9nBK-^U@JUw`I?vL? zbKrc*Si*04_{^vHhYu)_yidmy{pu$_t>5_fd_w>9Kl#to}U25bFs zd98aMy#4q%iHUbZ7SQtu;C=L&`F$Uodu~nLIAc#Rm(MMzXW`g(y>VO+HC zST6RX>n>*XJ|=f58&DTU2G-nH~;l$HwJ z$1zRaY=RB*vMCnLb1+wbySJY$?%3nfLXyViG-)P5KfJuqM?dSXzWg_TiQf3=9X;IM zBMCgzmR>HbueGf4>XY78GXl${~+ZUbh_pv>xlm#`ZacX z{DrSR-D2!^k+0F^$JDlF=Hrr2*ctb7%}_FJ68^P13Ve^PQ$#nO_MwBsc9Zc=M=)78 zBECX{g+Nj+;U}>!7Le2j-lQ)dPW*mu-P)Wf|IYiXci+%2{rubdUH`(rs5f5wu=0oG zHr|M5Oy1^;Y?IhG_+hcvVAAL}X_79OeHWa6;RwVk`TTd`j4z20uT-Dov< zi7%Olpiiiz+ z+UE@Qqn{uIz+y}OCKg%{P?h&b0hwW+; zk%_^pLCD5Ws$cHklseSv(3bJ8XLLMrET2&u@77I^ZTL0t^KEAF-HyWxQI7@AQq}w< z!PwrJ*=`GWCwVcMzS!2$*{(&q(OTegoh{-UPrKFU-jztm&2YaYt{YU_yK%FFKp`90F%1Gq;B|Zj7cIX*NqSdo=U&r%RZ(r{<6>3I}d(EWjxo+=kvr`@L#ftF%~m#J1OH*ycXdy zwTY+{37f)i@1if_uq}Iy5$PafCw!_NL%kkcXs4i0{9WGp&>-^A;0li*$vL{~C~7@g z&KMG9>K}vqN=dAf_Hz|~YY=)ZW<__;b|Ac%2!(O z7rVLA#677{7h7VUDpCd|K#reTg9_ui#;oQmG-D7{ent+2uQE_?!OYwRk*#U?HKH%f zDd7^Mc9@7KvVtF>FO1h3X?us*f~r@15YrNGWVL?e*c^|+9KVq>chSvM2p~ED^~IQI z7!~Yv$ZacYFzaNZyyYjBlM^fJ!7fNt*Lw7|uv~0ct}u3CY|8XC@!D2h^Nc5qa@dFZ z$U5TNHZhMCuvOLLq^4X3Vk*Q!ss16jR_5bE)ood@rjHjFjO^CBc(B!UUP$6+B=Hmd zYyZl>S`+w8|Hg5YjUQSWhumYTW!sewP^}KzN>Wdp;uuNv`Fuan9>rS6@D0s}G(<(b zrP5qXy$bxoj~vWC3$Nv2OMq?glRz zJ)Du3EchNS6OxGty66L^_RWj<0}t++0J6b~ji;%yECM#fCa5>%!mh}0aMQ08$|jxi zdQu9TK;7ur$fFCGyXvFUE^Czbu`L8wsL0;0@Rd@|=i(%Rl(`vcbE|m{=K3jfqTa*? zIwvvniSwK{^@k5QmIlbx>%tV4pX~$S0ihD9zjP#M@b6Lj;o)Sp{z~NoLbD*Q9=aAzc9WK|&dI zIZt|cd8x~?)xhWXFZqqH>T|#Nqx#jie@S=WxUGrL@9#W(OGEZf&zi8CNa(d)+MZQZ zA0h~l$>bf&e!cucjsP?HLdNy9LHVW%tmT_j&qZ## z-c--U`87^$Uy;@gWRZsNSVUKdd3wJhTm-gddnp}~_&t;yma<{TW@d9^-`Oc6>xJ;a zy4?BD=&~19ij%e>YS|c}Y>ac=DB0(B$vnwED0Ls*@*5>NTxAx08I{kp4c81w(Zo;K>hs87bRE@Z z=}YI=G<&yY{&a#!Jl3G!K|p9 zoScN-VEJ;0`^>!@(OwhfFbtWI+7CPIHAe4I^s-iemN$mkB*YEwn*4{FSj1tQYu#Ha zJ-#G{j`{}2j+R6}i_J@HVpOl2Eb`PXv=Pi~MnUedir6jo@zZ@4KBt#?teatBLz?Hg zCi^Yf`*Ob2IEu1#aanZ1+t47%af0h0B0iWHA z9$dUlNcc~wUP(4*x1C68#|OqjS$!j5R_Kx^--6^9dw%+ zCV(MJsmjtYxo0);i4vPtN3g?aF_E+0(%j((;G z_b&9m{a^p@ShY#y8`a;zclV?j=RMM*a{;|gyGcgs;Gi*B(5gfR*GfxMi=U&J{44!*K*EFzhcg{tmVpSx51|vd9dbF zo@p=}V&4qgr&x&_bB9Y_%}beN5>4vl49*cL;9Wn*jLJ@ z7kY5{mM-T9nnl_vEG^nf`$0ikwAEZLQ>=(@DTxGIX%~N?UUU_GFwnR2a6FrAf3+X4 zpH+5k?Q6F7)8<-s9dBFqlOxH?escSC+4gl!OD6iVkH5!zO!b)Ka=Wd|D9te3wLhtn zhmEu!zQevDmTvG2sO?=0ao79P`IPry%@E`~m*t_9y0hR171pTpzzV%jLjTe){f+wV zzx^N5<;7+FyXo1y2N}0Rg|*PMpTu2td%aeNZP^VG+YXVbCl0eO*iA<9135EB??=O~ zPpN7*?)%Z;b8I6k;5oW~{;u=sbe`w*W4HRAbRj&c-J^ODA8S0y_Ilm3l_r+=>X8M$ za*<)R4rP1wy1no>q1Wj8<1%S8sk0`h*hC+C6G+k3c4i}-YEochWH*P^JkMaOxm@US z`#{rtu6d?h<Z8nYLV^ zda&bq%MNg<*+rD9bbTpot~0^3_*1v-7p+`Gs11wFzV5(S^N?lgAnbOq$Q@E)H{QmM zH$-xR%8G`6GKhM6;Er|#Tp@f`jKmM}U{rx2U&pGAofzm-9-6`fjgjsBWX}w!5TWp< z^^tK&`6k+RYkhdz%&LO z_5KFCvD$l5#DDOfoa`z;Vw!l%X=ZKXsC-%Mp@|`gywPjyP}&(I>QP7B>~p=@BU8Q6 zb>+ae%6O9~Y~~=vw2|aIYcH#NkD4!4zf$bNhu#c4Eq=T2nj}*Ii4lvorDC@&v$oup z6KxgxP-r;+-ZiGWxVTXM_lOe)$1v=fFY8;mFSX@=V zbjlhv*8UERiz~Doi6KbQ41#S~7_P^s9o@0?*JL>z4PN@r4KI5SPESv>8iU#udp|E( zxa^zUBu#Dab+(s<&fQZt&_i0d%Yy3k=n}v4^0s%R+7<2Q-*%PRUfQe0-K8WU59Nx^ zcD0$=)Ux5EU^kgP$^w*&r1(fou=He5>ZophR^QZjdR)xPv=3~g)Zh&_wxyt`@)3D} zU&@8HW>iU*^-mL1j%_xa!KYH{Gj#duHWiDX*p$RhIc*;Thr}0|*nDNmgknP(kHYSj zv9EgMMKk~9TIefrFMem0eE3GUB{|N7Qh<5eS-^*zi}RVcah$02nQ0@rZ}~laRzY9p zTe>_ap8vt8e?%O~2`_s0jx8ZcP+OXZWF9C`+1cmg~@Hp$%kND~J<{LJ_GZ^KsyT ztW-?G79R!By0$LbBAY#M4TR4l=BYU#ufRD`E_0>Id-=WDOKX zFb|o*w3YIw+KqApFh@`{PnW!zZ$Wr#dy&4MYP#I;MQPSXxAedK7k-y69zM{>*fNYN zrC>klWIWYoIFX3Q^G0TE4vdQFqiDOvGmI)=NW5yt=o(opls=hq=992w#!!Q0seyV1 z)o(%OXmoHSo-QajX~4J(S2WT!y2`d}QydPExYt1W$kX=XM=Pq`kyt?uVWB(S%gAK3 zUdFhOW3CetA(Ikv0Os_d{45IgexW=Ie^2h?nkn;qO?5XJH(z5Oea%fGY(3*28eW3m zRrBtK$Y4uWJJ?`Llk~B_LKi<}SEtp?wLci8PDkV&Y-^Q`E=*_z&Ce>a)jiq(01yC4 zL_t*iH)&d~q0!aNGaE@A7B(>WnH@eG>5Oces8dncK&6S_D5Ia*@sdQQnvf;F!0T)N zzAw@je&VxqKE0)f^L>q{R@xoMk$Q!Y7$e3DxA5$x#8Q?GR7=03uuboYg@SE{H=sZ= zvklWuyYqNtBh`Ysv5rNRRvGPPH_zM$sUF%vnXqa**`R9zO60Y@g1jOAOk!wM_`eP$JhgL*f2|+NsfVSb=zH^k->`q#j*RP)06})PpFr>Zn)CxjY5p&*jk-Y|C!wmRY0WvmTdW zAdeP#xsQ0Vj2aBuZpFhREMJ2z9(?&oP_oo%NJzVl~#GZj$sST%$e4NUj4@U&Y3MWuEcnaqT}%bH~1}8xH&#y!7Yr zviBgrq0@&<2KPgOmFyJtOpYvuVC~F)a{tG5->si@y{F+H)&7(Cxv75SrH~Z3I~q@d zVux(}Uf$b?yz^_CO=xBhi9UR2N41O3MIU)&p;yt`?><6?ZZ-|{0-Xzyhs^dfWl9G_G zgEvKc82%7R7FRK9sBiouzD*kq<2WcueHc_}P3k-dbXnxX&}5nYc?+q#UkSvs?WxkM1VKCYYJk&|}lq&D^V7I{TKmWOevH5Upghje3#=br;MkrfG~Z;*I^C%rGod@X z>zL!Zw(CuBo$ZSzJC6O`#Ao}UT6}gy5x&##$LP8#8wG{vA)IXq?#t{^yN85AqHMsh zp51Oo>jrdKCrP`oDF$_8q|uqpDg4A6%nXyH-Y8oj`XqS7B<-TuB*Eu@ubMaZPkl!E z%HRB|KL3k9SNBQew_iVzPcGPq?<;S<$=_n5EKXncAOuxijXalG`NS-wW;nxV)7?Yr z{5XypMyX)vK?<@z+g51DNMf(xjX7`0!N{9@{to?^H{}y4vP~%EJ+)3wM-3;1zTmUc zvXu99b7qYJbwf9)!-LF*FBKKluN1!{4+onQ!&7G*v>}-vwv7z?*o`&rq1{7+l~-)r z@}PFu3oVT<*FNtlhG(|IYVPI>jjk$gxWqnKs|$4xTU%>YW3@3-J8T{z_r)eWy4rh# z#B_|Abs5>r;tLHbPjChdlP=G>2Y6E-NW?Qa{H;%)>!19`|0(73`mK{&%EL|S*=EdH z2)sUT^8ms?J--yd9Ly>*Yg6eQvDqyi+qFN44$e`oW!th{`I|38b!$J)?q(0qTa$5l z!Zw`a32k3kvwr)$umL_{U^A0Z*0z+_#plMSosQ?$$TM>MM2p+A6zJ;F``P`JI^x{a z%Vu#NoV8 zzWU4bOP~HZjkhPwn+r|8>>&|BJM9rBw^o2pN`<6_TLs|}DgA2Yf_$d?Bna&C3A#a| zI~wAKO}ZvMTsQYcp#w>CkD`I3S`#@*w}9G-r5@|BO6K31N7u<)aI7e=(wV; z^4OUA<0NRt4EY13;pt%BUE{l=PJS|1rHBQ@An|Oj+;4&$5 z3$vW@wMN9X9V+D>nfp3q{nfQNN%IVN5FIth&^LeiP5nJz^kw>*ulO1j^ei5ok=Reh zQ^og?F~o7!wcX+pbw@B@5Q@5<+z!xz2HYsuQQ;-KQC)K!jv4jwt%|*I`n4}Ln?sqH zH#)vIQcA%}bik(FH4c>TtzgE@@6E*rUpu4x!AJ4lBgjMqkM(rCpdMS{DGcl<3gIa{ zzIW5!-(woj_PdQYiJDEVl-&&(|Bu2{ZTEGWZEL^rhrSYDAim@0girVRn+PKh80i`?~7Qv>3o)3#V{JE=_> z>;yM3wr|ih@uT|PEL7+0N$k4)v!d%Z=M4C>a{K}M$&7!1%|Dx_nV8(I!4-$LW&1#m z{XzLj3ihFo)Ok04Qt!nCJBYziaO*zWhhq@k6|nJSW6GOJ)0rDKD9|<9jXZH9&1|7T zv+p*kvqqcVwM|0p<(Z8zpT~1!Muyv(toxgK;>klr^z&S_oo8)%8*0gWQWALT@RK@n zFDM$wJooNh=pi}#9OPe>`+L6PRsEK)`zl@f9?AS%WeBz!Na!AB)>~qoB~LQRYyDyY}Oug z6%!lYQhatqUV+cToOg(FkQGo-N?rHd77A*egNv7brUz}aI2XTj7BajUL%aDm!E_K1+IEf_S)Q&1yU|)u z;wjqKL6^9il555=fIT<|N&)2BGk)=fe^p#-q=GnrJkzt>?9}Jy=g+jQ55n1_M*f3{ z=iRnhY`@#OkE~}~wy)S^)3_phK?UzQ)-o((Fwnz*jc2(t;`@jg=zOOMBk~<)*S1?Z^G3YHUrOWvecEdvMw0u%r%i&K zy9|EI+j{zNvE9nNeO{n@$ea4-eeoUrz5l>hX&(3;eSDyr)JGJjF0MANpl^qP+d7@i7}K}IYrF7u!hn4z-Cos8LUc&tnEX=V@mOL=>5?Z zvL5_HII;5KtFW}QdX;V)1kyk&aul>re)!o~#@TR`i>3FKs z)6@DOdyzMF8ne@0hjooBXtmVBN*}Npt(84P~(G>NyhA=Pg zth%|iewONc_CVXTf#bwmlR5Rb__@p{_lbG|`gyYQCx=DSooV3waw`!A`>P(XvA*(} z^bh`yFW2OERKUn5c==p&Iais_mE<+>>AAkS_fO;0=f#P)EQ#DJ!qJeVPx229Mji$> z)T{N`#d#60z9NfmkD}-{c_1Lh#^aZ|%~m2mR{!xEM4qG{Y*12F!G`@l?QIAy7;O!b zI=>mM;DkgRAK`bXZCj_CQMXP{bnC3)>~y4#YV4^OB=O(5eS!^d<)ajv)02UR5GkMT z^Mzxe|0C@rnI*aB!N+|4AT;eTO13Ev%xjx`inWZQ%Cfd>Q#^#~kvat(>w+dwY@avLfc@F@ikP~ zmTmQ8NDvh4$OgFmX^*wez~I}_tB!9h(pKmik)=+MI>A~WJ~p9`17Rz8oZaK}c8mKa zJ7O$nXJsxft7tlbf8b+2~1-4fx*7H4^MhiyqDl@%WgRKlZ2?U zUqU^ePGcWF$#CYjENmJncX^qG?@`A`5`F5#_R(;yy0>W?&92Pu*?6nK&+L{#ivw9< zGY`;34_i0WLYy<3Ddx3a%JGvvr2bT9ZP|2_*zb_kzxua)nS8p{>DjE;UgOQ_Bd_Z9 zH*V|I*KX_XD<|Moue^F&ue^3guf2X(Z@dOxe--+gKK#Zj`p}182h>6K8oF0rxvM*O zZtM1)Gwff{Yp=bcS6{tb>4&g+{f*bC_qyJIzK-py_M_zwTANkO$ z`sf?4>%-w+e_e09M!T=v(QDLu?Umd5$m_45@3^avfA|%B{3GDQuj;ct`kFo-`jOZ4 zS@da-lwU#iiazqjU47)kcl9yw;Sb%`8}$FxS5EPN0#5Z2WUn(0uim{?llUob=;VD8 z(5P4Lo*{Eh=8>JAjk2_?{g!Xs;ak4>59nLJ`J41D-yGRD=?{JDx9AW3!Ee^Lg3v$s z&B(sxoAn33g?h-o`J28;-~3JASn*AN;2ZRf-|!#l8~*@+elz&`-~0RY^}qK&1fjnl z`t{)V=|B1pzaDfNKKKS?-=J^!eGUK7@BapTeY3vl4}6Qh=^MY582@Aa(f{;6)3^P} zZ`1dE@Av74e(;C&bAS8i_4a4p;!S+eXMOCJKKhZ{y3JdB68J6N;7|G4-~{BWLzyo$ z@TPorDxF|3Pv=kzR^wSfFuDRI0MtUeJ zv@Z>Z!c{5iHCCzvmhcrE2(KyFK9JgnrUE8-Fi*DY=_R`X%Ykty{OUh_gDM zWWI0gm2KI+QUB|T^$0Ld%O((MHl9wyuMv3=8QYxZ<>pSg-pHjKY-m-!lJ&ZgulbZa zA`g1G(MZ?;@g-s{G#2^H?ihsc@?LH>R$=F!*+{3Uu(kw>_PT+xN$#QIL;0lJuS!4v z3x7*L_0vD8pZduk*H8ZBPw21x%unfOe)=c$(?9j&$bVcv`BOitzxoqDsvrM}AJUKg z*bnK)f8?*|M}PPS^rO%p`H>&gkNjxEkNz0)ANyhbIQY>Y{Sp26k5cEyegwN8(vSS` zU(pZ!&=2Z|{>l&Nhkx)d>xcjHU(#RtZ~vlx@W1_mN`K%l{6+mYf8qP|-+bTq>o1T{ zzyEu_N8k5d-vz#N!FTKXzx%uN{onOHm45GcL%;8PNWR}o`Fr%e*njVL{@42M@BA(u z(C_-MDSwx~8~T0U`Q7@S@A__qejk3m>pT9ug75r}|4QHSXa21IHR{IirUjX)g zA=-O>G5d9LFaqo#3J!#|(8sgWD&&%KAU1)}0C6Tm1y?~YCwX>$#=i5Z!(N5G8?w_b z`fgJxyJrFM6PkLFM-(K6pinn&IPKvf7Widt#J=FWu-R3F59a7QO4id3sWVTa4EUs3 z>YqR!C4R$H2_5SH@a6oRmU(sL=VjNo z(35dw6CSh~Rm*25qi&szx_kRXcX(wDZk=v)`|L!ar|3_{LHXS}G}sJQ+8sS;uz+hU zBjsQ-TAQ>}VVw@v+2%y2{9t{8uM^6fVblq+kJM?2VN}Xpp86@DY&KPX>-0>wPtHiv z8{NHirdMv?f!?Nkf_~5&ufD4LZ@;a-@iRZeL&JCLKlu0lJ^gF{^1rNa{pN4gy?5TC zFN5yhIjhP1WSq4bN`0HpZ|n6p|IMf*^^w>IV0#(UqWY%pl&ynh+V~u zjV#c8gLn8yA_`BYGa>z8YO7O zs&3-l7YEn?@`y8I6&#zyR|Sf)0_dfG>?^WzdU_hyN0u*F@$n=467**nhRjaz z*l6uTGyAaoSO*`FfxVA23;X>FqqnuAY#QN%7Q;|Ck6sQvMziCZn?8gMrQ6Irk9GcO zC~kJ`9@mrhF-0d?{YW`TIdq;${q!jmcJXPohX(d3c9+JR#LfncZDCWT3o5iT@Vn*3 zR{VZ`M&iArjW=RF#(hPmEs4j&td1dn3ZA#n!CO2`$S27&*g_^`TZt{*ypGlXed~Ow z?R24ed%0lJG+hGfF@9QLwxbb*Ziu^ru8;J8lW5VAGxoCH?TFG5s&lj7g{Q zoAMP(=VLEu+AH|jK0Md?{Ri4U;OPcGryfb_r+WDIJ*E9yJOG@MoZoomRgJd(!uNbP zZ|=WaU;q36-}>pF{4u>!llrMtE~&TCT^?i#{ojn%I1a#!t29s^hCv;+We0)=;jJ8Y zqm9q?5aqB~OE&XNwd-`%;4zmvUXQWq>YLjj0F1h?(87F{tUW4aoz@4)U1C!BE(6_#zW&{$`s^0pi0{v5-jv8fBMqr+~t5ht$D z$02qdkF$Lidb`DF`@o%jSY9a${0v_DGjse>cjET#+ll{?lVRUDk*O>D(fVri>lgNK z5?X1Qx&AoyI00tseL<*}y8Uua^z2WP|G{d%GIfkzDwy zNMj)F)j!-;W?kZ}{-(}OnYuyZ3*CXuGoRr3`CI|jh?o3>Uz*nCC5e%3VGA{FvmNS$ zH{YVjC(ZM=Vw%sX54PvpZZBASg#-r#$gm@Kq;XDKQ7DflY_FwaqxJ_a)xOXPgtm#zaP72xP>wWXbaB2Fd6&2RQ{J|3 z>+Ix&xCv>y)dfj@L-HLx5Wh5CZgqM7P}_?O`mAdEWBjb}dw$Qqum9)o`M>KIe(~q@ z1z+${o$w{%-o3YV_mw*uM{6^T8i#>8g9hk;4AOWQ+wgkq;bCuLdtW%>?51lYaj0a7 z6hvw-c9q0Bh$fi1%vZ(?;n6uCl@E&<u}40oh~L_o?q(r>23XnFaAWG zhaTL92dLDuU1Knvw?_5DZt|2(A`g8W?0ucb*>-)8yO-Z9eU|=gufsVgtQ9X4zgpri zQ`lEx-A#tQV`cC8D>V?!W)B)!7UXs2%-iD=OmY$}e+UEXM#YyMmL zQG9HJn{Coo5MR(5BQX(Y`dTPwlO!yqTtTAWFhujVD9MIeP@&&yE+o!02-3qrW=pkf zE3e6Bz++Q=lK82m1~EnxNE{$<>&sjf8jpR@5_0CSkVG41+AJjP(7?=4n6?WT zXf$JVy4ln*i0;nWExpcL`%nMGPt@Ps|M0i|Cz>xF=!?GaBhvhkWIkyaw;*&D`McJQ zpI~J1+QzexOB0P0Q56xYa1EX-ovSbw5+=Ioun)B?0R`ROHf8)QLi@;`{&w4A8q1ER zOKe3)E>JPDjD6M}1?ISQj_K6)vhYAyG@lpU+T7AteCb#4b!e1XqvoF7oSpKJd8v|$ z2f#sj2r6m$LuAou8Aql>wO`m5c9%lv;zbf ze>%OO#!=>TPQoJj4Vq;VPe*hm4G<3*Nhz}~RA`r~)mPrcXB;PnN=q9v<41fZE_^!9 z=gPbR1*zA{v*J5GI~7MA4vQovVJh^6=W+!LVN|A8JoE#*)8!*xSo~vg4vggWHjoDRpbSLBPaS3XCwd(@PY1TKv$FhxVkzKps>%v>9cZvsMR;*!J){CH zDnL}vLL3$JS;{=k!ga+^2JSh)S{%}T^w<^FAk8tp+99^12EJ|-9;U=Plkx}QoHcYg z>eRRN>0f$BzwK+jPMhJj2JWehP5w^$$b(bu2jd(Xh-1k8M}5y5iCAnC*C;a{sCTDr zk>r#=OCgThSLps6cC3w$fEkI;or1pY=YFc<=4WQkrhPw&U;r+vGA&0Q7{%J}I4*0j zGG85dO^cf8bC9-z#oYmkJg^$+*)*z9s6!qrS-yXz3A{uohlT3W7q&vPuyQcIk<1QG1~^6o1@86=0@W#i>w^b zwH$X<*L)t@;U8W6U5Dm3?KH$Dvd}fUtkdW^PmVf>J@N{6_DXB}^qp}igmX}ra+hJg z8zHt{%(h@wvx%igmS*r_kT-Cv}^*<#Nr0gms0zT5?+jVI6HF^uEF+lAMDFYL#@bl2K{7>z}km( z5LF*Ga)u%g5$*m`h6>$C`YXW7QIpjYPs)23TWgSX`9PYs1~}w_V7eHnf1;0m=(F^Z z*FP##nl84oNAZ#)+bYCuHy;$LY`~j6svmW!BkN@$Fio`b&`8&#TLe!;Y*#zG(OP)H zEPtIp zY%ANHKWyTQRLC1<*#r5OSM;5na^<1kFJ-Rf=*!}4 z7YXIab|6BLkgC4xNKEA>ben^OO;2{^Qpbk?D&4iStJm~h9=kP?z{Mw<=<9ssW?o%|E0m-P+OHdGa`(6+7BHW{myDAoK*(5#EZpo7CUx`@yxn*lpAM<^*I^@8xi z-k^X`tB7N%W1B)dZSQO{&lD-gy2Wa{8P;p$FvQNao+eSppG3H$l@eXlXvH7=IQ zda&NB*AK2fGyK6d{(iS?*U5Xon|)+^c8kewG{$!0?M5Se9PGWVz2_fSFM8Xu*#Ml< z(f9iUPZWZUqMYc+k-J&N!3x=k;KRvCL|sA{r)yua86sUNKQbvg1tqTl5@zgOki zC{t$#9xSM;96Q+}=;fxH7%MtLq02)lk^p$Jf$3#fY!Bnv%MNX8G9~FmAKdIC?H~G# z?H&_}V5Z#(#O^AM|N0<7{|XNksas`S!q-@Ke0($@TF%!0rLd4$w{D%0lrDMmzR*a* z*^uDRPEK{_R{nt9s6Y2-zg>Uv`~Mr|ufN^Cb*hQvf9v)Mp-dV#R+2SU6xh-phPV1* zH{QNVZC6{{)!g<$xeBkn%sV^V)x!2p7J=Q$+$t$n*sgSc$sM&XrB8HlC&&~JRLOJS z1HNcXe43x8!kqB{%^JMC+{*m-xu5&F$fT3aS^cD+^=hL5iUT~_<|^5Z>YBqE^>^8k z!5H|7Zg|=I{nB?J6JW0%Wg}RCymFn^!rx_=_uBVt))}%+JD+lpvUvIrP1$TPLH${l zw}r@$5xdAjgUWMDWz*|rsV&WvgPGLF_DRA?q7Aa4Ax(l=kPQq;eMc-f3ldu)rrJiK zj+Jy&{X{k+D>B|267tRmw$b-;r-h2R=i1IPH`=d>_gX$J^L8mB%!XYk$1e7LysFGI z@s*0$7V4NfgcEZReM{~XI1|7gu}{4q{G(8Rlh2<^pp8O2)%H4iBNSNWRomuli+N`vRuUw;Rh>Cc#3T)Xy$}@a9LUeniW`}Kw zK37yx-U?+HT#(s?NCwKKMEnmo> zp0-5_%V zyN>q2rpz8QW421>v0DQzb}ccj@3PP!b`5Oa*mha< zMS@JZA#KmQ{zsO2&8{F%eNbbeyr$th=xhmpL}!w1m(TMSe>1>e?1-Z#W+SV0TP%ld z(`K~|1&du`Y?zck6~EbRbmz{UI%Y}MBkMN*Sj~g`?}+coIy*hlAN=NT)Z3qVQ*XTX zir#qrE{W~F#!<@KY?8Sx2^72}1`(H*!$tvbAJS^a+=SYeU4Yf=+VFbJL7laQeSIC< zVma+hZMU`-iQUS!k+1-3T-w=W89cbZReLj+CK7l_V&956u`Vx)zTk7eNSonAGrH+= zR{lVn4vqoi_}Ah$JTl#I)PNtMzoFVoC@#c2v6qCxzP#t(6|;3bJ+_wxeXRLl%C?_L z!}jC+Fx|OH1D?;*Xg1c+pz~3O5470jjiK8#x#a9JVvnFgc`Vp!OM`h0=H_7|YUNVv zE<8^Z1td*Wct{#(w(N6-Tdt$VMv{$vpcP-LPqQtShDjnn?J%LARI2YuhOV*5OQ+}- zdqSWY{F;v6aQ5+udd;V-nrPx>rcXprW|`!u$ODX0$H>Jvg`fKyG_rDlqC$*GtZU5O zo?ew8b&hFdJG3~CZ4*<8&2imcMmIC4JDbP@`s1ML7T?%*yXqG^x+;x>Z0mCQ-+kV> zeOrZxhwa5fy?XZ*O&90NpJIRK%}?u({NX=Ha+mUNzWE#gG~LV zi(ts*eTVJ$z0DgMOduYHA@l~5mqYx)%;XC)Hug(De>aTeb<=uP#ywx_*L%BiQZ}Su zDK2DPci~fs4Wr;B_VYZm$uy)Kq;hBn&g(|f%ayVzRa)4@sL=U>|L}-7(`Ep>I#VTx zD7Hc8LxaQ>`7t6V->A29sJ9CJnn|dteQdGCUqg$PBCX?I{V9avW6onlCec0J0K3l+VuCo^!*$ z+F-;s41{yMuZU+cmL z(OrkGTl?;I_NcB84Z7}_Mi)eWov-}eStGZO%$u6Tj<57DOL}kDM>Y_)oh<8LP-6=rpz+krxtK;SF}?O|;8ZWWFpjfKP`m{}}^A7oIe$7`=q#jXmPSd3Z6^+=pc^~MX zl=bz_+l8cE)V?e+J~&Sd3==g+70Tmqs?Yw|=LjP)%)~V^9z%sjJo7MN8~Gkorf#VS ze;qPMLD$)%Z1-ZNH(hL3TYFDqYdd6z_z!l1g|U55e-XbYg3DUpZoX6Cp7Yt+S=g5v z!%H3gK6v&JR`gP~_Y<{48$=ccIpUzuH#j`)QfFmnn-fe#Ao^K<+=Xi_kWMhPBsX(x^?@M zgxA)59yDxg4|tVmmb`|U>r!K+DLWXHXj)}mAs`RM*iAsZ?G~)#%o|2%C6ly_GxG*5f6l` z!j2tw+?ugX@p>JKgVT=ff#`)Vn5Qw;3peTuAL=6me6ksOq>B$$$>Qf>pOt(~7)+A< zglnrW_)f2J_iIX2GGs|yPu#144W(@meT?>$L1x!7{tC3BESomli@@Ht`M?=xSRbV*>wZ%uSPffl*;teN$CYwC0&Z}znw_331irl=Phu#>A) zef$FWst--OXse!b?04wp6-|jY) z*fQR0UDXc-+ZAF{cf}^7I`uJ8snm`yN3cVZ`pmcnosZJq+u3`*>ugt_br@)uy^OM> zmU1r@k(j0X?-bo9p^E`2KpJ4Jyq)t*RPFPJ<;qt2_(wh_23(^o)wPl}GF6*g`dW&v z`HD2CoepyaHomS%-%r8wYb)cicxSX1?jyO7P<&L%8n(z9XXl#2s z-G(`5&noJhS(i84{Zfd$%fbii@|urs4_E&w_qM`cXWwMGUIX`7NHj?>p7Be9Ed|=p zYc>DTM;_Yhl$!WbXQIDbwQ_8N89x&U4`Lhi#2H%4O($vxX{+#*Q$bv%B7X2!?e{dv zHg=ts13mpqpCSW_AKFZxdg5+;`Xb_NiKXf?2Gl7~G?+CTn0jWJ6_RxdL?+uBx?Z03 z000mGNkllSb7=lbsN{I7KT>{J5@UK~mG`E<{Vc^fDE<~MoUwJs+0v|HI; zQIfj7FUQbsE1i%^>e#;JI|Ug zI7>hI6$o2K5A&MP5q$K+A5qyBQvRsSnN|nYD9zxebEpcNx_GFCr?}X}{1o;tlBw*sRga3nZA;lG{W?v{?$}1`Kw5>L-rOSop#K zH8k6j#2AoFo4-YlzcxlQz5-GsHd+#IY{NIScvO= zCVmTThnlwXZ>h`J2b2Ypz$%3o2@(VFqXzOf)l?G;U0_8CM0k0zsvAD<_VMBy~_@TE~ej4-37X@O+3FRRCE| z+Y5J6-Ghbo=_|68iRXrmw9%^0&6I6IIPUvAvx1 z`m1jc9{w29ff}fm6T^k;#H^#vcWNI5Ci}45?R3q(wb$tp_4Gm=SyM|ug|ROS|I(7- zrH%YB@Mk&^wx8RAw0#))b1hvry?a?-%eF-<3WZ2}{uGx$|FLgSN4-#}zUX2gF(nU_wq`h9R)8jT~ z;)yQuCqZn3*ml|ySdv%Uh@%Ul?;uDes>t_9((I*uVi!H^_11}WFKd0C`H?j!rIov< zN6};3lw%(n)V^GE`$d;ndThN;r_lv@OH59SJYWiH4(U{6Hjm5=gON|@Gf!FbU{>Ubx6vv@=8Bv887tc% z754RfRUTaCT59G+i6w$z7)1Caap^Okd8mn3@In?Y3`nSrf4-tk`2jr@K>q31^fky| z_q%<1#;}R^}CcI}VdXk?OgeAriI$2PreKe=tM^CHq){G9Xb<9dHR<}P@D zTYNsPnPcZU3p>~e9>b(=@EEgqrG8$1-&HWRQrk%C2Bm^z5PO{1GvFomjY=b9OQ?Sm zmhE(>k6ro{3~o2_!C03iS#+A_Y7%sPqbM{G`+1?gEDg2YlqdwcKfGah0m?eCpBX(#mn1I*sPKN8~}3u%U|fjIYqWtof<~T7~<| zt}dcAPqCiY$)q}XRWG$zTcIT`)kNC()DNN$Ewye+@sZkj(@zqaBvF!MEG+FtPX z`ldeptG}ZBb+6l}W7QY>Ky`T>mi4hX#2*r|9IBiPr(=2m`>L!P*f;B%&Cp0)AB`39 zu2Gka=0!Qn1M}i-wG#CTZ@g*c;+(hh&paTxpJ6*H|A0&$W`v)-1I@3__;_E zZO_)pc&f9r+lr#l7|$&VMp;8zamHg-JE()y#!tjexVzfk@z*hS^oJGg$gZ<~;bp)6 z$^Au{qJgK{-0-1U^oh%E3#%;aEjM})-5QZ~tmRM3-{hlkQ;D0V`-@%YL)%8j4VKM0 zh!EH#*W7T129Sf3I!l@RSa!1+<$42)!!|8j_8zL62}yvqY%7OpD=1hCIXkz;GHWE6 z8i?N~8DdRl^BD(zZl0BWp)8xNu;p%mAfMUzQwLM_T{*$k4X0EdSJ;ge(>!S6Goi2z zsx9`Z*T-$ps6=Vkt4h%!X4TDY+Rl~6M^G5|v}HEQK>E* z7JN3}5!g*0o)J*qDua}5%ZyVHKY{3LN6PV?KY(Q_I59H;86n^6N`{ zt`VKx?Dld7%`LDdOUoc*QINBCM0;6p;%kCcl;7W3a@vg6_ToaPXB*lt{C0eyaU=cp zzxI=Q7e1nijR!Xz%AAOB)T-G&B6HFL(7r>#cH5b z07J(pYAIZDS-4#$F*t$codo_QNzwzwY`Zx-xhZOmQxNTaTw z>mujYTde1WE`-g#-I7b0ySv^f6bJak(l-!9xG^_}@j28DA^NtNp(>@yVwc4go!B^1 z7B^dfiO6Xf57kduoQJOM!&hJ&Dvr=&XsO?*u?irx8(Y!8lseMRo-f3(?|rqW-df-J z(5k1s(DZ2Vks=Jp|PQ^wyI0&jcqCuopz`ommcCt`092QscXHzy}N zjLh0>Ho8v&FHuqLmwxe6Iy=3C$wpeu7lL*oD{|v=O+D@t?nul>Bm)l#1sWU112*?4 z0*QF+$W@OnH#S< z*^Ek1n!Mnn`C7`+fgVfR3Yv{0#(%R-A-Z7AryNA48{%xb!)szGgqrwTz1B{AHL&N# z;IP_bOR3w9O39AgOM6-tznxYM8}hmwz9eC|kmz~$qE3|^JpeH1=y?dpR)1iFtlI8r zZHG(qgWp9j+F3tXVA9USgkRA@1?8rA^fmq;ZL!xL+a~G$?Z5fAH1V6dZP~7Ca$wDl zW45-r$_5!HMW~gB01EJtsZ!eqbQlJ8#6A%H&KthnY{g;2^U2)wb>J1)l^4E5{eYHZ z^9b-^L1!no`5P(Gs@Y7U)3F5Y#&JMR<=z)U3GS&weXxS_Pz~!&p&B%ZG0HN(rWZ_D~XS8^TNK| zWO%0GzaY-+r=Rc-BJ`t0^+AO2*fwp~8`o|$6%uz4-dw`3fk?f)=DTc7^X9cKAM>f- z(RC?@zlnC;b^0!gP0$}o!mkm#ARFph-gq`k?*H(q&zH=D{-$1FXLQ0f#J+LaSeBh7 z0uphENE#I+h+Z~#sBPw;D2b)QDZ84~`OK$Dev@+awN!bA=VTn!ht@_A1;`kR zF|+qF#PzhzSbN7;hKubgx7V~NpRdJl;LV?~VV)cs6fP_j1-j-KROV61()ow)M&|mH zpFG#E{>q!Wed`rX`8i6gwK|VDy~aaH(qE9ZXj^te#;I3nynFGfsO_E4M^&`_DD@NR z?Dy{DxS`f}hMDPzt}HbCf;7tLFQu25{MzWmz$5hwJ_&tM^)QdiUH<;iOnb1j#>TsF zSBRapjyO;Cz-}8%Hjnh7Ava4=;}^=CMNNjCau*%wxv5gdbFHU*QU$4#FQw5nr2T=7 zxzzU$)$2gDj^1|SGV)#y4O%=R_V_N#<`>^F-j$a)t6f>@U=X`C4*T8g=2?my^Llps zT6pY*%8O0vg$)gk`ILhsaas)9#tts_T^^b;VsZt^ZZ*HrwYuwe=L0{f7n|62MAm&a zexlAAOlTBO3depOL&}Gh*X%UPi`(PgP0qGd2y92`F(^@O^$De^;=42fifSKHX?Km(KXI z{;6MroUPa=!m`m2U;pO^>upXcA9kykg3IPqNQ@xajP5eiZ z+gD{rCQn=$2=;ib>ka(uN&GC zefZFhU732FZ?whN?T%@)Y3+AjN&YpiR=3N0-Cc}}O%i%^v9ld=BVrfC;E*P%(BAS! zs|*fCv8hQF`Hp8?62qMDwXRDa%T^y|fBH}sKYL~8T77#TcWbA~yH%&rb^azAn~vBN zV(QhLRO+FtBbsck*!Vef72 zJulMi-p4HRExrpdfRg0N$BR5NA%`NjrIFh)f8*{KeqQ>8pZ^uj(f#!C_jt#7##+sJm>C11@+i1EdP0|_-YRI6vLL)~mLRluc?2vY~Po@SG&GB&8t z)FJCj@m1m*gkNKki#kDx&+1L8?N^_zHw6+%?O{UoAl6l#h|=!$FisbH{H|@2SmV3I zSJmZ~3M89)ddy*h)BG5t>(uKy$2{dapS>Ps+OT^(X|uuh5@##ZQ7VOpD~%%H>^>rE zyzq%)Iaw2BQhV#w4ae1b8Sfj655#th({`1w>pghzKqn`oA{!Wb+e@ERX+E)!jPpRe z1AQ7LR?UpAG$gq$XnmTo=x6w)Z`AKn+m$sL+_vn7fdGcQi4#Y7#ORiGh@rjJ3zCrs;(pQTaHj3US*NGB`Q&cpAmosW8VzD@h1%crISSj#y6<1NnymFy zbzMf%L2y~G6m1iW;jTZs|E}%j(4fno7qxGW&AwT89FN^>7jmV~%vCcuuK1Fbnc0^t z;_JhG?~`%8-}fwil12X{Ht#NwHoZ^lf4SFdld0Pwc9zDod4wmivkz9gB;OE6s@+Tp=Rbw|=3r}#dN7);h^lW%>*cN%dfjP_rMN8y~37tEbok{xd{`zOMy|gytnJzEP z!^thmvuv}zad^#HN)ztJVs)QHV*;=p_Ay`)VPQXsEry)CHsSkexB7OYUH)FE{oZ}q zH~X;nI@ZlHqVFZ*558`j%@00~_Y?sOY|f1xC-Ab{L`60Xu4UU%bAz-*6T6@aA91mL zP(P-r*O78yH@on`u|BdO^3XMG?~2g}U9jf6EVRo*?N;C3Q@io8wGVk`<0))=Ea4>8 z_y}Sf*p}Up#O7+x=0ZYde-b;WX5P?k*9|mZH*0+obZ9WMp#+o62UqXLU9sUo!1$_m zRLd1Z&}CEz^qD` zRzJc~b#rL&h<097)$pPQ_LOneiC9n2Q`Udp z=FiQ)UAz8h|8*z8uF~tL+=utS*n1!Clg&mmChRs&l4eKA_#Xp{qjTMU<5Q;_b-q&K z0Ixt$zbyn3eh)Uo(vnzYsTUfA55fmZj1ho>A4ol;3+A5O%idq)1=|Yx79UO41}MCU zEAk9dEmyBsFY&RLw!ZeV?aDr?e*9OX)n#p882@#7W%fku zo;scb>mEydDK{AVfCjTulW0pv^-UYQa_oX7Nk_iei2fA(2YCZ(zOkEA ze}#f<1rbZFi?8szdOI7{!#0-mlQyd0#tZ%Ex@t?EUS83((~IGQUKV}X^Wown2;XpM zZ-fDa^H7I?$aMf69r67r9GRz>d97oX!V&0q0k&m(r#z19xE#YSv#rf$)Q4VsT}hZ? z;pEMF41LI+|@C_zNF zL%2V%A-*j?$|tF(|IGuQkXNJKB{J-H=Qk+5mFG-{ z=j{J|ac{nZJ?+F(=drQfWD|MEW1YMo23OkHPix|gx>K>qrrfcUcR{DvT{osxmQA{} zq!;tZOrj0#=<<}q2Zas%u-trfS!mE~b8ofHD&-*cLxb2o31WNP=g3m`AgPyqEQS3p z`U?75=~@rpd=4t;<1gbFgg?ekezdXf`-WhvJ!sUPD7Q6ro7eG+htl0!B5jk*fA*e! z^^^BhE_f@~nFgQYX9G?ov8GsGZEZ>(boEF_kq$PW>t~UODiG=N@{(V~E>-v`BI6U; z9xUvozkAt>&!r#1<05?$o3#(yyCP%AvKjV*o#y3Yu3IiIR}WgW+{%r~)!xrbCOL?_ z!`|z=Y)x%*GkPt0uKaO)@3pZf@%JP)>p0k>lo1I{-Hyn_CbYJ#Jjq+2u!mHzNmV(Q zB!49lg7`ti%Y@~c1hS#IilVD^@sT|&DDerZ0hg59=EvrSyw)k%NcUyTBzzaz3m==% zhTH-TWvSofR4MWEBuI_sBV32w`mrwCmL0SuYrCtjP;kCGy0g=X6}>JF<@V*lc-^^RjZut3FB5 zZoJ*-u`K1lHV+ari8PSR6y=|Q9fldcy`ht8&V4#eGYd_Pc#z#IAb7{8tANi>ve;}^?{16`qoAGj88tA2RSYE`> zge1=3nK*k7PHZzq-g&V1{7%>yGk!NJo5_oRJ)ZFwKjP~neCa3R`ffd6=~Lfub@OdY zrq(4n_hdIu*_6yaIv*f*2jDHa1Z*Zvo~dhzGresxHYxTRUWN`5XV*1qbODB`2&z23 zgO&fngGb_QAWq7WHSFKOqhHHfx%rQ8u@z?nrYYKP^6=Ne_I2VbGP~K?ZCuv|cGE>> zTXsW_p~@2zut$o6Y(ph$Y@lpgM&Dqqk0!%*(F!!`o(6u1AbfG>acDYjqBGen}#GnUWRB3UH;TKvr&NAoeI0s zfUm}*CzSZ@Kq7HTS=d!dIcMrNx=*;#@6r0VFVko$93gtv2oD(o?O`LbAf{<2{2@$# zz%Jf3zi-Dp(8Na`JVJv>E#B}p^&D=Hsg|)wNkTY`5@XIV$u_(ywY{U_uv-~+f11Gd zrD~s|Z3t~u3Y(L04cVjVZSU$uXFH_IZ11Ruv7xFDFIRjYs#CHZaxLtP!J(>swP>=E z!Kv)PwZXP*#+A?7Wt#PgPkf0sn>M$ZLo*qn%(Rk#?PhPUX~9RKXOYeD0+x_;xZV{b zK9CGTAG?{dD~xR5n|i@-Nt+;3N>L-1Vc2K`y>+tDkN@y5=@-@I?}`TZ50 zUrPC_cBSarBl3A!Un*W~XmsKB)gl{MU$e@5smtv{ZRc|^E7j-4eoY8HRGDMN#Lm z6x^^wg;wAz8{SxJnYN@P*qNt`)K6O#_^4$n75I4pxquCo^jiw`7$@Y3WoD<&dr%NQ zG7*zFN-?&v2PF`#ODddsdveh@648d=O)Vvsl!t-7+4K~hM-6QD7Gq;Fk+8$IP)}RLEpn{Qz!#3AJ8b)B%wgtcRK^!KA00SxV1;*}C7>M^k??L0pJkwTf zhbN)4>;M1|07*naRJKQ&4h9!-Du-DW5ACz@Sl}2h;MP&lHL4Pd$q&1X4t)*tDW5_D#=)^ zP-t04hwUnfpFkQY54(O-xc0PtAh%uHk1&4Hzw0nFZb9tUsC7z{gm0V-O+b*B`+Vs; z!K5tH!QmmxO1|A~Ld7T}GQ70!S^>eaacoRL>*O1J4cG5T+j3ijgZm3B{ z+Li4lw>yolWJItT!CD?2AUWrslpRl4KP2&qH((Fa+1ZJ1-x+FxAI7r$l{8|yWWi6T zI>G3jx1{g=uAkQS0SWy4E}z*?HC=9$`$Zgu2MOhYLRkR|Jge|vm~~Z1ij~&2M`E9; zRL@}*tCe*%NYGh{g`t^-=P7f3c`yN-uc=rzM4GOS@9?4 zpwXoq)e8jMb)2pX3DtGtGUcyGT&H=7WLXO@QMfmT%GxSb2~(+kbwazY8vI>!c{ly$ zewB~L$lgn;&zUjL8!`J>enStkM+A90tu~8~Y&?#Fa^w{x&xiuVb)%?q1f7Z`RgnY- zB9CoH%9vAE3)uWtf~F@<{1kSnlv6jfBW2+`sBzM-PGeKs$0&RNyE~7|JH_LeWs|hs zEQu|Q-O9FQJEBT$KZ1^X9sP92uFj}_xTdgCq3N%7N4A1NHaftyTH43$#wKX;>VvT} z7^orIuAppNwrdLmG@PC5fGmKlPsYH#KGeh^j{HF{yFFMXsh*x~^x(mL{f^)H51~~o z)%|DQf~w$Ro2z8I0z(CPC@}s9-=-LxXh=w#OX=iX!v>6(JUBc=KIqojU7elY#&%?# zSzCVe-d=2VvMHLjTbZ?dqBEWGgz+;!d9LsL^FP7M`$tLQcQkKLRPvQdN~%=e`l%pl zBFWm6=sM4~ZS6+h@1Lx%07Er8OD-B@P`g5Wk_RadA3y)|Kd*s8v(@fMIheJyQChp* zAiB=qL}L>~e@xf1@b4FF%iiOrc6+=x>3^-rhhGaFaqiBi>`0&B&?muDy8nR8eWl&> z?TRo0&$&JpUTqF$17xi0#**JJXI$ioKW%idD^-1xcbApev4Ipey`3!k39?-K9Xw!_dDj(fQW2S1*%LC;>#;&33_B8(DONxvuwyMWauens4Fbh(C6k^u( zYyKvg(gzH^wCyF~cY9ZTizhZg>Qu@&gwOnK_>tTeZ|COeTwYo!h3pfhet+hdicRm}}ffw{Ban zyvk1igR~hoy0yWMb^e#W|5x-QfBA3g!>@iqm-lXyd+*9RmA&{*OiAlFo4J9~zfwrE zc{9&jIq33GJ?0n&$>JELfiDTa^eexpGFZxQN{9>X1hBi4FzeFD5YL$I6xT4R+tJl2 zhwn7}O?VQUo9aGFev@C@vVBnXQMM~XkNMQI61$RN3uupI?O1jPx+9tDF|cMf8hD$vPh@^2Pe}uS`1Ow|}FzX``ahw(wUpu?A-if;13!Vh^Ob+Jv{MC}9bT(iPM& z*t-zVK#>QWesbhL$+O%>ol$E!=qmg%{z3`zy_+vb+JoQs22>)L3 zw0@q({?|Xbzt`D$M)jGr@+ep6AEo{^s`GpA7n{-AiOueT%Q+6vPVr%YNj>fO4X{COTQ%W9?Z~YhcB3Lj(}t@*%i+<54#P%vlhtgcp~fYsvD1}f zkRz{g4rl><>}Mt@<2=t>;*qBLLbvan>3{f7{}X!W?Fo-qk(d)}U>mzZ^(`CTHhf1x zCooiKNI8fsKu#P(P3YkVKCRQnAmuZElDT?XVU0p05Hf$8`{WU}s_p-c10^>DA7? z8%G~toveosu=!`#wB6>yZZwk`9Jg?t?AYc}<@Z|uX5YExf^HE$=;bwCPh0q)^?Pmg ztoZbqy-(Rd(n`vOWN4CeKD}$oreroIx6KP31qCHGCHhjssdcN&s5_cIy1Zot;REq- zpp|{v4Ay>2+QTMxP9lI$-9XU=Y_5FG)DDr=h(y15Q=%L{9pXs+^jScv`$- zD@`1sm|r<_-8JD?qrg;u~v?)be48Z_7%w8W2{I2;MQfhup-3RWJZS|011 zWbB7wP?Gw@+wjR~jhOt8{+WMTAOGkrU0htqw$jE(-9e2XklB`)xFmL5s9{ibFror< zUBk%aZYJ@?TpLTxPNJ!5nt$o-*6EgNJWbD#DM+aK{~ z7n$IH?w5XE_qPwYuO{V)=@oR9~xU}V8baXRx~>!1NbR5v9i~dqWEq4 zW`BjR_SnUCg*YzN&t4bV8r2u^YZdRI-UN|Yp=(9u2d>0hT3i%3BZDlby_A=}i+bXKajYpbYJHwO=Vq`OSPowk`a|d~&kU zty`!1=l_NOncjT!TxXOsk2*LqsZ#qb+^WgW1-jySIvvQ|oY+n1qL)$*Y$?f}W}#b+ z*o)(2JR^~B7y;>T{LHz&XTK z|37-vaRjB&)r}y5y znc=-pWZawgzWb(MzpDN#F)MeT6X%>baUwD@PDbAUf2q4!#4xFISX4!wNhl{-?4fZ{ z7vQ-nS9;k22uLa?nuCrntDVn1@hA@TpKgU>PVkG6$H$PlUTvvrj z3x$etl8vaRQPTJi_F9l58GDiPQzt~(w98tU2$%KKAF@^-`=t^q^C|&b=Dz!rWtL6o zWn+1T8rGuLXn`wj+pa)uSSc+POVU_DyixROKUW(l2Jv3o3@~c?;&y_nAC5OG4#RQ9 zmkG(qq|dU87!xkX0w7mykSt}{L-9~{zHd#MfWbE)6D%W%dXVN5Na@0^1=SCVH&W6k zTlvfi-|+#mm;a#RDLeC2{}Wr--aI8Yb-9tw2$|$S!WfO}8A)x~#=1()Lt|oCMkG)^ z0*Zr*t8KNDZ6<14I_Z=A82+8QrS6;Jr>M(Gb&}e%U;oK=k8@HQnjhZA&TOJj?AP$u z{@edri5zHB8TY1X;Bi}laPoxed0sc#xk8DHdLd{Okazi$M3}dY>xFCrqz3SdL(^nT zCt2k=W>bRlHECMhw@ci<2E66xK965~{fF?`PY`>PF95r%KsQt6M)d(ELoc|oE7#o6 zZWubU#;S*Vq=HToAQ{MtNkai2w(D%$$)#wWhmMu%gW-QU4nv>71*Ur~{>`ty7nFbh zLpL5fnJOLvl0yDTs=Ru}`doNyIoxK&7WG!qraeiJ+-kciblpuExvFcH*UM zBs^9Ibdc-yB#Lv~X0;8)CBA6tUE=7U;m|z+#&{;0CmGF|`B4|t zyK*nE(`k!?gIn0!pW^zBC-BGq*#8y3`*;6)`26G7<8%A&Y^FH~kQ6Gdyvv?n9a?R* z{rh)T0$ePPG*vq-_(0nvxQ)ePj-&Yj8hw7B&N8T7?xH&o1r|pc4_$4suY~`ecOBqW zKk-3)=zZ64W%{MKy89(Se+9@h!D9p+x;aX)IQEPs=?M2(48l3;kwTt5_Xk)b*2H%r zs4vu|cmljzPwZ5;$5Fq)BcJ~qdI`JKqgT1{xMWq(>3M62jk{)Apm+$v0_gjAt{aL!t+nODwEkH7WyL@<(jB%&#?W5Km^Hp|HdLAF-31bqA&S@=b>Coa& zjV@)Tk`|G;s-!h6d%-lyLY0+ZVn?l&s4Pys78~iKEM7|6tN`?#oDZMt-Iof)O9D#$ zEFbcnw;_D1{YrC&6F5{h)TGJ?2Z|`EErIb{{6*X^=l%>Wr>WPMqpeP4hYDu@>C=Yh#i53CbafFm4S2?jz z*AFmZlO|4(WVeY%^&80}?i#-+VIP9@97AzYNQ%`IvMYcFj*>P)r46j}y`CQ&Vxq0F zzx-UidKJ5~9UR=cjY&h4oq2;>H=o4s`Q86V{FT4_pW%^57HE~7ukP*Zv-TlK3V^yM ztWoLpJwex+~ z96#~HAHmz-@^Q5Ni*R-4i{r1P9b8Z9n}OxgJDC!mdIHg-5hepz#85iw`)(i?7oy**7Xr^55_@DOZNgiv1WIQY+kLm7~fIRM-T*oay(i|W9{SxQG%Aa&&#ovUJEuUodEe@lpCbfA}L`8%nR0~lq8qV zW$Cm0DFY}_0!YyYB~Q{9$iDJQP9X~_xv1*^1k^6pXbPW{uXRi!Hp}dbsuxiFmO+Op zN!can3X%gF07)n(#qaL!$F6)f-Hil&^X5(L?d`xjXDCrExBSAIGE=HL8lc=WMD zB)OPOCfd0lC@IgvFNT9;lr=;_nL0RmL!Z9AJ8zl{|0{3av8!{)+QrV4ygjD9-~5yt zr|lkgr&G*t1E2Xg@C&d16yEmMkKy3P6+E!_r8vCNh^Kx6NP6i2kcE0KpR+-r`w8{Y z_qEv7zN-@FfRJ;KtfB!Qx}RAEosws$7=5wI&zUJzCxQAeqf;L|Rv-J#kK?i1pGU9p z>6NTC6sqsJsSxc<6)7aa{zar$!)69l`A}Tb7hTbe`69VH*Sqcag8tSAn9mUpku+W| zLx*J|8|UiLu-y`lKj&ENma^y`HP%{9H4ZCB@}YL9qzK7Lf}~bhf+}Nq)G?GwzDwMc zR`qSCn8^e#{ivPrYa5x_MxY@ZQ;!)`J_~e0jyerErslU{uuq}^^(Uzvo`9s63Hx;% z9xwQCf1p~*yaxvdO0u)~a|lx8Qu7yR)NTX4r0mY{p^yB!BG9v?5AL~xPJ9t%SGnW3RPx0hUOizW?lJ%eK{5_ zfFM2I6&Q|$GJZ+rH1J|IZr&K$uSzV*^N@2)JFvL~^icl(b7SZ`&3J zO76FB-&PX8ipjLWt=l*7U;j7%Z_IWaAK(Oja4?5$+hz(MawT6t(x+yUqUO_*_IV*b zshuV$S?<+z+TzM?i+&E=cV&wE_KDAb7I^a;KZiHG=Dqmv`=8K@!~OdG{7du`K!f=~ z#zdd#7Ypd+1B%c03DD#RpotI=8)u5^v5VSXm&s0J36IMXwvgOJI!yy)UtFf!K{V+< z@JAA@UM?W{M?UdUw0b#lrX~r`Pn>YrpJ8>S<|`~SYI#ggl8#tVWUP(PUVN3Pm& z*&vII6xD-n*-0Q}BPF?_WZ@-VJ4tFmkTg)rcQ1FEk02ewQdZ{x0Nlg zz{X2~Cn)8swrgrsd#eolwJhq(hxveB7NGG8UWW0~G3;Dwol3(-_E~Tn6_x{vWaP)P zC2TJp$yy~Sl?1UoQj9Zcn({U0)B62fL28$t;c@mDH7P~?ta%@dFM2j;DvafouT<(|=U6x9 zj~PD00=$U`sqrik3#(-YUQ*D*D3SpIZ@ ztP{1;^R?u`uw@yL9)()sMF-2p06Mf`LM$lamuD<4hy|qj!f=Fr@X4Ej;5-gvKnsYI zuw`C|OtUH$T6dp{Di6Gup~{=~TJWm9_-F){*bPs%)MnI?Kd5bwSyqfrR?CM-i4&yM z4v?HJC=DsCdY*z?*t(iml}KAtd$5GRNz<7xNxkNb&d+kKy6xJ*eb;17G@OUyOhH zE&mw5^WXn6eD<@qvAeq)yZ6~_rtWH|Jefct!(TV-jsy(%T$=eF2#6 zXgnKWq0cf|b5^c!_R-6v0(vymxaUUv<1$GCK^Ad(u81KS&z{(z)Juu2Jb#G{^GT7B z84E7(`RkA3$?Km-?iL!89wgy}P9=2e3x(@IF{hmEQXYcn(hESAA2T_38eelpkBp*s z3BeEL!M@0!+I96zJvBch&v5YXhB`f0y6Ih(`$Dd6?6%25uJpD)2_)me@Y^oQH8YZ- zvyi085^mF23I>K;x3icedOh43TIjtx3=gz*kj#ib1g;0sJ_|ZMXdR%>Y%a@zo;5~1 zr2G*GIaXBc^0ZFRwrnI)d%af1UiGp7mCJLh2Y=M2AMN@lXTA7(fg>4#MJqM^1 zr+kqOC?rt5A!ykE^eXomBvJa1MSIl436HYLS$$G>Ys^_PfG~}|z{^fXmLJ0&3=9Wz zR=Zg*A4&e*R)y+FXCGO%gklw-BV=mFls!Q6f;d1u(M9b6*6RXyJ>*HhXsW*uC?qY~ zjS@oRIv(diPqkzHM7kC;WdGl|7A?@V(o7VqJ_Rp;Hi_3xE(ugJHzY+s;|TdpvT4*- zD^QhnWTI62+kiY#2PYt=NK_)rqQbH)8RaFp4Yrsl4#VxUOQ8Ca zWZ%fYDSOwSMgN>wu}G@313zEWS&N3iV~>6Y|MCCj58?0ry}yMo`l2g%;)&aE5?v^B zH|-SjMUT05=N&rCa}PD^;RjVWl6YEx=?H8N6!Rgda+7*M=S~8y?jRENd)|J4*T3pR zc->F_Djxr2gLM00?D9oO^L-`rsm4y@uKrMC3f(jRWZ!$d)nK0~cbYLtRB?F@WXYiU zLD^8MF}yAr#ZF52(DTj&$ak-ojviGmo&OrBkR)nBB(*WsXLVVt)PsG--n2zNa;-VK zy+eHHv+oBumu&|w)Lkta%}$G>>@nz&aD+x<1|@7L?mQrPLSv}LaQq;pJT4CV(w}aN zb|tb6;K+?4Ckc|JpewoJRb11#jI6o?vc}k0ljXdOC+s&aWbyAUmj!ZW^ zT&|)<>v9ErLbS^pK`z-7OwSgRPu+)G@dPrx`{+-1Nj>c90!etSvGKMVdOkF>)6&Lv zry=Y&4BW>t#*3F1pQ2M7qX*A24rM)!I{i>8=0RqCMQKhkS~mpXhuV5S*g<7vI>tB- zsSz&?Vr(OV+P=$%?G5u4w#ynomkaMWwfJd)$s(4ykJY!!GDojPjIs&n9#Dy)XOdjw zpz}S!*#H0#07*naRQr;}Cy0@qcT%qB#AfQU+asyZ&I@&sASo4tQcH8#YEce~dL(IV z)4<69@@O7VzZy1!K=O!P17_mNJhI!Ujzyg#*}Q(D}nS%LWaw&lJB&g z!1VC&5EJdFTkRHm{QzyBJJTtemY6gdi}`KbxcRtt=l9|N_y7N!_#gkLKPP+O`i%_# ziS*t(%nY^Zc%1l?x@AE3uoD)7s75#LLnpajCR*-ens%_Ow)+F%;~xgz^!mr~svr9R z-t@W;;&Y!O?%(}#CBO%e`hBEsS9FS`moRyN2L2dwulonJt$Ul}8Ph;wb>uoJRw9rL ztVF7B9}?K!fPJZM@RHZl_mL#}c?Zwe*<^}F)emkRV5)vmUmn{11|RzP`_+bRdW~s| zdcU)hxOd&iinH&+t=s4!pK7lc)X5@O48eFD1EqwJ_d`L1x)~xE+eG>DU#8P(?c9Ys zbFWm;^te|-xwAM94h}Te>r;4VKE}03j*DmVen8GNdWH<{G?t6jv%LW=OucptMp-kI z@bv6^fw#Wb?!t5o-7FZES#OxH&}tNVRYlGe>f3)nwq-3#5mU&Pmm+ihrD)066F?F^NDh1onwpF>fgsu{&eVaT z?NmOsnD$yYyCphw)UFJ3^wQ-ea+Hgmt_sxpUK9;6hH?>%rHo#~dT7uCS!DyTe2fmj z3RK&aZL1%Yo9RpmSNlozO-Ar|&gXL+9UWj_KT^-?Rts2Gq`ViU+Rj;L2o*`IEpS9|hk0y6f7B|0Z7d%3sI7eaDl?2QS9+@BiQ8>g>z0 zxV5KlP95^OvHc#es&DE^u9O33n+gqK^lMN-Q@*mgrvTMgkEiV>iP3lHP(F`&*ea=i z@IxO!uk7!-jb6lTFB6dKZPzxf8}lxo1lygY8_Q3&)7*kFw%*Q@>+x>4y0~;(Y~-pf zk{(O`)(-vRV|&+YX!6FYL$2STyWY?R9d4UM=XFu*R(jrg7q!zG;E5d7&T5OKb}w50 zl-ROY4%8z_sN^%qs-LX8V*WJP!=`>yru!QSV$;rL>ug_PnR(NS)5^)2O|b2zon={l zDm2G9Ro=En(XZy6Wn++4r!kU(Y&2E`Ss)RRGmp-;nMD+&I~fsE<3GqFFR} z{4>D6dHW%L_SKKz)vx#syzVDIgb)4N4P1LjUIZ>(41Ca z?#LR_TIr&NI!`LSaD9bDQe(!bI%y`sdtO+uzqb=F0c6G_k3E7%pL|TWPOpCb0-e5S zxLqWIx(_{di&ZJ>eu^AB!&Xw$F(isPUupK|dKaE1kP7r^@a;aGD>$mJI^wy7P z*6-H_xrfu>x?@1~ptfDtZ7<9AuQ*)JiZAPTeWRa4zd5&`TQkc@FM+2{)h1m-|+SL`mg^x_=A7wccIm9+>bwY14+O0&b6aY`c3(v z2OdzeJpwhx4O%pMacS8=xn7^p0e!sCXK3K?I`IB?F7TGuU&Fup;g8^_f9Sn<%g=lQ zAAetm+mGFk{@?{@`xgLn@rN_jp?m@QsDNGlsJ^r7Ia}{70T`Cy*s+`kRpWa-MwJh= z+HX>;Nvfr0i861j0sLYUpmo+l(%R;u%`KTud}li<`jbJ_)?{-M8`P zpM4Df`V}9>zx=^>;YVKfEBJ-iz8@cW*Auw$`720^7a<)zU!T?Qhm-iMxer+}HX8d$ zll1bC)F|}AG#8H4z7iyS(3f+e-tTJ6jjEgaYIhQCfbiM&x+Z*XX+ zSGuN!zNll-bx2K*MnCz!?N@&Vx$V*EXPDe{HkF^Ga>gQSfK|UIL+(eimv@QQPRCpX z#om0@3qcH)3ujTg{Y`M*ZFn@~r66Sq5A5_ne4a-3L!Dhw&f$2Y&#+>v#Rf_&vY-Kf&+*4-0|Dit# z_6@UA0{T+Pc zH+~bo{oB41-~O%Ngm3=lug2GZ-Cx6>|1bX&{D=RCFUP|VCB1k6`a%q48$56y&}tO4 zy+nVQ;{Rfnj}i`_O!(-B=J>#S7I?>-Z{QU__)+}SkAD0Rg8N*KC-O2uvr9e#W?k*?x-;fAoX+ zCqitS7Bg6I8jR(RNUzYPyR@MXBN_Y&l8A5T6GeCp%Z z@xFii1m5;bPvA{I|2Tf}=N`k)zu~iZ%V?cX06J72xQ3Xy(rY7Y`yI zJpdj)AlZHTExc8Es-(WJPw{)2fhue6c=(w*?PeFq=#VsBQ`=aqazfH){3cD&LyC|- zJr_Tp6`L(E10>*=4wB*}P(;w-jDG*$R^3gXEf?JxX@P4y|f4cxd1+`66O6Zyfd zMxUq+s%(gl&*&*^R(8ccKC$0uaO--)&FeXEOTRbYd@|$u6B*Y&-{J8`=Xm0=BRu)U zLg;b*S{HcoiN2H{7ybMK*PoF5@hn-!HOn6BaP83}eE!ix?Y8H5^f!smew_I9M~P2; zB;%7G>hSRo&hfDi%<<9p9|?1nvvf(6`1M~q!bjiV<8z+bkpF8w<9>_S9 zZ~-W(8<{;i)$Ma5nq-rZbsr$~x<7{JgK2<3Rz~e)r74A;DbT59^QJiyz2f^{+L8}upiw(*5>@5aAmTUw+#kvB0%WIT-l%uC@XPOdGtdAF z$$GuCxQ{@uT7Z*zUmgJUK%zv|EpR-NsO+okH8$ofuNaprU+LJ!vI6;uwhJX0V%r8I zU9IGXQ2+oC07*naRJm8Z+RXxwq1bJ|k{7ez=7p?2R$9j=P|v!NSDMSorOO%iv8eCa zevoAn)riUUa+N=Olv>Vk@gpZKFnI|(;2`t*khR!(feV^#CkyEz=mAna3X*FflxE{x z-A>!WzU0G3E^ngBE3NufxwKWwrWMsT4{ek(t6DLZx<#&dv!IwoWaBsu+Zz(;Bv-tO zqcg=K28}pL$u)+3gKT(Q;t!g&=(~wzvp}zR)xK*aQyhIOSu5V){F+%P;kIChNv9aC-*kPInVs0;Q{;j-bf>9Yh6T;AE)d6t^L zd(Ec1=jz$9AmGYS&4+9@eQP`G9e%k(B4Lk=fcwt|qh1H3jj8+cJv2M<=<%;;%&js&7X* zW9s4RWm(Xm*O!kTQ_%|Tb+aJb7;|0XHd9QT@}>KO(EHvPp*Zp4wgbL*BdjXe#~8r! zUT?JrNb1FJ+PU zU)_Ll?h5y+;rOoHt1+G@F6st9EjNT*cdK^J6VFx~a^2TfqYDbj?LMDMoSPwJZx6ou^ST@F3Uh7wV)%9A7j3O^_ zE@PIi)bkjtFIr@62QB=KcoY?irFp_)ccBBU>yAtd z`Z=U%@^kT|2XE#;8iSS@|(NNj|o@D6i0#RY)qC zXCxXO8g@oK-3C*>BHXrt-uL~^(Uy7;pCx0KPy$zlh9d(UD+iBddqy?-^}9TR^@exp zhWU!sF1#jhC=R{UIC1+vjsW@2hGPR}IL-uxYiiyuvcyThp!3JwgxqLBb>{QonkP4c* z?+VJhS-(H~@19R2Pr9#>*i(mjcN_OT?+Sk9SAQ9g9X*C_+N0AB-99ShDduM_JEkSC zHZ+(jdJr^kvMDsKu7zXu#&xlAoTl3_UGqya+9M0yw0#!K{lcHNp2Yy}M$^M;dN*7> zJ3jQghi7GAbCQufGbr+88}ek`nU1#@Y{s|6t-_Bb!v-mt!`#jxqaiy7I3jdhW9 zv@n?81{=Cg(RmTeYY%vl7g^<_&fwF$u}x>0b;c%N%ck2<;&wb`AuBe8uezYUy%)Jc zLg`BxZ`TPM4V?LkQe*u}5+p-NnkG=m!HK-qvoGafXp>shQ{t_=?M?}$ldnR1^r0hc(`#bc>GeJ zG3(Wi;g<`yZ@mHeGlI{0Y!!P}Y%~>my%e|%jZI)k+*vv!WY$SeGR8A5RHHED(_$kO z>laF$1)CdDW!)r79@k(tE@Qd*F*Z!2c$VG~lZ?4R5^YLo^#a%cqQJy|LQaD|o3)tj zPtfV-h+ls9Tb0Nc=v)0vp>OOxGO{{V;XWEtf$|goEUffmx1P1$R^>=&yHW9JXGuqY zyqTvdj^FwX{rFHnJCc5fjAgx6=P|0xbO|lwS&*p~A16|+s@2IF)lN?fd=+K!hAP2W`pc_DY-KB-S|O=A zW7)V4ln8R@-Hz;S7zx7eS3cdJT5rssCbKL1FDNCFg;BdW2DeEiS&zp6CCRJ=TA>vj zP`M;UE!|3}p;O|_g2&Scne8kST*oMK^;3TBD~np@UAC!V-idh~M5j;Uy%MZ`xJ6#H z1uFOY+@1#-ZRjN+3-;fOc5{6O9=-_ZtpNoJyk8&-$O=@bV~Js2xF67qfKhe+6mMvd zU}QI_Oq`9Ze(AUIW#MS`}=$Nm3RFLKKMq4Bid_a_8MV|NMb8H+ZuM4hB{GJ%#~W_dM+;^Tg#Va_0Mfonduso zw8K{GN~P9kl}#Z%huE-oP>Y=5vN!k~xx5$?vc@=}o0wVWQqJk-nb ztznC{O}#_rpfj)h2d&@;o%-Oi5#z4>XgNe(htl`DXHMRo5^Pq2@1?W7?SfY>=;w-C z)Vt^k`!H;4P%&`$6Xx(vJd(Wnprj6=kFxIX;HOtRhTAmE8;;Yc{e1dz2N= zIZ;N?SI=QXyvLd9yx4M9z6T-XKExa-BvW%5^AN|yaRjrR8}@A!>G--;>KJ5{HE#B0 zsv?3)XPL{M2Gm?KZ4{lMS4{s9gGM)LqQLYDrFtD^I|+yVO<qW!DR;woKiQX+dAe z30IP#Aq$OyF7{!FJ$91ow#N(x!AbQ8^+ky8gkCFqPs)o_yZ3B0E$+RbE*cRv^LkJ2 zQAOWugTve)2WDh5W=U!Sudu0enOy0~!dq^TrA)5qNpf;!Lm6#Dd5C&FdB}Sem0=S7ogiyJnoa_ zP%_Qh6}yZch(^@7??YD7hU!~xKiOj|I59K&DRU?Fn!u&A+^`?FW!`}71M)}JvAeftBV6g?Io2z-PJQ(k3ps`1SYi(8 zzQlNm`u?d(#6rzU18N82kI|m+7*wD~X;&{o3DAzb5r~NfWP*&Dg4)&2d@_}8ugBgC z8vNWlUWX5S=6z^q^lgJbcCgQ&0}hA1t~t=Dp@ru)dwH9q*QfdjK>jg~QEvkZMO{=q zDiGI>zUkSJR4a|FGeAK)3@AH&#NlzM2tizuu7Z5U{o9~P{cBQ!dU6bobDO-%l43C* zRAH$Y;jK}%?(UETSs>A|({T^Nyt}K<9z)@>l5|-EKPhgKC#jy2oLu=OSNv0QI;u>b z$EP2?&0Drvh91D;n1!rgwb+o-h^{M!QplyRMLmd~^hpnnMb?nIh8>WUXe_s$gUpMf zqjEifdY+9eS^yn>_%`)kYn&vr7G3M?N2XEFRIL>Y3VJto%VJv07Gjg;sf?_6fxc^|Mf@vU@MOyc6cPgGyi|S9NNvTBx(fT#3MajCMdI zwUs2NUKU7dAxi=RjxUmq>e|L-${&14W*8S<}OqVXeqKW%G3emvf`HEbWSeJ*2Z|AyN&HO!v|g#lx`O&Y`lRSY2zt-jT? zVh!oqg``HiS(BZ=Ftn#Jah-fZfF5W6k5-hob^kiuTj;K4w6gc^ykUo?UEna^#G*OG z8-Mu?m}s}&Dbe1(eH$}n`=lt{2I#fHoN3l=FjYC^<_(p7`hJVXE!J3w-8hSF8Kbb0 zzR1KpCr*;jiGFN$B5evTY5CY}Bk%qVBMFknhKM{aD-Xjl(=!ER@kJJTW`(EGfLz6p zgMltiAD1p0vM8;C_w?Un%KgMr%ZVxxR0y?Mhr(=kqz zDWQ0Sv$EuH<{SV35CBO;K~%N><8iF?S?#aGzQ-UrE^hE1E2D@>#yDEy1>Wrx&H8my zPpRpfA?hU8?s0ERJ&4EpJR$UJnZA7s@rsyjd4<4%WY@6iJ>derA^&t+4tCp zR|OA=4Ez-Y`dl#8%keC=m^BIQgh;a?|GIi15Os|fXNl_@@0pf%FEt`i2rHp z2YK=hC#i>u5x~84@p|cavMzprMdBp67Ox*jBa|B#k|7!E$9yQ2Y^fb);fJdh`GG^$ zYT!Brb@8q=qsX_@r4KDUC-JUNmN>^YmK&CtZlYHf8_R50QRI$MF9}2Z))n~>>r%dY z(Xh=JWQmI`bgZj9wI73zVa(iyiv?v$Tb6YbO^e*uqJzfYr7>)`4YskoQu7tPKCf5a z)XCd)8(hD_dXlxc^?p{RZ7VDr^DbW$u76UUZMv~+%%9|=%57UwWn)@(C(*HO<;gN; zzKhHm)Q)?id%4@R*hwwgCV@={Oc$8l-{9urN&M{3zYep>1c!&Wk(BJ4X@hyUK(G2( z9)9xks-zu$_UDp8lCu-J{0`+HqnCj!U&f}~@4$FjSE(lQeHTqsUIW#&4jxyeNc?cn-r)+D}O1;Qut};{D#|XADY!?{faGMaXloVA$=Zj>PL-$||_rX9!MPSeuSz4A! zia&ljHsiSFbV*iwMg%$PmutCS{_WT;CS5ezvD2P3R3dLM$-qSSqscu|I|pZT>_2}8 zuldEF!lO4oi=%uXIipUmmw+BUd^k=gZqr)skueN#_Et+i=W<=d!jx5>{dBhQZeEMI=`s?AyP zjP>Mt+hi$w=^zVV{BD^#T3m}S>6W~$ojiPtw1j0-%bZM#ywm|mE>UHs26R*^R!KGo z%x_|BS9jyKvD{_De3hB6SeKO;*JW;ZkVhYBb-+FVf-ERyY&}SBk!##SI?|1AWa!kk z#xN_X8i+(SD9#|`c+4B)`Z?Mv_b7B~!%1k%1xKOg!E*?tgPRuo#cP>X~^|Z3r^C1c4SaK-AXSL=2K{`ua z?nlWSVW+xQ1~hm<@BSs?xchET^;p|9H8zaXM)^_}pA((X$j7u5z3ZoMys_!nGGkMI zH_PPN-SQ)6T(sR)?AvR5ZLhFzlF<5&D(m3HHq^DyMg#2I7>Xy5GWCkxdOwRSX+e@) zNfSbmp~xbMxxQ_N7}Sbw?ikUz;;;Ci+KN6xU+CfFnbprit9-QhDMp1boQiwkP-C^; zsB-x-OVR{%H?+fc!R^;_^in#PsruGc8`HA$4J#E>_z7G66hcA5;Uq}wMb&oL$ZrG2 z&KyIWZo}}+c7LlJvNMwE8cVsyP4Kut@fqe_*Qj>pjas(<0Lqa>jI5Y2v}LO1V4xng z4aJ*h;Sdt+eKcJOZeG@u+=^^8(lT{Vl~TTC*94HPwVN$8@`Z{ic_TB)b9 zm}(Gavlh*c((U9xyYqjA&)<3kxAh;V#6tF+CS)($Meg;RdX8b#Er9!H^lHO&4f3B= zr=DxP24MWB)~n5m%A@NGE7|4X#$(`!T{hi-e);gk+K^ttqNdL!jQV)UtFAv6X@1&NbwEiUP`47V~=<7O+7TFvNVVz=(I@ow_vQ{FG(-=0a zW%HY`ZYh&Lm&dTKmd&3A$7oyIm>!2UST2@DuQ4f|4|%chwe}TS#E1T(9Aqhzg$`2L zIi)sgpwu0Y2bN=Lol$BzW^<0kHicSJK(a7rUhQ)Qa$L{yGaZCMZQs-g;R%ojSuP#r1BoVu%70Q@J}XHA1?+hBJB%)B!fZQ6`E4siYG z3B393zku7kjmiGBB=ejxo6YoFx_0ZsbD49mdDsIG)$TjTPP~I2t&kxSdy~3#-iVExWZfF9H!LKV)m%IgB;x^3DZwClqOBEV(UXN*RzWg+jmwocC5xVMQA6TxEMsYhHq)VuWILRSo#bZG27!*yCQDbf(+HU0E zpd6@f)s5;q&VYwOJ}8|5)i)@)AqVosfS-*fB_zdex#RX11jD>#QAb}QmgT{37KP#n z8`URi7Fq28<|{mw9{UQH1;v<8!^jV*KBM>}O|f?Pepis|ddEEmG`4OR>XSjWW7r`{ z*pKa#j8uvwK9S3rLfB~&W=+y=eF4{TSdF&nxAP4=^pXeg!>{_0_(x=V+1@z3sf50O z1{X*Azp>yG`6dD&&gUl|a0$xls{OWuPLzU~JL zH@a@zw2|iDE!GdF258yo&2g#ae0&I3bvAURPJ-6ZuyJ{K_Eq+@GPKZImc# zRX*5-Y{lEo7{tek{911uR~CtNCxc2Dd5OCChP?(YYd|*nuvzL>{i)+iEzdaP2%p(Y zD`Fu>J=9)dJuU;($0FOf+Sb?*Z!5Yz%Qq?DHmunWYn^Du+UjF08}n!B&tq#J6}C5T zdJ&L-ffEi=2GVW%-d47(LRHpI%g2JXvXXeKEUlc4CDg3)cqxmYvWF9UYI=0d9Mk)N z{fBn&mUq7i@BQ$2a^HzQFZ1Va-_PWPCE*2DPJtP!_jpYahXD&2$VdXAOd>+c2`mfOK3grR5@%z5YsP zX+aXH5L?fjQEOY2GW6RvLgu7e%C3jy;r6XFP%J^UWuP3ox{e`Mr~gqGgNolisdgxN zJuNp@HL9ji+e5oiw{1zKo4m{9*s~?)fL>s-Vymb%)Gg?)Aj{_(G;WT;sJ1ex?5UJo zk8kXwRX%S;WG$pBx|Ecx{fO{gxZE9zZs3ep^ug#}Ct7Uj7|9^tNOY6RLsyLlGU9&$TZiWujD00 zt%=pYn$ua<1d`$bvQXP#%aN44ApcsCwQ82S!H4Zo4&Ikl*|Atsz(P*Al3d0?a%n}H zw@+g!z7SLUWon#OnRI)nS!f7yxd*ngzw^Mhn~)pbAN$+X950XCIIdkuY_}@ew0C7!tbR+02&YZkuJQ%TbVsXNld@g ztDsZ1x}J2B>UD+Z?+J#a>`u_#Z83RvTjYs0?yzyU!+OU3rDwdwyWHTx!GZsTVnZIk zS)SN;xw*UU&M}^bi#Djdv$YCY#>Y9TzQKo~9`c}DE+k?5Eh=aUh~>n(56FmKsO zDEbmh;a8U1%A2xkZTSjr+de&=>r2ESbJ9Es)tMHhm9O$#=V2hna?AW;;%b(gMsgnp zsmd&ed=`|_wrjOtuCFrEM2?-g*sUkk7u!=>!#=V@c5*S5+^1g_8h!pR_DM;-kvItu z@0x|ja8P|PhT@VG|5^9819jJ{!X8))qMPML)`nCdiHLa=pXEl@I2#zotF~vI5#y~s zFKKoxD*yxrNToQPytY8OD{lcdj@o{ur2QA8D%+%?Rg6jFon&t`ipl@?G_Xff=abx+ zwyDQN<%vFz%tZI64O)F}@6&DUJVgBbJAVf6{P-^+&*oU*2(AwuZqtdp(>=%o;6L0V zU7Ttss4gU-VFf9wLFtht^_XOhPk{yYX*!06X!%@2?NlAfVC~6>!aaW3*1yw2UMv>t z55;N5_o(jDcVHOTm~Fm+#vr?^i>SgG7 z2>+ke4!Z-*t4`2-Q^L+lusu3Jsh)Wb?PLTMhtqMH_3pgT3_X%!ozh)TEagjNWmDy{ z(cI2zLu$1-$X&PqNQO08XcV4>OC5`CO>YU!!5Z7lFO@7^s%TFLmSiu4);H3K!oLHT zUenMxZ&$}-Q^)Llbv0F%3C9a)Sr}^F>UWb?{aJJz%a~u)a{^UomF%)sse%%N+uX#e z-iq6_aoJkaO;~PJdHK}dE1CCN?6mA@YhGihHm2h`=2y`8DfU{ntx?OH)a$OXDL=F9 zdt?lTwGDa)FO--yj^beqc9qm9-L|2PWK|VP^}*A8?tpf#8^6x z8Yg0m90lNcP~}Ui$sR8=IY(W~FM`quw_8xlCsF$z>o&_&s&>qd*a^D<I*6C#BzZOP{V{6!veAv$dD-IM7H`@nyo55Z z?8Y+mvnJ!x@C@c|`8lJRbE-FQ*YPqgQD!t)<#x~ zh89)p^Js;zD8|Y@M)KO8BTrg{Y(s1~w6haYYF4%DS2#&k`6jJ4V_I#-vh2X6$nlck zxZIxl7-dD8y+CeK%WPx1(#cIMra?8NZpZv-SlPH;!Q&T6ZCfqrl`s%MIl^A7sUa4w zZ+?LLm`DAwiQ6^IS8SH6{3L37V^hCPwrNw!o+$ll%(BP7XvWv-uhP)fzNwDo6`>n0 z8w++wt6ilrR-y6O2D5glc9mLbzZFTzm4+TL5-{wk0Wj5lm`E@Y@0Xj@WF&oFpX#BQ z%BY=W+{oAPqTl{J{M66A5^w*|JCJ7brqA;J*Wh|3eoxO{{-I~aF(6)^>Nw@KvCFax z8?|S9#Pg;dzT_5Vl}#--t$)JZZMa@<;W^>{6w_XPR>xH!3l)3mPW7KD>22%MMZO-e zrq#OOl9s3&vKBhYW$4JdRf z=#c@%lS&?Hug}y~ycRR94a&__;?=FxB1c+>c+edSS?tV12TAQID0`@0hhv9*$UlPu zTBp$H;++7zm`Ile|2=D<)e=E;D#P~jjp3l;c5MDXexd7CwFi0$N3e|o-H< zwov;l=poCYHOP&YEA5(nPYw6iuz~3qYckbwx8Ob?A@}x0_RYfQx_K`h&<(>#(yoObROdSX|EOKh82aL$J+pWj$%bf}20pTe25QS=YJIOo*pPRr zr3@bl$Zdh7Ux!oS~exQ z^x^Ry-v^hsfh3UZy_XAl)Xnf*tF{uB(=iHCYc6RB)Hm}`zl^N@2i)%gy0uu2#UNXj zYgs-u)Mc3Ev+^-!(vAC20#Vyp<0144AhFZ)q0iq@(GfsT1u#>Wc7$})L+M{QWtTupN&5L)t>exbU zcWSej&f}G&Pr2F@>fiADn1iY(z3qC}5wiUDf_(%p?-~+KjU31tPq5MWE&h8e0dU4o>y7AlZ*&Gt3tY>ulrR7*#5&1xVL8t54S( zFS;(7VKZ-)U(8?e%hD( zd%TtvM*W3!)B<~4ue#DL>xlPwvibw1GDM{F)hO+X8~0Jg&C`7_&MUW_?YF_YXBzJ3 zNl@k*M#N+2$SMVD9Sj!ql$437ku% zUIn{({E7Dc-Gq*FOz%&)hR5(7Km2W?PvCZUgbo?Aon3g|rb(-Er>Uk1s=1ILD!tqD zZ8OXlSZ6K+vV{&OaNh^9I;*}=Cvpj4AJJF3bZ1z#asK*l^Sst~;{Uw9pKErSa?iCc zo!y_OGC?j{_aYN_9Qj4+IVRg1V;|??UDtM%n)iZWUz@OQEc2o>E?aMGlU4bOrsbk( z`zjyvV_DYHwkmhay1jNZ%E2mo5w?Y18MLMWUZD!VX=D4U$8fma0@pECJChE2h#(*?1x^8PhI;Y z9=iV_Ec&@VX3eo!%rVjL>qkci$nswMQmNyHO$Lm?;x`z3u0y6x^rSEmapNg}4pl-ScVz^r&PzNeb zy4lvdK~uy0y@HcsK1u#`$6dy!UykXxJ;yW#w5VC1id9-&`pm-SYnQ@QMWb*cq$n$N1M6gtNIdjF){a@uASTg=WB zo#bOvzFCfhz2UvuK=+dxW7{OzB(3*B%?m}Bl-L{P?-c)VjQ&2jILzZ)JCI z<2D}HpJIP!g8eHk(vFh){vm$sU;hw(?Kj?o2X^nrwI?4#mk-e>kss>w{K5PHv)M$? ziRXJo4T*$RjS#$_)yTCQAe zDRUvmCwXfGHR4?s^#io)6v%rv;L+gO8=9);S+Y6l&bxed3e;)$p2j1vt;3fmcu&8RBJU@Xg; zCXS_+D^=Gu?^M$Gz|g&fK#eIylc*78@uo#jeGP#Ud4o1Jm?(QUx>1djdDEe77nn8+ zusg(y9^S>`@D^su7k#>g-3J@|;7`32Z+!b};hp&H!yEekw7_K6Dybh~veV%3@HU`; z-M0$paG!EK8(DE@)hlo^AKPv6s)`<^PIfJK+(OA96<2@zs{QNXNJ?(i?8h6Wk)&pbQEq_ndkJha<7u))}l9egr_$vSc z0pADng?f$1x-Bb|1p}j_`Y^TFw4#;^Es4GADlNLx{Z?DkYH!|fxx!_`>w3m{<5rGZCWF=DRu(n;v8>Z#U>c~4i7JXkNpcyz3d==f6migo4f+fOfkTD_utT8`sZ+Ow{ce zwlOs-@7;NhHUO$~RX)B4-dWWo0Ufp}&~hsW))`WjIP6cc)Hv&g_kod}9eM0ky8F+Y~4ym097h140^rVGK*Zbw})PB-_-x9JcZc`OKomD6}=U zw!0=A6{^0Bp$2`VGrN@g#Y(7Ue<7%-vKSJ%$O7CCL$i$fQet0*?@fRijfTva67cuI znWz(c+fci8Vn;T6GPu%Y>`gMBxcNEY>H-gZ+5LFk+g^*;zT-9MXA2zWo0#fT{B*a$ zqfb5p@6?l=9?WlnO^;SQ*sJ+G%n3aa3Q|`D^jv866rEz|trJusWR<#~mA5y;WuxlM z2iT*uiKP#m^B@(3o>v4;s^r$kILU4$cZ^1rkZz}z?i%&oziZB(Ek~M~&sNiSx7~Wf zE46p>X++ug=c{!Gi%y=9Z*m`H-;QukCC^6vO~Dly!xy6J8?x)=S7CqU)LwDOLfWK0lpom%&F zqQ+2ZZHr`KD^+Q1Ll*r>GXF+A)|oft=GaMc*>^7vW7wD^?9aSbHDt)%tC<8OV{xgz zUANPY(`9m{>s71Q(`mVV6=-3q$qZUHRarli!^zTiMzK@vWnC>`8T#}|0n3Brl0S*D zziqmZYjIy5oa6ui5CBO;K~yKSLZcj(8MXbc>tYOCw%jmpYjxKIB*Vr!k}%DBAuV7{l7RYTWVM6gAQ}B0ipL09^}XzJpu`99MMn~=qZiu)x?bc=x+f%;K2NGk z7Mo2y$qg`etbWSK`CE3P|N3`u5X-WYa655ncoKMErjinfb>y)`7 zex*3ZIfja{(z0>6@|KTL<)&_L6PBL^`#Z~Ko8I-tuuUzSKMx%5rk#CQMh&<53ixYD zme2|th;PIv16c{ZM=M!U0&G=IlK1%lhle-u{0AD~aDj*Q34PZ|I^ROt@3Hgz2|o7t zNAOKA`^Pw#NVb=7V}1i^s%JpSs@L4e^5=xzM?&r4$>cI5H-eX4r#7KO(?OOQriMhp zLpJ7J$Gda$y(~>P;Xa4VZ3J^?mu=&FXP%y(TFZW(?%ofPak|wd&~C1@mmuWhA!?_z z^=91blkIp<%E zsuri)@h;oQ+G#q$*_ROpy12d}Hcs=lkHVIVeQpseIuxkS6fio*b*wY29?M-GV^ilo zJATJ!)Us|GDoULUYDep@BvL4*tgjtCoU9vUeNykC+#7$`E&j>YCaaudciy%grn?DO zu1;`x>i`em-(r$FFdbn31v|j~z~aIEO#6Txtd^}pkq|0WU(O$l2!byQ?kTD z7V3b=!jpwsb`g+V_R(We>ykxIvWm{2{c2H&6|tCCpMCtEtAym$A{ON`or>N`u`Ebg z&veeUT-vrxV5uu|K>l*6S6VCAu$8=&5OG!6CapGOx@l9(wlQy7;c|gvrW$j<1oZ)q zPyNl}dnHCgniy}=_N& zZ;-{t@(RmOLbchXoA%ak%C?nlXH?youQJoI-2510{a9XkO(NY*!pS?Szo!1v>y3up zG?1ud-eamnK52kyGT&p?^x}a@n=zSY>?vW-7q_r`U&6e-fo|^r@Ba8Z@vYzg&oI&M z{pR9H9Ogqj@ZeR<_1pN(+c#8ist`&Z>73L7d_hOQY)6F=uZPb9q1cOgiYKYyF5rVa*`7REcY!9tbX)((yXQX|xd$%k z9*E(tgg`PD)v+_*L~f*y{ zRcNs*$uA`G1tAB#z{!6opvA{_c3_#3y^<*f+d^>^Us-mpS1OHV+xTL2{A$~kluOp~ z(?B#Zpm?Bzq^?k@#I;#Bl)6|hvOJe%jd2p})w@is&tX&brSBe>tlcCq9uEwMZ4DPm zf}~lYj3e2I_xP3=y^A$Sf?OetbP1haVC>UyOomizr)5SJx}M>3)DuHt+i88pSZ~?5 zyvbW{Y|3ldx-l%L`r^2#q({AR`rkoskx@Y1(X2#Hp&xjcdpNP{r*h0RjI*{!%Q>b~ z*~~<*X57q=;)P%FAb#Q9Ka21AvG2qUJb~-olend1zvut6+O@}J-(sp4g{rqe|Hz(vQ%i*4Cko7plb74PLWc^OR;>&%v{30Ob zc&I?G_(8NH>;VJ!{y`mzlF2{Vi#hSU1PV>dOAzHbLM-=WWmqTfN^ETuq%3fih#G74T)5|jPOZz2X^p^Mfl>ybC{KxoVd8~ZO#$!3?9iN6m{!~XyDX-dDIuKKJVwb07&{#u0m%=TK?Z&7k zRaVO<(Q0RUlFc@`?JKtFtIRf=yk!+tw@E#&{-ZaICS9xXcOq{@6N!dUA~(&5&+f|n z{%2jCgeM7@H3;lB2@hV~N2|~5m@F`Tkl6o%8Gh*1FUR-&^mpUQ?lH7`z>_zhz;tJV z+lL2e_51qa!7a?R{qOxxyN>Uj6LpmJIvmvZQ@rGU4&wz`FCBU`B8fwC_Yyd8ykXve6Nv=omCI#M=xGwoOKM%#%2j9%-Izzml?-0=3gBJA7_q?hvXwq|D1{>A zki7b~KPoEIBCjz?II_zwF0pB2#W+s$mKnh-fo2V=`v#4JWfj#|Et|UDCM;hE!(?4n z`nApH*v=LGH2{hwpx7WdzJPf{_hM5Y=_c>8LESQniDa#$-3VI1(WHb{KO5;MZSfEfOiAsYlOWn8?6gf_7PQ6gq{W^x z`koTA0ewEdZ*Lb5z32ga=GLe255M~x@Txbx0-P<7+e2Kx^*G2*iF}Hrx{JjE6Mefs zx^)Xp-+}Pl%rU<(I@~9tip*!tbA){t;vS`5=<>ibEJE*#ZXFEsMn(^4Kr#T`B3+-} zlO)J(faJKB4nE7l+?D3jUGefPI!P%}54`5IX7nfyvfLj_4Xb1n56+lcez~9raxLIm zuCyqb{K$%Tax)pm14CJ&(OJf7Bv?M}X}lV&8I(d<6v|H3<65Mn>(Jg<=iNiKu)IR| zakxJm=+FllFKxuTr@aK4FPmkjZdM#si zYNZHN_oqy*Q%&z}WxBV+J3G2tded#&HtA-7EQ{l5waB=4VN0&;&vDaFb9eQxKw{XD zrYZvxQP*;MZ0kZ~y6C#HSV|rKOl96FA^GoXTSjla^&26*3y~Z{e0i|dk|ar|b}dm! zE4J&6;dWIW#zH>Ssus6J)LSU=X_H?1<)oT5#sbwJ1LBPyN%kv1n`h=9G@ps3YmRNL$pgzB5S8`=7-i-T?h!b1@FnUn>>{yuO z3+VcJ=o=!)n>0w2Gm}k)%Rgclj2i_IQ!8(x6j^j4#@IvAT{c?&?IEt?;H+# zc#PebE=SaW;~l|ujb5=90QTRbP&tF)SdAs0CGM%dWdZ6^))-KA#iP)y7!Aly!G%7` z(q|F|9Lq3{WYXPDNSOhOwz+}9oZw;T)b2d1O%!PO&^H8Ige!F)-jORu%NJrt-pF40 z8WY*1b^)dZ+DV7WE-_K!ZYLS3onvRxV|UhJSNyI%v2%J8dtXAl<2T=eZ~5VG!Xw(D zcWsC3+O_Y^9>A@mF8-h9i{A5`nkRGhU-u9_4)PJYCS$Jo(B(Odg=O75Y&zXjGq<(2>M@ z{W0vbB7tC*!HFlOF+_Bd1i6Al7e3wJZI#I4cdR2BMUt9#IqNw%HQF$~1ebJDjF(LA zev~_;z{w@6(&;C8e%sSMZP(_F2Wq?3N_x6|zcLJNa}ExAJ@}#4MH5vMwi8yiB;(=9 ziL%&sdN`p6GKC#{XtmI3E)Z+5xR9y668F%)77M9#Y5`LJ^RVNzO;*ARI+DroT&L@~ z-4*)im*s|?n2urDX|U~B@3LW;sR0b*l$8vl!wJhn|&KGaob`A zE!-L#WYyJpQf(P|@Sojj87H|9KUwyHPEys;6&iQzj40|-`7t$LA(J>=cCSn=2rB>P zBjgx^VH|=F4Qyw)R6tOPmpo3PzJ$#{Gh~xP5`N6NN&9nu4a=ah2&$KgLZO;iJCgdI z)TYNrd0Cc9#sr$&2bh8KJ9&nakI~43mUB#%;U`M^t;T<^o#3i|@1Dt5KDdq49q1E# z!UOlukS0e+I}6OTE62`l>^`r-kG}Thc-bqz8=pUT1dGXB33?7{*MD$$2sO0qa=AC1 zTJ(MEl(aJz?4-|ja( zwCcvx{2B(oW0~6?m(5#dpjRtdkkDmA?An=?w6b526csB}WJk03d=pw^XJ7;y47>`aK+ zD+L{!V55*(r@5W60F)RVSEIP6ig~7H_tot^LtFM8^?z6LDS%dqyBUxqXas6E?*B`g ze;?mZfPVhQ747_AxW9)N?C)a6j7AA{|G^m+I|rD&fVh420N?f_--e%k^UvZ_PkaJN zgLHF#9n@#~q#gYc4&ipneVJ=CN}O3Ke+k88eWxU?(BiD);X>udbS3ZQa|Df%b%yOb zYw6NDtXr3yuS9NfcD}05h}E6;YaHBfCG_!VT!_DW#=Z$!OdEMz&Ny<(N(?1=oOzJz zboE%ZBZY;GNJ^FmK4%%eF`kDwTLG@=6rujj%H#m^Z}6sVI@m z$**#jxqx}Y1;=D+RGImxXJ!QZ-o!{9>{t)A8^TjAwm(UovNE?4EtS-hlDc=`{**pb zVxQ@SL_cL?Hzn+{QBqC7ZX-;7{L&N4GGYPO#_?bzhXs zbw8ZAS19+$Whj34F`&e53dK=qhWTo>iBwYv8nhmlO|vFF{gI|0cveD!zY_MWVB zbacdfGO!B-l`dd=PnzDoecN%J>qd~pj4ad@pDO%dRh-wC?YG%h`6k_FJC@nr7|S+! z^6vK8{6$MXyED z0Lmj$lhk<5CtDxxwuRvuvpp$ zTKFDWiNsbp{CVN+(SU)Z@gWIj9D}2&phiC+yDBqvJyR!T`>$}>sAcb*ja7No9^#;` zR1B#gN(erw|9+t`+@8@WewR(5L*GykpU^Lz_eA8xS!{1#8<$MF5HdMUp1$NxD#J^xKS zfycoaaO2=fT)X`Q_8-{Ckv`EMEND_>R8Dn2AuRtx#29ULq z7CBLO=cYyG<+ylpxp1m8UP#4Po!Bsm@~~SCUQ$y-?6H%QC`Z9NRIR-#t=bsZNxH{Y zR-0xhTTU_(J9LotC`RW=Gos4v%aG}rrxW?uXk5>HKy8zZSjZ8TvQbzhkwB0{BN=Hr zAVCS5Lefq4$5g0K75{{_GB&YwfxUEtA!M`hFENXdEOWUWN#fBnq~ zeRppM2im23x9-pBIg)jctmK`8?+Wj-CV}sn0r{{VO6xiIOB*MABg+;VgPtDJ7xg!`Cy&Q>Qak6BlXuDI==aXrc8$H#DqqpKT~YK7q(BRj7M{&I zE(c`Y>zT_kHdNGtm%Tg&!X}r5YQEQ^XqZ0>P|5%(e_BeNuno0id6uqtMI7tCWG}lc z^iIg4HnNTBc`&vuyyI}I0OivUsFOG9p)pdcwG`B%r1(u6G)nYIQ74%Z)ct7&nfZYF zN5OGZpQ9mbBY4?U_Usdkq_Tk%4}$7MAI~A+ZIXRzBx_M798F|H!^JWEqyjg`n*o;NquHSr8bFag6e}?Ja4A-<%X9KmLFrOcyX>>1T(Zl~KiR%TT++#@U`QD3m(suBU zxrYV`&^*l0kQQX|g8vpmCrj5a=w#Q)TD7c4hX1yMUm&EBy!Z)5mw0w4TS_iSG@QNY z7%FZ(dn{iNw(9RaA-UGk(*m?1=kvMgX?FL#F5UAHo)KW*_c^7tB~KH*JQ!zGy+ifA zU#&dwWeiB_L0rMnTi^Sy#pGgR*;9rV{?mQ*fEE-$6lWifacs3&t~$%Nq3X}3CqDxA zTk6Y3{mWiuL1ZVxikz{sE7q$%*1l>X9d={Y!c@5nhG(x~-Z1YrY*S&GQT3LSRPx4f zB=L$0vGL)M|p+uj8R}Mv^3Q0~=lhMeD zcjq(F$t1%&@m=xz((ksN>h##VLhS5yn5QR!{Ubd1rTbW1y{+HKe;I%O+y5SZ{#Sn% z^Hxb-JM#q((I?p|VNa(mGJ8CJ?eqG*e2)G7Jx~EWas3Ijvld^jd7P>S?L7n}C-l!-YKbtx~t=zn@zG z<)qSo+txNJt#!_%lpigk-oY5f-hK;!@`&`Zaa>u+GK(o7phVww>dWwg4HM*R;dl{Ta>fA+U3R`(M0+=7A1xeCN;NAAip`;yZryJMifI zQ5-3eA5D*N3%AiJnfp(&6Fr=iJOA#!Gf}C-(P9zLz+^T>r+K}aPZ{;-6~AE#N!?PF zWUHGglRo_Pesc5z*vq&7jR4PSDRmz*`~sodB|Gu5fw*4dVtqj`pZ2bj`tBGEmEz2* zV?<%gC6@^>(x{(mqY6kWjuoohl-w@2?&$)#YLnzzGqo?K71h9H^Q7-V&b(}&9~}G^ z=&{%$y+k4{pq{-8g={KPPTw$t!#ySkrl{}P>oXOY$7WkwoAPma%)4%04r!T2ltApX8<*ue8A@EOFiDLPcY+gr!=!o%{;yFYkNT?9(8*mk*1L`p zTkCph682Ns+D}CSkP<`ZI2zg`KoV_Y(1cv55<~)_oQaf@^eK|{RQyEt6GBOvfhm)8 z<}HKb9sq>iwp01vQP%eExuTX$cbkk^L+rM|-lV~fDiv4`m9yH&qOJ6lUBXoNWYRCN zrzCg(w83uE;rAiSN{=k#&!Il`XILH%k_L$n!GUTGnY? zHH|bPeSu z1%Bx}vA3tsT%`Y{IDJjbqplriN#D03Ckc|JaLKRadOyhZ@>A)a7m*S5NxY%(kpO{#CZ$59Zv?N~)OaV=YXY*1`i+uk8@5Sy`Z$b6#{O(QMw<``x=mDq zhES>G9Lh?ntuT?zWJFy@ITrRk1)wk5!FxB|sGqH6@?jgx4CPiBMW%$@0RF>i6;mb6 z%FpyRv(o@O379G&?>1SyU@G|Yd!xiUQyu>mv->B&19JZGq{RabaKGl!efmWIz)r@M zoeq=95q7Th*n5y@t}bw-WZu4@$6J2==kRSm^iT2qKm9Vi?^FL4{jA62$`rXtSZM70 z>xA#0?wZnUC)pf5kFXFl6SN!e0==}h8>7_8(hq>BplPM#-oqQivg_`LIh7^P#a?!Z zqem}ZQ&hT6l3sv#+t%_(7S__+N@D=S{DokD#;Ov@RpB|tQHSnZzUG@*4xRoaPsg*YM<9If8jeD0Pb|J5%ibyM%GO(&65vp$szs^^3qa`kuGKuK7z4<%KXXL3AI*@<{6 zpHA2_eZqDdMv-SINaHR501yC4L_t*5%aOQe@~@BA^4~>yr|B`%C-YhO>m}=P-|hsn ziDK`M(BvabIfwsQ*1gFBvv!ULt~UA<{Av%qLGGT|LBN?wRRl)^QNol@rpD@bb@ zg66P?4$JIQ(@1t%lHnZuL`h?48`q7pE~SiB9otJ@&(N!$%jOO96`SQMuasnEgOaN< zx#C0l9J~1W>ipZ;CGVFWuS?zoh3}hj)?}60^1O)cveG@mkWc-=s{J=LYS+eml~qcX zc5P!=PFhx`5NY1hbyV98>3UJ6tcB7L8>j0ToswR!rO>#kF(}$7iLNJZ zC+s#pmrEzw==1p`t$5G-Nz=lK)|ezJ`3Y^pq(ZV*n6^b0c8z$UwLAe+VWK!qD|VDL zz4Ntvj8=95CE`|a(ham(2{dCzNq9$zw_;cFG4@p6YXQ?;y-Dn}NuR}m9qq>bht(=( zE#!~-oSv~OepfF5JGK?SHvuXd9Aerm5PwR2uff$T4R-f{i9W5jyK~&^AIJSK+(q-i z0?mU9OkM!I;XOZtzxS-4jtf^&sfOZbVJ=kmZ<&dOntAPwXh@V%`w@oH%R0PwR~PYizR_%g6lKwzgq? z#Y&%0^()zm3wtd?L3L9$X|=5KTHd5pZ)%$mZ)V}wqtWza1#I;lfT zwDn7S*_p41g*p1^1r*Ufv`8ARXH?rtTkW_fO4LnC+D!{X;%lZ~l&%1)*e$wDt0ylB)RzR)i4uC~Rd1rt=x%qWo%@MUpTb7p^QVeCep^@Y z`>hi9l(>Jd#Z@Kpot++yKB;#+z#=Ir&yE0nUVr#Y??$<_|c#FQT+9<{U7iH zul_zfmLJh>{Sci#ulLi8L+$KubvJRlSABg-pXUSII(!nH_)faGufErY<_H3a`dWf$srX!U<8F&tCpKCk&S%A3@_{d)1JQi?*q7IUsw>g1>_c$+cSK zdU;Lx94(O1=YBfXM7ZRAOqV?VweZQfF-sX)%ER*z@~1NS!@)gvAqkQvMpgS;>4}D? zC`cY_Fq8ztrjt#;h4{U||BniSUMCuyo%>hzC!a&w%f8;rg)xB4pm_p*i6`HhTa|vr30Bx|~gMFO_m1@b*Q?JwNHFU_a3{{~wDjv&H zUezz<^=q-yf*9h{ZNo$t-&Z3OsPXhDK6*!@1Q&|;vWwR%TJ3C29w#BZKY{b(j zaW~jiqTi9jirEa3D-q8cy#$ELx7*mWFYVy%e@{N0sCOr7LkT;mcHy&G#?DM_?`6z{ zymK48a)jCQ5_VqL;&A7Neh+^qzUwEx1OL<4{g3#*SHB$Zf8>4WW^>Gi!*qbT680QN z==JkJulv(#T&Fuz_~mEezbe+vF<%_wu)D39rbMoL)l)BRD0ei+{TrTyNW@uo9eh}4 zD^uU=tUB?+s&;xI3w_RE--~yhUUQ&OUxCbFXCAch*7Q*pH`t?6iF~>}(>n#pJFWU$ zsHf#Z5AG%JNxJ0mNBGq1tS7S(0$n82f}6GbK>#`|6WKWZz!YRZr~>p5o*x_Tjune< zi?_B72`)*QR8uPnpqBx+ zXGk2UZ7YCY?PT~3p)4>xR6_4XiV#m*sBAtd*>%D~GA($D7l}}lgJ1DcNYWQ9ddQy3 z^1SO{`0wL(YQwF`M0zc#DmyOdo@FhLy%ysvS#h2~^{FQC@T-d5fL`r(vgj1NJi+}P zgCwZLN)kToR!@A)DYrq@%i&|9x{XX0pklB*c?^-U&|=@|)3W{A7D(_PPy>2t4>d-D z635P@jYPgTorn@s0!=@U--#zmCNm`<|0cbg0RO$}9ks2zhJcfAla+k4>H@QNjHI#> zyb{)qlI6}sI@!;JiIV8F0d|#qy#t;qf$pf`T~WjQp7@{s>g>LL+n((lqMh7AGrft)&TZ`9*I`e;hwpva1UDbPhIfDNZFt%1z7v168UW$@s@C?d!%vC8A&fzlYRo_XmlF?g+8x${cX$_O5mDrGSq!a zNLgH;(QD_O_43t2_e#m zxVlgZ!CJV{4&?^yl+AXWBq&bz%u0CDwir<^{D9Jj@W`P~+^a*{} zdaZjwWw+DJvD+>N^w^i(o)YqY*ks(d+vqp$6u9q7!u|RzzQ2p`H=E2c+g)I~vp_pr zpwaK%trGWiPqHg>Os_1!z1x63kxw54Uhrl2;fsF9eb{^P6d!y1!+66xUW0$|?SCKt z;9I{2-}?Rk1h4s(SL4&SK82*;&XacU*SjaMXbv&gIP@Bqj*A%gtb5MhlOgNw%mclW zY1Vz|`wkTR@&?~4B~Q(0+323>t!vA$8co5SlX?rMOpVYvqyzxk(kBw z0<4dRQgcmq7InxbsCGbD!nV+1Un0O@k8>QP`g(>PdEACPE<3|bsL4C47nPr-i|)72 zGWgF@40og1y7u1Fr=eH8hT9y&vK}RN*SF5pFkcs3m$MRu`;v$8$z`;(RA=qtk3j99TP1L1V^F)= zM!Q~23& z9@tm%+}oK;W)%6Z%7*1jD7o&==Gd7XDybg{3)Np>SBZ9KcOKOIzCM9p5%%TdihKuD zzPg)Xxv@7r!cKdruLrl#;wC1ziJj&EyOK?(hv5D#^jEG2c>irop5I~TB?;>X|eFY(pi_7CvieBFPG|K)qX7B7F@58?wKe;*#b_9%LN3261T zp;ywrt)2FfcF)jD#axNEk0iX%cy~*5x(B_`_5E^=cs?A1?pZ&&&v6e`uh&iX?i%t~ z?rkRtmh<;m9r`X+o~ov41aYu46tc`K@^0l2XsIF>G7Ql{Yo2ywXkCxNN>Iz+=A3HmPllDvzYD z#oBFIZkRXB8)M$(tZo(R1$Z?cRkm*LW!(!shI`eiwK`&wLCJhN$9!@P)2nmrJlJ9H zp^Tjev?+ZUc;LkiX7_iP-oKEn!|Z_q+s(9N-L_13cc{t(fK+T8z;f$-&Uwyd2{zECS^AKTQ#!P;U-3NQ@2s5>@BcDsEcz5K(b#@f< zuKZ7>H+EFl*wt^_d(UrhpMKwd@Ws1$_>1?2D|nvhLtnJ3-@Oys7xtLne+!TCGy1*z zJ$U^)UyYaj)OX>ZeBVF9|NISq9e?8={&jrqKmWVP%EaL{5d)M&Sv>DpKT*~(PjA+275m) zwtEB=n0J@+c{=;-Lu4@!FO-Iu4{Ba$G`ht1YV zZi-GUljpY`I~hOVWBDde7XKupKh~2pSt3XB?o}4F_;`_0A$~s`z6M5J()F}6_s%%^)9^k6Ys#k{nR_~YoB;$!N=c$fAh(=uX*X;$KU$azm5O?8~;1}f4||c;eQbQTi^0G@Q+^pkMM&(^K!iL*ItizeEe7N znS)Q^8Xm*Z>^Az@0gfg&aoAkPqP+#~E-<;$0{W8S9r11#!Ks{PO2H;FmwoZDXOAfGoY|X{p((q}q|h zd(|>8Nb0(3L~Uew?*pC-eduJP`KGH%#naMOpYP2F^o)8|^|Fof9bCv^@~SSm;#J&w zl4sj&RwOT8XVb?sT_z)DUs@gMnQs4Xw|D6~Fbs(2`@pV~@iR~#u62v`66t%plDW=l zSXr((k|4PfJOz$i`5{;QX)ek0*;OC?R`b59k5SuKjK>B_(#A@rs*(H=3z|wnSNiqa zX1Q%ng7wClUVSAQ<4tl+_=nP~;om#mi(sY^dJ2wCVsiIniO`V%%e z^b(>j!r?>9cBU3}*?AGqIK*#0_7VK@yWfDHd+V$4hPS*DZ+P=h;&pHO3B3LnUx8P@ z;YaaPum1u3wD2k=xmUjK2k^?*yd1B1&CBqL*BalCfBCu}#*e-3hw!7X{XzWjt6z>E zeD(L?9=FZ;=t;=6z1yYRiQ_)ff3Si|x4#x|{=l2??oYi3AN>3W@VUh!xP~WjONn~Er=+`cfCDA# z8~FyVFRtN6_atu2uj8iZVZN;qJVeJMCF}zn^|$fl&CkQ1*cX;*mp<<#UmWUIb(qck z=NmNAxmWV;lxTb1k4{N_jy`@TU+6w{O0q&E(0*C&Wt}DKwQKL4z|EVY({1gGo&07) zGE=ppTJq(x;BpqVE=yy71zA4IX=}?|%9SeXJs$=;@Kze?b8sv&ElBa$p z36f9U(X;456o~gxAaO@#hjAPm(YdOuafLiVaw}UF%zis6- z+OjkAmTf@dII|aXp%*&A{tDz(3BsR+clKKx^ta%Ilw0i>^}F(9x5M834W@e;v#U*D zay6m7g22R7I$n{VD@sm6w||80im<;xx8Gs0KNl9jeHtC(exScUFOkJ(eX2Kl@d4TO zLU&(}z||gE^(w+67{az1IsxGgpJ(W$3^nk`r@@{Wl5_P(co6Sue>i7AD z`gx>2&(k61X|C6sLt!pnaZiE9e_Xx++F6U8ogGZ4Q;-C@ULTT(2Kss6$}ZAOvZg~; zLieYotWWRr`5|uHx}p21K6vbf-U(Ubo`p{3F|x|mtLM7^z3SyawaLa@+Ow8`}n z^dL1h;*OuzmH3Y9&MzTP@_&9G&!#20j6Xe_aonx8pA(%J24i_=hVqgO&(*Sg85weW z_eh@d(4M!a7kIGMo^x4UOiYbsOWL6>c`+n)z1T^fvyR%z3dZf9g#MI2^wdeGrFgm6 z)|UkPbi{>@9})9+6@%_QNd zJHlMM%q}ajWhFX&Mn3FKCGY2|d4z?2|6NeLJ|!RJ0~~7SdV~XgWIhZmlpyppd=Ynxi`B}drixUX=T>JuQT+85uuj>~GZ*NbCk zn*sKT;4##f;qn~(s9uOvH|W!JAy0nB$dh$XQ#jAM+pObR58^HaplN>>ygd6Z)@C4C zioQo?3sy4Aw}ng=n{B$2WF#Y@l7*gRSLJTosO7WhrtM~VT;DpwGE;-3i^o7R;y$L3 zfL!TPDpSOXG5Ey-rdf-s)>c52sR`O?QM8@g?X|=Olr&xLT&A24r~mXV<=WKMsUPf( z%2(>TLzN{V8L^WbWO6icl{hv7iZ37q$z{FN5C(K;v#&&vVM-Po*9{B{tP_c$?r1Sq zLXlD@x~_xr3SvxTyO4p$#>vJDc<)cPg88h)-sP-ua-txK+$RuI`H{BEXqze0WQs+f zK{g;JLR*j~$aGnslGS*RjD#Mo{7#gBTJ1m^rTa!a#Z!S%dP+vUAVk?R!GGsCD=tWG z9^C?Yp`K{g zTvayWob5z&S}Qo)5j=MsQ<~Rk062ueA?;Y?Zr&KvPmz!I07y3CPZ|Ksq@=tvW|WOU zda=BJ<8U~P;!iWG)0}*f`%)(Ym_6w^1~6ZjZThG#h!TA^hbaU0m*?UfxB%`mYfQ7aqoU&k5DrD5Nx|w8xiYBN7?W#aWQTm!FA1pxoig|zq9+X#Z%f2LM zJeO0ZqT=Agfv8cZ`4TT0yhuiij2jupbDz*OHjPCHO^m>?WsJ$oXUyGmmIaIE?Ma-- z_)|>ca9rwBxp2biRg(m_B|{oG!aB({_Z*x0itN+Db|7!Gh|J$;o!Xo)^0MLNGY;pU z0RzuvO*vT}!R30$jm%Ryh{~SI-oy318uM6P6)?SNfhpg$yIR}8-sU6(Uc9G#va}+d z>LKvAHQh}aGh^Ys)RF;vJIO8QB^$aiGj?+UF!O{AOl6Dw0W4B4r3+0#T4W4hlB0o( z3yoE*RG4Zmb~P76r{uYeaY>7`pdBY)?MS^*>Oy^S;SqtECwM;|j|1o6gTpK|4uIrru((IJGhck= zt3}*2R>3`j3<=Q&7@L7RNo^D(Ae2&6*I2c0p;FNlxKE3%)+ zd9AG`SKFBVxtzHuuUy!3o}y#rK%YqJwv1c5cl!32j2$Qor5&$f$v6?pnp)bkpPC3f zzd|H${KziQO4q0N!FZY*p*ib*jnLGLz9piu2gPB3Ca_%|iXAEU*na5+(_606rp*U7 z1*4ri*nU31)EAiY`-yc@eDe8f@=24_G)^ly^##3|cwJ#a(>xr~IpGU~$~I%6;Xag# zyAL;a*}w1XrJ+T{gD(8ieZl2SixL+#E>CW)Jfapz!F4WfeaWUJC>qiUg=o4cmcl*o zT0AwT=!<)t#8Y~aUy;hlr-j6K^&A?t$s~9jEXKQ3CUXew{ zSng`lC_jNk!EIuo9SbbmQF04xCj{|X&OTUjQfew1CRS;LmlOk2>@maYHakPg6tNd&j!FEmx&H&+X0x#XJXti02=P(lfJo` zmvuAkUG?c1Y&Y|wJ-ccb%Co@gp1z@PZrVzF0|1Yk&CAm-4u%j zA$KUlF6fhN+X{`t!N5`Q{-UV>34!JUNjf@l+!y9!be{e)^yQm;k{NfDa6}GDGT9`a z&Q`VjrPr7RdeT&(C=vz$01yC4L_t&;G+_VCj&sHyVl;+p`O=s(6I3=_u6FeJU|W5W za-5P2V_HtY-RwvOl0|UGQL|76+u<9I9iFq1Fo=`3t>W+J4*qUm5s}5VX7SE_>)*W}a||^Ld!#(D;0CrMsZeoDMnCyh z+^tvS18J=|MBrQqX$KoPw~<)}V(h4G#}v=Om_hb_&BcjrWIOU4`FSH1A89A(dU9B> zNSNE2=^O^y28M1x-gj92t!^zS`4(M8tb}<0V6m9?uodC3a z0L<6}v$7eZV-je74xuZqc!v<>3Wqo=1zjgY*z5~splvg<{s!@v(QCShvejq#WveHrC*?( z__2?L*0j+a>U<=s>ou@HE+w>eIsHVX5mj8L{E|-wo5_z%mWi@C_h1GSoot1Ppup4b7 zMAvuPhQ+3+PG!-=(`|C(@k|7oI~5&5vY;=?ln-UHSJ4S-YmPm?+CuXO#-xwZ39=W> zh>BKoBrK@hfkvV(cCe|~*X)fws2H*k2Z04OG>FtD*~t40vQyvnn5RBLuha$QQZxRD zJLilsi++hD=QoS!m>4mq5qHXC?Yx=)+9=riI64O8*G90xm>1SK7;0VvTw^2WfU7Nk zBM&xap%u2-&o=p*V-JL=gR(Jm7960js2*!t`bVhhN&@-ASm3}SAJ|oLSgGK&l79(ldkCZK_4`C`)ylJbSgYvbuAHaF- zH=ip7=KBEib#nur5JjHDOslHG`s)9-c0$fhO5@P3u6&YH*u8xN*jHg+zL(}E<${!a zns#dVcb0HD^mZ;(zR+a9Onq6AS*7(#jET{Lm;4k>x@gb>5z``6)6dsi7E|%E@iN_bC=dLO-X1e|p=Q+DjPis?d2pEQRM1$sBVTN z5QwiizMuyYUMo@kaBevTyg z2*7VnX#f}xhi*2Ww24l7LGmsnKQ4ebyn{bn>+fL$${Fllr>3$23qqxpgOcf?<-b`d zxDaXa5TutzRM5CY0dNiy)K1}e{c<6WTCll@r3JO(8l=UI%M=$H?Wm%D3uhl|m2_jX zO8Q`E{%)|5nry(xgNpL#B8`O-KM*Ur2}WvM|I~M|{E18`Bg>cwb5>u|0;Mrdz?=gY zZS^DYM_S9!Lajd?@051z&;rNfUNm1~2kltMjR1=z=%2uR zxkCr#LB)?b2E}1NV-|Pqa%<)om}{U<8)Hfc+R7&~2T>K$47C$Rv=wSgb}3R#pAq}h zZ}~gyXUz7PZE2;e$w%fS7^&vU!F<7SXgtQqf@~a&p|+6vs@Z2GT*5gcuQ}n_ikxKm zs2^RJQP(#810mU%eFn~Ru*hD7nv@z|cQyNuF&Y3DpFfIvr4`VU`G2XI2oo{Sqz$-;Df_W^AN2~&3vpj=L5h1 zxE4s%8>jrMI_3EvTKWL|1Otg4THqrJq{SqK=9Qcneg`5QkxPKM;EPtYnv0Kof<^vU z^IncxE@={oG6tJGA{Vs)GR>g{RA`zOSjcMQ(!scakUTJ=Q?}KYpc_sy@uJq`rM82e zM1eli#|Y0Y@yaW*9SHG~CPXf_3DTFS-C(NIScTGN%Db_?_|V~m{TpW}@yRdoQy=A> zWg3s#Lh{pmmieYz3?`YNm<+v=wc=0e}VVy;-bBcynY z8JJI09z-B&j>y4rOes0Wk6kbqA1`UlU<}3MsB98Yk4pp0m2QoP7q5;R}D^yErw z^%;E@BkhfIChqVAWK`zU{if3cw_Z-1!q9#?Fx2(s9mSk z=1@Z(Z!qqXpe$L7fz`w`P@aHEbBz27sSuQBE59NJl}b8`Aopo zG4t8JY-e6H>nD^2HOnK5s`v!sVk$(^Da;=Xc=^)LgeFk(1#LS}ZbD2kWUlz48EUg@ zF6wO?m&?!nQ(Rtm>?5WJC8P-1F13v7cZ$=M8$T{%8BqaHXr0czTmBGN0a&g>V@bn13FD`MPCdmo|ra^X9lYW~Ny_j{>vf z(V{iaKfMN?7tOfjnX$$-2I*4DCn8hDmqE7=wP(O!GcqpF@}GyU{XDy_Tz-;?I=IX` z^~E_Y#<590YR%_EL(*qmG}bFG2$6OPk?rXmXM1obo_UrP8++lCt)S9DX(aDCh;QKS zq~}aCb|IHeP%KemTG$#pA4_e41yluR0t==>u3{VI-`Gj-{VDZ zNMAh+m2Jt0CPt3aMyLW+q(GVnU`qEm3hdGTM4G2n z_(Z0*r=(8o$=J)yyPK`_-C60Ab!`^4lfM?awiUS0OH%3qPP0ITd0RmVy?Uo#(2kmVkCUn7{2e?H6FK7q z>SbS(*B(`|6*tm8L$bj36oI2Iz!;JKid)*MxCx>06))trXtynYL9r&FzhpTtWAEZc zBd<$AyeS7xaEi(Nd~QgYpLBv=iBvDK=p4Zgu`3@}5k$_{?AW&3-&7K38=ig&|LPZBjOXqE`{yRH6 z*sIS;v!B1RowQ6B=d>+F+g5S4^*sE zq@+|^T&ChPpm@asm*rhr&V9*u?fTlKeaUCrQYOAcwh@(FG$Xe~6PnlWO##p%FS+a| zl*K78;-U$GOFD~$bOH-dk}Cqy6s?I&dbbNKDuj4Kg+awx)NT1&yJ5_1YCdR?<~aUa z__&ePj8T){A;xYi-xP!G{2)P1Z2Thol1+^I?OdoCqoLHvSB1-M*iN+3Gk4c(#N?%G zwVAWxD{qhC{(-%#4;3!XllxIds=1ZF;!wgoWS4EU&YtOIvd<>~*|) zb95G7yPx`k-kb#N^#qpoeHTEVtn|53{6X`Y@2|y)P_Y#j3{VdMTJ~}*(HU^@)S{~} zfKCr%tFcEYu3SV*^vSZm)ERf}kxv#`Cz|wHoW->vy}%M+8fVsukEks24M75nIfo@X z^&va?3F1xT7rc<9q9b|MiwbQ!MvDRqh!@4Z%7iid3WR(z0(E_m{X8g6(#ca?n+8PAP;k`D zL1F+a^d9_8Df+Zt8QM(;{8zv&c@mMKCT<(wFg; z(Tjhe?U<1S`uH3Mb}PC5&u!zx(?|A3%gADFY#aNH9#0PmsbodUql&)d8M*Dcnh{c8 zkq-mz?|D$(q9NHyCO^_i&P_X#){fMF-Si`w;!bJg7`;`{?45M7kDT`@<(z9yImW;~ zrgG&;9p$p;6nfH01yC4L_t(}{$wY0YKv<@@i{KV5u_6>A(uQkj)RTHteu3j0JECl<*RC_N&qzMYoVtF@@rDMq`vn&0 zaRd2ga0V^VCvZn!JH{qmRNMxEejb}`=>+L(wzcygvuKY!#+bgfqvqJ^dTgRWV-F+p z)H9aa@^{d0S4x|}zOEOwQI?Uv0{>Dz1lTp(rccL*Fq$uFA2GD;)x zT1#rvS-wisk!ftk_MZcG^Xu;CBmi37&>hjSpU4DXi$prC%%bnzE3*vq8(W2MrT;^?K zk>cX$i=RvR6^TU$7qG@xR*_5aGWp9U1JI?zemoB2amiM!DWp#=`6MoV`xvL{W<+HS z@vLSs?30hNz_+BOqk^E#;` z$n(}AHO_~Mg?54--vWC{{pYr*QJxXWdqGj&jOY5ybDU~RpAb{7jZbXs1Bn-+Lhe>8RQlM>#bRME9rjT=0p0!2OxeDwjA)R53U$zNmI(^d1M>;b<^KL9% zRG*Xu&bbR2&jfGiE88!kvYQ2v?k6D8S&I}CnAHPG24L20C7GFln9pCK0orp1@SYB# zSx^eY6Uoj`imTFmsYMvtiR!+QlEkju^s)bjyAF`ezvM3oN z#(V>6@f4a-PUKBnw#EfFH#}t@((#avkh!$DOj2ih%CTf%#We)l#Szt~L=o47D1Ai6}&3Z3Bw3nY` zF*3gLa4MeujO+4DUB{Bx*yWfZ$7dgrHZE^?wS_be`6_llj73tmvFUF*Uy%qh zB)Y5f%3v}Bx;7?_4g+8!0YB9l_{U}PiZvP7DP^W-=9CWLAZhbjT*|%w;$|6c7OSim z6|D2X>M|~_-Od=nv?J=u+ih7*C9)SJQCuPOvkNGfedS0*7Ox4KT+-sL1y;xlIDJ?M z_4}cj_y&yO@e7&y-VKx7$V66g*^}kMp2nq|)Q_n0Q6Hj}`eAW|Bjq5u?2P#-MnZ~{ z`1W_%mwe?H`S25C9&4hez8vPoK-WdKF@0D}twEq&&|ESv#T4W(DxbjIq<2jE(fqcd z-EA9DQ3>~)rHqwkLA@D!VLVz{J}>hq^H*M7$XnNusL<8(R9Qx_Xwowj56w5x%!f5J zk_P4|yO{H$bvgoPt#;&?8bKGj<}~+0^0UeLuw6dMmw7dIWegW}=f{QZFZpD&HLKQw z83^O?SeNDklasGH0H8o$zl?%@5=J?_0}hal-^9F`4-n$SxN0nmlF53}4B3kpt(qCl zZOI&*AIStQq{iRUUCu8pA|zZkxy1E8V)1^_g5(zDxim%=CR)?2>Cp9*upbI}$d07% zplzJk%^K{clRlxndrPt=pC+tiVxv}aF@#uUCrd}vHx(=j(Z{86D7NG*CM*zS-t9+C>qc?50 zY4OS5!}(XTca~o|*Fx8iD$k=UpVyXsiFtOC_t{C7b)t(P+Y++vwkI;qbz9e+k5W73 z(Yfvccrl+^^mD*+cKzN{DS&BX#qBx+`FwMCuA5?4`;%^PRtoyQuVP^83ZQlK0XXUB zua)njWb=hP*QT?8m3H#Y;A*YXU|!4{BLJ;D05qCK1!G!V6x$t`1CV`LlyWihLZbz> z#H>FcU)`65&RB?8U=?5eWO$KeK~%fcWxk~1g_f-qw+{1my}DO_Y7mj}jcxe};vKY4 zg_h%Sn<8HyYkym>xfRDjKc{ZbVz2%hUUJ!{KNQ!E#XdQ&NgpgdluT`uU&<}X%6U@U z$On}+l3nz^#dE$*=YR?En98M_$`w}#^jidZ2!Z2Gh?-GT-VXY+8(x;O5|`k*$@x%D zNv?0@#F3{M6y*Lr0?&=|qS%kfxpFX;Vw%aSgq!tzsm~H^699hC0ExD>tJZPO004Ct z&z(Cbda6;ZN+)z2N0^pwY=sB>go>R2n3YYGCy)V}e6=7czlKIGgvKRMqTRs-s40HA zU~4gxKZ+PyLp67Sv4PZYWPk3X9XojdT(Gq8&FeK?dY&Kc%SAMMsE^FgQs(MH^)Uqi zm+DLYCAu=Tg=7=BRIAu2mU)4Y++xbpd^N9ueQ1tFDPnx(K+rC#csfmOMx#EOVnoJBe$+W1=^k7kxNBE2SJ95HOY&q- z|6HURYLj!D>=-B=JTKE2@@qpnV>StLY3tRuq$MPqfsc{Cq_T~qT^Mt5c&>qF{YYcf zoFveYhvtTaxkk?EG~eL!mEuybK1h!G0CeV;>m~a6%lR+ov$ERr44jz>ZdR*3Pr!+F z&<(I`asbN|ZA0GK9dBBVmY#>@do zZw8*}{QDdL<0~fj2MZNgw7@wqE|x&DH_D8k0V0c3vpBjv!u*Z)Xz~$_mAQ@df&PMcAA{!F z5edUy1<0v6r z3%r&Bk@^XqqoS&<@m3tK5Sd>K`p0J+I~I*-`Z3L!gRyu{ISrf#<0p;ZXsy^8;0Oe7 zlNW9E)AY*#esFk}0MaSUVAcSA!t5H6u@jGIqJ2TYZ2AN%U%?qVq!->w-=Sx8@hSO$c}`E@?Qj+%p6nu+T*ns;@HR%EuH0; z+a+&4zDDCb3zT02qRf?pjT$6P>V^;0E5^9_gM*O;Ah4)&cDQJmfotdr*TRwrYBKuREz}{D}*+U+>e`_~vEU3;MOL}a^6 zjj}Ex`&VD0>Te2$<{aod$r87pZvee+z^ojYmGwGP2rO?)^Fu#0XF@kH0KW#h@}p`4 z*i-05<^Y)cOt{GVOBdRV;xq})N9v z>wfI7X-$ZajbrMg6Qd3z+m2}ICXmh>N5J!^T=nn~TD)oB0Mo`C4%pph zj_ba}nj@j>41i-rSA3*x;7I!mN1FUG1wShV{YcJq--W#Byv*1yGCl;|y#A703u9hr z`a6@IkhR>jIXfvVJqxEBG+zJ<8hO21^@WyvK7e#e%J||1*@z0_Gjdy0$ns^Wd@@R( zQYL^wF?}nZ-1|`jAU1?12w1 z1RqYkO)B3Y+Vm~kj9A3ZvVN-E`ZY%-U6dODFH!*Jb`QX9vH_c!T}XlC2Vi0c^z##g zV_@=pbh`rJ;{+1z>gV}&?T)Jr%;rhaQr!cP9jdBwMvc=7u4tz<^^|vU64I)h#K-!5 zr7v#vzn1}b?Wb)`VPH`ZO%bQM)Hd$2Ra}fkB_Yd6S-e!xSQd&Hy2N;&6oqp(CVh(o zpncKKto*vjI#ED=DiVSuR^-v}s;EScL)O6DqF$xPXy7hgg*WR(jYduxQj}(lo8nal zi+rW7LI;qS4bxya3oLlXf)_Y10EN_<(Sv6y000mGNklaPSvt)tQDt%%UlwHh3k@^8uJHvkxbi2;CCd!lF zjravDXGhaBeE?WEK47tak@M>RhrVcbj#0{K~H+|gd%7`4c^Su~V>kP8+*($~R+oYx8`p=c5=|+~v!*Y;rrYn5i8&E>Zn{ZiY1eW*pTZ+Ro0V z)%Do;aA}b(O^j~ zo&o*Pc@ZQd=xc=ADVA&)M@0P!;wPZ3?7c1Cne+wd97m2Px*!#2^ESY_@bM&_F-!B$ zUfS8#9gqAA(0CG-=?qwG3-s%OBnL3l4KOR4F=_+=IM--e$4>zC_OEM5IR1>x^8sM_ z15EiWD=Dy8FI(QTynR;mZaHsmN_ojQ zP7P6?&RR9F)(s{fmK-aKzBM0Uxp`KM#)ZWzwvgq0RDMER$81zA8{tIH0;hsku`pLm zHY1WZ%CeoBKqqhNn{Wmj+xGTd7REb17h~?$M`2|81B3qBJ??O)K5C62$5ssG02cem z!YkfFZRYvvoW_Y?vgN#UcMmlj(E{z|mq0z`ir<3AjvmXL=L14a?Z^iT$+}4KWXF6= zai>Vrk=oAtLjc=7H2T<@7>!@raYUvMRNdTHvMCL_}i>j{fNlY2J zB7N!Ay6J%yFuhXNPE@v`v#2U;q)qZl-+&?~UOpL;h!+*aXQ(ZxEt;WrMwXRSy0V@5 zK6#8eFI`ULo5CHKZ&0%XV=)H{_IT$K>)|#RxKtzq3t;pK6&JYqWkFMsX7R09;8G4g zGc0DgXa!_>xR0M7y%G?}Hox=GQ>&67nhA z%kTh}G3&Jf_V3#p@Q8()AEDa~06*_t`AKL4n41RBuj3do7z}jd&-FbGic>#;&CN|M zM&JpV%y(5)t&2_TU8&E8l9kBvy-)zHzPi4O-<7EiBtIZg$@T&OGv)x8OC&JSss9pL z0rc2#(KQx;(gsL&d?jTzsHhHBhq0w=KXS>hvpMy!YBKi~&x`Rx9}|pi+Jlr*0Fyj{ zDNmIjIcIP1E4M|Ji>RO-Si&@ho~M>k$uvoZ=keKvhFvKY=9|k*>K#TU^i6s7rJ(I?L7eaczTt`CB-Mj#}NIJnA`~u+D2-#s>*J+_*9egMV9r!z)DdBWH{mIIh{-|7#* zvEP_l09#448r=D{+FW#fAuZ%=S6rx~7JBl5MJKt)Mrc}K32+1pv`f&ZD2cm zogO(K>*w{4%#3VpFtY8<&zaTR1`B-9fiDn&;~A_~{`hQc%z zpFW#6YY#Lr|W4}@7gu2wGulta?EQg9@1d>J`)wh~W+gbO`b}pHIxo+B=`cGxz zBp1XBC9n2r2EglJ;Q)dGyV)!Za3JMbn(G5iMBxl9cRup%P|RWEgYcZ2q3Ga*vs%4Q zc#4bDYfeIdwm^kxaiR+8(Xz~8ZU@o{rNLq4=yA0G3uOmK!58<7_gfT*z#X27cwoPw zv73&Og^qF^l4Yo!k!7NiiI$M8i>&toSLBlIq))tOmDu8&<(4HgTK?a>T2siQ4H&I5 zkV-b@Z`ajO|0*0kwBzP3-7w-$-k0p+-JfOmo5aY5)qq8u@%-&T;Ed2-g_$cz&P6mD zeF@Wt=MC(QubI?xxD9>Sm41%*n6BWLbR;fQ(Kq%Iz0mVvz^SwZ<3`31jPVo9{fs(V zI3LptFiJhe#7|a2plyBt2^7pCF$T>K*DlmhkcM1{7=Zv7{{7I75`VS2rHqp64sWe@Tf zRM@2xFMX!s1+_~^CS;lTj4aDE%SE%yDT`5-Wh%M&K4e|wjy!^;l{@l4L>BW9S)j#N zFjSTKn}|Ve+oQ|2abe9aUi^%WxI?e_%H8596~xHJeazxm2LoA0)3N%BET$fZic|zO zzajamxcUPC=bLuYM+>Gv&QoXnMB1eS6Ip24iDc@R)ad6B@1Q@a_nmFnHumV`0AD$# zXg7>x2Sqn#yRC5r7Du$jYyogN&`)VHa{$S9MzK(k=3XXr7g-2kwhsVveY*2E*YJ!g zfcbL+^y>gj&i%5}0zF+VYkY$3&O|}y$?y}XdDaaedJvTH_8LdNd-dtFR~(^FSIU;L zDeX(XJ6B#XSSYeQ)6&Oz7L0WL2UEdzS@WfKMAHz_j#onRWjxsk6t@o9%tOnN4cU>j z4j%)H9^c$}p(|~~XT5ki291}@Bp2puEaDc|C|%=qH85CZgSDaQ->$C)=C9Rj7Vj^( z#|8WJZ67n+x!Z%SSm-8+l2O+TPXYKe*LSq}hX=E>sER%Efwke&Dm z~ZaUplR zCBCx%5~jy)0)SIL#X%APu&cXFgwhb0`T_8KO8q<@z>Erj`vZy27+ggH;Be=Fx7}p+ z0ECT=jY<2oGSz8Kypwlz5*Ha>BwUEOXcoyzCKRF;yGYkET(YrSyo%`w=!vH}O=1<9 z#La#)KqG86;@%1oe4%^r%S$V2mh`mt1)! zI-O5zK9Y+S6%t=t=2JWSsc(TP#mI9&yXGN4_JZuGb)xnna747Lh}lDanxBIKrgJq9 zv%T(3QpR1@GyBLs^Yf@|O31Ek&*Np6APb$l1fbnc=PBvLOP?Xj6&B9`;1jyoC~0F| z^Ea}@pW5&KJN5JpCtvZNIRIZIys?Kb0DG`Lm??mL;HPWxns z7R)!{P`X&>yTEtucx^Or26 z^wd}?Q%+N9vXiXh+L&m?;%%B2nelgWBO<708>D;WIDZO5)TU)!fX z@jwfxoV1hH16UlV`wkNDWB-PWDe6znLz`vtV=SR z%vEkj8aBN3eX@>ruI^G7UNv6;PtGo*1$MXDCB&|3pxp=1ZtohPG1#YD<9+oJVBtK2 z1wG#&r*?AE%5`efPl*HYMG=@%EyP-UDdOUo?D7JdTIpM-ID=sGqu&9 zT7vmP9oR)(AYVU~K|4c&N{cK4%3ny~qE!r;5Xcje0v_svQBAha(^u43R75jqN>Ss) zq8|A$D!80GxCsqAX54LpMQFg+&R&0lG5*+$avQM7urQ6s!GhgJ z@e`wk<~#zRKr=!a<@tQ7D@IqUEr7X#6OkYb24)_hFG?z&@Zhblz_G^()UyD9GGVII z`H^lBWFySCX?5Py;5oDT@G)^5eYE1FPUU7WvoznBOJU}A%TjAT01SY)08DrnbiVRA zOw^30h89>Fvd^H?nLGga90F6C2MQpwyr(#SZ2E28P@Y^f-iLzb*qV=cAvNLg#Y?99 zZGC+`s(qTFJLNRzZ!Fs205lF!vWpL3DYL~v7HD;GEKqk;fo-KuQXByMVgpb}`&jw+ z$&-xhnKPNPUUBhgU$ZJcZe)d=MMyG@1*#lY@jpq}A6VGAfST?giMn8c?TTjG)4tTl zG>55WlE+rQvA0jnUp1Mpfx!!KLlGdh;rUj5sEe#RyNlE z78UG+MSq7Iq^bf-VbJcutt000mGNkl1@8QF7}-*6rYhJ#MLWa% zF~T!u2yiEwc*$jxXnNyeolj&uK{ll-GG=7qRo_Bg1uvZle4QRH*sxx=0rmQyuEgQk zv|}Hf8`_w!&@1k)uQ>;n{Y&=YTmRfFZd%3Nw&G|U4xO8(L&*iRK#69Pq&Sk!%qe8k z@U+PzbsgIfCLtyarP(L>yI@~mf!ACl)YCKoklTSmfn*Oy&VRm(VjcwT$N*->(6%`G z*6w3I`W&w29yI?M%gh4+H(Tw{eHu>0m(Gab$p90Ui=~T>{7Vc&MWbJc!<$OG0H~m zx@jl5F;;AB#7n6+7=n0X+(gs&)Hb#E`VD}2C+yXC#{LQlfTI}$km3UU^a>7n)%6}` zpuA5ew^!Rg-+*oDDQj^hJ7pR7r;9#uK8bTqpf6;7eupK?{U0=58K>lPT-Kb-vg`#b zi)U#+%d4QkV(6JZ7NzKge-fItBo+-d>C4H@aX}t@)ok3gf`v(tgY<%UYmyboy~db1 zTIiKM;W?334XBwMiY2|Mgrb7@z!}gVV9PNBD{URA;qT?`g>sA13WiVu_vRP`S2M;jF*FH$O0 zLXUODpt6%nexi<^++1FK$v&eHPgxfGsKhBrb}+RCV`;99Z4Fn2obAZA*_y>_KyGLi zhK9j;;vD&P8w@C`)+|sTRPgop&xZpW=+DZ;JM4bO1iy& z127<=&l{Mp>$5*3*`C4xDM#R7bhl0c>}ImkpU@f5no*=y@pI}=W>pO6G_yJp!qQ^^ z_GG?P+|C8Elut6!0-d3SJ1yw3DFU*IDiRJY*4ZZU(j!K0exulau;5Nd9F2R_emm|e zttkwVF%60t)M2uhEO9J0%5w^1AA&9_FZ7LFCLXFOz!O>bJg9Gh1^^3k)1T);Sw7Tu za8SX(tYLL93q)ieX;(flV=<51e>$teJ6VZg$Sh*3AZ$VL*0`MSghB!tgv@ zH;i@NHqMW2xb}Iw>y8iDogaUP4K}u|9$n@fM)PrU-1<|pg4%-4oxq5#LvtV`VK4^Z zJgRt;xAf7t5@#G45pCvEQKrB1u0Bfp8AHvbFr%0i05AX@h4K=xS$3F0d_~W>C!nktH(yHL_Bu!lQPxBNW>}1*TyqWLmf;qK4sR7m22I zAfh;q0Slv9ARk%4%?tUOMH`xS+d(_?I5bu0ND9g$t94B~>HN(^#Rr2m`V5DL;f4*? zhc+1Q*s!|O?sNUv-t&$>vg+K(tiEVL|93V1#?BtuFL%*=_=?;~M?G|rO0HsU%s_Ip zK(039ca&c9u2$svA=M#Y>64!%!DG9OelBnPjPYbK!9+fdH_;s;1N2-h`-l%5PMm#o z-S4uJUX?QtoBNLsVMaqJIG0!6B5Fhc?O9RD3fi7l&jcyv7lZ> zis0-Se_*C92s6&Nt+DWp$Fy&JUEyE6q*zo<&>oh%Wg|5MkAa!sv%_wkvgzlVciC#YisLf zLvUAK*$(@}`Z>dJ&CXvK*v8V^-f4b&CKxl*qQFY!RRB=TYtQODONXfeD_n1Hj~* z^rf!rBBM`#8e}>Dd34?wWMAsk&XAn9kcb0-)8-4j-_p(52u>rZPGiz#&Q(?EMD&hB zC$h9X4jxDs36SV<^f`Z=EQMNlS!T5O`abgiQrna2uW~mw|TVT}IYvZue2y$Y+Y6KEl94)Pn+d z*4%{~`M|ycU!zBdiZ}~4Hkb-+ux_ZXU_<~-+2?W7AV z$~6bYb0j-*k^P%&2)DshC%>$tY3qnFGFGHaAfdm$1?fbWMQIOcu9x-P-457$Jms?p zyZ3q`ELKUJ{29nM&&khpL55m67pPCZYHMrDDt?$L_*wYmN}FRnlux(AK3UsFi@1=b z;)UGS;;lBXKVR^(yiK)X6xvcV$jujg#YkG z&B{La!}mvBNmnzl#~DCXRoJ7?i8I$aa^jNOM?&CdA8tqQa+rB(QJ$A^SJ!H5A(msN z;v1-7o8$sz;4590En{UeuoxK&m5oQn*HJE`3FXH2m^4~t`5q8Aur%JvjJss0XtVGd z3t%8`{Jq+^g9eK$D2EELWGvFgNJ5m|TvROJYfK>_(huA!SAYGdC9YDC#@$ z4KwLj#I3P46@5OX4Auu0Y-CKwE^l43d*5)u2Dr;mf6A^K-fr*uvzyHB_^1unY8xV2 zRaK^c{ zS26}y-YePtq4k|sF^1EEMRG+CpnY8ci`&a2J8xVS*ZlG%f(y2wg}?ME`I-LcBbgTg zFBqE6z*19F%nm4v{N2`k7%EV;m*2EY9~b}!jm=mYBPzBBX0!s^iLw~8h{g%DJ8r)oUZgp9WDWY_!;*;GaL)OB!2TjpOU&4@Mb&UyGMSQX4%wDy#QP#T-)%<#2B0l$ zS^(zi8j}MNAjJo8pg1e%)eP)m2F}FAR_$THC)Sj^N3Cp=1@R;#3LO9SF|+75#iT8t zF5ao&aO${&RAGWBcZN{0accf&;i@$jKE7f%0}ogjjcvf9ZTtW*us}wqi3bw33TCuZ zfi>EIJVcF!(}o7}6$@<(EYQ}Kb=qj!sD{ik#~PX$t25sKM8-E3Zm`f-qH9B|1{)^( zYOrP)tP?}4)&{n@GqR2I>!$x5YKWV5!#&Kdzjo7J_xrzY^=Ch1=LS1A3?r*|Hf^xR zB5qq&*V}e(eZ#``$gergug|6N!#R(f$H=nJR-@PMo%2kA>>}san0cffMU9R0!Fgj0 z6?+0^;A%p2Dzv>zm}sH^Y_An$M+?ygyk zV*#*OmoVxe9~NxIH83sXTLAg$ zyv6FAfyEr!jXTzDz`||SzziG*3oW;>V7!q_$#kBn_y=5Onq=V~q579VO%AGZDBC{t$UZ41;2kW!bsSGHsBplg4<%N$uK z0{OtA5<3f^3V42%ZnQHmIOZi4j1#LJix^YNj#$iNU_lK7TdQE(TNe#O#;-4#osag> z553F2=w2+Cqr2>UwZ`4*fCYZQSVIE~^?*C&0e8bSTZ7@6RfB;UDhpU+b_3E}gs(BH zHrVg_6fD${Fz~#(jYh8ce$7R@*EOHBhd!XTPk-=L_U_mG-)7r?W5eO5jW+K#Fir$)JM0)Z z`PH!U;=;E=7S~{P71<7)&S1s|pQyJOddT59sVwkni)Q-6aD*Ow1aH$8oOAc_^TdZK zQsXC|H?plGP%lJ-2~x_|7&`;(6I1{&@S3uasPmMkgSlETjRyepOVP-gP*Em`=iodJ zXOd$XIImbGjIs6$<&bikerrs^RMO7@FaZ6^JxK$t4+6Sh!9#PEk<$R+a|0lYt~doS z$q7J_PeuL!&~;8kUh9I9cQ9)EsCYT6-;AUM)HdoO@B3j)($o8`nLNGOGjlGkJPDEO zF7%-!og7`{-nhbF$|IwjisW6{UfUcrU0A{+r@r=l&p5s+`gfDhl!ujauA_ zIb`u!ThnDy8-Fxr!Mn>Yf9B)%*-!qZ-TR(g?#+cD+tLa}2*DSDj*AaI9!ZlX2 zi0kisgS%v3{ZRN_jQSz+T3RR7>{aa`J4elb6!o zGSVw|-rqI7npM!a<|Y=vgs1*Q99q;f4m%;m)lw%YHURFN&0#$s*+)LvYR6@yoGkOA z!r~PPvtasH<@mOm?3g-EvtGO3b(?+iN5WNW-#8`oWL*WULAv-9lp{5d;+-8C%uwV}Rb_rGDozVNzepZ~-M z?9H$HANH9K-)hD;5W{l=tG76oD%v&Yhj!`W-DXYk)?9(SK($X3G-sARX}*k87?Ys+ zmS2WM-~)kRoLjXgfQq{r=q}j2kCehZ{fu03g&D<+0N_NftG;*QkIKU4{|DhYAUvs=t-_A z`V1DWo|sK-j0J=jd8}CUc`3+6u(5$K7M+U4Lq$D~M}~o!4aWSsY~wh}+zykF8=+u7($E6o!Vubu9ANnhmcp49^qi2o+xH)&^D$$+N&~XASZg zN-wOtoqh)&tgX4-+WL@m%?2#wgN?*r^99#%x6MKvgN>N4v9MR?)@_IUotr!CJKDMP zM)9WI_nwt~*#oY%c;`p#9j|+Z{ne{qYWDfRF~b&zy2+jS-Acp9zP-K02ZS}wTi_g7 zyHI;kHVSJ?1)o$+e+VMOIa7g;#hlZSF#?r30(nhz({74+Ixmvbrje05mIJ`C^t0+@ zH3KJc1^~nmQXyG7zY}O!oxq&-l+wnApM+**+>`6E81q80E?OOH;TBrL!)v4>F^!ko zV8LVEu>t=>9C*=Eo+>HhwU>^|qW?aS_e4aIlZHN%nJ_xf`z>(Rp4_&|_=0Ze}| z&DMq%SdeWvF!8LQPAe930o;9ORKv=!o-iQa!n&E-*a%jgi$pM6+hXQ-vd_Eh{Muc1 zk8^k17u`45b!(rq55DWw_GhntzTN(zHjI8yiF0+`PoOVO)Oe(5@RS++Z()agNlME%ISgfDtx00`B06^~~r2A{4(v8Jm{?;ls_fe8GZrs@!tQ;K9UE46*>Etj;aX(fCYqwWw!UG5jExNg zizm3tuFkI;&J8B0&aGJn&aWBHR|%9u;7)wVmjGdH)5h_37UYX8;AUTXzi9V4|2Z3O zebC_&Y!<**YE-4n)MyS_)fd$b?a7dU$U)>cXA3woX?o% zgi~3q4LB2#s2yp3Ml9B;U>kSlnrFVlFg@%DrnwZFIR`!A#hHRkDPzkI`3tEL%ygb@ z`qA(86`M@QCU)*mTc=d^9tYL;20e5kppnfF3oO>p_dFXK0RFH8^wYC3%Hth{*0G)* zwJ7$8PnSq*#p%kplPyoqxT@?TsSeiy>Eids3HV}SECO+Y!%RuB6%T+&KfFk2fuB{Y zRH;$~7H}qL=3#0x7Nw}-Jy^5BU|@9}jSo(?Rt+rf>`=W$x4X^i+st-8Zh!ffSJ+>? z@uhadx!dhQe`jQC_21aNuDjdLt$oHehPT_f;T^WI&Yko6Ch28j%Q0NPM0&~AH!hp_ z^^HyPo3^oW$%d4jBRzlavYjI~sB@k&ms5Yu@S=U;4Z$A#h3j^|>$Yv@^B=Sizw0&j z=YRY{d-w1En(ch-ZD#gqJ_uZ5o}abt&D(8fbkTM=KI4V6xyjfYYqmaIhUvtZX=-fNal0wW8os`|Cwi@Rw~X|Gt~C-W>hi@xTR{wBE~8wXR;;WGNm&Up!i z7q)rkQeIuZq0gI%D=p^^k_ElGTZK)pg$2qiw{w1K z3u=oBC7;`p?G0LB7XvH;f-lfoq#MK-4A8Yy_88a@NO}+HSS+~Piv*DT!J;Be`j`}g z6a?I@SL`pccuYVa*$2u*xjm(EQ#cfs@dJIfN7{|AS*R9`6u7_{^4*qmQ~>5DJqn_+KT|7rS4vM zE#)CKa+g56??Vc2zB6R`u(_XTkp)VaHHlLZ(~F)X8A=f|9U1CP7R$@5b$6T9cUXPr zN9;2ndaK>?#+TV2|IRPi+ura(`-_`@!`}X;7umbs{9=3OTV86nyy?aE_L~#l#a;b7 zZ+?ko+(O>*hTk;N_pngE`%N#kzj*zN?X7?O0(1FRhPJp*Tb>H!#lnfcNBIV0>5$Zip`i)=E#h|aTHBp>s^o3%p6 z!Ed%j$sL34gcS7sj}*Z80S|cKh##F055(WqdoMCqcE0Gpv~9(N%`lq(}u{YV~N8V&xA9`H&uoz#o!RWGKyk!IKy4RR+nh$WOFymYZRUHk4ctm4)j${+eDo#UmXxJ9` zAfUNo(Y6eF1fvaUBqnm|;kFFQd6K{43*x7sp8lz=Ii8}TKRDMFmDR+C4azwHF%I=3 zB$@nOJo5$be7=6hVI?@y833@S`11n|oOx8mz;}Wy>aqcU zJ5Jzrt>VYm?WVtvPQ9tPusFz0$Z>0?FsjEk9&Hhu+{|toB6q~tHme9+r0dpKl8AGK9{gHweu zPpo_sxB9;NW2g(7d~-7Psjs;^V|#L;x$uO10qEqVYe?u%5a`;s?R3EkZRu=lV$&2C z?OfeTZSFF?FtMN;fmm6^lvMl>Ii~_&&6^jpRA>~BriNkkUHM?6vcWp|`+79fzb032 z8^+sKv50}iu^QKgonX~CS~W5o?vU=(=DTIZWsx-keo+TeHoCc-g>Q{h`K!WeOJW zip3i~FXQA--Z&QX`LfAJ^AIVAgJYsbZ`w21w8$bYh}S%WeI-beP?>x=W>Rw`xzRQu zwUb{+jRSrIIADa8`?50tfY%>(HP>|_bPfAtU7IW2zEU}=UtzD~2VhlI`FY`5vUB&{ zck2eU)BLGUbMiU&>#x7zHokJ-Rw2(aov6&8L<+z!q;75ix*~4_{c>v-e_ham8!z}O zZ4%Goz{}Wq79PYk7FpPUvQ#|A2HRmb1dE*Ux8um-l?q#J_0DAr^_JBv;6hM=WMPin zEu$W>sMj`Nab7dC^?})t1#!)bj)&`ZW z4F?m9_rdR zoVl_;u$brm#aHwOb1>?du+i8iA3pd@crtn*NQmsWCP?W|6%S4*ztg;l5jgkEN}Lcm z|Ds0?00!WwyE&HlnzKbXmLwK*0^rQd^@3sgwZ2Gy6uNxg4-$JY7#NFrbY8g0#jKEqM>|ApnFCUAo4CJ0L25sMxkx zZ`ycti97U*Hrl?M1%7P9VX$-O%+{I9hTatL6@VTxqz@QdVE$v~KITsc+f{8l4n8h0 z=SZz_9L7vECXfg19eh0xyIK{Y(~Hxuw-dGT+o~LR2I2Nuv$}IsYr+wSg72GMUTsf8TIBL#vt`# z#$B}Q=%=Et0ux3om^(Wb$2(>$-W3a?bdh|`LI@U5#R_V3$3CJgY8O1HZLp?}$1ICu zlTXD$+NCug24c1#INgG5ES(MCJe|*KLqHtfckROW@Y}W z%6^@U*+0if8^wxz@Gy=q5~9jSlm+69Je)o7^mUWT&hj6+lc12_$l0W z+B{MY&?t_yi&gMLngPE&8EZ;;zp#Chjz!llu=}+Jz<~Ma8|$Jz`|KCVR3rF1`u6$r z=RXbT5B@B&KKZ&cQP3}6zFZFm)u*}0Mx7v@7{5pYcUa{q(1j3L6$RrmE~;Ck7cqL_ zOFoG&V;vr^Qy`>>Dj0%_AgXAQg*AiYGsyyFfki?{UivOZK|Q$Zlhmo&EHsMi@UhXG z6oLvA3puzGj$rYuc4$4av1Aw+j36~a@xZ>p;%a;?ZuAA`5^lqJqC9dZtDKG7+Dw(V z12F$^=c}EpP}8Rm8mHu<8ETK&ul$6-2L-{Hv2ruzuh;1;^87u3nWchV`8b22CYT?T zx9E6mkq-c1%)p`^Y;3`I^&^gQWV(g~qdlJ%*-bDXH|;CttsKlKbC>}fUo+pv>ksOe zIuJ%aI%=L?A)53RuSuKtp}qX12H^1lQTp;6SG;sUnlY1$92)>=xRT`D_e;xI$Ik;O z=46&X{*3n5qppk66M$8f#$z0x`3Hd0{eA&pEbQ04*AIZl1lm-_X-vF8c^BFo$Y!rl z?0M+MlAUCtg7^};x39V8n!8v-J}CWZhi3bj#5DtrMgZCc4bP zWV?*rNwLwg>$&g-a@zY98)>%Av=Ou5Bt^Y@Jd^+bKRzU(QVA)VP$bNu9CC_6a!N>Y zs+`JcIUi>zNzNo^6QL4vKAR5ChdE_AP7a&%*v4$L?R$B>f1lg$ziYdm$H(=&uE+gx zmaQ(Zcr*TF_HzO&zXIP4%J&r;hQ(J@ZR}ZbVZp4LHw7O@&A#r3oqH>t+4bB^H};wE zLi9jf_D1K~vk?XrQCWe5T{`Yk@hALe#g%*u)vJ5@Oxe#aS<10HYMWF)`385HvAY14 zx&^;q`l#zKfrn(Sc3&ckB9TyDXskENjy7p0oKx}2FrJ!QuzIRhR1}h=pWk0`p@Xu{ zWwptx$_B%IHw{Q$id@|V8Bt#SRzK+gIy%s;?K^)|&c4}ib&t6XX8cm)IlnHxZzXXl z_7xT+VhgA5!aG&I-R;7dqk{ig+t?I6r!0Pl7A}Y8Lj|qRMvq1<8XHGguLFr@({PKa+Gv&wq~44DrxJMM7IOUy?LH%iS%}q@9qEa>U>SeGO}cf`OEjY*l4LN{NJ`uE^KjOzw#W&P{#$KkA9awYL7` z;KtI|&)dDOpxaN!Ju_^uX{|`;vW>b?)c)_yg*n*HLTCBxv%I79tg9=`EeyQ{+5Yn1 zPY0qC1-~MN1yCqAN}KEixvnXCnYjB?dI}w#U&OxpzA~WT=8GviE^{qDVeJoCXoi&z+cEtUzLetb5q-@$~EKHWu>d%y)z~+zT^u>FyFI37nCWUouUv4j8GbsvO0S z#VZtksmqr5E@EYDqm;^-5Apru+kQ&pO)<}MY*x5cUu@>RW+7k4_7uARjm>~&uBmEE z_Zw`Rf{*-!Wk&0Yav2k?Rn>a&U}9$X!RoJzdvbKXd~>b)-7Bg9NcqJ@1jdi`uX*o0>pk|uJAd!^L!qZq@?eL_Ib~E7uQd<>c6UU$%0pzI5~>k3^66ds$27 zd+-`FV#$rhm@cEjB=hqH*FpKq!B28{)Qr`7oeVEnx7Zr#U4pKj zE0T}RYx4TdD@y-V#o>Ys4_$fY9CAbv)Bzf}b9DF`Lf%$qIM9>S`{9`X#uXq9VFBWz z_@!-swRLuf6|Y(UWEF{C^1xXVht~d&z%ZgRoH#v!Mk{l?inMF4J#e_XbY4wW!fHzg zT?jFH;QdA*Z>%p!yT<;xfTW~pWcnDXw0pJ(dG>YzkdyU`l`P63GX9o=s%R01zLWPu zqfHOXj^7vJYJ;`k8`q9k%-Tm=gLX}L=GBne5=UC!B=nOt$MG_LwC&FPZ0N zy2SgCpBmy=!&!6_3+ttonMcO<>uLh!I0Td(M(E?m4*EojeLAHc`NPfSH_wkDyXY_s878XO*L1S0u_bMuG_N6k~7kprwV0`+S;wc9*CXdEpg{Lz!wpj%Gwr9M8$RkCRt3 z#yvlkdS12KII!xx!zZD{nE2bTyj#Y2*Y3OHV&qO3{uTF&TZvoYOODutvnhG1OhKw!?y$9okz#f2{}D*ye&)0r9{ zP%QvFxC<1nAzy)th5SodPu^XUOAPpt8hg(xvho+_h&0L~Wb#Y7ro#Bp=P;px3`gZ% zRQQra+h@XAcAO|t58U}RGYPBo%e7ij-^(y`uxMSvA=36}`C0gaAsnFYb1t9=1jfsXGGlnM*7Flv>hTDp zz2l%ZIxC%^5%}`=$yty9b*j17}37fyzaFO#m(4forw8W>@(hMbAFBW>a;{U$4jR##@Gn<*t+6X34yX? zuj8BdzChofh%M2%`u$Uuf$5zot}cLc_uT*CtY!{r*H!D+tux;mGsMZSN=T{UV_?|! zgw6)`RdHUvc*L6$Z{@}JM#<%`0tV%)d|{55l{;5Ll+KvC46z6e1p_hd%5ufN&9<>t ziZ8FYfI2D`&mDhKl)~6BQLywz;xzDrO_Y{J_jatim&qs)_>!0K^pjac8pVIK3s+VJhz1kNlz&;WnJoBasTxaQdryIFh&I*setOKpd%3CXh17d^PeP$v;H9Qv2^9S< zR+UcwKV1OC{-qi=@l3AR>XGHo4JQkqFouXfM9mS(iD>Xq6^Bi+{h1JO{1@W<-jd>O zqfpZ4Au9sT$^D{mYUb*UYKLSKGIh!a3_2?ArvdWQO;ZAqbB;Y6a64+UdK|R+_^6y> zRC6N#{?W7>KP)($3YuSb3kQyerO|dOY0Z_P2q#zeu^YJf_?4<8*habfQ*f_DrJ6LT zJ((poU{|8;#5~uh&r##6N&;X34x}|9cU@b%wP-RcE^y4iERMu!Fdo+LV%?+5Znn2j zdVl7qBCKBuw+8VR&lDM*yI!Gp#(Kpw6{?1I63t3}Oic_+J~jCEqFBSZ1nS+!J21{U zm4iwHE#2p!aJoPtUrTl*uQFS6Ky=%cqXo+fB)0)RI*u{s-PZu}ukLLzBmOMW5o|rB&;RxF&vB7y$~EZb{V%8%`_cin z_pW#NO*uB7y_(T4Np-ci*p%Jh_q<$47&(=$_2r^$D}0Y-tH&l}N)qa5&$@E$95h^; z=lIFxp+}pPaa*&$LVP%VxlyI_Y-e^icl^%(Ro3O^5!r3*xht<&Z3kw50-{=6n*<)y zz;S6`8OXhdjNYC`JMO?vFZNH?@YwV#TTe`YhB+5&dwy{ubYH{UAy$q(JG z@4Y^KwVjHHP=mKw-}lh=nRME>VVRN)jO=~T`D1&UXEWEZ@xH?g&EJC&k>o9@S68yz5T9jCwNmxU$o3-r-_@(_RMQ?wJHrTW#xs(=B&Q_ z`+{jQMarPrQ1awa}Ostc5+EMO1IL1c=+GmP1i#s!-mpf*i$=DHimao}Q z#4lz=slnyE-a7IcMdxbV!HUWSV}&w}jbB7l@0y z`%V(~H}f`k{L>R6Hacjq03Tar)A1|DH7dG`wT&=4+iS18+!V+A;l-8~E_b?H*B6;}(F!>hK3X>!C~V@~Iv1SB))gGbYhk;0 zWEN7L(8|2SJD??r7oJOZyYNMD;F!#f$7Hd}3+*n-6Hza>W|{&u5B~8mU+4YcDcv}p z`0h3}gGq3h^Ff8q5mArE-P^oIdc{qRZ;nqGJS_XPz}`CuiT`cv{G}YPnYF~BuPKu? zl=L~LV4PJBBzq4tq~y@tbfyemdq-EY4hwgW|7pw3H=vuT@_`3*MlW1gC`HW=m8AOC zls)&Ob#lU6+asb-m&K?{pWg=C?7ily{#JU|SNy_9Vbh0w!S^hm{87CmC8Kb^*DX{5>AZ@DcS?(wc^A~)d2g=lEu7KNOqeDY|eaHNCZk)9P zE$o(F>@>XcKFll7*;+Q_GU!uW?KD}aD(A)VDCKO(lk@t~5-jca^&k;HMB@qyq9m?Y zVA&I+hxb`Jpu$p8gnx+{;i*Qqrau=NHsbH3oZGEB*6Wr5-IZUp;(nbR_JGAS_zM^n zxo6-mmyd|L+oyr|XJ_d|)iYg6}7!W-7$@blU+JrcikoVZe&3Zgey6;5^?`E54r z3)KA3z)IL3>A)?%Jkv%kTv|6I`Ndx}G8b7DYCZcnwOu}4L4?njL2`SHr|3!BH@^H= ziJ*>`wiofO9KSK-p^a7z`TOf)z!;TE)$ifZ$lH0HdIPc*w!098l_w)xj>c^ds^?Q;wBd^LFTwCu-R2&=~&9!(FdoGhe z5V+E3m#JIdaeYhnDgRf%+!U=;YWAhc=4j~)kL92Wzd6uBGt0|j^GPdauxy~Oy%Wc$ zDz?AW5-7FAiaCp<1v1?lf=VqN@+N*~^wOG8u&vUI`yCt%{y1t${;z;^#M~C44*O!@L~W$jF1hyb(GTtU_0GtLmj(tHXExYkAkT6Zdp8cDH$6m$^A&Lio*it;a&Rgkxf5*j9 z?DRsw2zn*^+kRoCATLqzquFo3lXeS;qQP|4UW&93d2tf7!98~mb`O#4<86s68xd$1 zewB44nVM;q`z#V0c-mxi*u+N`ba4L26lrPcc!_ATw7NyLlmpV~Xf3Q6{cf(ytqs=< z28C@o(97VvKBJ1{n19y=qFnSzMo|(K;eF=$gckLWc-Nf?uGLEW>yPq+_=0vM1ciS6 zB;C^p@?^`LX)G3ts9sfmjV-urTbKeXi7pSyYl}mMMSl?FE_9+r3pnsUAww^oE!kni ziJsZ4Dvdd2=qtCVxLcw-sUpYYNBj%F3rh25F%M^+w#s+)J{mCw^IU1q543M?p5f=a zhujHjrPn#o>L9_SA}|cg*a}u1QnVYsF)X1)>4o=NNmm~IUR-)3@403<<(X`oh_!(t zV%ZHL{!+T!uX*!BYKx_qMTBmRiJGpq&yQ@abUW{U$-5a(q=j7g9GxQS38(#7EN3te z#|>rQLM{pFcrqlqnidDI@%yx0)-7bK-bE<>4P931Y)K&fSezQZ$9{EzvHdIN-vb z&mqX(L6LdK#M|^AKaIb3UuXL6;zagw&#tq?|ONpvTBa3Mf#K7UhuP&0F<&#=K`*@ss?*#omY9X167u3+NV0KN@ zjT>}Dz~p28_v+JEqBIl;e9c}Rt>Kmtua{WF)kMt9<=1tq(G?Ge6E%)%3`jj>z5UW; z{!!Tz_nr*X(HsGCkE%tAr`XGwi#h1&!V*@~=iM)KHP>u<6m|q7DaV>*vgeN6VZ)q1 zN0Xb1fU>-PM|tb=d9%PC%1I&*{DH^L;NL%{%2}=79v!0RW6OfGI%^T?F;~yt70UGk zwRWlKX}-R{5p(g)5oqo|$Sud1B)Qkmb%M3tLjJueC2@mL#Ixs*%6YPx+c&qdKBuvb zDJAc>b*bMA%|m;I^&aVEd7C-4d^DlO8=>FJDU1j`SOhZ*X)`#@z?NTvjJ|6G!Hb2f zONqfcaIM!buc5q**93mNRCzzt44!JV4=6Sv0C}E*3E#9%a(#-U$Yx#7T><%c!~?Gzf6z>VT{HN3pM7=Os&c=Fu- zaEvDDS-omM>Fd=MPymAlXW}8a*l=OQ8fhGhvlM8WPsoQ%fVb|HzSFHYFH0Nrmc7FH z4wZQfCwHE9M0%SyKka%Fp8K**#`?#O)04+@ajomFV{!7(B^}5Y|D|`A#b-%23Qwh0 z|LsSNih0JKbv*sJi{{~nLr%}R{w@0{gGyhql%KzS(J9yy?6Xq*^9!@$sZ~jR^5$=D zH+SHNhSSlQ{alH=KdDGLA4c47g_`I~`Saa1>PvugJ zMQ8T#E70kMzt9dK_n`-OpY|yT;O7F9<3E6j$$@uK=R!I$F(lAw6+yfEhN{of1LC3Cb;d?oN&!-=}xEhnt${du*t(=Z`(2WGA=y21}rO z?3%ne5T2k+uEv06OG{(Mh#Vua{g)0in@M$Gt`m_YLnLW{(MD9~$+EX(&y&#F{zJRa zeh%~Z|8xP+#(!_n1j~Vw4oKR3H$ zAYJ#)09OzQchYX>3zV%}>&mRyPczee>Z8}y@X6(XUP$VT6B#lNl{sA0AoY4*+>kB- zLEVEg_cAmWPA94OGULY8!7$QSsg-OH{YE_iZW;Z|UWsPq9Zs(piSX*Xpv?>1&l{Gt zc$zhzQ|qrs-vb|D+qH`fs+%5TED*h@SOE^kMYaF{im#pP~1Fp?ehMH z=17Kmp5WW|d&$rX;}1;=`y;n#H(A?`q>9}HW!h&Ro1i~$)v;=Kt8|$+8Ukv&$O-38 z%m7+w$kJ4?y9_S-VqidkER2!?r*MZ;OW{;Y^L4sUX@8~0?b2&xm@91-L!Si)ubCsL z>j(7Zv0GB=AeV*JsNSEgYN$=k$5Z50)57DK!huDZ z6NOof-hDD{09kbe@sG*t#sn& z@fxplxnG}?xxFka*57=6oopBx`7x3LHN1)(c!e~SVeLk3l*Vuc#2Q@Zx-k{c(6G39r1pPFo8bd>z_gW_tjoW^eAX0xgdir9<{6v+4UWkx zsEGKL7%a7*u^q`Xvs6ZQSai=0JRRm}ZgcDhiG1z`$o^dG+bKRnflR*`&Q59X%&H_Q ze!K)v-WTPQ6z-0S(~+~D0Vk>z09QoLe3tSPsv7efw)hSo$6lAmIc#xiWJBB1-S7QA zL)k;KjRLzw9ICtj;H4EK+f|d81qB7J1R_auCpiEwr7+6pi{ESCU@4umqA?d+QF^L=cNW;p?zSZ@toYi z1Y$TVg@71Nl%OOY6C5UZKGPmk4e^)A;uwCcpMDLL5)GuXNVc?fye$9GZyvu)z5TN5 zal7x}4fMergs}i&bjN6HJPrJyWW|e-U9lSLQGK|5b;}jL=ZZPdbs*^?J6Xd8xEmRM zpO~){mlAaIAFkdNI1A>SJZriT69nYD%kDP!7v&j>#5^ZH&$=FfWZomfb~=8jv6RSz zq&3=uIoUElM*vBvWS@Y98ky|>tiF~#Iofwa_ZPc}j}(jakVaD7r>QLB@|UEBXPsZA zw(}J@(4O=>qP#-`e8Uf$yO-pIYt2jQR2<<=^J`PFWB@?lT(?mAp>AhPq*L z=eD1*IBXtyH>KY`JT>~uT^@{A8~YX4ioDxbL^={rnOtIv*ra5hyQs+aDfi@*OoYKP zksVOj0B`3z>qD(l-?t^|DsRV8ltrbn9mZgZ{jlAKsB@KYd_vqR@i-9b2g;ezq)-2D-YKB z+;r)~rlWOBlW_T2K<}ijSg;;^+UM&sOjfhMQr(i%IzE%BcmMXF6u+~|Z^+RrJcBOZ z4@PpvcXvsqwW8{BjsB51`g=@~@UofC%xRAg9TY_;NnrZs| zJE|30I>-{5v{S>+ktT^16_Td@3j%kxVh7HKp01u1m162r)4gu6>EB=@ zR%rA_7p;-MFSu>4cC7(*xTsUXt%bk--ACqN4yrTSHoDt4`r9|4)Qw8L$*%&ztNg>SC*CX@bI_`ULj|7t7GL+hmJK=8;y>& ze%B|KKn9xCycfnE8k-Tj$U;fy8@@$31o6-SK2ORm&mHSwgkv7 zWdJqGdOz07oz4Z7G&MZVBK1B#$^jI2k03K_BNy?$@T`hflZE3O0Vvk7b?!`pgts(p z(7-o&MBsLFg-|9xp#$9GEcR9av>(DPuI5$i#t@?ZJ5QkT=sbjhg%C5L`Ocd+^yn~nZL-&VpW0H_|&AJ z@lkO&PzwmDQxs%dNk^T_e-hbMHfsVVAm3*By8$|JPwihs6|)C*-)=xgU&IWFdL4|1 z{Rsy=`n9N7Wh8|Ha_f7jFV9vMWpAX{tI60&Vs0cT&+R)(uOaVJaFnmzl`8x}N`oXV z_F>*nfI`SFOfaD*&L5~Tn0Ilz>;g05-{Y;;7mt-6Jse7NrN7IM7wkcIsfQ?3x8AH) zxkff>|L{T8-EP{;M!wpJShOIMwQp26k=VURJ)jW5)Gp{j7o7Ojs%?#LJn56eM-5;h zbNNXR4Rz_xkkCmK5PnMzXNP=^3%gmn6SSe5%|7Sj4OP975SH>ai(+J$6{LN0IS6BgWYjb#Ih`>zn_nK{ zR2rNzlnte}ZC%;W)6@UGHOTh8x=KrVuW* zzu|gY0!o`^g&n=(u->bi&B@ctZ6!MPfAtvS*1mc4{>uG#mHSmz?Synl!KRyQzC=Y# z1y=~_lb`O?7vzdAgbERmwGH|=F)<7q^bD*!QmJ#p$y??u^!9_48uq z3XkMy!~|ex(xJYC^bRdXH*;UY{s7=7+n0EPdjgVH?^z5)4BZ*Ats=V2tF3h>@1MbIF zk*TQaG!vTdL&_^`-HDZ zt#Cg4jy144(2;xO@g%}o8mJBVxeB4sJ_Q4j@YAO9b^k#842{ieWl2?7b%@TVa7_<1 zO1rzkouhQ>R@|;m1e(eENOzeP1mp^W7DjwxE2;G?z5G(MFSKaMzmluiL)m^$=Y{JB zs;u*6#4hng==E8C5CT!5-XKPUoNj!zb^C?la`4tOW?R~#d+DHZHbXf|e5|hHM9L#1 zzSiaIB`F)i;}5bJayuEeX{%x!->CLne!zv0-Mp5#jjuLi2Al9zqjf zE;t-qXg?Wp12Wu?w)IWOmrOvC_jRRg_yVRva+J<&a?*BF9Aal)054`@s@q8gA1y#V z;q1E-*}1kTqVkf}DmY>m%lKr9@}ht(;Ny!ZGToarhUS1X_ly%iWAAl_T|ZB0mbCZT z>Z0>p@TCj+S}~Y2Oa>VcNJA(4^Lwo}!5eW+se_0}OKpUfvhR&nIeq21phb5CxfMs> z2B2FKrp<44v=jjOQL^{#-*>U%ncNR&ja1(rW>>W!%bfwE=6$(|Fj$TRm7 zqp19R&8<_{zPS`Yj}mrUj&Bkn&Z>G>Te5=(^#czmjEz2D8%d764~y%+ zJ<*B;9`ryDra!O;PP)+k??O0X!Ujs`94({5Uri||_4hnvhhRsDmUG}Q)2 zL`f8(Y!AMb0aV0RWW1u*qmyz;H_YEX*8dP!f7ZC@npMjk$9jpvghcz6bxqv)7c&Qc zCni4NXRe~P7MnX8^5BF40ON8nmrNN;=q-Tv4;|D@hpuf)DcSRp*z&_P4d(XVD7XHz zVp6o2IOa9T3|eu<*}j&#&fgyA(n20Kejr7if+wGnCvQIxnWX17USXIjfhORVXrfYNVdL(E0Vf z>58_!DGY5D!B_&??#;68f{+_@$3EWZj*qu zgKRMq*usbZe+!JNmo%-P#pq2|jEh@LduJho5uy zq7(n*-}7HarFGzH=sF)=WCdS3+nruB62udgrjR(6-R;jnKj83-?R!9tk_*Z!tSIz}$@amf8ReB<&5n{c`oy_89U z@r2E@A@n=D9}Nc{tL_Ik@4VK%IGO=uO_C;BH&|uUaU-(}GUprohzl+hubs-rbW4Tp zFyVosQrLNH!nJ;~f+pqto^#8RVNPm>vEEfwt6vtX))c74M6J0qGxHaC72Z)M_V)JH zFYbQUr}qphV-5xY3=4*7pdWB3PT)%wC8OpTxKlySIu>a9&9MFjMm*{slL9NJhk=gjEftFR-hx{5mS^~dh* ziG04jy+3R(VdaOEeGT(jrPN~YfUQG9{Rj`;p)$ZUnPss4A z@U1_U;k#qd=G78R(Do~ESZSD*%8VdDgkO26hu7`@+CYMSaCiZww^v8jfdZ~SAOLs&Z&D@pb~@l#Ebg7Rk_- zHR`49OHV^AXLKJac_mi$*ek`6`d3bhbt%v{(0Y{4bL91Q!t~{OyWZ141d>5iY1`KNI^nA*pFqFiY!_Y!AoN9iK3`RU86vef_ zQ_o`IU)+ab$gqaCodjsuB$)9JthspTHN`Xg^4AIY@6t&%S%-G@!Z}ZW-?tybd|o+c zEww(qW19gL6_BQM=JdmO;$+7>x2{bG0A&*|KOfSqoklvrdICnB!!OEs{to;|Y(Hj4 z3#M;eK?vv*Dk_}>?Pl$#RZZf$EbfR;%hp-2HIB{&B`{L#qnIndpA6oa*U|fSZXr(<*$~D&MHb zie~U71|5CKPJb)Wcp+aPkp3OeUo0N-=4<=o)LG1~9cEV}jLxuWHPHNCRaMm)wF&os zrTnJlK^NjD>2*2U#SCdMxG_5Wawx;=XT0haeJSjeMl5FVb~A7^^Nt{XZSXhv<+$2R zy+s88tG0VQ1z51AtJ{u45cGlzp=R-;0spZi;Ja@W?~z9LrO7={41W9~|CJX5Mv%Vt z$IlR_H?GkF<{Uic3i zUX=HYV&L;$cc}qqMhWmI3@WtikQYp{!bQiCTiBKz_sX_Cf776!yo_1M!B0pS%H4rB z1*UxjQ$IG0xTw*}S9XQ~2?LA|Bk%3?=xY)bwHS+9j1nH$%5AM3cjTUX5`4h%BC!_m zOvpzz*3=N~=j5oj{nVtlsaEMT4$(7Nm)nDUjo;DFU}2#za1pKc}fjjF7@Qw z`=Y)@2z5lv@gL-cu2RG9Sp$dkeOv}pU7WtvglmjjkepEWqhlvG|Gy*ZGqkd{H#Ky) z*F}GN#_sgsP{p=>wBP9f!%C8!Qw};(^TKMgPw$aVt-!5Z{O`$&Da->b7CVjH=05G&>$;@CqqS$i(4?|C><|c=x&0paZ)nZ+y1c5@C$HiC=d5uCF2!*n}^}bzb(;5Zo|VS z#sCyybCEh6WB&=YrgBN~yVQ!r(w+A&w%>38lF{ugDhhyZ$VF@pG<;`zZm%AXE2F@v zzX2h|f%u13X$I#FT*WVs5j;sPcfKNz7BU@Ey!74;IH2N2SRd(B&)%P^f$#!_p&a^X zK@=|Q3|Nb2?`2*YGwMBuXDJo6YRBNintfG%6lY>zcr_}l1b~6Ll<lK=`4-sf zk-6cyEBwn_X5H?Xsp? zI%nhTcd(bbM^xG8$<{B*LT@G8Ki}A$-@=(tltp60hkA`ZKdTbvd*!ZuYQNre{iieI zQ=qaxyuT5@p!e^_AvA+8VVE>HQ&{WZm)1dEWyqhh{DHyW<^VK;lS}gfEh(e72tZaS zt&OKV=099}{&+t)R9=~`Wo4zIffx-;+)NuB+a12qJ-0iJq)$WlfpLCe=GQA-xB2M2 z`)U&n@J)31CRq!x@a$tM0IrO1e2c-lQ?0zc(M~Ob;MA@NeKS65QE&LO( zYX0Yi<**yd@4gTvB=t(NtTxC#Mh!}*m=_6nxBH1!gm2JZ)r>X$;8?B&i~$9(sf2TX zR2^+h&+rN3@fE1_{wy>1qn*#eF{LdjrT#KZZCdB5uRYyNBD@f%+87-q@GT4QR|!g$b;frLMloLKZ2BOQV657jj*xL~9 z{UXN2ww>BzaO`uT3+a4{(G&jpVwFA3M-d-Io=LQNY(xw!`z#U5#-+ToSh7BLrf&)4 zuF+OPn`!H1s@Yk#hD#w+zgyd9AyT75^`1jEpYX0`i!u!Q@=5$bQeT6H-QzlCv%ZsVC%CgbLw#8C;an;Y#KLeXew0`HHW8W`??)T2z;_7^07jox)q|?uFNs)^+_++C%4r#?swY?Z?jvO8DN( z>DjTgbC7WJ%Ar^eeQTLTg{Iay{K@|WuYeX(FGDA)6CGNQ?lcbZPF9*0=6lMv(EMc< zU5tC1+}#Ir$PEXC3ULLmtx{$MbP#Hn6713*oKX!apVbcMiSo2LCFgg`PWj&;&0JMb5Sa;!#w}P9Q9{2TFI?H&~qb%YK$rg&$TPs$|^R zL#0WWYT7$EFfExxsvH8vylZP~dti^lgEc}(;Z3&fIfE|H7&FWaruN*NJIjZ=1}c7-G!&&BY7+ugfkfV?6MIt}mjZl>kxnfetK_KH)6LUfR-9Z&XO*E^*OV0O+*c7 ztjT+SSWC=161a2ZSZ;nqRCq|JOV<%9SC2_VhXI%5ibRv=730H>M>P3>rn@ioBJO1c z0CfadPa53Owz&1*94q1l1%XlNAO0If$o!VJR!M!;u~Ub0L~?RVuJF!<8A<@tM-orp zA4a)C%3p&b+gYo5&(%dlX|8R`s{)e)3(W+k&rGuiuK(+)223A#grst~v{UC^0Zrf7 z$#DQbNPNjKR#q+#A{YwP}NzX1AMg6y)8RJ{p$Q(rJ zVatNbhz$;4SPq?4z5h1VB@HwU4jf20Y)a+IH49*;$19CJIHdTKW?&9K@?78ldUlxA zTwBp!Ryz0JdRD{=3dr8+>HkeZ;ydSLHOwJma@aDW<0>#iX`?&V=)WnBvSOMFc&gPd z|408LiJ}Ds;OI$_!yND{~L7!cvQmCZ2zOp;aXO= z^0NNdFfSsiy=W!$zefmA>ngC8@${VkdI=c-xvT diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6a06d7878..6838fedd8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3674,7 +3674,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.26.0" +version = "0.27.0" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e68ba5183..68e5260ac 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxycast" -version = "0.26.0" +version = "0.27.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/agent/mod.rs b/src-tauri/src/agent/mod.rs index 16768426f..0259bfbf0 100644 --- a/src-tauri/src/agent/mod.rs +++ b/src-tauri/src/agent/mod.rs @@ -1,13 +1,24 @@ //! AI Agent 集成模块 //! -//! 提供基于 OpenAI 兼容 API 的 Agent 实现 +//! 使用策略模式支持多种 API 协议(OpenAI、Anthropic、Kiro、Gemini) //! 包含工具系统、流式处理和工具调用循环 +//! +//! ## 架构设计 +//! - protocols/ - 协议策略实现(策略模式) +//! - parsers/ - SSE 流解析器 +//! - native_agent - 核心 Agent 逻辑 +//! - tool_loop - 工具调用循环 +//! - tools/ - 工具实现 pub mod native_agent; +pub mod parsers; +pub mod protocols; pub mod tool_loop; pub mod tools; pub mod types; pub use native_agent::{NativeAgent, NativeAgentState}; +pub use parsers::{AnthropicSSEParser, OpenAISSEParser}; +pub use protocols::{create_protocol, AnthropicProtocol, OpenAIProtocol, Protocol}; pub use tool_loop::{ToolCallResult, ToolLoopConfig, ToolLoopEngine, ToolLoopError, ToolLoopState}; pub use types::*; diff --git a/src-tauri/src/agent/native_agent.rs b/src-tauri/src/agent/native_agent.rs index 4210e0f97..4c184857e 100644 --- a/src-tauri/src/agent/native_agent.rs +++ b/src-tauri/src/agent/native_agent.rs @@ -1,215 +1,38 @@ //! 原生 Rust Agent 实现 //! //! 支持连续对话(Conversation History)和工具调用(Tools) -//! 参考 goose 项目的 Agent 设计 +//! 使用策略模式支持多种 API 协议(OpenAI、Anthropic、Kiro、Gemini) +//! +//! ## 架构设计 +//! - protocols/ - 协议策略实现 +//! - parsers/ - SSE 流解析器 +//! - NativeAgent - 核心 Agent 逻辑 +//! - NativeAgentState - Tauri 状态管理 //! //! ## 流式处理 -//! - 实现 SSE 流解析,支持 text_delta 和 tool_calls 解析 //! - Requirements: 1.1, 1.3, 1.4 //! //! ## 工具调用循环 -//! - 实现工具调用检测、执行和结果收集 //! - Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6 #![allow(dead_code)] +use crate::agent::protocols::{create_protocol, Protocol}; use crate::agent::tool_loop::{ToolCallResult, ToolLoopEngine, ToolLoopState}; +use crate::agent::tools::{create_default_registry, ToolRegistry}; use crate::agent::types::*; use crate::models::openai::{ ChatCompletionRequest, ChatCompletionResponse, ChatMessage, ContentPart as OpenAIContentPart, MessageContent as OpenAIMessageContent, }; -use futures::StreamExt; use parking_lot::RwLock; use reqwest::Client; -use serde_json::Value; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; -/// SSE 流解析器 -/// -/// 解析 Server-Sent Events 流,提取 text_delta 和 tool_calls -/// Requirements: 1.1, 1.3, 1.4 -#[derive(Debug, Default)] -struct SSEParser { - /// 累积的完整内容 - full_content: String, - /// 累积的工具调用 - tool_calls: Vec, - /// 当前正在构建的工具调用索引 - current_tool_indices: HashMap, -} - -/// 工具调用增量数据 -#[derive(Debug, Clone, Default)] -struct ToolCallDelta { - /// 工具调用索引 - index: usize, - /// 工具调用 ID - id: String, - /// 工具类型 - call_type: String, - /// 函数名 - function_name: String, - /// 函数参数(累积的 JSON 字符串) - function_arguments: String, -} - -impl SSEParser { - fn new() -> Self { - Self::default() - } - - /// 解析 SSE 数据行 - /// - /// 返回 (text_delta, is_done, usage) - fn parse_data(&mut self, data: &str) -> (Option, bool, Option) { - if data.trim() == "[DONE]" { - return (None, true, None); - } - - let json: Value = match serde_json::from_str(data) { - Ok(v) => v, - Err(e) => { - warn!("[SSEParser] 解析 JSON 失败: {} - data: {}", e, data); - return (None, false, None); - } - }; - - // 提取 usage 信息(如果存在) - let usage = json.get("usage").and_then(|u| { - let input = u.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32; - let output = u - .get("completion_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(0) as u32; - if input > 0 || output > 0 { - Some(TokenUsage::new(input, output)) - } else { - None - } - }); - - // 检查是否有 choices - let choices = match json.get("choices").and_then(|c| c.as_array()) { - Some(c) => c, - None => return (None, false, usage), - }; - - if choices.is_empty() { - return (None, false, usage); - } - - let choice = &choices[0]; - let delta = match choice.get("delta") { - Some(d) => d, - None => return (None, false, usage), - }; - - // 检查 finish_reason - let finish_reason = choice - .get("finish_reason") - .and_then(|f| f.as_str()) - .unwrap_or(""); - let is_done = finish_reason == "stop" || finish_reason == "tool_calls"; - - // 提取文本内容 - let text_delta = delta - .get("content") - .and_then(|c| c.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| { - self.full_content.push_str(s); - s.to_string() - }); - - // 提取工具调用 - if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) { - for tc in tool_calls { - self.parse_tool_call_delta(tc); - } - } - - (text_delta, is_done, usage) - } - - /// 解析工具调用增量 - fn parse_tool_call_delta(&mut self, tc: &Value) { - let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize; - - // 获取或创建工具调用 - let tool_call = self - .current_tool_indices - .entry(index) - .or_insert_with(|| ToolCallDelta { - index, - ..Default::default() - }); - - // 更新 ID - if let Some(id) = tc.get("id").and_then(|i| i.as_str()) { - tool_call.id = id.to_string(); - } - - // 更新类型 - if let Some(t) = tc.get("type").and_then(|t| t.as_str()) { - tool_call.call_type = t.to_string(); - } - - // 更新函数信息 - if let Some(function) = tc.get("function") { - if let Some(name) = function.get("name").and_then(|n| n.as_str()) { - tool_call.function_name = name.to_string(); - } - if let Some(args) = function.get("arguments").and_then(|a| a.as_str()) { - tool_call.function_arguments.push_str(args); - } - } - } - - /// 完成解析,返回最终的工具调用列表 - fn finalize_tool_calls(&mut self) -> Vec { - // 按索引排序并转换为 ToolCall - let mut indices: Vec<_> = self.current_tool_indices.keys().cloned().collect(); - indices.sort(); - - indices - .into_iter() - .filter_map(|idx| { - let delta = self.current_tool_indices.get(&idx)?; - if delta.id.is_empty() || delta.function_name.is_empty() { - return None; - } - Some(ToolCall { - id: delta.id.clone(), - call_type: if delta.call_type.is_empty() { - "function".to_string() - } else { - delta.call_type.clone() - }, - function: FunctionCall { - name: delta.function_name.clone(), - arguments: delta.function_arguments.clone(), - }, - }) - }) - .collect() - } - - /// 获取完整内容 - fn get_full_content(&self) -> String { - self.full_content.clone() - } - - /// 是否有工具调用 - fn has_tool_calls(&self) -> bool { - !self.current_tool_indices.is_empty() - } -} - /// 原生 Agent 实现 pub struct NativeAgent { client: Client, @@ -217,10 +40,18 @@ pub struct NativeAgent { api_key: String, sessions: Arc>>, config: AgentConfig, + /// Provider 类型,决定使用哪种协议 + provider_type: ProviderType, + /// 协议处理器 + protocol: Box, } impl NativeAgent { - pub fn new(base_url: String, api_key: String) -> Result { + pub fn new( + base_url: String, + api_key: String, + provider_type: ProviderType, + ) -> Result { let client = Client::builder() .timeout(Duration::from_secs(300)) .connect_timeout(Duration::from_secs(30)) @@ -228,12 +59,23 @@ impl NativeAgent { .build() .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; + let protocol = create_protocol(provider_type); + + info!( + "[NativeAgent] 创建 Agent: base_url={}, provider={:?}, protocol_endpoint={}", + base_url, + provider_type, + protocol.endpoint() + ); + Ok(Self { client, base_url, api_key, sessions: Arc::new(RwLock::new(HashMap::new())), config: AgentConfig::default(), + provider_type, + protocol, }) } @@ -247,112 +89,7 @@ impl NativeAgent { self } - /// 将 AgentMessage 转换为 OpenAI ChatMessage - fn convert_to_chat_message(&self, msg: &AgentMessage) -> ChatMessage { - let content = match &msg.content { - MessageContent::Text(text) => Some(OpenAIMessageContent::Text(text.clone())), - MessageContent::Parts(parts) => { - let openai_parts: Vec = parts - .iter() - .map(|p| match p { - ContentPart::Text { text } => { - OpenAIContentPart::Text { text: text.clone() } - } - ContentPart::ImageUrl { image_url } => OpenAIContentPart::ImageUrl { - image_url: crate::models::openai::ImageUrl { - url: image_url.url.clone(), - detail: image_url.detail.clone(), - }, - }, - }) - .collect(); - Some(OpenAIMessageContent::Parts(openai_parts)) - } - }; - - ChatMessage { - role: msg.role.clone(), - content, - tool_calls: msg.tool_calls.as_ref().map(|calls| { - calls - .iter() - .map(|tc| crate::models::openai::ToolCall { - id: tc.id.clone(), - call_type: tc.call_type.clone(), - function: crate::models::openai::FunctionCall { - name: tc.function.name.clone(), - arguments: tc.function.arguments.clone(), - }, - }) - .collect() - }), - tool_call_id: msg.tool_call_id.clone(), - } - } - - /// 构建完整的消息列表(包含历史) - fn build_messages_with_history( - &self, - session: &AgentSession, - user_message: &str, - images: Option<&[ImageData]>, - ) -> Vec { - let mut messages = Vec::new(); - - // 1. 添加系统提示词 - let system_prompt = session - .system_prompt - .as_ref() - .or(self.config.system_prompt.as_ref()); - if let Some(prompt) = system_prompt { - messages.push(ChatMessage { - role: "system".to_string(), - content: Some(OpenAIMessageContent::Text(prompt.clone())), - tool_calls: None, - tool_call_id: None, - }); - } - - // 2. 添加历史消息 - for msg in &session.messages { - messages.push(self.convert_to_chat_message(msg)); - } - - // 3. 添加当前用户消息 - let user_msg = if let Some(imgs) = images { - let mut parts = vec![OpenAIContentPart::Text { - text: user_message.to_string(), - }]; - - for img in imgs { - parts.push(OpenAIContentPart::ImageUrl { - image_url: crate::models::openai::ImageUrl { - url: format!("data:{};base64,{}", img.media_type, img.data), - detail: None, - }, - }); - } - - ChatMessage { - role: "user".to_string(), - content: Some(OpenAIMessageContent::Parts(parts)), - tool_calls: None, - tool_call_id: None, - } - } else { - ChatMessage { - role: "user".to_string(), - content: Some(OpenAIMessageContent::Text(user_message.to_string())), - tool_calls: None, - tool_call_id: None, - } - }; - - messages.push(user_msg); - messages - } - - /// 发送聊天请求(支持连续对话) + /// 发送聊天请求(非流式,用于简单场景) pub async fn chat(&self, request: NativeChatRequest) -> Result { let model = request.model.unwrap_or_else(|| self.config.model.clone()); let session_id = request.session_id.clone(); @@ -363,42 +100,19 @@ impl NativeAgent { model, session_id, has_images ); - // 获取或创建会话 + // 获取会话 let session = if let Some(sid) = &session_id { self.sessions.read().get(sid).cloned() } else { None }; - let messages = if let Some(ref sess) = session { - // 使用会话历史构建消息 - self.build_messages_with_history(sess, &request.message, request.images.as_deref()) - } else { - // 无会话,单次对话 - self.build_single_messages(&request.message, request.images.as_deref()) - }; - - // 打印消息结构用于调试 - for (i, msg) in messages.iter().enumerate() { - let content_type = match &msg.content { - Some(OpenAIMessageContent::Text(_)) => "text", - Some(OpenAIMessageContent::Parts(parts)) => { - let has_image = parts - .iter() - .any(|p| matches!(p, OpenAIContentPart::ImageUrl { .. })); - if has_image { - "parts_with_image" - } else { - "parts_text_only" - } - } - None => "none", - }; - debug!( - "[NativeAgent] 消息[{}]: role={}, content_type={}", - i, msg.role, content_type - ); - } + // 构建消息 + let messages = self.build_openai_messages( + session.as_ref(), + &request.message, + request.images.as_deref(), + ); let chat_request = ChatCompletionRequest { model: model.clone(), @@ -407,7 +121,7 @@ impl NativeAgent { temperature: self.config.temperature, max_tokens: self.config.max_tokens, top_p: None, - tools: None, // TODO: 添加工具支持 + tools: None, tool_choice: None, reasoning_effort: None, }; @@ -480,28 +194,280 @@ impl NativeAgent { }) } - /// 构建单次对话消息(无历史) - fn build_single_messages( + /// 流式聊天(使用协议策略模式) + /// + /// Requirements: 1.1, 1.3, 1.4 + pub async fn chat_stream( &self, + request: NativeChatRequest, + tools: Option<&[crate::models::openai::Tool]>, + tx: mpsc::Sender, + ) -> Result { + let model = request + .model + .clone() + .unwrap_or_else(|| self.config.model.clone()); + let session_id = request.session_id.clone(); + + info!( + "[NativeAgent] 发送流式聊天请求: model={}, session={:?}, provider={:?}, tools_count={}", + model, + session_id, + self.provider_type, + tools.map(|t| t.len()).unwrap_or(0) + ); + + // 获取会话 + let session = if let Some(sid) = &session_id { + self.sessions.read().get(sid).cloned() + } else { + None + }; + + // 获取会话历史和配置 + let history: Vec = session + .as_ref() + .map(|s| s.messages.clone()) + .unwrap_or_default(); + + let config = if let Some(ref sess) = session { + let mut cfg = self.config.clone(); + if sess.system_prompt.is_some() { + cfg.system_prompt = sess.system_prompt.clone(); + } + cfg + } else { + self.config.clone() + }; + + // 使用协议策略发送请求 + let result = self + .protocol + .chat_stream( + &self.client, + &self.base_url, + &self.api_key, + &history, + &request.message, + request.images.as_deref(), + &model, + &config, + tools, + tx, + ) + .await?; + + // 更新会话历史 + if let Some(sid) = &session_id { + self.add_message_to_session( + sid, + "user", + MessageContent::Text(request.message.clone()), + request.images.as_deref(), + ); + self.add_assistant_message_to_session( + sid, + MessageContent::Text(result.content.clone()), + result.tool_calls.clone(), + ); + } + + Ok(result) + } + + /// 流式聊天(支持工具调用循环) + /// + /// Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6 + pub async fn chat_stream_with_tools( + &self, + request: NativeChatRequest, + tx: mpsc::Sender, + tool_loop_engine: &ToolLoopEngine, + ) -> Result { + let session_id = request.session_id.clone(); + let mut state = ToolLoopState::new(); + + // 获取工具定义 + let tools = tool_loop_engine.registry().list_definitions_api(); + let tools_ref = if tools.is_empty() { + None + } else { + Some(tools.as_slice()) + }; + + // 首次请求 + let mut current_result = self + .chat_stream(request.clone(), tools_ref, tx.clone()) + .await?; + + // 工具调用循环 + // Requirements: 7.3 - THE Tool_Loop SHALL continue until the Agent produces a final response without tool_calls + while tool_loop_engine.should_continue(¤t_result, state.iteration) { + state.increment_iteration(); + + let tool_calls = current_result.tool_calls.as_ref().unwrap(); + state.add_tool_calls(tool_calls.len()); + + info!( + "[NativeAgent] 工具循环迭代 {}: 执行 {} 个工具调用", + state.iteration, + tool_calls.len() + ); + + // 执行所有工具调用 + let tool_results = tool_loop_engine + .execute_all_tool_calls(tool_calls, Some(&tx)) + .await; + + // 将工具结果添加到会话 + if let Some(sid) = &session_id { + for result in &tool_results { + self.add_tool_result_to_session(sid, result); + } + } + + // 继续对话 + let continue_request = NativeChatRequest { + session_id: session_id.clone(), + message: String::new(), + model: request.model.clone(), + images: None, + stream: true, + }; + + current_result = self + .chat_stream_continue(continue_request, tools_ref, tx.clone()) + .await?; + } + + // 检查是否因为达到最大迭代次数而停止 + if state.iteration >= tool_loop_engine.max_iterations() && current_result.has_tool_calls() { + warn!( + "[NativeAgent] 达到最大迭代次数 {},强制停止工具循环", + tool_loop_engine.max_iterations() + ); + let _ = tx + .send(StreamEvent::Error { + message: format!( + "达到最大工具调用迭代次数限制 ({})", + tool_loop_engine.max_iterations() + ), + }) + .await; + } + + state.mark_completed(current_result.content.clone()); + + info!( + "[NativeAgent] 工具循环完成: {} 次迭代, {} 个工具调用", + state.iteration, state.total_tool_calls + ); + + // 发送 FinalDone 事件,通知前端整个对话(包括工具循环)已完成 + let _ = tx + .send(StreamEvent::FinalDone { + usage: current_result.usage.clone(), + }) + .await; + + Ok(current_result) + } + + /// 继续流式对话(使用会话历史) + async fn chat_stream_continue( + &self, + request: NativeChatRequest, + tools: Option<&[crate::models::openai::Tool]>, + tx: mpsc::Sender, + ) -> Result { + let model = request.model.unwrap_or_else(|| self.config.model.clone()); + let session_id = request.session_id.as_ref().ok_or("需要 session_id")?; + + debug!( + "[NativeAgent] 继续流式对话: model={}, session={}, tools_count={}", + model, + session_id, + tools.map(|t| t.len()).unwrap_or(0) + ); + + // 获取会话 + let session = self + .sessions + .read() + .get(session_id) + .cloned() + .ok_or_else(|| format!("会话不存在: {}", session_id))?; + + // 获取配置 + let config = { + let mut cfg = self.config.clone(); + if session.system_prompt.is_some() { + cfg.system_prompt = session.system_prompt.clone(); + } + cfg + }; + + // 使用协议策略继续对话 + let result = self + .protocol + .chat_stream_continue( + &self.client, + &self.base_url, + &self.api_key, + &session.messages, + &model, + &config, + tools, + tx, + ) + .await?; + + // 更新会话历史 + self.add_assistant_message_to_session( + session_id, + MessageContent::Text(result.content.clone()), + result.tool_calls.clone(), + ); + + Ok(result) + } + + // ==================== 会话管理方法 ==================== + + /// 构建 OpenAI 格式消息(用于非流式请求) + fn build_openai_messages( + &self, + session: Option<&AgentSession>, user_message: &str, images: Option<&[ImageData]>, ) -> Vec { let mut messages = Vec::new(); - if let Some(system_prompt) = &self.config.system_prompt { + // 系统提示词 + let system_prompt = session + .and_then(|s| s.system_prompt.as_ref()) + .or(self.config.system_prompt.as_ref()); + if let Some(prompt) = system_prompt { messages.push(ChatMessage { role: "system".to_string(), - content: Some(OpenAIMessageContent::Text(system_prompt.clone())), + content: Some(OpenAIMessageContent::Text(prompt.clone())), tool_calls: None, tool_call_id: None, }); } + // 历史消息 + if let Some(sess) = session { + for msg in &sess.messages { + messages.push(self.convert_to_chat_message(msg)); + } + } + + // 用户消息 let user_msg = if let Some(imgs) = images { let mut parts = vec![OpenAIContentPart::Text { text: user_message.to_string(), }]; - for img in imgs { parts.push(OpenAIContentPart::ImageUrl { image_url: crate::models::openai::ImageUrl { @@ -510,7 +476,6 @@ impl NativeAgent { }, }); } - ChatMessage { role: "user".to_string(), content: Some(OpenAIMessageContent::Parts(parts)), @@ -530,6 +495,49 @@ impl NativeAgent { messages } + /// 将 AgentMessage 转换为 OpenAI ChatMessage + fn convert_to_chat_message(&self, msg: &AgentMessage) -> ChatMessage { + let content = match &msg.content { + MessageContent::Text(text) => Some(OpenAIMessageContent::Text(text.clone())), + MessageContent::Parts(parts) => { + let openai_parts: Vec = parts + .iter() + .map(|p| match p { + ContentPart::Text { text } => { + OpenAIContentPart::Text { text: text.clone() } + } + ContentPart::ImageUrl { image_url } => OpenAIContentPart::ImageUrl { + image_url: crate::models::openai::ImageUrl { + url: image_url.url.clone(), + detail: image_url.detail.clone(), + }, + }, + }) + .collect(); + Some(OpenAIMessageContent::Parts(openai_parts)) + } + }; + + ChatMessage { + role: msg.role.clone(), + content, + tool_calls: msg.tool_calls.as_ref().map(|calls| { + calls + .iter() + .map(|tc| crate::models::openai::ToolCall { + id: tc.id.clone(), + call_type: tc.call_type.clone(), + function: crate::models::openai::FunctionCall { + name: tc.function.name.clone(), + arguments: tc.function.arguments.clone(), + }, + }) + .collect() + }), + tool_call_id: msg.tool_call_id.clone(), + } + } + /// 添加消息到会话 fn add_message_to_session( &self, @@ -541,7 +549,6 @@ impl NativeAgent { let mut sessions = self.sessions.write(); if let Some(session) = sessions.get_mut(session_id) { let final_content = if let Some(imgs) = images { - // 如果有图片,转换为 Parts let mut parts = vec![ContentPart::Text { text: content.as_text(), }]; @@ -569,196 +576,6 @@ impl NativeAgent { } } - /// 流式聊天(支持连续对话) - /// - /// 实现 SSE 流解析,支持 text_delta 和 tool_calls 解析 - /// Requirements: 1.1, 1.3, 1.4 - pub async fn chat_stream( - &self, - request: NativeChatRequest, - tx: mpsc::Sender, - ) -> Result { - let model = request.model.unwrap_or_else(|| self.config.model.clone()); - let session_id = request.session_id.clone(); - - info!( - "[NativeAgent] 发送流式聊天请求: model={}, session={:?}", - model, session_id - ); - - // 获取会话 - let session = if let Some(sid) = &session_id { - self.sessions.read().get(sid).cloned() - } else { - None - }; - - let messages = if let Some(ref sess) = session { - self.build_messages_with_history(sess, &request.message, request.images.as_deref()) - } else { - self.build_single_messages(&request.message, request.images.as_deref()) - }; - - let chat_request = ChatCompletionRequest { - model: model.clone(), - messages, - stream: true, - temperature: self.config.temperature, - max_tokens: self.config.max_tokens, - top_p: None, - tools: None, - tool_choice: None, - reasoning_effort: None, - }; - - let url = format!("{}/v1/chat/completions", self.base_url); - - let response = self - .client - .post(&url) - .header("Authorization", format!("Bearer {}", self.api_key)) - .header("Content-Type", "application/json") - .json(&chat_request) - .send() - .await - .map_err(|e| format!("请求失败: {}", e))?; - - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - error!("[NativeAgent] 流式请求失败: {} - {}", status, body); - let _ = tx - .send(StreamEvent::Error { - message: format!("API 错误 ({}): {}", status, body), - }) - .await; - return Err(format!("API 错误: {}", status)); - } - - let mut stream = response.bytes_stream(); - let mut buffer = String::new(); - let mut parser = SSEParser::new(); - let mut final_usage: Option = None; - - while let Some(chunk) = stream.next().await { - match chunk { - Ok(bytes) => { - let text = String::from_utf8_lossy(&bytes); - buffer.push_str(&text); - - // 处理完整的 SSE 事件(以 \n\n 分隔) - while let Some(pos) = buffer.find("\n\n") { - let event = buffer[..pos].to_string(); - buffer = buffer[pos + 2..].to_string(); - - for line in event.lines() { - if let Some(data) = line.strip_prefix("data: ") { - debug!("[NativeAgent] SSE data: {}", data); - let (text_delta, is_done, usage) = parser.parse_data(data); - debug!("[NativeAgent] 解析结果: text_delta={:?}, is_done={}, usage={:?}", - text_delta, is_done, usage); - - // 更新 usage - if usage.is_some() { - final_usage = usage; - } - - // 发送文本增量 - if let Some(text) = text_delta { - debug!("[NativeAgent] 发送 TextDelta: {}", text); - let _ = tx.send(StreamEvent::TextDelta { text }).await; - } - - // 检查是否完成 - if is_done { - // 获取最终结果 - let full_content = parser.get_full_content(); - let tool_calls = if parser.has_tool_calls() { - Some(parser.finalize_tool_calls()) - } else { - None - }; - - // 更新会话历史 - if let Some(sid) = &session_id { - self.add_message_to_session( - sid, - "user", - MessageContent::Text(request.message.clone()), - request.images.as_deref(), - ); - self.add_assistant_message_to_session( - sid, - MessageContent::Text(full_content.clone()), - tool_calls.clone(), - ); - } - - // 发送完成事件 - let _ = tx - .send(StreamEvent::Done { - usage: final_usage.clone(), - }) - .await; - - return Ok(StreamResult { - content: full_content, - tool_calls, - usage: final_usage, - }); - } - } - } - } - } - Err(e) => { - error!("[NativeAgent] 流读取错误: {}", e); - let _ = tx - .send(StreamEvent::Error { - message: format!("流读取错误: {}", e), - }) - .await; - return Err(format!("流读取错误: {}", e)); - } - } - } - - // 流正常结束但没有收到 [DONE] - let full_content = parser.get_full_content(); - let tool_calls = if parser.has_tool_calls() { - Some(parser.finalize_tool_calls()) - } else { - None - }; - - // 更新会话历史 - if let Some(sid) = &session_id { - self.add_message_to_session( - sid, - "user", - MessageContent::Text(request.message.clone()), - request.images.as_deref(), - ); - self.add_assistant_message_to_session( - sid, - MessageContent::Text(full_content.clone()), - tool_calls.clone(), - ); - } - - let _ = tx - .send(StreamEvent::Done { - usage: final_usage.clone(), - }) - .await; - - Ok(StreamResult { - content: full_content, - tool_calls, - usage: final_usage, - }) - } - /// 添加 assistant 消息到会话(支持工具调用) fn add_assistant_message_to_session( &self, @@ -780,8 +597,6 @@ impl NativeAgent { } /// 添加工具结果消息到会话 - /// - /// Requirements: 7.2 - THE Tool_Loop SHALL send tool results back to the Agent as tool role messages fn add_tool_result_to_session(&self, session_id: &str, tool_result: &ToolCallResult) { let mut sessions = self.sessions.write(); if let Some(session) = sessions.get_mut(session_id) { @@ -790,273 +605,7 @@ impl NativeAgent { } } - /// 流式聊天(支持工具调用循环) - /// - /// 实现完整的工具调用循环: - /// 1. 发送请求到 LLM - /// 2. 如果响应包含工具调用,执行工具 - /// 3. 将工具结果发送回 LLM - /// 4. 重复直到 LLM 产生最终响应或达到最大迭代次数 - /// - /// Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6 - pub async fn chat_stream_with_tools( - &self, - request: NativeChatRequest, - tx: mpsc::Sender, - tool_loop_engine: &ToolLoopEngine, - ) -> Result { - let session_id = request.session_id.clone(); - let mut state = ToolLoopState::new(); - - // 首次请求 - let mut current_result = self.chat_stream(request.clone(), tx.clone()).await?; - - // 工具调用循环 - // Requirements: 7.3 - THE Tool_Loop SHALL continue until the Agent produces a final response without tool_calls - while tool_loop_engine.should_continue(¤t_result, state.iteration) { - state.increment_iteration(); - - let tool_calls = current_result.tool_calls.as_ref().unwrap(); - state.add_tool_calls(tool_calls.len()); - - info!( - "[NativeAgent] 工具循环迭代 {}: 执行 {} 个工具调用", - state.iteration, - tool_calls.len() - ); - - // 执行所有工具调用 - // Requirements: 7.1 - THE Tool_Loop SHALL execute each tool and collect results - // Requirements: 7.6 - WHILE the Tool_Loop is executing, THE Frontend SHALL display the current tool - let tool_results = tool_loop_engine - .execute_all_tool_calls(tool_calls, Some(&tx)) - .await; - - // 将工具结果添加到会话 - // Requirements: 7.2 - THE Tool_Loop SHALL send tool results back to the Agent as tool role messages - if let Some(sid) = &session_id { - for result in &tool_results { - self.add_tool_result_to_session(sid, result); - } - } - - // 构建继续对话的请求 - let continue_request = NativeChatRequest { - session_id: session_id.clone(), - message: String::new(), // 空消息,因为我们使用会话历史 - model: request.model.clone(), - images: None, - stream: true, - }; - - // 继续对话 - current_result = self - .chat_stream_continue(continue_request, tx.clone()) - .await?; - } - - // 检查是否因为达到最大迭代次数而停止 - // Requirements: 7.5 - THE Tool_Loop SHALL enforce a maximum iteration limit - if state.iteration >= tool_loop_engine.max_iterations() && current_result.has_tool_calls() { - warn!( - "[NativeAgent] 达到最大迭代次数 {},强制停止工具循环", - tool_loop_engine.max_iterations() - ); - let _ = tx - .send(StreamEvent::Error { - message: format!( - "达到最大工具调用迭代次数限制 ({})", - tool_loop_engine.max_iterations() - ), - }) - .await; - } - - state.mark_completed(current_result.content.clone()); - - info!( - "[NativeAgent] 工具循环完成: {} 次迭代, {} 个工具调用", - state.iteration, state.total_tool_calls - ); - - Ok(current_result) - } - - /// 继续流式对话(使用会话历史) - /// - /// 用于工具调用循环中继续对话 - async fn chat_stream_continue( - &self, - request: NativeChatRequest, - tx: mpsc::Sender, - ) -> Result { - let model = request.model.unwrap_or_else(|| self.config.model.clone()); - let session_id = request.session_id.as_ref().ok_or("需要 session_id")?; - - debug!( - "[NativeAgent] 继续流式对话: model={}, session={}", - model, session_id - ); - - // 获取会话 - let session = self - .sessions - .read() - .get(session_id) - .cloned() - .ok_or_else(|| format!("会话不存在: {}", session_id))?; - - // 构建消息(使用会话历史,不添加新的用户消息) - let messages = self.build_messages_from_session(&session); - - let chat_request = ChatCompletionRequest { - model: model.clone(), - messages, - stream: true, - temperature: self.config.temperature, - max_tokens: self.config.max_tokens, - top_p: None, - tools: None, // TODO: 添加工具定义 - tool_choice: None, - reasoning_effort: None, - }; - - let url = format!("{}/v1/chat/completions", self.base_url); - - let response = self - .client - .post(&url) - .header("Authorization", format!("Bearer {}", self.api_key)) - .header("Content-Type", "application/json") - .json(&chat_request) - .send() - .await - .map_err(|e| format!("请求失败: {}", e))?; - - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - error!("[NativeAgent] 流式请求失败: {} - {}", status, body); - let _ = tx - .send(StreamEvent::Error { - message: format!("API 错误 ({}): {}", status, body), - }) - .await; - return Err(format!("API 错误: {}", status)); - } - - let mut stream = response.bytes_stream(); - let mut buffer = String::new(); - let mut parser = SSEParser::new(); - let mut final_usage: Option = None; - - while let Some(chunk) = stream.next().await { - match chunk { - Ok(bytes) => { - let text = String::from_utf8_lossy(&bytes); - buffer.push_str(&text); - - while let Some(pos) = buffer.find("\n\n") { - let event = buffer[..pos].to_string(); - buffer = buffer[pos + 2..].to_string(); - - for line in event.lines() { - if let Some(data) = line.strip_prefix("data: ") { - let (text_delta, is_done, usage) = parser.parse_data(data); - - if usage.is_some() { - final_usage = usage; - } - - if let Some(text) = text_delta { - let _ = tx.send(StreamEvent::TextDelta { text }).await; - } - - if is_done { - let full_content = parser.get_full_content(); - let tool_calls = if parser.has_tool_calls() { - Some(parser.finalize_tool_calls()) - } else { - None - }; - - // 更新会话历史 - self.add_assistant_message_to_session( - session_id, - MessageContent::Text(full_content.clone()), - tool_calls.clone(), - ); - - // 不发送 Done 事件,因为工具循环可能还会继续 - - return Ok(StreamResult { - content: full_content, - tool_calls, - usage: final_usage, - }); - } - } - } - } - } - Err(e) => { - error!("[NativeAgent] 流读取错误: {}", e); - let _ = tx - .send(StreamEvent::Error { - message: format!("流读取错误: {}", e), - }) - .await; - return Err(format!("流读取错误: {}", e)); - } - } - } - - // 流正常结束 - let full_content = parser.get_full_content(); - let tool_calls = if parser.has_tool_calls() { - Some(parser.finalize_tool_calls()) - } else { - None - }; - - self.add_assistant_message_to_session( - session_id, - MessageContent::Text(full_content.clone()), - tool_calls.clone(), - ); - - Ok(StreamResult { - content: full_content, - tool_calls, - usage: final_usage, - }) - } - - /// 从会话构建消息列表(不添加新的用户消息) - fn build_messages_from_session(&self, session: &AgentSession) -> Vec { - let mut messages = Vec::new(); - - // 添加系统提示词 - let system_prompt = session - .system_prompt - .as_ref() - .or(self.config.system_prompt.as_ref()); - if let Some(prompt) = system_prompt { - messages.push(ChatMessage { - role: "system".to_string(), - content: Some(OpenAIMessageContent::Text(prompt.clone())), - tool_calls: None, - tool_call_id: None, - }); - } - - // 添加所有历史消息 - for msg in &session.messages { - messages.push(self.convert_to_chat_message(msg)); - } - - messages - } + // ==================== 公开会话管理 API ==================== pub fn create_session(&self, model: Option, system_prompt: Option) -> String { let session_id = uuid::Uuid::new_v4().to_string(); @@ -1107,6 +656,8 @@ impl NativeAgent { } } +// ==================== Tauri 状态管理 ==================== + /// Tauri 状态:原生 Agent 管理器 #[derive(Clone, Default)] pub struct NativeAgentState { @@ -1120,8 +671,13 @@ impl NativeAgentState { } } - pub fn init(&self, base_url: String, api_key: String) -> Result<(), String> { - let agent = NativeAgent::new(base_url, api_key)?; + pub fn init( + &self, + base_url: String, + api_key: String, + provider_type: ProviderType, + ) -> Result<(), String> { + let agent = NativeAgent::new(base_url, api_key, provider_type)?; *self.agent.write() = Some(agent); Ok(()) } @@ -1134,32 +690,40 @@ impl NativeAgentState { *self.agent.write() = None; } + /// 获取工具注册表 + pub fn get_tool_registry(&self) -> Result, String> { + let base_dir = dirs::home_dir().ok_or_else(|| "无法获取用户 home 目录".to_string())?; + let registry = create_default_registry(base_dir); + Ok(Arc::new(registry)) + } + + /// 创建临时 Agent 用于异步操作 + fn create_temp_agent(&self) -> Result { + let guard = self.agent.read(); + let agent = guard.as_ref().ok_or_else(|| "Agent 未初始化".to_string())?; + + let client = Client::builder() + .timeout(Duration::from_secs(300)) + .connect_timeout(Duration::from_secs(30)) + .no_proxy() + .build() + .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; + + let protocol = create_protocol(agent.provider_type); + + Ok(NativeAgent { + client, + base_url: agent.base_url.clone(), + api_key: agent.api_key.clone(), + sessions: agent.sessions.clone(), + config: agent.config.clone(), + provider_type: agent.provider_type, + protocol, + }) + } + pub async fn chat(&self, request: NativeChatRequest) -> Result { - let (base_url, api_key, config, sessions) = { - let guard = self.agent.read(); - let agent = guard.as_ref().ok_or_else(|| "Agent 未初始化".to_string())?; - ( - agent.base_url.clone(), - agent.api_key.clone(), - agent.config.clone(), - agent.sessions.clone(), - ) - }; - - // 创建临时 Agent,共享 sessions - let temp_agent = NativeAgent { - client: Client::builder() - .timeout(Duration::from_secs(300)) - .connect_timeout(Duration::from_secs(30)) - .no_proxy() - .build() - .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?, - base_url, - api_key, - sessions, - config, - }; - + let temp_agent = self.create_temp_agent()?; temp_agent.chat(request).await } @@ -1168,66 +732,17 @@ impl NativeAgentState { request: NativeChatRequest, tx: mpsc::Sender, ) -> Result { - let (base_url, api_key, config, sessions) = { - let guard = self.agent.read(); - let agent = guard.as_ref().ok_or_else(|| "Agent 未初始化".to_string())?; - ( - agent.base_url.clone(), - agent.api_key.clone(), - agent.config.clone(), - agent.sessions.clone(), - ) - }; - - let temp_agent = NativeAgent { - client: Client::builder() - .timeout(Duration::from_secs(300)) - .connect_timeout(Duration::from_secs(30)) - .no_proxy() - .build() - .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?, - base_url, - api_key, - sessions, - config, - }; - - temp_agent.chat_stream(request, tx).await + let temp_agent = self.create_temp_agent()?; + temp_agent.chat_stream(request, None, tx).await } - /// 流式聊天(支持工具调用循环) - /// - /// Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6 pub async fn chat_stream_with_tools( &self, request: NativeChatRequest, tx: mpsc::Sender, tool_loop_engine: &ToolLoopEngine, ) -> Result { - let (base_url, api_key, config, sessions) = { - let guard = self.agent.read(); - let agent = guard.as_ref().ok_or_else(|| "Agent 未初始化".to_string())?; - ( - agent.base_url.clone(), - agent.api_key.clone(), - agent.config.clone(), - agent.sessions.clone(), - ) - }; - - let temp_agent = NativeAgent { - client: Client::builder() - .timeout(Duration::from_secs(300)) - .connect_timeout(Duration::from_secs(30)) - .no_proxy() - .build() - .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?, - base_url, - api_key, - sessions, - config, - }; - + let temp_agent = self.create_temp_agent()?; temp_agent .chat_stream_with_tools(request, tx, tool_loop_engine) .await @@ -1287,12 +802,12 @@ impl NativeAgentState { #[cfg(test)] mod tests { use super::*; + use crate::agent::parsers::OpenAISSEParser; #[test] fn test_sse_parser_text_delta() { - let mut parser = SSEParser::new(); + let mut parser = OpenAISSEParser::new(); - // 模拟 SSE 数据 let data1 = r#"{"choices":[{"delta":{"content":"Hello"}}]}"#; let data2 = r#"{"choices":[{"delta":{"content":" World"}}]}"#; let data3 = r#"{"choices":[{"delta":{},"finish_reason":"stop"}]}"#; @@ -1314,9 +829,8 @@ mod tests { #[test] fn test_sse_parser_tool_calls() { - let mut parser = SSEParser::new(); + let mut parser = OpenAISSEParser::new(); - // 模拟工具调用的 SSE 数据 let data1 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","type":"function","function":{"name":"bash"}}]}}]}"#; let data2 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"command\":"}}]}}]}"#; let data3 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"ls -la\"}"}}]}}]}"#; @@ -1336,255 +850,4 @@ mod tests { assert_eq!(tool_calls[0].function.name, "bash"); assert_eq!(tool_calls[0].function.arguments, r#"{"command":"ls -la"}"#); } - - #[test] - fn test_sse_parser_usage() { - let mut parser = SSEParser::new(); - - let data = r#"{"choices":[{"delta":{"content":"Hi"}}],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#; - let (text, _, usage) = parser.parse_data(data); - - assert_eq!(text, Some("Hi".to_string())); - assert!(usage.is_some()); - let usage = usage.unwrap(); - assert_eq!(usage.input_tokens, 10); - assert_eq!(usage.output_tokens, 5); - } - - #[test] - fn test_sse_parser_done_signal() { - let mut parser = SSEParser::new(); - - let (_, done, _) = parser.parse_data("[DONE]"); - assert!(done); - } - - #[test] - fn test_sse_parser_invalid_json() { - let mut parser = SSEParser::new(); - - let (text, done, usage) = parser.parse_data("invalid json"); - assert!(text.is_none()); - assert!(!done); - assert!(usage.is_none()); - } - - #[test] - fn test_sse_parser_multiple_tool_calls() { - let mut parser = SSEParser::new(); - - // 两个工具调用 - let data1 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"bash","arguments":"{}"}}]}}]}"#; - let data2 = r#"{"choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_2","type":"function","function":{"name":"read_file","arguments":"{}"}}]}}]}"#; - - parser.parse_data(data1); - parser.parse_data(data2); - - let tool_calls = parser.finalize_tool_calls(); - assert_eq!(tool_calls.len(), 2); - assert_eq!(tool_calls[0].function.name, "bash"); - assert_eq!(tool_calls[1].function.name, "read_file"); - } -} - -#[cfg(test)] -mod proptests { - use super::*; - use proptest::prelude::*; - - /// 生成有效的文本内容(不包含特殊字符) - fn arb_text_content() -> impl Strategy { - "[a-zA-Z0-9 ,.!?]{1,100}".prop_map(|s| s) - } - - /// 生成文本片段列表 - fn arb_text_chunks() -> impl Strategy> { - prop::collection::vec(arb_text_content(), 1..10) - } - - /// 生成有效的工具名称 - fn arb_tool_name() -> impl Strategy { - prop_oneof![ - Just("bash".to_string()), - Just("read_file".to_string()), - Just("write_file".to_string()), - Just("edit_file".to_string()), - ] - } - - /// 生成有效的工具调用 ID - fn arb_tool_id() -> impl Strategy { - "call_[a-zA-Z0-9]{8}".prop_map(|s| s) - } - - proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// **Feature: agent-tool-calling, Property 1: 流式事件完整性** - /// **Validates: Requirements 1.1, 1.3** - /// - /// *For any* Agent 响应流,流式处理器发送的所有 text_delta 事件的文本拼接后, - /// 应该等于最终的完整响应内容。 - #[test] - fn prop_streaming_text_completeness(chunks in arb_text_chunks()) { - let mut parser = SSEParser::new(); - let mut collected_deltas = String::new(); - - // 模拟流式处理 - for chunk in &chunks { - // 转义 JSON 特殊字符 - let escaped = chunk.replace('\\', "\\\\").replace('"', "\\\""); - let data = format!(r#"{{"choices":[{{"delta":{{"content":"{}"}}}}]}}"#, escaped); - let (text_delta, _, _) = parser.parse_data(&data); - - if let Some(text) = text_delta { - collected_deltas.push_str(&text); - } - } - - // 验证:收集的 text_delta 拼接后等于 parser 的完整内容 - prop_assert_eq!( - collected_deltas, - parser.get_full_content(), - "收集的 text_delta 应该等于完整内容" - ); - - // 验证:完整内容等于原始 chunks 拼接 - let expected = chunks.join(""); - prop_assert_eq!( - parser.get_full_content(), - expected, - "完整内容应该等于原始 chunks 拼接" - ); - } - - /// **Feature: agent-tool-calling, Property 1: 流式事件完整性 - 工具调用** - /// **Validates: Requirements 1.1, 1.3** - /// - /// *For any* 包含工具调用的响应流,工具调用信息应该被正确解析和累积。 - #[test] - fn prop_streaming_tool_calls_completeness( - tool_name in arb_tool_name(), - tool_id in arb_tool_id(), - arg_key in "[a-z]{3,10}", - arg_value in "[a-zA-Z0-9]{1,20}" - ) { - let mut parser = SSEParser::new(); - - // 使用 serde_json 构建正确的 JSON,避免手动转义问题 - let args_json = serde_json::json!({arg_key.clone(): arg_value.clone()}).to_string(); - - // 第一个 chunk: 工具 ID 和名称 - let data1 = serde_json::json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "index": 0, - "id": tool_id.clone(), - "type": "function", - "function": { - "name": tool_name.clone() - } - }] - } - }] - }).to_string(); - - // 第二个 chunk: 参数的前半部分 - let args_first_half = &args_json[..args_json.len()/2]; - let data2 = serde_json::json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "index": 0, - "function": { - "arguments": args_first_half - } - }] - } - }] - }).to_string(); - - // 第三个 chunk: 参数的后半部分 - let args_second_half = &args_json[args_json.len()/2..]; - let data3 = serde_json::json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "index": 0, - "function": { - "arguments": args_second_half - } - }] - } - }] - }).to_string(); - - parser.parse_data(&data1); - parser.parse_data(&data2); - parser.parse_data(&data3); - - prop_assert!(parser.has_tool_calls(), "应该检测到工具调用"); - - let tool_calls = parser.finalize_tool_calls(); - prop_assert_eq!(tool_calls.len(), 1, "应该有一个工具调用"); - prop_assert_eq!(&tool_calls[0].id, &tool_id, "工具调用 ID 应该匹配"); - prop_assert_eq!(&tool_calls[0].function.name, &tool_name, "工具名称应该匹配"); - - // 验证参数被正确累积 - prop_assert_eq!( - &tool_calls[0].function.arguments, - &args_json, - "工具参数应该被正确累积" - ); - } - - /// **Feature: agent-tool-calling, Property 1: 流式事件完整性 - Done 事件** - /// **Validates: Requirements 1.1, 1.3** - /// - /// *For any* 完成的响应流,应该正确识别 finish_reason。 - #[test] - fn prop_streaming_done_detection( - finish_reason in prop_oneof![Just("stop"), Just("tool_calls"), Just("length")] - ) { - let mut parser = SSEParser::new(); - - let data = format!( - r#"{{"choices":[{{"delta":{{}},"finish_reason":"{}"}}]}}"#, - finish_reason - ); - let (_, is_done, _) = parser.parse_data(&data); - - if finish_reason == "stop" || finish_reason == "tool_calls" { - prop_assert!(is_done, "finish_reason={} 应该标记为完成", finish_reason); - } else { - prop_assert!(!is_done, "finish_reason={} 不应该标记为完成", finish_reason); - } - } - - /// **Feature: agent-tool-calling, Property 1: 流式事件完整性 - Usage 统计** - /// **Validates: Requirements 1.3** - /// - /// *For any* 包含 usage 的响应,应该正确解析 token 使用量。 - #[test] - fn prop_streaming_usage_parsing( - input_tokens in 0u32..10000, - output_tokens in 0u32..10000 - ) { - let mut parser = SSEParser::new(); - - let data = format!( - r#"{{"choices":[{{"delta":{{"content":"test"}}}}],"usage":{{"prompt_tokens":{},"completion_tokens":{}}}}}"#, - input_tokens, output_tokens - ); - let (_, _, usage) = parser.parse_data(&data); - - if input_tokens > 0 || output_tokens > 0 { - prop_assert!(usage.is_some(), "应该解析出 usage"); - let usage = usage.unwrap(); - prop_assert_eq!(usage.input_tokens, input_tokens, "input_tokens 应该匹配"); - prop_assert_eq!(usage.output_tokens, output_tokens, "output_tokens 应该匹配"); - } - } - } } diff --git a/src-tauri/src/agent/parsers/anthropic_sse.rs b/src-tauri/src/agent/parsers/anthropic_sse.rs new file mode 100644 index 000000000..ef838c3db --- /dev/null +++ b/src-tauri/src/agent/parsers/anthropic_sse.rs @@ -0,0 +1,209 @@ +//! Anthropic SSE 流解析器 +//! +//! 解析 Anthropic Messages API 的 Server-Sent Events 流 + +use crate::agent::types::{FunctionCall, TokenUsage, ToolCall}; +use crate::models::anthropic::{AnthropicContentBlock, AnthropicDelta, AnthropicStreamEvent}; +use tracing::{debug, warn}; + +/// Anthropic 工具调用构建器 +#[derive(Debug, Clone, Default)] +struct AnthropicToolCallBuilder { + id: String, + name: String, + input_json: String, +} + +/// Anthropic SSE 流解析器 +/// +/// 解析 Anthropic Messages API 的 SSE 流 +#[derive(Debug, Default)] +pub struct AnthropicSSEParser { + /// 累积的完整内容 + full_content: String, + /// 累积的工具调用 + tool_calls: Vec, + /// 当前正在构建的工具调用 + current_tool: Option, + /// Usage 信息 + usage: Option, +} + +/// Anthropic SSE 解析结果 +#[derive(Debug, Clone)] +pub struct AnthropicParseResult { + /// 文本增量 + pub text_delta: Option, + /// 是否完成 + pub is_done: bool, + /// 工具调用开始(id, name) + pub tool_start: Option<(String, String)>, +} + +impl AnthropicSSEParser { + pub fn new() -> Self { + Self::default() + } + + /// 解析 SSE 数据行 + /// + /// 返回解析结果 + pub fn parse_data(&mut self, data: &str) -> AnthropicParseResult { + if data.trim().is_empty() { + return AnthropicParseResult { + text_delta: None, + is_done: false, + tool_start: None, + }; + } + + let event: AnthropicStreamEvent = match serde_json::from_str(data) { + Ok(e) => e, + Err(e) => { + warn!("[AnthropicSSEParser] 解析事件失败: {} - data: {}", e, data); + return AnthropicParseResult { + text_delta: None, + is_done: false, + tool_start: None, + }; + } + }; + + match event { + AnthropicStreamEvent::MessageStart { message } => { + debug!("[AnthropicSSEParser] 消息开始: id={}", message.id); + AnthropicParseResult { + text_delta: None, + is_done: false, + tool_start: None, + } + } + AnthropicStreamEvent::ContentBlockStart { + index, + content_block, + } => match content_block { + AnthropicContentBlock::ToolUse { id, name, .. } => { + debug!( + "[AnthropicSSEParser] 工具调用开始: id={}, name={}", + id, name + ); + self.current_tool = Some(AnthropicToolCallBuilder { + id: id.clone(), + name: name.clone(), + input_json: String::new(), + }); + AnthropicParseResult { + text_delta: None, + is_done: false, + tool_start: Some((id, name)), + } + } + AnthropicContentBlock::Text { .. } => { + debug!("[AnthropicSSEParser] 文本块开始: index={}", index); + AnthropicParseResult { + text_delta: None, + is_done: false, + tool_start: None, + } + } + _ => AnthropicParseResult { + text_delta: None, + is_done: false, + tool_start: None, + }, + }, + AnthropicStreamEvent::ContentBlockDelta { index: _, delta } => match delta { + AnthropicDelta::TextDelta { text } => { + self.full_content.push_str(&text); + AnthropicParseResult { + text_delta: Some(text), + is_done: false, + tool_start: None, + } + } + AnthropicDelta::InputJsonDelta { partial_json } => { + if let Some(ref mut tool) = self.current_tool { + tool.input_json.push_str(&partial_json); + } + AnthropicParseResult { + text_delta: None, + is_done: false, + tool_start: None, + } + } + AnthropicDelta::ThinkingDelta { thinking } => { + // 将思考内容添加到 full_content 中,用 标签包裹 + let thinking_text = format!("{}", thinking); + self.full_content.push_str(&thinking_text); + AnthropicParseResult { + text_delta: None, + is_done: false, + tool_start: None, + } + } + AnthropicDelta::SignatureDelta { .. } => { + // 忽略签名 delta + AnthropicParseResult { + text_delta: None, + is_done: false, + tool_start: None, + } + } + }, + AnthropicStreamEvent::ContentBlockStop { index: _ } => { + // 如果有正在构建的工具调用,完成它 + if let Some(tool) = self.current_tool.take() { + self.tool_calls.push(ToolCall { + id: tool.id, + call_type: "function".to_string(), + function: FunctionCall { + name: tool.name, + arguments: tool.input_json, + }, + }); + } + AnthropicParseResult { + text_delta: None, + is_done: false, + tool_start: None, + } + } + AnthropicStreamEvent::MessageDelta { delta: _, usage } => { + self.usage = Some(TokenUsage::new(usage.input_tokens, usage.output_tokens)); + AnthropicParseResult { + text_delta: None, + is_done: false, + tool_start: None, + } + } + AnthropicStreamEvent::MessageStop => { + debug!("[AnthropicSSEParser] 消息结束"); + AnthropicParseResult { + text_delta: None, + is_done: true, + tool_start: None, + } + } + } + } + + /// 完成解析,返回最终的工具调用列表 + pub fn finalize_tool_calls(&mut self) -> Vec { + std::mem::take(&mut self.tool_calls) + } + + /// 获取完整内容 + pub fn get_full_content(&self) -> String { + self.full_content.clone() + } + + /// 是否有工具调用 + pub fn has_tool_calls(&self) -> bool { + !self.tool_calls.is_empty() || self.current_tool.is_some() + } + + /// 获取 usage + pub fn get_usage(&self) -> Option { + self.usage.clone() + } +} diff --git a/src-tauri/src/agent/parsers/mod.rs b/src-tauri/src/agent/parsers/mod.rs new file mode 100644 index 000000000..cd898a324 --- /dev/null +++ b/src-tauri/src/agent/parsers/mod.rs @@ -0,0 +1,9 @@ +//! SSE 流解析器模块 +//! +//! 提供不同协议的 SSE 流解析器 + +mod anthropic_sse; +mod openai_sse; + +pub use anthropic_sse::{AnthropicParseResult, AnthropicSSEParser}; +pub use openai_sse::OpenAISSEParser; diff --git a/src-tauri/src/agent/parsers/openai_sse.rs b/src-tauri/src/agent/parsers/openai_sse.rs new file mode 100644 index 000000000..cd1891401 --- /dev/null +++ b/src-tauri/src/agent/parsers/openai_sse.rs @@ -0,0 +1,262 @@ +//! OpenAI SSE 流解析器 +//! +//! 解析 OpenAI 兼容 API 的 Server-Sent Events 流 +//! Requirements: 1.1, 1.3, 1.4 + +use crate::agent::types::{FunctionCall, TokenUsage, ToolCall}; +use serde_json::Value; +use std::collections::HashMap; +use tracing::warn; + +/// 工具调用增量数据 +#[derive(Debug, Clone, Default)] +struct ToolCallDelta { + /// 工具调用索引 + #[allow(dead_code)] + index: usize, + /// 工具调用 ID + id: String, + /// 工具类型 + call_type: String, + /// 函数名 + function_name: String, + /// 函数参数(累积的 JSON 字符串) + function_arguments: String, +} + +/// OpenAI SSE 流解析器 +/// +/// 解析 Server-Sent Events 流,提取 text_delta 和 tool_calls +#[derive(Debug, Default)] +pub struct OpenAISSEParser { + /// 累积的完整内容 + full_content: String, + /// 当前正在构建的工具调用索引 + current_tool_indices: HashMap, +} + +impl OpenAISSEParser { + pub fn new() -> Self { + Self::default() + } + + /// 解析 SSE 数据行 + /// + /// 返回 (text_delta, is_done, usage) + pub fn parse_data(&mut self, data: &str) -> (Option, bool, Option) { + if data.trim() == "[DONE]" { + return (None, true, None); + } + + let json: Value = match serde_json::from_str(data) { + Ok(v) => v, + Err(e) => { + warn!("[OpenAISSEParser] 解析 JSON 失败: {} - data: {}", e, data); + return (None, false, None); + } + }; + + // 提取 usage 信息(如果存在) + let usage = json.get("usage").and_then(|u| { + let input = u.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let output = u + .get("completion_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + if input > 0 || output > 0 { + Some(TokenUsage::new(input, output)) + } else { + None + } + }); + + // 检查是否有 choices + let choices = match json.get("choices").and_then(|c| c.as_array()) { + Some(c) => c, + None => return (None, false, usage), + }; + + if choices.is_empty() { + return (None, false, usage); + } + + let choice = &choices[0]; + let delta = match choice.get("delta") { + Some(d) => d, + None => return (None, false, usage), + }; + + // 检查 finish_reason + let finish_reason = choice + .get("finish_reason") + .and_then(|f| f.as_str()) + .unwrap_or(""); + let is_done = finish_reason == "stop" || finish_reason == "tool_calls"; + + // 提取文本内容 + let text_delta = delta + .get("content") + .and_then(|c| c.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| { + self.full_content.push_str(s); + s.to_string() + }); + + // 提取工具调用 + if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) { + for tc in tool_calls { + self.parse_tool_call_delta(tc); + } + } + + (text_delta, is_done, usage) + } + + /// 解析工具调用增量 + fn parse_tool_call_delta(&mut self, tc: &Value) { + let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize; + + // 获取或创建工具调用 + let tool_call = self + .current_tool_indices + .entry(index) + .or_insert_with(|| ToolCallDelta { + index, + ..Default::default() + }); + + // 更新 ID + if let Some(id) = tc.get("id").and_then(|i| i.as_str()) { + tool_call.id = id.to_string(); + } + + // 更新类型 + if let Some(t) = tc.get("type").and_then(|t| t.as_str()) { + tool_call.call_type = t.to_string(); + } + + // 更新函数信息 + if let Some(function) = tc.get("function") { + if let Some(name) = function.get("name").and_then(|n| n.as_str()) { + tool_call.function_name = name.to_string(); + } + if let Some(args) = function.get("arguments").and_then(|a| a.as_str()) { + tool_call.function_arguments.push_str(args); + } + } + } + + /// 完成解析,返回最终的工具调用列表 + pub fn finalize_tool_calls(&mut self) -> Vec { + // 按索引排序并转换为 ToolCall + let mut indices: Vec<_> = self.current_tool_indices.keys().cloned().collect(); + indices.sort(); + + indices + .into_iter() + .filter_map(|idx| { + let delta = self.current_tool_indices.get(&idx)?; + if delta.id.is_empty() || delta.function_name.is_empty() { + return None; + } + Some(ToolCall { + id: delta.id.clone(), + call_type: if delta.call_type.is_empty() { + "function".to_string() + } else { + delta.call_type.clone() + }, + function: FunctionCall { + name: delta.function_name.clone(), + arguments: delta.function_arguments.clone(), + }, + }) + }) + .collect() + } + + /// 获取完整内容 + pub fn get_full_content(&self) -> String { + self.full_content.clone() + } + + /// 是否有工具调用 + pub fn has_tool_calls(&self) -> bool { + !self.current_tool_indices.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_text_delta() { + let mut parser = OpenAISSEParser::new(); + + let data1 = r#"{"choices":[{"delta":{"content":"Hello"}}]}"#; + let data2 = r#"{"choices":[{"delta":{"content":" World"}}]}"#; + let data3 = r#"{"choices":[{"delta":{},"finish_reason":"stop"}]}"#; + + let (text1, done1, _) = parser.parse_data(data1); + assert_eq!(text1, Some("Hello".to_string())); + assert!(!done1); + + let (text2, done2, _) = parser.parse_data(data2); + assert_eq!(text2, Some(" World".to_string())); + assert!(!done2); + + let (text3, done3, _) = parser.parse_data(data3); + assert!(text3.is_none()); + assert!(done3); + + assert_eq!(parser.get_full_content(), "Hello World"); + } + + #[test] + fn test_tool_calls() { + let mut parser = OpenAISSEParser::new(); + + let data1 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","type":"function","function":{"name":"bash"}}]}}]}"#; + let data2 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"command\":"}}]}}]}"#; + let data3 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"ls -la\"}"}}]}}]}"#; + let data4 = r#"{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}"#; + + parser.parse_data(data1); + parser.parse_data(data2); + parser.parse_data(data3); + let (_, done, _) = parser.parse_data(data4); + + assert!(done); + assert!(parser.has_tool_calls()); + + let tool_calls = parser.finalize_tool_calls(); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].id, "call_123"); + assert_eq!(tool_calls[0].function.name, "bash"); + assert_eq!(tool_calls[0].function.arguments, r#"{"command":"ls -la"}"#); + } + + #[test] + fn test_usage() { + let mut parser = OpenAISSEParser::new(); + + let data = r#"{"choices":[{"delta":{"content":"Hi"}}],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#; + let (text, _, usage) = parser.parse_data(data); + + assert_eq!(text, Some("Hi".to_string())); + assert!(usage.is_some()); + let usage = usage.unwrap(); + assert_eq!(usage.input_tokens, 10); + assert_eq!(usage.output_tokens, 5); + } + + #[test] + fn test_done_signal() { + let mut parser = OpenAISSEParser::new(); + + let (_, done, _) = parser.parse_data("[DONE]"); + assert!(done); + } +} diff --git a/src-tauri/src/agent/protocols/anthropic.rs b/src-tauri/src/agent/protocols/anthropic.rs new file mode 100644 index 000000000..3b28e79a8 --- /dev/null +++ b/src-tauri/src/agent/protocols/anthropic.rs @@ -0,0 +1,491 @@ +//! Anthropic 协议实现 +//! +//! 实现 Anthropic Messages API 协议 +//! 适用于 Claude、Claude OAuth 等 Anthropic 服务 + +use super::Protocol; +use crate::agent::parsers::AnthropicSSEParser; +use crate::agent::types::{ + AgentConfig, AgentMessage, ContentPart, ImageData, MessageContent, StreamEvent, StreamResult, +}; +use crate::models::anthropic::AnthropicMessage; +use crate::models::openai::Tool; +use async_trait::async_trait; +use futures::StreamExt; +use reqwest::Client; +use serde::Serialize; +use tokio::sync::mpsc; +use tracing::{debug, error, info}; + +/// Anthropic Messages API 请求 +#[derive(Debug, Serialize)] +struct AnthropicMessagesRequest { + model: String, + messages: Vec, + max_tokens: u32, + stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + system: Option, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, +} + +/// Anthropic 工具定义 +#[derive(Debug, Serialize)] +struct AnthropicTool { + name: String, + description: String, + input_schema: serde_json::Value, +} + +/// Anthropic 协议处理器 +pub struct AnthropicProtocol; + +impl AnthropicProtocol { + /// 将 OpenAI Tool 转换为 Anthropic Tool + fn convert_tools(tools: Option<&[Tool]>) -> Option> { + tools.map(|t| { + t.iter() + .filter_map(|tool| match tool { + Tool::Function { function } => Some(AnthropicTool { + name: function.name.clone(), + description: function.description.clone().unwrap_or_default(), + input_schema: function.parameters.clone().unwrap_or(serde_json::json!({ + "type": "object", + "properties": {} + })), + }), + // WebSearch 工具不支持转换为 Anthropic 格式,跳过 + Tool::WebSearch | Tool::WebSearch20250305 => None, + }) + .collect() + }) + } + + /// 将 AgentMessage 转换为 Anthropic Message + fn convert_to_anthropic_message(msg: &AgentMessage) -> AnthropicMessage { + let content = match &msg.content { + MessageContent::Text(text) => { + // 处理工具结果消息 + if msg.role == "tool" { + // Anthropic 使用 tool_result content block + if let Some(tool_call_id) = &msg.tool_call_id { + serde_json::json!([{ + "type": "tool_result", + "tool_use_id": tool_call_id, + "content": text + }]) + } else { + serde_json::json!(text) + } + } else if msg.role == "assistant" { + // 处理 assistant 消息 + let mut blocks = Vec::new(); + + if !text.is_empty() { + blocks.push(serde_json::json!({ + "type": "text", + "text": text + })); + } + + // 添加工具调用 + if let Some(tool_calls) = &msg.tool_calls { + for tc in tool_calls { + let input: serde_json::Value = + serde_json::from_str(&tc.function.arguments) + .unwrap_or(serde_json::json!({})); + blocks.push(serde_json::json!({ + "type": "tool_use", + "id": tc.id, + "name": tc.function.name, + "input": input + })); + } + } + + if blocks.is_empty() { + serde_json::json!("") + } else if blocks.len() == 1 && msg.tool_calls.is_none() { + serde_json::json!(text) + } else { + serde_json::json!(blocks) + } + } else { + serde_json::json!(text) + } + } + MessageContent::Parts(parts) => { + let blocks: Vec = parts + .iter() + .map(|p| match p { + ContentPart::Text { text } => serde_json::json!({ + "type": "text", + "text": text + }), + ContentPart::ImageUrl { image_url } => { + // 解析 data URL + if let Some(rest) = image_url.url.strip_prefix("data:") { + if let Some(comma_idx) = rest.find(',') { + let media_type = rest[..comma_idx] + .strip_suffix(";base64") + .unwrap_or(&rest[..comma_idx]); + let data = &rest[comma_idx + 1..]; + return serde_json::json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": data + } + }); + } + } + // 普通 URL + serde_json::json!({ + "type": "image", + "source": { + "type": "url", + "url": image_url.url + } + }) + } + }) + .collect(); + serde_json::json!(blocks) + } + }; + + // Anthropic 没有 "tool" 角色,需要转换为 "user" + let role = if msg.role == "tool" { + "user".to_string() + } else { + msg.role.clone() + }; + + AnthropicMessage { role, content } + } + + /// 构建消息列表 + fn build_messages( + history: &[AgentMessage], + user_message: &str, + images: Option<&[ImageData]>, + config: &AgentConfig, + ) -> (Vec, Option) { + let mut messages = Vec::new(); + + // 系统提示词(Anthropic 使用单独的 system 字段) + let system_prompt = config.system_prompt.as_ref().map(|s| serde_json::json!(s)); + + // 添加历史消息(跳过 system 消息) + for msg in history { + if msg.role == "system" { + continue; + } + messages.push(Self::convert_to_anthropic_message(msg)); + } + + // 添加当前用户消息 + let user_content = if let Some(imgs) = images { + let mut parts = vec![serde_json::json!({ + "type": "text", + "text": user_message + })]; + + for img in imgs { + parts.push(serde_json::json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": img.media_type, + "data": img.data + } + })); + } + serde_json::json!(parts) + } else { + serde_json::json!(user_message) + }; + + messages.push(AnthropicMessage { + role: "user".to_string(), + content: user_content, + }); + + (messages, system_prompt) + } + + /// 从历史构建消息(不添加新用户消息) + fn build_messages_from_history( + history: &[AgentMessage], + config: &AgentConfig, + ) -> (Vec, Option) { + let mut messages = Vec::new(); + + // 系统提示词 + let system_prompt = config.system_prompt.as_ref().map(|s| serde_json::json!(s)); + + // 添加所有历史消息(跳过 system) + for msg in history { + if msg.role == "system" { + continue; + } + messages.push(Self::convert_to_anthropic_message(msg)); + } + + (messages, system_prompt) + } + + /// 处理 SSE 流 + async fn process_stream( + response: reqwest::Response, + tx: mpsc::Sender, + send_done: bool, + ) -> Result { + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + let mut parser = AnthropicSSEParser::new(); + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + let text = String::from_utf8_lossy(&bytes); + buffer.push_str(&text); + + // 处理完整的 SSE 事件 + while let Some(pos) = buffer.find("\n\n") { + let event_block = buffer[..pos].to_string(); + buffer = buffer[pos + 2..].to_string(); + + // 提取 event 类型和 data + let mut event_type = String::new(); + let mut data = String::new(); + + for line in event_block.lines() { + if let Some(e) = line.strip_prefix("event: ") { + event_type = e.to_string(); + } else if let Some(d) = line.strip_prefix("data: ") { + data = d.to_string(); + } + } + + if data.is_empty() { + continue; + } + + debug!( + "[AnthropicProtocol] SSE event={}, data={}", + event_type, data + ); + let result = parser.parse_data(&data); + + // 发送工具开始事件 + if let Some((tool_id, tool_name)) = result.tool_start { + let _ = tx + .send(StreamEvent::ToolStart { + tool_name, + tool_id, + arguments: None, + }) + .await; + } + + // 发送文本增量 + if let Some(text) = result.text_delta { + let _ = tx.send(StreamEvent::TextDelta { text }).await; + } + + // 检查是否完成 + if result.is_done { + let full_content = parser.get_full_content(); + let tool_calls = if parser.has_tool_calls() { + Some(parser.finalize_tool_calls()) + } else { + None + }; + let usage = parser.get_usage(); + + if send_done { + let _ = tx + .send(StreamEvent::Done { + usage: usage.clone(), + }) + .await; + } + + return Ok(StreamResult { + content: full_content, + tool_calls, + usage, + }); + } + } + } + Err(e) => { + error!("[AnthropicProtocol] 流读取错误: {}", e); + let _ = tx + .send(StreamEvent::Error { + message: format!("流读取错误: {}", e), + }) + .await; + return Err(format!("流读取错误: {}", e)); + } + } + } + + // 流正常结束 + let full_content = parser.get_full_content(); + let tool_calls = if parser.has_tool_calls() { + Some(parser.finalize_tool_calls()) + } else { + None + }; + let usage = parser.get_usage(); + + if send_done { + let _ = tx + .send(StreamEvent::Done { + usage: usage.clone(), + }) + .await; + } + + Ok(StreamResult { + content: full_content, + tool_calls, + usage, + }) + } +} + +#[async_trait] +impl Protocol for AnthropicProtocol { + async fn chat_stream( + &self, + client: &Client, + base_url: &str, + api_key: &str, + messages: &[AgentMessage], + user_message: &str, + images: Option<&[ImageData]>, + model: &str, + config: &AgentConfig, + tools: Option<&[Tool]>, + tx: mpsc::Sender, + ) -> Result { + info!( + "[AnthropicProtocol] 发送流式请求: model={}, history_len={}, tools_count={}", + model, + messages.len(), + tools.map(|t| t.len()).unwrap_or(0) + ); + + let (anthropic_messages, system) = + Self::build_messages(messages, user_message, images, config); + + let anthropic_tools = Self::convert_tools(tools); + + let request = AnthropicMessagesRequest { + model: model.to_string(), + messages: anthropic_messages, + max_tokens: config.max_tokens.unwrap_or(4096), + stream: true, + system, + temperature: config.temperature, + tools: anthropic_tools, + }; + + let url = format!("{}{}", base_url, self.endpoint()); + + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .header("anthropic-version", "2023-06-01") + .json(&request) + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + error!("[AnthropicProtocol] 请求失败: {} - {}", status, body); + let _ = tx + .send(StreamEvent::Error { + message: format!("API 错误 ({}): {}", status, body), + }) + .await; + return Err(format!("API 错误: {}", status)); + } + + Self::process_stream(response, tx, true).await + } + + async fn chat_stream_continue( + &self, + client: &Client, + base_url: &str, + api_key: &str, + messages: &[AgentMessage], + model: &str, + config: &AgentConfig, + tools: Option<&[Tool]>, + tx: mpsc::Sender, + ) -> Result { + debug!( + "[AnthropicProtocol] 继续流式对话: model={}, history_len={}, tools_count={}", + model, + messages.len(), + tools.map(|t| t.len()).unwrap_or(0) + ); + + let (anthropic_messages, system) = Self::build_messages_from_history(messages, config); + + let anthropic_tools = Self::convert_tools(tools); + + let request = AnthropicMessagesRequest { + model: model.to_string(), + messages: anthropic_messages, + max_tokens: config.max_tokens.unwrap_or(4096), + stream: true, + system, + temperature: config.temperature, + tools: anthropic_tools, + }; + + let url = format!("{}{}", base_url, self.endpoint()); + + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .header("anthropic-version", "2023-06-01") + .json(&request) + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + error!("[AnthropicProtocol] 请求失败: {} - {}", status, body); + let _ = tx + .send(StreamEvent::Error { + message: format!("API 错误 ({}): {}", status, body), + }) + .await; + return Err(format!("API 错误: {}", status)); + } + + // 继续对话时不发送 Done 事件 + Self::process_stream(response, tx, false).await + } + + fn endpoint(&self) -> &'static str { + "/v1/messages" + } +} diff --git a/src-tauri/src/agent/protocols/mod.rs b/src-tauri/src/agent/protocols/mod.rs new file mode 100644 index 000000000..1674985bf --- /dev/null +++ b/src-tauri/src/agent/protocols/mod.rs @@ -0,0 +1,70 @@ +//! 协议策略模块 +//! +//! 使用策略模式处理不同 API 协议(OpenAI、Anthropic、Kiro、Gemini) + +mod anthropic; +mod openai; + +pub use anthropic::AnthropicProtocol; +pub use openai::OpenAIProtocol; + +use crate::agent::types::{ + AgentConfig, AgentMessage, ImageData, ProviderType, StreamEvent, StreamResult, +}; +use crate::models::openai::Tool; +use async_trait::async_trait; +use reqwest::Client; +use tokio::sync::mpsc; + +/// 协议处理器 trait +/// +/// 定义了所有协议必须实现的方法 +#[async_trait] +pub trait Protocol: Send + Sync { + /// 流式聊天 + /// + /// 发送消息并通过 channel 返回流式响应 + async fn chat_stream( + &self, + client: &Client, + base_url: &str, + api_key: &str, + messages: &[AgentMessage], + user_message: &str, + images: Option<&[ImageData]>, + model: &str, + config: &AgentConfig, + tools: Option<&[Tool]>, + tx: mpsc::Sender, + ) -> Result; + + /// 继续流式对话(工具调用后) + /// + /// 使用会话历史继续对话,不添加新的用户消息 + async fn chat_stream_continue( + &self, + client: &Client, + base_url: &str, + api_key: &str, + messages: &[AgentMessage], + model: &str, + config: &AgentConfig, + tools: Option<&[Tool]>, + tx: mpsc::Sender, + ) -> Result; + + /// 获取 API 端点 + fn endpoint(&self) -> &'static str; +} + +/// 根据 ProviderType 创建协议处理器 +pub fn create_protocol(provider_type: ProviderType) -> Box { + match provider_type { + // Claude 和 Kiro 都使用 Anthropic SSE 协议 + ProviderType::Claude | ProviderType::ClaudeOauth | ProviderType::Kiro => { + Box::new(AnthropicProtocol) + } + // 其他使用 OpenAI 兼容协议 + _ => Box::new(OpenAIProtocol), + } +} diff --git a/src-tauri/src/agent/protocols/openai.rs b/src-tauri/src/agent/protocols/openai.rs new file mode 100644 index 000000000..7ac82b4bf --- /dev/null +++ b/src-tauri/src/agent/protocols/openai.rs @@ -0,0 +1,380 @@ +//! OpenAI 协议实现 +//! +//! 实现 OpenAI Chat Completions API 协议 +//! 适用于 OpenAI、Qwen、Codex、Antigravity、IFlow、Kiro 等兼容服务 + +use super::Protocol; +use crate::agent::parsers::OpenAISSEParser; +use crate::agent::types::{ + AgentConfig, AgentMessage, ContentPart, ImageData, MessageContent, StreamEvent, StreamResult, +}; +use crate::models::openai::{ + ChatCompletionRequest, ChatMessage, ContentPart as OpenAIContentPart, + MessageContent as OpenAIMessageContent, Tool, +}; +use async_trait::async_trait; +use futures::StreamExt; +use reqwest::Client; +use tokio::sync::mpsc; +use tracing::{debug, error, info}; + +/// OpenAI 协议处理器 +pub struct OpenAIProtocol; + +impl OpenAIProtocol { + /// 将 AgentMessage 转换为 OpenAI ChatMessage + fn convert_to_chat_message(msg: &AgentMessage) -> ChatMessage { + let content = match &msg.content { + MessageContent::Text(text) => Some(OpenAIMessageContent::Text(text.clone())), + MessageContent::Parts(parts) => { + let openai_parts: Vec = parts + .iter() + .map(|p| match p { + ContentPart::Text { text } => { + OpenAIContentPart::Text { text: text.clone() } + } + ContentPart::ImageUrl { image_url } => OpenAIContentPart::ImageUrl { + image_url: crate::models::openai::ImageUrl { + url: image_url.url.clone(), + detail: image_url.detail.clone(), + }, + }, + }) + .collect(); + Some(OpenAIMessageContent::Parts(openai_parts)) + } + }; + + ChatMessage { + role: msg.role.clone(), + content, + tool_calls: msg.tool_calls.as_ref().map(|calls| { + calls + .iter() + .map(|tc| crate::models::openai::ToolCall { + id: tc.id.clone(), + call_type: tc.call_type.clone(), + function: crate::models::openai::FunctionCall { + name: tc.function.name.clone(), + arguments: tc.function.arguments.clone(), + }, + }) + .collect() + }), + tool_call_id: msg.tool_call_id.clone(), + } + } + + /// 构建消息列表 + fn build_messages( + history: &[AgentMessage], + user_message: &str, + images: Option<&[ImageData]>, + config: &AgentConfig, + ) -> Vec { + let mut messages = Vec::new(); + + // 添加系统提示词 + if let Some(prompt) = &config.system_prompt { + messages.push(ChatMessage { + role: "system".to_string(), + content: Some(OpenAIMessageContent::Text(prompt.clone())), + tool_calls: None, + tool_call_id: None, + }); + } + + // 添加历史消息 + for msg in history { + messages.push(Self::convert_to_chat_message(msg)); + } + + // 添加当前用户消息 + let user_msg = if let Some(imgs) = images { + let mut parts = vec![OpenAIContentPart::Text { + text: user_message.to_string(), + }]; + + for img in imgs { + parts.push(OpenAIContentPart::ImageUrl { + image_url: crate::models::openai::ImageUrl { + url: format!("data:{};base64,{}", img.media_type, img.data), + detail: None, + }, + }); + } + + ChatMessage { + role: "user".to_string(), + content: Some(OpenAIMessageContent::Parts(parts)), + tool_calls: None, + tool_call_id: None, + } + } else { + ChatMessage { + role: "user".to_string(), + content: Some(OpenAIMessageContent::Text(user_message.to_string())), + tool_calls: None, + tool_call_id: None, + } + }; + + messages.push(user_msg); + messages + } + + /// 从历史构建消息(不添加新用户消息) + fn build_messages_from_history( + history: &[AgentMessage], + config: &AgentConfig, + ) -> Vec { + let mut messages = Vec::new(); + + // 添加系统提示词 + if let Some(prompt) = &config.system_prompt { + messages.push(ChatMessage { + role: "system".to_string(), + content: Some(OpenAIMessageContent::Text(prompt.clone())), + tool_calls: None, + tool_call_id: None, + }); + } + + // 添加所有历史消息 + for msg in history { + messages.push(Self::convert_to_chat_message(msg)); + } + + messages + } + + /// 处理 SSE 流 + async fn process_stream( + response: reqwest::Response, + tx: mpsc::Sender, + send_done: bool, + ) -> Result { + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + let mut parser = OpenAISSEParser::new(); + let mut final_usage = None; + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + let text = String::from_utf8_lossy(&bytes); + buffer.push_str(&text); + + // 处理完整的 SSE 事件(以 \n\n 分隔) + while let Some(pos) = buffer.find("\n\n") { + let event = buffer[..pos].to_string(); + buffer = buffer[pos + 2..].to_string(); + + for line in event.lines() { + if let Some(data) = line.strip_prefix("data: ") { + debug!("[OpenAIProtocol] SSE data: {}", data); + let (text_delta, is_done, usage) = parser.parse_data(data); + + if usage.is_some() { + final_usage = usage; + } + + if let Some(text) = text_delta { + let _ = tx.send(StreamEvent::TextDelta { text }).await; + } + + if is_done { + let full_content = parser.get_full_content(); + let tool_calls = if parser.has_tool_calls() { + Some(parser.finalize_tool_calls()) + } else { + None + }; + + if send_done { + let _ = tx + .send(StreamEvent::Done { + usage: final_usage.clone(), + }) + .await; + } + + return Ok(StreamResult { + content: full_content, + tool_calls, + usage: final_usage, + }); + } + } + } + } + } + Err(e) => { + error!("[OpenAIProtocol] 流读取错误: {}", e); + let _ = tx + .send(StreamEvent::Error { + message: format!("流读取错误: {}", e), + }) + .await; + return Err(format!("流读取错误: {}", e)); + } + } + } + + // 流正常结束但没有收到 [DONE] + let full_content = parser.get_full_content(); + let tool_calls = if parser.has_tool_calls() { + Some(parser.finalize_tool_calls()) + } else { + None + }; + + if send_done { + let _ = tx + .send(StreamEvent::Done { + usage: final_usage.clone(), + }) + .await; + } + + Ok(StreamResult { + content: full_content, + tool_calls, + usage: final_usage, + }) + } +} + +#[async_trait] +impl Protocol for OpenAIProtocol { + async fn chat_stream( + &self, + client: &Client, + base_url: &str, + api_key: &str, + messages: &[AgentMessage], + user_message: &str, + images: Option<&[ImageData]>, + model: &str, + config: &AgentConfig, + tools: Option<&[Tool]>, + tx: mpsc::Sender, + ) -> Result { + info!( + "[OpenAIProtocol] 发送流式请求: model={}, history_len={}, tools_count={}", + model, + messages.len(), + tools.map(|t| t.len()).unwrap_or(0) + ); + + let chat_messages = Self::build_messages(messages, user_message, images, config); + + let request = ChatCompletionRequest { + model: model.to_string(), + messages: chat_messages, + stream: true, + temperature: config.temperature, + max_tokens: config.max_tokens, + top_p: None, + tools: tools.map(|t| t.to_vec()), + tool_choice: if tools.is_some() { + Some(serde_json::json!("auto")) + } else { + None + }, + reasoning_effort: None, + }; + + let url = format!("{}{}", base_url, self.endpoint()); + + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + error!("[OpenAIProtocol] 请求失败: {} - {}", status, body); + let _ = tx + .send(StreamEvent::Error { + message: format!("API 错误 ({}): {}", status, body), + }) + .await; + return Err(format!("API 错误: {}", status)); + } + + Self::process_stream(response, tx, true).await + } + + async fn chat_stream_continue( + &self, + client: &Client, + base_url: &str, + api_key: &str, + messages: &[AgentMessage], + model: &str, + config: &AgentConfig, + tools: Option<&[Tool]>, + tx: mpsc::Sender, + ) -> Result { + debug!( + "[OpenAIProtocol] 继续流式对话: model={}, history_len={}, tools_count={}", + model, + messages.len(), + tools.map(|t| t.len()).unwrap_or(0) + ); + + let chat_messages = Self::build_messages_from_history(messages, config); + + let request = ChatCompletionRequest { + model: model.to_string(), + messages: chat_messages, + stream: true, + temperature: config.temperature, + max_tokens: config.max_tokens, + top_p: None, + tools: tools.map(|t| t.to_vec()), + tool_choice: if tools.is_some() { + Some(serde_json::json!("auto")) + } else { + None + }, + reasoning_effort: None, + }; + + let url = format!("{}{}", base_url, self.endpoint()); + + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + error!("[OpenAIProtocol] 请求失败: {} - {}", status, body); + let _ = tx + .send(StreamEvent::Error { + message: format!("API 错误 ({}): {}", status, body), + }) + .await; + return Err(format!("API 错误: {}", status)); + } + + // 继续对话时不发送 Done 事件(工具循环可能还会继续) + Self::process_stream(response, tx, false).await + } + + fn endpoint(&self) -> &'static str { + "/v1/chat/completions" + } +} diff --git a/src-tauri/src/agent/tool_loop.rs b/src-tauri/src/agent/tool_loop.rs index 597f70e03..51dca0072 100644 --- a/src-tauri/src/agent/tool_loop.rs +++ b/src-tauri/src/agent/tool_loop.rs @@ -150,6 +150,11 @@ impl ToolLoopEngine { self.config.max_iterations } + /// 获取工具注册表引用 + pub fn registry(&self) -> &ToolRegistry { + &self.registry + } + /// 检查响应是否包含工具调用 /// /// Requirements: 7.1 - WHEN the Agent response contains tool_calls @@ -227,6 +232,7 @@ impl ToolLoopEngine { .send(StreamEvent::ToolStart { tool_name: tool_call.function.name.clone(), tool_id: tool_call.id.clone(), + arguments: Some(tool_call.function.arguments.clone()), }) .await; } @@ -891,7 +897,7 @@ mod proptests { // 验证:收到 ToolStart 事件 let event1 = rx.recv().await; prop_assert!(event1.is_some(), "应该收到 ToolStart 事件"); - if let Some(StreamEvent::ToolStart { tool_name, tool_id: event_tool_id }) = event1 { + if let Some(StreamEvent::ToolStart { tool_name, tool_id: event_tool_id, .. }) = event1 { prop_assert_eq!(tool_name, "echo", "工具名称应该为 'echo'"); prop_assert_eq!(event_tool_id, tool_id.clone(), "工具 ID 应该匹配"); } else { diff --git a/src-tauri/src/agent/tools/mod.rs b/src-tauri/src/agent/tools/mod.rs index d3f82fa2b..2b0dba6ab 100644 --- a/src-tauri/src/agent/tools/mod.rs +++ b/src-tauri/src/agent/tools/mod.rs @@ -30,3 +30,44 @@ pub use registry::{Tool, ToolRegistry}; pub use security::{SecurityError, SecurityManager}; pub use types::*; pub use write_file::{WriteFileResult, WriteFileTool}; + +use std::path::Path; +use std::sync::Arc; +use tracing::info; + +/// 创建包含所有默认工具的注册表 +/// +/// # Arguments +/// * `base_dir` - 基础目录,所有文件操作必须在此目录内 +/// +/// # Returns +/// 包含 bash, read_file, write_file, edit_file 工具的注册表 +pub fn create_default_registry(base_dir: impl AsRef) -> ToolRegistry { + let security = Arc::new(SecurityManager::new(base_dir.as_ref())); + let registry = ToolRegistry::new(); + + // 注册核心工具 + if let Err(e) = registry.register(BashTool::new(Arc::clone(&security))) { + tracing::error!("注册 BashTool 失败: {}", e); + } + + if let Err(e) = registry.register(ReadFileTool::new(Arc::clone(&security))) { + tracing::error!("注册 ReadFileTool 失败: {}", e); + } + + if let Err(e) = registry.register(WriteFileTool::new(Arc::clone(&security))) { + tracing::error!("注册 WriteFileTool 失败: {}", e); + } + + if let Err(e) = registry.register(EditFileTool::new(Arc::clone(&security))) { + tracing::error!("注册 EditFileTool 失败: {}", e); + } + + info!( + "[Tools] 已创建默认工具注册表,共 {} 个工具: {:?}", + registry.len(), + registry.list_names() + ); + + registry +} diff --git a/src-tauri/src/agent/tools/prompt.rs b/src-tauri/src/agent/tools/prompt.rs index 91fa21b97..ad2c6e90f 100644 --- a/src-tauri/src/agent/tools/prompt.rs +++ b/src-tauri/src/agent/tools/prompt.rs @@ -49,11 +49,18 @@ impl ToolPromptGenerator { self } - /// 生成包含工具定义的 System Prompt + /// 生成包含工具使用指导的 System Prompt /// - /// Requirements: 2.3 - THE System_Prompt SHALL include all available tool definitions - /// in a format the LLM can understand - pub fn generate_system_prompt(&self, tools: &[ToolDefinition]) -> String { + /// 注意:工具定义已通过 API 的 tools 字段发送,不需要在 system prompt 中重复 + /// 此方法只返回工具使用指导 + pub fn generate_system_prompt(&self, _tools: &[ToolDefinition]) -> String { + // 只返回使用指导,工具定义由 API 原生处理 + TOOL_USAGE_INSTRUCTIONS.to_string() + } + + /// 生成包含工具定义的完整 System Prompt(旧版本,保留兼容性) + #[allow(dead_code)] + pub fn generate_full_system_prompt(&self, tools: &[ToolDefinition]) -> String { match self.format { PromptFormat::Xml => self.generate_xml_prompt(tools), PromptFormat::Json => self.generate_json_prompt(tools), @@ -177,25 +184,38 @@ impl ToolPromptGenerator { } } -/// 工具使用说明模板 -const TOOL_USAGE_INSTRUCTIONS: &str = r#"You have access to a set of tools that you can use to help accomplish tasks. When you need to use a tool, respond with a tool call in the following format: +/// 工具使用说明模板(适合桌面软件) +const TOOL_USAGE_INSTRUCTIONS: &str = r#"你是一个友好的 AI 助手。 - -tool_name - -{ - "param1": "value1", - "param2": "value2" -} - - +# 核心原则 -Important guidelines for tool usage: -1. Only use tools when necessary to accomplish the task -2. Provide all required parameters for each tool call -3. Wait for tool results before making additional tool calls that depend on them -4. If a tool call fails, analyze the error and try an alternative approach -5. Always explain your reasoning before and after using tools"#; +1. **自然交流**:对于问候、闲聊、问答,直接用文字回复,不要调用任何工具 +2. **显式授权**:只有当用户**明确提供**文件路径或目录时,才能操作 +3. **不要主动探索**:不要自作主张读取目录或文件来"了解环境" + +# 可用工具 + +- **read_file**:读取用户指定的文件或目录 +- **write_file**:创建/覆盖用户指定的文件 +- **edit_file**:修改用户指定的文件 +- **bash**:执行用户要求的命令 + +# 重要限制 + +⚠️ **禁止行为**: +- 用户说"你好"时,不要读取任何文件 +- 用户没有给路径时,不要自己猜测或使用 "." +- 不要为了"打招呼"或"了解用户"而调用工具 + +✅ **正确做法**: +- 用户说"你好" → 直接回复问候 +- 用户说"看看 /path/to/file" → 调用 read_file +- 用户说"列出目录内容" → 询问用户要查看哪个目录 + +# 输出格式 +- 使用 Markdown 格式 +- 简洁明了 +- 使用中文回复"#; /// XML 特殊字符转义 fn escape_xml(s: &str) -> String { @@ -305,13 +325,25 @@ mod tests { } #[test] - fn test_generate_xml_prompt() { - let generator = ToolPromptGenerator::new().with_format(PromptFormat::Xml); + fn test_generate_system_prompt() { + let generator = ToolPromptGenerator::new(); let tools = create_test_tools(); let prompt = generator.generate_system_prompt(&tools); + // 验证包含工具使用说明(新版本只返回指导,不包含工具定义) + assert!(prompt.contains("你是一个友好的 AI 助手")); + assert!(prompt.contains("可用工具")); + assert!(prompt.contains("read_file")); // 在说明中提到 + } + + #[test] + fn test_generate_full_xml_prompt() { + let generator = ToolPromptGenerator::new().with_format(PromptFormat::Xml); + let tools = create_test_tools(); + let prompt = generator.generate_full_system_prompt(&tools); + // 验证包含工具使用说明 - assert!(prompt.contains("You have access to a set of tools")); + assert!(prompt.contains("可用工具")); // 验证包含 tools 标签 assert!(prompt.contains("")); assert!(prompt.contains("")); @@ -321,13 +353,13 @@ mod tests { } #[test] - fn test_generate_json_prompt() { + fn test_generate_full_json_prompt() { let generator = ToolPromptGenerator::new().with_format(PromptFormat::Json); let tools = create_test_tools(); - let prompt = generator.generate_system_prompt(&tools); + let prompt = generator.generate_full_system_prompt(&tools); // 验证包含工具使用说明 - assert!(prompt.contains("You have access to a set of tools")); + assert!(prompt.contains("可用工具")); // 验证包含 JSON 代码块 assert!(prompt.contains("```json")); // 验证包含所有工具 @@ -339,11 +371,9 @@ mod tests { fn test_generate_tools_prompt_convenience_function() { let tools = create_test_tools(); - let xml_prompt = generate_tools_prompt(&tools, PromptFormat::Xml); - assert!(xml_prompt.contains("")); - - let json_prompt = generate_tools_prompt(&tools, PromptFormat::Json); - assert!(json_prompt.contains("```json")); + // generate_tools_prompt 使用 generate_system_prompt,只返回指导 + let prompt = generate_tools_prompt(&tools, PromptFormat::Xml); + assert!(prompt.contains("可用工具")); } #[test] @@ -361,9 +391,8 @@ mod tests { let prompt = generator.generate_system_prompt(&[]); // 即使没有工具,也应该包含使用说明 - assert!(prompt.contains("You have access to a set of tools")); - assert!(prompt.contains("")); - assert!(prompt.contains("")); + assert!(prompt.contains("你是一个友好的 AI 助手")); + assert!(prompt.contains("可用工具")); } #[test] @@ -399,7 +428,7 @@ mod tests { } #[test] - fn test_prompt_contains_all_tool_names_and_descriptions() { + fn test_full_prompt_contains_all_tool_names_and_descriptions() { let tools = vec![ ToolDefinition::new("tool_a", "Description for tool A"), ToolDefinition::new("tool_b", "Description for tool B"), @@ -407,7 +436,8 @@ mod tests { ]; let generator = ToolPromptGenerator::new(); - let prompt = generator.generate_system_prompt(&tools); + // 使用 generate_full_system_prompt 来包含工具定义 + let prompt = generator.generate_full_system_prompt(&tools); // 验证所有工具名称都在 prompt 中 for tool in &tools { @@ -494,14 +524,14 @@ mod proptests { proptest! { #![proptest_config(ProptestConfig::with_cases(100))] - /// **Feature: agent-tool-calling, Property 3: System Prompt 工具包含** + /// **Feature: agent-tool-calling, Property 3: Full System Prompt 工具包含** /// **Validates: Requirements 2.3** /// - /// *For any* 已注册的工具集合,生成的 System Prompt 应该包含所有工具的 name 和 description。 + /// *For any* 已注册的工具集合,生成的完整 System Prompt 应该包含所有工具的 name 和 description。 #[test] - fn prop_system_prompt_contains_all_tool_names(tools in arb_tool_definitions()) { + fn prop_full_system_prompt_contains_all_tool_names(tools in arb_tool_definitions()) { let generator = ToolPromptGenerator::new().with_format(PromptFormat::Xml); - let prompt = generator.generate_system_prompt(&tools); + let prompt = generator.generate_full_system_prompt(&tools); // 验证所有工具名称都在 prompt 中 for tool in &tools { @@ -514,14 +544,14 @@ mod proptests { } } - /// **Feature: agent-tool-calling, Property 3: System Prompt 工具包含 - 描述** + /// **Feature: agent-tool-calling, Property 3: Full System Prompt 工具包含 - 描述** /// **Validates: Requirements 2.3** /// - /// *For any* 已注册的工具集合,生成的 System Prompt 应该包含所有工具的 description。 + /// *For any* 已注册的工具集合,生成的完整 System Prompt 应该包含所有工具的 description。 #[test] - fn prop_system_prompt_contains_all_tool_descriptions(tools in arb_tool_definitions()) { + fn prop_full_system_prompt_contains_all_tool_descriptions(tools in arb_tool_definitions()) { let generator = ToolPromptGenerator::new().with_format(PromptFormat::Xml); - let prompt = generator.generate_system_prompt(&tools); + let prompt = generator.generate_full_system_prompt(&tools); // 验证所有工具描述都在 prompt 中 for tool in &tools { @@ -534,14 +564,14 @@ mod proptests { } } - /// **Feature: agent-tool-calling, Property 3: System Prompt 工具包含 - JSON 格式** + /// **Feature: agent-tool-calling, Property 3: Full System Prompt 工具包含 - JSON 格式** /// **Validates: Requirements 2.3** /// - /// *For any* 已注册的工具集合,JSON 格式的 System Prompt 也应该包含所有工具的 name 和 description。 + /// *For any* 已注册的工具集合,JSON 格式的完整 System Prompt 也应该包含所有工具的 name 和 description。 #[test] - fn prop_system_prompt_json_contains_all_tools(tools in arb_tool_definitions()) { + fn prop_full_system_prompt_json_contains_all_tools(tools in arb_tool_definitions()) { let generator = ToolPromptGenerator::new().with_format(PromptFormat::Json); - let prompt = generator.generate_system_prompt(&tools); + let prompt = generator.generate_full_system_prompt(&tools); // 验证所有工具名称和描述都在 prompt 中 for tool in &tools { @@ -560,18 +590,18 @@ mod proptests { } } - /// **Feature: agent-tool-calling, Property 3: System Prompt 工具包含 - 格式一致性** + /// **Feature: agent-tool-calling, Property 3: Full System Prompt 工具包含 - 格式一致性** /// **Validates: Requirements 2.3** /// /// *For any* 已注册的工具集合,无论使用 XML 还是 JSON 格式, - /// 生成的 System Prompt 都应该包含相同的工具信息。 + /// 生成的完整 System Prompt 都应该包含相同的工具信息。 #[test] - fn prop_system_prompt_format_consistency(tools in arb_tool_definitions()) { + fn prop_full_system_prompt_format_consistency(tools in arb_tool_definitions()) { let xml_generator = ToolPromptGenerator::new().with_format(PromptFormat::Xml); let json_generator = ToolPromptGenerator::new().with_format(PromptFormat::Json); - let xml_prompt = xml_generator.generate_system_prompt(&tools); - let json_prompt = json_generator.generate_system_prompt(&tools); + let xml_prompt = xml_generator.generate_full_system_prompt(&tools); + let json_prompt = json_generator.generate_full_system_prompt(&tools); // 两种格式都应该包含所有工具名称和描述 for tool in &tools { @@ -588,15 +618,15 @@ mod proptests { } } - /// **Feature: agent-tool-calling, Property 3: System Prompt 工具包含 - 工具数量** + /// **Feature: agent-tool-calling, Property 3: Full System Prompt 工具包含 - 工具数量** /// **Validates: Requirements 2.3** /// - /// *For any* 已注册的工具集合,生成的 System Prompt 中工具名称出现的次数 + /// *For any* 已注册的工具集合,生成的完整 System Prompt 中工具名称出现的次数 /// 应该至少等于工具数量(每个工具至少出现一次)。 #[test] - fn prop_system_prompt_tool_count(tools in arb_tool_definitions()) { + fn prop_full_system_prompt_tool_count(tools in arb_tool_definitions()) { let generator = ToolPromptGenerator::new().with_format(PromptFormat::Xml); - let prompt = generator.generate_system_prompt(&tools); + let prompt = generator.generate_full_system_prompt(&tools); // 统计每个工具名称在 prompt 中出现的次数 for tool in &tools { diff --git a/src-tauri/src/agent/tools/registry.rs b/src-tauri/src/agent/tools/registry.rs index 6ced93a55..fa1f68ee2 100644 --- a/src-tauri/src/agent/tools/registry.rs +++ b/src-tauri/src/agent/tools/registry.rs @@ -160,6 +160,15 @@ impl ToolRegistry { self.tools.read().values().map(|t| t.definition()).collect() } + /// 获取所有工具定义(OpenAI API 格式) + pub fn list_definitions_api(&self) -> Vec { + self.tools + .read() + .values() + .map(|t| t.definition().to_api_format()) + .collect() + } + /// 获取所有工具名称 pub fn list_names(&self) -> Vec { self.tools.read().keys().cloned().collect() diff --git a/src-tauri/src/agent/tools/types.rs b/src-tauri/src/agent/tools/types.rs index e7085ac6e..c9381c5fe 100644 --- a/src-tauri/src/agent/tools/types.rs +++ b/src-tauri/src/agent/tools/types.rs @@ -48,6 +48,17 @@ impl ToolDefinition { self.parameters.validate()?; Ok(()) } + + /// 转换为 OpenAI API 格式的工具定义 + pub fn to_api_format(&self) -> crate::models::openai::Tool { + crate::models::openai::Tool::Function { + function: crate::models::openai::FunctionDef { + name: self.name.clone(), + description: Some(self.description.clone()), + parameters: Some(serde_json::to_value(&self.parameters).unwrap_or_default()), + }, + } + } } /// JSON Schema 参数定义 diff --git a/src-tauri/src/agent/types.rs b/src-tauri/src/agent/types.rs index 8512832fd..bdf21a727 100644 --- a/src-tauri/src/agent/types.rs +++ b/src-tauri/src/agent/types.rs @@ -5,6 +5,74 @@ use serde::{Deserialize, Serialize}; +/// Provider 类型枚举 +/// +/// 决定使用哪种 API 协议 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ProviderType { + /// Claude (Anthropic 协议) + Claude, + /// Claude OAuth (Anthropic 协议) + ClaudeOauth, + /// Kiro/CodeWhisperer (AWS Event Stream 协议) + Kiro, + /// Gemini (Gemini 协议) + Gemini, + /// OpenAI 及其兼容服务 (默认) + #[default] + OpenAI, + /// 通义千问 (OpenAI 兼容) + Qwen, + /// Codex (OpenAI 兼容) + Codex, + /// Antigravity (OpenAI 兼容) + Antigravity, + /// iFlow (OpenAI 兼容) + IFlow, +} + +impl ProviderType { + /// 从字符串解析 provider 类型 + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "claude" => Self::Claude, + "claude_oauth" => Self::ClaudeOauth, + "kiro" => Self::Kiro, + "gemini" => Self::Gemini, + "openai" => Self::OpenAI, + "qwen" => Self::Qwen, + "codex" => Self::Codex, + "antigravity" => Self::Antigravity, + "iflow" => Self::IFlow, + _ => Self::OpenAI, // 默认使用 OpenAI 协议 + } + } + + /// 获取 API 端点路径 + pub fn endpoint(&self) -> &'static str { + match self { + Self::Claude | Self::ClaudeOauth => "/v1/messages", + Self::Kiro => "/v1/chat/completions", // Kiro 使用 OpenAI 兼容格式,但后端会转换 + Self::Gemini => "/v1/gemini/chat/completions", + _ => "/v1/chat/completions", + } + } + + /// 是否使用 Anthropic 协议 + pub fn is_anthropic(&self) -> bool { + matches!(self, Self::Claude | Self::ClaudeOauth) + } + + /// 是否使用 OpenAI 兼容协议 + pub fn is_openai_compatible(&self) -> bool { + matches!( + self, + Self::OpenAI | Self::Qwen | Self::Codex | Self::Antigravity | Self::IFlow | Self::Kiro + ) + } +} + /// Agent 会话状态 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentSession { @@ -239,6 +307,9 @@ pub enum StreamEvent { tool_name: String, /// 工具调用 ID tool_id: String, + /// 工具参数(JSON 字符串) + #[serde(skip_serializing_if = "Option::is_none")] + arguments: Option, }, /// 工具调用结束 @@ -251,11 +322,16 @@ pub enum StreamEvent { result: ToolExecutionResult, }, - /// 完成 + /// 完成(单次 API 响应完成,工具循环可能继续) /// Requirements: 1.3 - THE Streaming_Handler SHALL emit a done event with token usage statistics #[serde(rename = "done")] Done { usage: Option }, + /// 最终完成(整个对话完成,包括所有工具调用循环) + /// 前端收到此事件后才能取消监听 + #[serde(rename = "final_done")] + FinalDone { usage: Option }, + /// 错误 /// Requirements: 1.4 - IF a streaming error occurs, THEN THE Streaming_Handler SHALL emit an error event #[serde(rename = "error")] diff --git a/src-tauri/src/backends/mod.rs b/src-tauri/src/backends/mod.rs new file mode 100644 index 000000000..8c1690953 --- /dev/null +++ b/src-tauri/src/backends/mod.rs @@ -0,0 +1,43 @@ +//! 后端调用层 +//! +//! 提供与各种 AI 后端服务的 HTTP 通信能力。 +//! 后端层只负责 HTTP 请求/响应,不包含任何协议转换逻辑。 +//! +//! # 架构设计 +//! +//! ```text +//! backends/ +//! ├── traits.rs # Backend trait 定义 +//! ├── kiro.rs # Kiro/CodeWhisperer 后端 (待迁移) +//! ├── codex.rs # Codex 后端 (待迁移) +//! └── claude.rs # Claude API 后端 (待迁移) +//! ``` +//! +//! # 职责说明 +//! +//! - **只做 HTTP 调用**: 构建 HTTP 请求,发送,接收响应 +//! - **不做协议转换**: 协议转换在 translator 层完成 +//! - **处理认证**: 管理 access token,刷新过期凭证 +//! - **处理重试**: 可选的重试逻辑 +//! +//! # 使用示例 +//! +//! ```ignore +//! use proxycast::backends::KiroBackend; +//! use proxycast::backends::traits::Backend; +//! +//! let backend = KiroBackend::new(credentials); +//! let response = backend.call_stream(&cw_request).await?; +//! ``` + +pub mod traits; + +// 重新导出核心类型 +pub use traits::{ + AuthenticatedBackend, Backend, BackendError, BackendErrorKind, BackendResult, ByteStream, +}; + +// TODO: 后续阶段迁移以下后端 +// pub mod kiro; +// pub mod codex; +// pub mod claude; diff --git a/src-tauri/src/backends/traits.rs b/src-tauri/src/backends/traits.rs new file mode 100644 index 000000000..9ef73bf87 --- /dev/null +++ b/src-tauri/src/backends/traits.rs @@ -0,0 +1,194 @@ +//! 后端调用层 Trait 定义 +//! +//! 定义后端 HTTP 调用的核心接口。 +//! 后端层只负责 HTTP 请求/响应,不包含任何协议转换逻辑。 + +use async_trait::async_trait; +use bytes::Bytes; +use futures::Stream; +use std::error::Error; +use std::pin::Pin; + +/// 字节流类型 +pub type ByteStream = + Pin>> + Send>>; + +/// 后端调用结果 +pub type BackendResult = Result; + +/// 后端错误类型 +#[derive(Debug, Clone)] +pub struct BackendError { + /// 错误类型 + pub kind: BackendErrorKind, + /// 错误消息 + pub message: String, + /// HTTP 状态码(如果有) + pub status_code: Option, +} + +impl std::fmt::Display for BackendError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(code) = self.status_code { + write!(f, "{} ({}): {}", self.kind, code, self.message) + } else { + write!(f, "{}: {}", self.kind, self.message) + } + } +} + +impl std::error::Error for BackendError {} + +/// 后端错误类型枚举 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackendErrorKind { + /// 认证错误 + AuthenticationError, + /// 网络错误 + NetworkError, + /// 请求超时 + Timeout, + /// 服务端错误 + ServerError, + /// 请求格式错误 + BadRequest, + /// 速率限制 + RateLimited, + /// 其他错误 + Other, +} + +impl std::fmt::Display for BackendErrorKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AuthenticationError => write!(f, "AuthenticationError"), + Self::NetworkError => write!(f, "NetworkError"), + Self::Timeout => write!(f, "Timeout"), + Self::ServerError => write!(f, "ServerError"), + Self::BadRequest => write!(f, "BadRequest"), + Self::RateLimited => write!(f, "RateLimited"), + Self::Other => write!(f, "Other"), + } + } +} + +impl BackendError { + /// 创建新的后端错误 + pub fn new(kind: BackendErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + status_code: None, + } + } + + /// 带 HTTP 状态码创建错误 + pub fn with_status(kind: BackendErrorKind, message: impl Into, status: u16) -> Self { + Self { + kind, + message: message.into(), + status_code: Some(status), + } + } + + /// 从 HTTP 状态码推断错误类型 + pub fn from_status(status: u16, message: impl Into) -> Self { + let kind = match status { + 401 | 403 => BackendErrorKind::AuthenticationError, + 400 => BackendErrorKind::BadRequest, + 429 => BackendErrorKind::RateLimited, + 500..=599 => BackendErrorKind::ServerError, + _ => BackendErrorKind::Other, + }; + Self::with_status(kind, message, status) + } + + /// 是否可重试 + pub fn is_retryable(&self) -> bool { + matches!( + self.kind, + BackendErrorKind::NetworkError + | BackendErrorKind::Timeout + | BackendErrorKind::ServerError + | BackendErrorKind::RateLimited + ) + } +} + +/// 后端 Trait +/// +/// 定义后端 HTTP 调用的接口。 +#[async_trait] +pub trait Backend: Send + Sync { + /// 后端请求类型 + type Request: Send; + + /// 非流式调用 + /// + /// # 返回 + /// + /// 响应的原始字节 + async fn call(&self, request: &Self::Request) -> BackendResult; + + /// 流式调用 + /// + /// # 返回 + /// + /// 字节流 + async fn call_stream(&self, request: &Self::Request) -> BackendResult; + + /// 获取后端名称 + fn name(&self) -> &str; + + /// 检查后端是否可用 + async fn is_available(&self) -> bool { + true + } +} + +/// 带认证的后端 Trait +#[async_trait] +pub trait AuthenticatedBackend: Backend { + /// 刷新认证凭证 + async fn refresh_credentials(&mut self) -> BackendResult<()>; + + /// 检查凭证是否有效 + fn credentials_valid(&self) -> bool; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_backend_error_display() { + let err = BackendError::new(BackendErrorKind::NetworkError, "connection refused"); + assert_eq!(format!("{}", err), "NetworkError: connection refused"); + + let err = BackendError::with_status(BackendErrorKind::ServerError, "internal error", 500); + assert_eq!(format!("{}", err), "ServerError (500): internal error"); + } + + #[test] + fn test_backend_error_from_status() { + let err = BackendError::from_status(401, "unauthorized"); + assert_eq!(err.kind, BackendErrorKind::AuthenticationError); + assert_eq!(err.status_code, Some(401)); + + let err = BackendError::from_status(429, "too many requests"); + assert_eq!(err.kind, BackendErrorKind::RateLimited); + + let err = BackendError::from_status(503, "service unavailable"); + assert_eq!(err.kind, BackendErrorKind::ServerError); + } + + #[test] + fn test_backend_error_retryable() { + assert!(BackendError::new(BackendErrorKind::NetworkError, "").is_retryable()); + assert!(BackendError::new(BackendErrorKind::Timeout, "").is_retryable()); + assert!(BackendError::new(BackendErrorKind::ServerError, "").is_retryable()); + assert!(BackendError::new(BackendErrorKind::RateLimited, "").is_retryable()); + assert!(!BackendError::new(BackendErrorKind::AuthenticationError, "").is_retryable()); + assert!(!BackendError::new(BackendErrorKind::BadRequest, "").is_retryable()); + } +} diff --git a/src-tauri/src/commands/agent_cmd.rs b/src-tauri/src/commands/agent_cmd.rs index f3eacb70b..fb3f96c0a 100644 --- a/src-tauri/src/commands/agent_cmd.rs +++ b/src-tauri/src/commands/agent_cmd.rs @@ -2,7 +2,7 @@ //! //! 提供原生 Agent 的 Tauri 命令(兼容旧 API) -use crate::agent::{ImageData, NativeAgentState, NativeChatRequest}; +use crate::agent::{ImageData, NativeAgentState, NativeChatRequest, ProviderType}; use crate::AppState; use serde::{Deserialize, Serialize}; use tauri::State; @@ -34,12 +34,13 @@ pub async fn agent_start_process( ) -> Result { tracing::info!("[Agent] 初始化原生 Agent"); - let (port, api_key, running) = { + let (port, api_key, running, default_provider) = { let state = app_state.read().await; ( state.config.server.port, state.running_api_key.clone(), state.running, + state.config.routing.default_provider.clone(), ) }; @@ -49,8 +50,9 @@ pub async fn agent_start_process( let api_key = api_key.ok_or_else(|| "ProxyCast API Server 未配置 API Key".to_string())?; let base_url = format!("http://127.0.0.1:{}", port); + let provider_type = ProviderType::from_str(&default_provider); - agent_state.init(base_url.clone(), api_key)?; + agent_state.init(base_url.clone(), api_key, provider_type)?; Ok(AgentProcessStatus { running: true, @@ -118,12 +120,13 @@ pub async fn agent_create_session( // 如果未初始化,自动初始化 if !agent_state.is_initialized() { - let (port, api_key, running) = { + let (port, api_key, running, default_provider) = { let state = app_state.read().await; ( state.config.server.port, state.running_api_key.clone(), state.running, + state.config.routing.default_provider.clone(), ) }; @@ -133,7 +136,8 @@ pub async fn agent_create_session( let api_key = api_key.ok_or_else(|| "未配置 API Key".to_string())?; let base_url = format!("http://127.0.0.1:{}", port); - agent_state.init(base_url, api_key)?; + let provider_type = ProviderType::from_str(&default_provider); + agent_state.init(base_url, api_key, provider_type)?; } // 构建包含 Skills 的 System Prompt @@ -222,12 +226,13 @@ pub async fn agent_send_message( // 如果未初始化,自动初始化 if !agent_state.is_initialized() { - let (port, api_key, running) = { + let (port, api_key, running, default_provider) = { let state = app_state.read().await; ( state.config.server.port, state.running_api_key.clone(), state.running, + state.config.routing.default_provider.clone(), ) }; @@ -237,7 +242,8 @@ pub async fn agent_send_message( let api_key = api_key.ok_or_else(|| "未配置 API Key".to_string())?; let base_url = format!("http://127.0.0.1:{}", port); - agent_state.init(base_url, api_key)?; + let provider_type = ProviderType::from_str(&default_provider); + agent_state.init(base_url, api_key, provider_type)?; } // 根据启用的模式构建最终消息 diff --git a/src-tauri/src/commands/api_key_provider_cmd.rs b/src-tauri/src/commands/api_key_provider_cmd.rs new file mode 100644 index 000000000..739fb152f --- /dev/null +++ b/src-tauri/src/commands/api_key_provider_cmd.rs @@ -0,0 +1,398 @@ +//! API Key Provider Tauri 命令 +//! +//! 提供 API Key Provider 管理的前端调用接口。 +//! +//! **Feature: provider-ui-refactor** +//! **Validates: Requirements 9.1** + +use crate::database::dao::api_key_provider::{ + ApiKeyEntry, ApiKeyProvider, ApiProviderType, ProviderGroup, ProviderWithKeys, +}; +use crate::database::DbConnection; +use crate::services::api_key_provider_service::{ApiKeyProviderService, ImportResult}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tauri::State; + +/// API Key Provider 服务状态封装 +pub struct ApiKeyProviderServiceState(pub Arc); + +// ============================================================================ +// 请求/响应类型 +// ============================================================================ + +/// 添加自定义 Provider 请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddCustomProviderRequest { + pub name: String, + #[serde(rename = "type")] + pub provider_type: String, + pub api_host: String, + pub api_version: Option, + pub project: Option, + pub location: Option, + pub region: Option, +} + +/// 更新 Provider 请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateProviderRequest { + pub name: Option, + pub api_host: Option, + pub enabled: Option, + pub sort_order: Option, + pub api_version: Option, + pub project: Option, + pub location: Option, + pub region: Option, +} + +/// 添加 API Key 请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddApiKeyRequest { + pub provider_id: String, + pub api_key: String, + pub alias: Option, +} + +/// Provider 显示数据(用于前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderDisplay { + pub id: String, + pub name: String, + #[serde(rename = "type")] + pub provider_type: String, + pub api_host: String, + pub is_system: bool, + pub group: String, + pub enabled: bool, + pub sort_order: i32, + pub api_version: Option, + pub project: Option, + pub location: Option, + pub region: Option, + pub api_key_count: usize, + pub created_at: String, + pub updated_at: String, +} + +/// API Key 显示数据(用于前端,掩码显示) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiKeyDisplay { + pub id: String, + pub provider_id: String, + /// 掩码后的 API Key + pub api_key_masked: String, + pub alias: Option, + pub enabled: bool, + pub usage_count: i64, + pub error_count: i64, + pub last_used_at: Option, + pub created_at: String, +} + +/// Provider 完整显示数据(包含 API Keys) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderWithKeysDisplay { + #[serde(flatten)] + pub provider: ProviderDisplay, + pub api_keys: Vec, +} + +// ============================================================================ +// 辅助函数 +// ============================================================================ + +/// 将 API Key 转换为掩码显示 +fn mask_api_key(key: &str) -> String { + let chars: Vec = key.chars().collect(); + if chars.len() <= 12 { + "****".to_string() + } else { + let prefix: String = chars[..6].iter().collect(); + let suffix: String = chars[chars.len() - 4..].iter().collect(); + format!("{}****{}", prefix, suffix) + } +} + +/// 将 ApiKeyProvider 转换为 ProviderDisplay +fn provider_to_display(provider: &ApiKeyProvider, api_key_count: usize) -> ProviderDisplay { + ProviderDisplay { + id: provider.id.clone(), + name: provider.name.clone(), + provider_type: provider.provider_type.to_string(), + api_host: provider.api_host.clone(), + is_system: provider.is_system, + group: provider.group.to_string(), + enabled: provider.enabled, + sort_order: provider.sort_order, + api_version: provider.api_version.clone(), + project: provider.project.clone(), + location: provider.location.clone(), + region: provider.region.clone(), + api_key_count, + created_at: provider.created_at.to_rfc3339(), + updated_at: provider.updated_at.to_rfc3339(), + } +} + +/// 将 ApiKeyEntry 转换为 ApiKeyDisplay(需要解密后掩码) +fn api_key_to_display(key: &ApiKeyEntry, service: &ApiKeyProviderService) -> ApiKeyDisplay { + // 解密后掩码显示 + let masked = match service.decrypt_api_key(&key.api_key_encrypted) { + Ok(decrypted) => mask_api_key(&decrypted), + Err(_) => "****".to_string(), + }; + + ApiKeyDisplay { + id: key.id.clone(), + provider_id: key.provider_id.clone(), + api_key_masked: masked, + alias: key.alias.clone(), + enabled: key.enabled, + usage_count: key.usage_count, + error_count: key.error_count, + last_used_at: key.last_used_at.map(|t| t.to_rfc3339()), + created_at: key.created_at.to_rfc3339(), + } +} + +/// 将 ProviderWithKeys 转换为 ProviderWithKeysDisplay +fn provider_with_keys_to_display( + pwk: &ProviderWithKeys, + service: &ApiKeyProviderService, +) -> ProviderWithKeysDisplay { + let api_keys: Vec = pwk + .api_keys + .iter() + .map(|k| api_key_to_display(k, service)) + .collect(); + + ProviderWithKeysDisplay { + provider: provider_to_display(&pwk.provider, pwk.api_keys.len()), + api_keys, + } +} + +// ============================================================================ +// Tauri 命令 +// ============================================================================ + +/// 获取所有 API Key Provider(包含 API Keys) +#[tauri::command] +pub fn get_api_key_providers( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, +) -> Result, String> { + let providers = service.0.get_all_providers(&db)?; + Ok(providers + .iter() + .map(|p| provider_with_keys_to_display(p, &service.0)) + .collect()) +} + +/// 获取单个 API Key Provider(包含 API Keys) +#[tauri::command] +pub fn get_api_key_provider( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + id: String, +) -> Result, String> { + let provider = service.0.get_provider(&db, &id)?; + Ok(provider.map(|p| provider_with_keys_to_display(&p, &service.0))) +} + +/// 添加自定义 Provider +#[tauri::command] +pub fn add_custom_api_key_provider( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + request: AddCustomProviderRequest, +) -> Result { + let provider_type: ApiProviderType = request + .provider_type + .parse() + .map_err(|e: String| format!("无效的 Provider 类型: {}", e))?; + + let provider = service.0.add_custom_provider( + &db, + request.name, + provider_type, + request.api_host, + request.api_version, + request.project, + request.location, + request.region, + )?; + + Ok(provider_to_display(&provider, 0)) +} + +/// 更新 Provider 配置 +#[tauri::command] +pub fn update_api_key_provider( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + id: String, + request: UpdateProviderRequest, +) -> Result { + let provider = service.0.update_provider( + &db, + &id, + request.name, + request.api_host, + request.enabled, + request.sort_order, + request.api_version, + request.project, + request.location, + request.region, + )?; + + // 获取 API Key 数量 + let full_provider = service.0.get_provider(&db, &id)?; + let api_key_count = full_provider.map(|p| p.api_keys.len()).unwrap_or(0); + + Ok(provider_to_display(&provider, api_key_count)) +} + +/// 删除自定义 Provider +#[tauri::command] +pub fn delete_custom_api_key_provider( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + id: String, +) -> Result { + service.0.delete_custom_provider(&db, &id) +} + +/// 添加 API Key +#[tauri::command] +pub fn add_api_key( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + request: AddApiKeyRequest, +) -> Result { + let key = service + .0 + .add_api_key(&db, &request.provider_id, &request.api_key, request.alias)?; + + Ok(api_key_to_display(&key, &service.0)) +} + +/// 删除 API Key +#[tauri::command] +pub fn delete_api_key( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + key_id: String, +) -> Result { + service.0.delete_api_key(&db, &key_id) +} + +/// 切换 API Key 启用状态 +#[tauri::command] +pub fn toggle_api_key( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + key_id: String, + enabled: bool, +) -> Result { + let key = service.0.toggle_api_key(&db, &key_id, enabled)?; + Ok(api_key_to_display(&key, &service.0)) +} + +/// 更新 API Key 别名 +#[tauri::command] +pub fn update_api_key_alias( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + key_id: String, + alias: Option, +) -> Result { + let key = service.0.update_api_key_alias(&db, &key_id, alias)?; + Ok(api_key_to_display(&key, &service.0)) +} + +/// 获取下一个可用的 API Key(用于 API 调用) +#[tauri::command] +pub fn get_next_api_key( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + provider_id: String, +) -> Result, String> { + service.0.get_next_api_key(&db, &provider_id) +} + +/// 记录 API Key 使用 +#[tauri::command] +pub fn record_api_key_usage( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + key_id: String, +) -> Result<(), String> { + service.0.record_usage(&db, &key_id) +} + +/// 记录 API Key 错误 +#[tauri::command] +pub fn record_api_key_error( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + key_id: String, +) -> Result<(), String> { + service.0.record_error(&db, &key_id) +} + +/// 获取 UI 状态 +#[tauri::command] +pub fn get_provider_ui_state( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + key: String, +) -> Result, String> { + service.0.get_ui_state(&db, &key) +} + +/// 设置 UI 状态 +#[tauri::command] +pub fn set_provider_ui_state( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + key: String, + value: String, +) -> Result<(), String> { + service.0.set_ui_state(&db, &key, &value) +} + +/// 批量更新 Provider 排序顺序 +/// **Validates: Requirements 8.4** +#[tauri::command] +pub fn update_provider_sort_orders( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + sort_orders: Vec<(String, i32)>, +) -> Result<(), String> { + service.0.update_provider_sort_orders(&db, sort_orders) +} + +/// 导出 Provider 配置 +#[tauri::command] +pub fn export_api_key_providers( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + include_keys: bool, +) -> Result { + let config = service.0.export_config(&db, include_keys)?; + serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {}", e)) +} + +/// 导入 Provider 配置 +#[tauri::command] +pub fn import_api_key_providers( + db: State<'_, DbConnection>, + service: State<'_, ApiKeyProviderServiceState>, + config_json: String, +) -> Result { + service.0.import_config(&db, &config_json) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 975d9c587..21a20e87d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod agent_cmd; +pub mod api_key_provider_cmd; pub mod auto_fix_cmd; pub mod browser_interceptor_cmd; pub mod config_cmd; diff --git a/src-tauri/src/commands/native_agent_cmd.rs b/src-tauri/src/commands/native_agent_cmd.rs index 3076fe137..2beb2826c 100644 --- a/src-tauri/src/commands/native_agent_cmd.rs +++ b/src-tauri/src/commands/native_agent_cmd.rs @@ -3,8 +3,8 @@ //! 提供原生 Rust Agent 的 Tauri 命令,替代 aster sidecar 方案 use crate::agent::{ - AgentSession, ImageData, NativeAgent, NativeAgentState, NativeChatRequest, NativeChatResponse, - StreamEvent, + AgentSession, ImageData, NativeAgentState, NativeChatRequest, NativeChatResponse, ProviderType, + StreamEvent, ToolLoopEngine, }; use crate::AppState; use serde::{Deserialize, Serialize}; @@ -24,12 +24,13 @@ pub async fn native_agent_init( ) -> Result { tracing::info!("[NativeAgent] 初始化 Agent"); - let (port, api_key, running) = { + let (port, api_key, running, default_provider) = { let state = app_state.read().await; ( state.config.server.port, state.running_api_key.clone(), state.running, + state.config.routing.default_provider.clone(), ) }; @@ -40,8 +41,15 @@ pub async fn native_agent_init( let api_key = api_key.ok_or_else(|| "ProxyCast API Server 未配置 API Key".to_string())?; let base_url = format!("http://127.0.0.1:{}", port); + let provider_type = ProviderType::from_str(&default_provider); - agent_state.init(base_url.clone(), api_key)?; + tracing::info!( + "[NativeAgent] 初始化 Agent: base_url={}, provider={:?}", + base_url, + provider_type + ); + + agent_state.init(base_url.clone(), api_key, provider_type)?; tracing::info!("[NativeAgent] Agent 初始化成功: {}", base_url); @@ -90,12 +98,13 @@ pub async fn native_agent_chat( // 如果 Agent 未初始化,自动初始化 if !agent_state.is_initialized() { - let (port, api_key, running) = { + let (port, api_key, running, default_provider) = { let state = app_state.read().await; ( state.config.server.port, state.running_api_key.clone(), state.running, + state.config.routing.default_provider.clone(), ) }; @@ -105,7 +114,8 @@ pub async fn native_agent_chat( let api_key = api_key.ok_or_else(|| "未配置 API Key".to_string())?; let base_url = format!("http://127.0.0.1:{}", port); - agent_state.init(base_url, api_key)?; + let provider_type = ProviderType::from_str(&default_provider); + agent_state.init(base_url, api_key, provider_type)?; } let request = NativeChatRequest { @@ -133,25 +143,28 @@ pub async fn native_agent_chat_stream( agent_state: State<'_, NativeAgentState>, app_state: State<'_, AppState>, message: String, + event_name: String, + session_id: Option, model: Option, images: Option>, - event_name: String, ) -> Result<(), String> { tracing::info!( - "[NativeAgent] 发送流式消息: message_len={}, model={:?}, event={}", + "[NativeAgent] 发送流式消息: message_len={}, model={:?}, event={}, session={:?}", message.len(), model, - event_name + event_name, + session_id ); // 如果 Agent 未初始化,自动初始化 if !agent_state.is_initialized() { - let (port, api_key, running) = { + let (port, api_key, running, default_provider) = { let state = app_state.read().await; ( state.config.server.port, state.running_api_key.clone(), state.running, + state.config.routing.default_provider.clone(), ) }; @@ -161,22 +174,15 @@ pub async fn native_agent_chat_stream( let api_key = api_key.ok_or_else(|| "未配置 API Key".to_string())?; let base_url = format!("http://127.0.0.1:{}", port); - agent_state.init(base_url, api_key)?; + let provider_type = ProviderType::from_str(&default_provider); + agent_state.init(base_url, api_key, provider_type)?; } - // 获取配置用于创建独立的 Agent - let (base_url, api_key) = { - let state = app_state.read().await; - let base_url = format!("http://127.0.0.1:{}", state.config.server.port); - let api_key = state - .running_api_key - .clone() - .ok_or_else(|| "未配置 API Key".to_string())?; - (base_url, api_key) - }; + // 获取工具注册表(用于创建 ToolLoopEngine) + let tool_registry = agent_state.get_tool_registry()?; let request = NativeChatRequest { - session_id: None, + session_id, // 使用前端传递的 session_id 以保持上下文 message, model, images: images.map(|imgs| { @@ -190,27 +196,40 @@ pub async fn native_agent_chat_stream( stream: true, }; + // 克隆 agent_state 用于后台任务(共享 sessions) + let agent_state_clone = agent_state.inner().clone(); + // 在后台任务中处理流式响应 let event_name_clone = event_name.clone(); + eprintln!( + "[native_agent_chat_stream] 启动后台任务, event_name={}", + event_name_clone + ); tauri::async_runtime::spawn(async move { - let agent = match NativeAgent::new(base_url, api_key) { - Ok(a) => a, - Err(e) => { - let _ = app_handle.emit( - &event_name_clone, - StreamEvent::Error { - message: e.to_string(), - }, - ); - return; - } - }; + eprintln!("[native_agent_chat_stream] 后台任务开始执行"); + + // 创建工具循环引擎(使用共享的 tool_registry) + let tool_loop_engine = ToolLoopEngine::new(tool_registry); + eprintln!("[native_agent_chat_stream] 工具循环引擎创建成功"); let (tx, mut rx) = mpsc::channel::(100); - let stream_task = tokio::spawn(async move { agent.chat_stream(request, tx).await }); + // 使用 agent_state 的方法(共享 sessions) + eprintln!( + "[native_agent_chat_stream] 开始 chat_stream_with_tools, request.session_id={:?}", + request.session_id + ); + let stream_task = tokio::spawn(async move { + agent_state_clone + .chat_stream_with_tools(request, tx, &tool_loop_engine) + .await + }); + eprintln!("[native_agent_chat_stream] 开始接收流式事件..."); + // 注意:不要在收到 Done 事件后立即 break,因为工具循环可能还在执行 + // 继续接收直到 channel 关闭(stream_task 完成) while let Some(event) = rx.recv().await { + eprintln!("[native_agent_chat_stream] 收到事件: {:?}", event); tracing::debug!( "[NativeAgent] 收到流式事件: {:?}, 发送到: {}", event, @@ -218,17 +237,30 @@ pub async fn native_agent_chat_stream( ); if let Err(e) = app_handle.emit(&event_name_clone, &event) { tracing::error!("[NativeAgent] 发送事件失败: {}", e); + eprintln!("[native_agent_chat_stream] 发送事件失败: {}", e); break; } tracing::debug!("[NativeAgent] 事件发送成功"); - if matches!(event, StreamEvent::Done { .. } | StreamEvent::Error { .. }) { - tracing::info!("[NativeAgent] 流式响应完成"); + // 只在 Error 时 break,Done 不 break 因为工具循环可能还会发送更多事件 + if matches!(event, StreamEvent::Error { .. }) { + tracing::info!("[NativeAgent] 流式响应错误,停止接收"); + eprintln!("[native_agent_chat_stream] 流式响应错误"); break; } } + eprintln!("[native_agent_chat_stream] channel 关闭,事件接收完成"); - let _ = stream_task.await; + eprintln!("[native_agent_chat_stream] 等待 stream_task 完成..."); + match stream_task.await { + Ok(result) => { + eprintln!("[native_agent_chat_stream] stream_task 完成: {:?}", result); + } + Err(e) => { + eprintln!("[native_agent_chat_stream] stream_task 错误: {}", e); + } + } + eprintln!("[native_agent_chat_stream] 后台任务结束"); }); Ok(()) diff --git a/src-tauri/src/config/export.rs b/src-tauri/src/config/export.rs index c4f5c5902..7ebf77ad2 100644 --- a/src-tauri/src/config/export.rs +++ b/src-tauri/src/config/export.rs @@ -501,6 +501,7 @@ mod base64 { } pub use self::base64::decode as base64_decode; +#[allow(unused_imports)] pub use self::base64::encode as base64_encode; #[cfg(test)] diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 860580481..718506c45 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -3,6 +3,8 @@ //! 提供 YAML 配置文件支持、热重载和配置导入导出功能 //! 同时保持与旧版 JSON 配置的向后兼容性 +#![allow(unused_imports)] + mod export; mod hot_reload; mod import; diff --git a/src-tauri/src/database/dao/api_key_provider.rs b/src-tauri/src/database/dao/api_key_provider.rs new file mode 100644 index 000000000..21f0ec129 --- /dev/null +++ b/src-tauri/src/database/dao/api_key_provider.rs @@ -0,0 +1,609 @@ +//! API Key Provider 数据访问对象 +//! +//! 提供 API Key Provider 的 CRUD 操作。 +//! +//! **Feature: provider-ui-refactor** +//! **Validates: Requirements 9.1** + +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; + +// ============================================================================ +// 数据模型 +// ============================================================================ + +/// Provider API 类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ApiProviderType { + Openai, + OpenaiResponse, + Anthropic, + Gemini, + AzureOpenai, + Vertexai, + AwsBedrock, + Ollama, + NewApi, + Gateway, +} + +impl std::fmt::Display for ApiProviderType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ApiProviderType::Openai => write!(f, "openai"), + ApiProviderType::OpenaiResponse => write!(f, "openai-response"), + ApiProviderType::Anthropic => write!(f, "anthropic"), + ApiProviderType::Gemini => write!(f, "gemini"), + ApiProviderType::AzureOpenai => write!(f, "azure-openai"), + ApiProviderType::Vertexai => write!(f, "vertexai"), + ApiProviderType::AwsBedrock => write!(f, "aws-bedrock"), + ApiProviderType::Ollama => write!(f, "ollama"), + ApiProviderType::NewApi => write!(f, "new-api"), + ApiProviderType::Gateway => write!(f, "gateway"), + } + } +} + +impl std::str::FromStr for ApiProviderType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "openai" => Ok(ApiProviderType::Openai), + "openai-response" => Ok(ApiProviderType::OpenaiResponse), + "anthropic" => Ok(ApiProviderType::Anthropic), + "gemini" => Ok(ApiProviderType::Gemini), + "azure-openai" => Ok(ApiProviderType::AzureOpenai), + "vertexai" => Ok(ApiProviderType::Vertexai), + "aws-bedrock" => Ok(ApiProviderType::AwsBedrock), + "ollama" => Ok(ApiProviderType::Ollama), + "new-api" => Ok(ApiProviderType::NewApi), + "gateway" => Ok(ApiProviderType::Gateway), + _ => Err(format!("Invalid provider type: {}", s)), + } + } +} + +/// Provider 分组类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProviderGroup { + Mainstream, + Chinese, + Cloud, + Aggregator, + Local, + Specialized, + Custom, +} + +impl std::fmt::Display for ProviderGroup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProviderGroup::Mainstream => write!(f, "mainstream"), + ProviderGroup::Chinese => write!(f, "chinese"), + ProviderGroup::Cloud => write!(f, "cloud"), + ProviderGroup::Aggregator => write!(f, "aggregator"), + ProviderGroup::Local => write!(f, "local"), + ProviderGroup::Specialized => write!(f, "specialized"), + ProviderGroup::Custom => write!(f, "custom"), + } + } +} + +impl std::str::FromStr for ProviderGroup { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "mainstream" => Ok(ProviderGroup::Mainstream), + "chinese" => Ok(ProviderGroup::Chinese), + "cloud" => Ok(ProviderGroup::Cloud), + "aggregator" => Ok(ProviderGroup::Aggregator), + "local" => Ok(ProviderGroup::Local), + "specialized" => Ok(ProviderGroup::Specialized), + "custom" => Ok(ProviderGroup::Custom), + _ => Err(format!("Invalid provider group: {}", s)), + } + } +} + +/// API Key Provider 配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiKeyProvider { + pub id: String, + pub name: String, + #[serde(rename = "type")] + pub provider_type: ApiProviderType, + pub api_host: String, + pub is_system: bool, + pub group: ProviderGroup, + pub enabled: bool, + pub sort_order: i32, + pub api_version: Option, + pub project: Option, + pub location: Option, + pub region: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// API Key 条目 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiKeyEntry { + pub id: String, + pub provider_id: String, + /// 加密后的 API Key + pub api_key_encrypted: String, + pub alias: Option, + pub enabled: bool, + pub usage_count: i64, + pub error_count: i64, + pub last_used_at: Option>, + pub created_at: DateTime, +} + +/// Provider 完整数据(包含 API Keys) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderWithKeys { + #[serde(flatten)] + pub provider: ApiKeyProvider, + pub api_keys: Vec, +} + +// ============================================================================ +// DAO 实现 +// ============================================================================ + +pub struct ApiKeyProviderDao; + +impl ApiKeyProviderDao { + // ==================== Provider 操作 ==================== + + /// 获取所有 Provider + pub fn get_all_providers(conn: &Connection) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT id, name, type, api_host, is_system, group_name, enabled, sort_order, + api_version, project, location, region, created_at, updated_at + FROM api_key_providers + ORDER BY sort_order ASC, created_at ASC", + )?; + + let rows = stmt.query_map([], Self::row_to_provider)?; + let mut providers = Vec::new(); + for provider in rows.flatten() { + providers.push(provider); + } + Ok(providers) + } + + /// 根据 ID 获取 Provider + pub fn get_provider_by_id( + conn: &Connection, + id: &str, + ) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT id, name, type, api_host, is_system, group_name, enabled, sort_order, + api_version, project, location, region, created_at, updated_at + FROM api_key_providers + WHERE id = ?1", + )?; + + let mut rows = stmt.query([id])?; + if let Some(row) = rows.next()? { + Ok(Some(Self::row_to_provider(row)?)) + } else { + Ok(None) + } + } + + /// 根据分组获取 Provider + pub fn get_providers_by_group( + conn: &Connection, + group: ProviderGroup, + ) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT id, name, type, api_host, is_system, group_name, enabled, sort_order, + api_version, project, location, region, created_at, updated_at + FROM api_key_providers + WHERE group_name = ?1 + ORDER BY sort_order ASC, created_at ASC", + )?; + + let rows = stmt.query_map([group.to_string()], Self::row_to_provider)?; + let mut providers = Vec::new(); + for provider in rows.flatten() { + providers.push(provider); + } + Ok(providers) + } + + /// 插入新 Provider + pub fn insert_provider( + conn: &Connection, + provider: &ApiKeyProvider, + ) -> Result<(), rusqlite::Error> { + conn.execute( + "INSERT INTO api_key_providers + (id, name, type, api_host, is_system, group_name, enabled, sort_order, + api_version, project, location, region, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + params![ + provider.id, + provider.name, + provider.provider_type.to_string(), + provider.api_host, + provider.is_system, + provider.group.to_string(), + provider.enabled, + provider.sort_order, + provider.api_version, + provider.project, + provider.location, + provider.region, + provider.created_at.to_rfc3339(), + provider.updated_at.to_rfc3339(), + ], + )?; + Ok(()) + } + + /// 更新 Provider + pub fn update_provider( + conn: &Connection, + provider: &ApiKeyProvider, + ) -> Result<(), rusqlite::Error> { + conn.execute( + "UPDATE api_key_providers SET + name = ?2, type = ?3, api_host = ?4, is_system = ?5, group_name = ?6, + enabled = ?7, sort_order = ?8, api_version = ?9, project = ?10, + location = ?11, region = ?12, updated_at = ?13 + WHERE id = ?1", + params![ + provider.id, + provider.name, + provider.provider_type.to_string(), + provider.api_host, + provider.is_system, + provider.group.to_string(), + provider.enabled, + provider.sort_order, + provider.api_version, + provider.project, + provider.location, + provider.region, + provider.updated_at.to_rfc3339(), + ], + )?; + Ok(()) + } + + /// 删除 Provider(仅限自定义 Provider) + pub fn delete_provider(conn: &Connection, id: &str) -> Result { + // 先检查是否为系统 Provider + let is_system: bool = conn.query_row( + "SELECT is_system FROM api_key_providers WHERE id = ?1", + [id], + |row| row.get(0), + )?; + + if is_system { + return Ok(false); // 不允许删除系统 Provider + } + + let affected = conn.execute("DELETE FROM api_key_providers WHERE id = ?1", [id])?; + Ok(affected > 0) + } + + /// 从数据库行转换为 ApiKeyProvider + fn row_to_provider(row: &rusqlite::Row) -> Result { + let id: String = row.get(0)?; + let name: String = row.get(1)?; + let type_str: String = row.get(2)?; + let api_host: String = row.get(3)?; + let is_system: bool = row.get(4)?; + let group_str: String = row.get(5)?; + let enabled: bool = row.get(6)?; + let sort_order: i32 = row.get(7)?; + let api_version: Option = row.get(8)?; + let project: Option = row.get(9)?; + let location: Option = row.get(10)?; + let region: Option = row.get(11)?; + let created_at_str: String = row.get(12)?; + let updated_at_str: String = row.get(13)?; + + let provider_type: ApiProviderType = type_str.parse().unwrap_or(ApiProviderType::Openai); + let group: ProviderGroup = group_str.parse().unwrap_or(ProviderGroup::Custom); + + let created_at = DateTime::parse_from_rfc3339(&created_at_str) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + let updated_at = DateTime::parse_from_rfc3339(&updated_at_str) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + + Ok(ApiKeyProvider { + id, + name, + provider_type, + api_host, + is_system, + group, + enabled, + sort_order, + api_version, + project, + location, + region, + created_at, + updated_at, + }) + } + + // ==================== API Key 操作 ==================== + + /// 获取 Provider 的所有 API Keys + pub fn get_api_keys_by_provider( + conn: &Connection, + provider_id: &str, + ) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT id, provider_id, api_key_encrypted, alias, enabled, + usage_count, error_count, last_used_at, created_at + FROM api_keys + WHERE provider_id = ?1 + ORDER BY created_at ASC", + )?; + + let rows = stmt.query_map([provider_id], Self::row_to_api_key)?; + let mut keys = Vec::new(); + for key in rows.flatten() { + keys.push(key); + } + Ok(keys) + } + + /// 获取所有启用的 API Keys + pub fn get_enabled_api_keys_by_provider( + conn: &Connection, + provider_id: &str, + ) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT id, provider_id, api_key_encrypted, alias, enabled, + usage_count, error_count, last_used_at, created_at + FROM api_keys + WHERE provider_id = ?1 AND enabled = 1 + ORDER BY created_at ASC", + )?; + + let rows = stmt.query_map([provider_id], Self::row_to_api_key)?; + let mut keys = Vec::new(); + for key in rows.flatten() { + keys.push(key); + } + Ok(keys) + } + + /// 根据 ID 获取 API Key + pub fn get_api_key_by_id( + conn: &Connection, + id: &str, + ) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT id, provider_id, api_key_encrypted, alias, enabled, + usage_count, error_count, last_used_at, created_at + FROM api_keys + WHERE id = ?1", + )?; + + let mut rows = stmt.query([id])?; + if let Some(row) = rows.next()? { + Ok(Some(Self::row_to_api_key(row)?)) + } else { + Ok(None) + } + } + + /// 插入新 API Key + pub fn insert_api_key(conn: &Connection, key: &ApiKeyEntry) -> Result<(), rusqlite::Error> { + conn.execute( + "INSERT INTO api_keys + (id, provider_id, api_key_encrypted, alias, enabled, + usage_count, error_count, last_used_at, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + key.id, + key.provider_id, + key.api_key_encrypted, + key.alias, + key.enabled, + key.usage_count, + key.error_count, + key.last_used_at.map(|t| t.to_rfc3339()), + key.created_at.to_rfc3339(), + ], + )?; + Ok(()) + } + + /// 更新 API Key + pub fn update_api_key(conn: &Connection, key: &ApiKeyEntry) -> Result<(), rusqlite::Error> { + conn.execute( + "UPDATE api_keys SET + alias = ?2, enabled = ?3, usage_count = ?4, error_count = ?5, last_used_at = ?6 + WHERE id = ?1", + params![ + key.id, + key.alias, + key.enabled, + key.usage_count, + key.error_count, + key.last_used_at.map(|t| t.to_rfc3339()), + ], + )?; + Ok(()) + } + + /// 删除 API Key + pub fn delete_api_key(conn: &Connection, id: &str) -> Result { + let affected = conn.execute("DELETE FROM api_keys WHERE id = ?1", [id])?; + Ok(affected > 0) + } + + /// 更新 API Key 使用统计 + pub fn update_api_key_usage( + conn: &Connection, + id: &str, + usage_count: i64, + last_used_at: DateTime, + ) -> Result<(), rusqlite::Error> { + conn.execute( + "UPDATE api_keys SET usage_count = ?2, last_used_at = ?3 WHERE id = ?1", + params![id, usage_count, last_used_at.to_rfc3339()], + )?; + Ok(()) + } + + /// 增加 API Key 错误计数 + pub fn increment_api_key_error(conn: &Connection, id: &str) -> Result<(), rusqlite::Error> { + conn.execute( + "UPDATE api_keys SET error_count = error_count + 1 WHERE id = ?1", + [id], + )?; + Ok(()) + } + + /// 从数据库行转换为 ApiKeyEntry + fn row_to_api_key(row: &rusqlite::Row) -> Result { + let id: String = row.get(0)?; + let provider_id: String = row.get(1)?; + let api_key_encrypted: String = row.get(2)?; + let alias: Option = row.get(3)?; + let enabled: bool = row.get(4)?; + let usage_count: i64 = row.get(5)?; + let error_count: i64 = row.get(6)?; + let last_used_at_str: Option = row.get(7)?; + let created_at_str: String = row.get(8)?; + + let last_used_at = last_used_at_str.and_then(|s| { + DateTime::parse_from_rfc3339(&s) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + }); + let created_at = DateTime::parse_from_rfc3339(&created_at_str) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + + Ok(ApiKeyEntry { + id, + provider_id, + api_key_encrypted, + alias, + enabled, + usage_count, + error_count, + last_used_at, + created_at, + }) + } + + // ==================== UI 状态操作 ==================== + + /// 获取 UI 状态 + pub fn get_ui_state(conn: &Connection, key: &str) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare("SELECT value FROM provider_ui_state WHERE key = ?1")?; + let mut rows = stmt.query([key])?; + if let Some(row) = rows.next()? { + Ok(Some(row.get(0)?)) + } else { + Ok(None) + } + } + + /// 设置 UI 状态 + pub fn set_ui_state(conn: &Connection, key: &str, value: &str) -> Result<(), rusqlite::Error> { + conn.execute( + "INSERT OR REPLACE INTO provider_ui_state (key, value) VALUES (?1, ?2)", + params![key, value], + )?; + Ok(()) + } + + /// 删除 UI 状态 + pub fn delete_ui_state(conn: &Connection, key: &str) -> Result { + let affected = conn.execute("DELETE FROM provider_ui_state WHERE key = ?1", [key])?; + Ok(affected > 0) + } + + // ==================== 复合查询 ==================== + + /// 获取所有 Provider 及其 API Keys + pub fn get_all_providers_with_keys( + conn: &Connection, + ) -> Result, rusqlite::Error> { + let providers = Self::get_all_providers(conn)?; + let mut result = Vec::new(); + + for provider in providers { + let api_keys = Self::get_api_keys_by_provider(conn, &provider.id)?; + result.push(ProviderWithKeys { provider, api_keys }); + } + + Ok(result) + } + + /// 获取启用的 Provider 及其启用的 API Keys + pub fn get_enabled_providers_with_keys( + conn: &Connection, + ) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT id, name, type, api_host, is_system, group_name, enabled, sort_order, + api_version, project, location, region, created_at, updated_at + FROM api_key_providers + WHERE enabled = 1 + ORDER BY sort_order ASC, created_at ASC", + )?; + + let rows = stmt.query_map([], Self::row_to_provider)?; + let mut result = Vec::new(); + + for provider in rows.flatten() { + let api_keys = Self::get_enabled_api_keys_by_provider(conn, &provider.id)?; + if !api_keys.is_empty() { + result.push(ProviderWithKeys { provider, api_keys }); + } + } + + Ok(result) + } + + /// 统计 Provider 的 API Key 数量 + pub fn count_api_keys_by_provider( + conn: &Connection, + provider_id: &str, + ) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM api_keys WHERE provider_id = ?1", + [provider_id], + |row| row.get(0), + ) + } + + /// 批量更新 Provider 排序顺序 + /// **Validates: Requirements 8.4** + pub fn update_provider_sort_orders( + conn: &Connection, + sort_orders: &[(String, i32)], + ) -> Result<(), rusqlite::Error> { + let now = chrono::Utc::now().to_rfc3339(); + for (id, sort_order) in sort_orders { + conn.execute( + "UPDATE api_key_providers SET sort_order = ?2, updated_at = ?3 WHERE id = ?1", + params![id, sort_order, now], + )?; + } + Ok(()) + } +} diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index 13aef57fa..db90bc9af 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -1,3 +1,4 @@ +pub mod api_key_provider; pub mod installed_plugins; pub mod mcp; pub mod prompts; diff --git a/src-tauri/src/database/migration.rs b/src-tauri/src/database/migration.rs index d0b748bdf..91d0c997b 100644 --- a/src-tauri/src/database/migration.rs +++ b/src-tauri/src/database/migration.rs @@ -1,4 +1,4 @@ -use rusqlite::Connection; +use rusqlite::{params, Connection}; /// 从旧的 JSON 配置迁移数据到 SQLite #[allow(dead_code)] @@ -44,3 +44,226 @@ pub fn migrate_from_json(conn: &Connection) -> Result<(), String> { Ok(()) } + +/// 将 api_keys 表中的数据迁移到 provider_pool_credentials 表 +/// +/// 迁移逻辑: +/// 1. 读取 api_keys 表中的所有 API Key +/// 2. 根据 provider_id 查找对应的 api_key_providers 配置 +/// 3. 将 API Key 转换为 CredentialData::OpenAIKey 或 CredentialData::ClaudeKey +/// 4. 插入到 provider_pool_credentials 表 +pub fn migrate_api_keys_to_pool(conn: &Connection) -> Result { + // 检查是否已经迁移过 + let migrated: bool = conn + .query_row( + "SELECT value FROM settings WHERE key = 'migrated_api_keys_to_pool'", + [], + |row| row.get::<_, String>(0), + ) + .map(|v| v == "true") + .unwrap_or(false); + + if migrated { + tracing::debug!("[迁移] API Keys 已迁移过,跳过"); + return Ok(0); + } + + tracing::info!("[迁移] 开始将 api_keys 迁移到 provider_pool_credentials"); + + // 查询所有 API Keys 及其对应的 Provider 信息 + let mut stmt = conn + .prepare( + "SELECT k.id, k.provider_id, k.api_key_encrypted, k.alias, k.enabled, + k.usage_count, k.error_count, k.last_used_at, k.created_at, + p.type, p.api_host, p.name as provider_name + FROM api_keys k + JOIN api_key_providers p ON k.provider_id = p.id + ORDER BY k.created_at ASC", + ) + .map_err(|e| format!("准备查询语句失败: {}", e))?; + + let rows = stmt + .query_map([], |row| { + Ok(ApiKeyMigrationRow { + id: row.get(0)?, + provider_id: row.get(1)?, + api_key_encrypted: row.get(2)?, + alias: row.get(3)?, + enabled: row.get(4)?, + usage_count: row.get::<_, i64>(5)? as u64, + error_count: row.get::<_, i64>(6)? as u32, + last_used_at: row.get(7)?, + created_at: row.get(8)?, + provider_type: row.get(9)?, + api_host: row.get(10)?, + provider_name: row.get(11)?, + }) + }) + .map_err(|e| format!("查询 API Keys 失败: {}", e))?; + + let mut migrated_count = 0; + let now = chrono::Utc::now().timestamp(); + + for row_result in rows { + let row = row_result.map_err(|e| format!("读取行数据失败: {}", e))?; + + // 检查是否已存在相同的凭证(通过 api_key_encrypted 判断) + let exists: bool = conn + .query_row( + "SELECT COUNT(*) > 0 FROM provider_pool_credentials + WHERE credential_data LIKE ?1", + params![format!("%{}%", row.api_key_encrypted)], + |r| r.get(0), + ) + .unwrap_or(false); + + if exists { + tracing::debug!( + "[迁移] 跳过已存在的 API Key: {} (provider: {})", + row.alias.as_deref().unwrap_or(&row.id), + row.provider_id + ); + continue; + } + + // 根据 provider_type 确定 pool_provider_type 和 credential_data + let (pool_provider_type, credential_data) = match row.provider_type.to_lowercase().as_str() + { + "anthropic" => { + let cred = serde_json::json!({ + "type": "claude_key", + "api_key": row.api_key_encrypted, + "base_url": if row.api_host.is_empty() { None } else { Some(&row.api_host) } + }); + ("claude", cred) + } + "openai" | "openai-response" => { + let cred = serde_json::json!({ + "type": "openai_key", + "api_key": row.api_key_encrypted, + "base_url": if row.api_host.is_empty() { None } else { Some(&row.api_host) } + }); + ("openai", cred) + } + "gemini" => { + let cred = serde_json::json!({ + "type": "gemini_api_key", + "api_key": row.api_key_encrypted, + "base_url": if row.api_host.is_empty() { None } else { Some(&row.api_host) }, + "excluded_models": [] + }); + ("gemini_api_key", cred) + } + "vertex" | "vertexai" => { + let cred = serde_json::json!({ + "type": "vertex_key", + "api_key": row.api_key_encrypted, + "base_url": if row.api_host.is_empty() { None } else { Some(&row.api_host) }, + "model_aliases": {} + }); + ("vertex", cred) + } + // 其他类型默认作为 OpenAI 兼容处理 + _ => { + let cred = serde_json::json!({ + "type": "openai_key", + "api_key": row.api_key_encrypted, + "base_url": if row.api_host.is_empty() { None } else { Some(&row.api_host) } + }); + ("openai", cred) + } + }; + + // 生成名称:优先使用 alias,否则使用 provider_name + let name = row + .alias + .clone() + .or_else(|| Some(format!("{} (迁移)", row.provider_name))); + + // 解析时间 + let created_at_ts = row + .created_at + .as_ref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.timestamp()) + .unwrap_or(now); + + let last_used_ts = row + .last_used_at + .as_ref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.timestamp()); + + // 插入到 provider_pool_credentials + let uuid = uuid::Uuid::new_v4().to_string(); + let credential_json = credential_data.to_string(); + + conn.execute( + "INSERT INTO provider_pool_credentials + (uuid, provider_type, credential_data, name, is_healthy, is_disabled, + check_health, check_model_name, not_supported_models, usage_count, error_count, + last_used, last_error_time, last_error_message, last_health_check_time, + last_health_check_model, created_at, updated_at, source, proxy_url) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", + params![ + uuid, + pool_provider_type, + credential_json, + name, + true, // is_healthy + !row.enabled, // is_disabled (反转 enabled) + true, // check_health + Option::::None, // check_model_name + "[]", // not_supported_models + row.usage_count as i64, + row.error_count as i32, + last_used_ts, + Option::::None, // last_error_time + Option::::None, // last_error_message + Option::::None, // last_health_check_time + Option::::None, // last_health_check_model + created_at_ts, + now, + "imported", // source: 标记为导入 + Option::::None, // proxy_url + ], + ) + .map_err(|e| format!("插入凭证失败: {}", e))?; + + tracing::info!( + "[迁移] 已迁移 API Key: {} -> {} (provider_type: {})", + row.alias.as_deref().unwrap_or(&row.id), + uuid, + pool_provider_type + ); + + migrated_count += 1; + } + + // 标记迁移完成 + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('migrated_api_keys_to_pool', 'true')", + [], + ) + .map_err(|e| format!("标记迁移完成失败: {}", e))?; + + tracing::info!("[迁移] API Keys 迁移完成,共迁移 {} 条记录", migrated_count); + + Ok(migrated_count) +} + +/// API Key 迁移行数据 +struct ApiKeyMigrationRow { + id: String, + provider_id: String, + api_key_encrypted: String, + alias: Option, + enabled: bool, + usage_count: u64, + error_count: u32, + last_used_at: Option, + created_at: Option, + provider_type: String, + api_host: String, + provider_name: String, +} diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index b13e4ea0a..c5ff95165 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -1,6 +1,7 @@ pub mod dao; pub mod migration; pub mod schema; +pub mod system_providers; use rusqlite::Connection; use std::path::PathBuf; @@ -26,5 +27,17 @@ pub fn init_database() -> Result { schema::create_tables(&conn).map_err(|e| e.to_string())?; migration::migrate_from_json(&conn)?; + // 执行 API Keys 到 Provider Pool 的迁移 + match migration::migrate_api_keys_to_pool(&conn) { + Ok(count) => { + if count > 0 { + tracing::info!("[数据库] 已将 {} 条 API Key 迁移到凭证池", count); + } + } + Err(e) => { + tracing::warn!("[数据库] API Key 迁移失败(非致命): {}", e); + } + } + Ok(Arc::new(Mutex::new(conn))) } diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 999c4931e..c23719516 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -1,6 +1,68 @@ use rusqlite::Connection; pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { + // API Key Provider 配置表 + // _Requirements: 9.1_ + conn.execute( + "CREATE TABLE IF NOT EXISTS api_key_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + api_host TEXT NOT NULL, + is_system INTEGER NOT NULL DEFAULT 0, + group_name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + api_version TEXT, + project TEXT, + location TEXT, + region TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + )?; + + // 创建 api_key_providers 索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_api_key_providers_group ON api_key_providers(group_name)", + [], + )?; + + // API Key 条目表 + // _Requirements: 9.1, 9.2_ + conn.execute( + "CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + api_key_encrypted TEXT NOT NULL, + alias TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + usage_count INTEGER NOT NULL DEFAULT 0, + error_count INTEGER NOT NULL DEFAULT 0, + last_used_at TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (provider_id) REFERENCES api_key_providers(id) ON DELETE CASCADE + )", + [], + )?; + + // 创建 api_keys 索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_api_keys_provider ON api_keys(provider_id)", + [], + )?; + + // Provider UI 状态表 + // _Requirements: 8.4_ + conn.execute( + "CREATE TABLE IF NOT EXISTS provider_ui_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )", + [], + )?; + // Providers 表 conn.execute( "CREATE TABLE IF NOT EXISTS providers ( diff --git a/src-tauri/src/database/system_providers.rs b/src-tauri/src/database/system_providers.rs new file mode 100644 index 000000000..3606a82d5 --- /dev/null +++ b/src-tauri/src/database/system_providers.rs @@ -0,0 +1,632 @@ +//! 系统预设 Provider 配置 +//! +//! 定义所有 60+ 系统预设 Provider 的配置,与前端 `SYSTEM_PROVIDERS` 保持一致。 +//! +//! **Feature: provider-ui-refactor** +//! **Validates: Requirements 3.1-3.6** + +use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup}; +use chrono::Utc; + +/// 系统 Provider 配置定义 +pub struct SystemProviderDef { + pub id: &'static str, + pub name: &'static str, + pub provider_type: ApiProviderType, + pub api_host: &'static str, + pub group: ProviderGroup, + pub sort_order: i32, + pub api_version: Option<&'static str>, +} + +/// 获取所有系统 Provider 配置 +pub fn get_system_providers() -> Vec { + vec![ + // ========================================================================= + // 主流 AI (10个) - Requirements 3.1 + // ========================================================================= + SystemProviderDef { + id: "openai", + name: "OpenAI", + provider_type: ApiProviderType::OpenaiResponse, + api_host: "https://api.openai.com", + group: ProviderGroup::Mainstream, + sort_order: 1, + api_version: None, + }, + SystemProviderDef { + id: "anthropic", + name: "Anthropic", + provider_type: ApiProviderType::Anthropic, + api_host: "https://api.anthropic.com", + group: ProviderGroup::Mainstream, + sort_order: 2, + api_version: None, + }, + SystemProviderDef { + id: "gemini", + name: "Gemini", + provider_type: ApiProviderType::Gemini, + api_host: "https://generativelanguage.googleapis.com", + group: ProviderGroup::Mainstream, + sort_order: 3, + api_version: None, + }, + SystemProviderDef { + id: "deepseek", + name: "DeepSeek", + provider_type: ApiProviderType::Openai, + api_host: "https://api.deepseek.com", + group: ProviderGroup::Mainstream, + sort_order: 4, + api_version: None, + }, + SystemProviderDef { + id: "moonshot", + name: "Moonshot", + provider_type: ApiProviderType::Openai, + api_host: "https://api.moonshot.cn", + group: ProviderGroup::Mainstream, + sort_order: 5, + api_version: None, + }, + SystemProviderDef { + id: "groq", + name: "Groq", + provider_type: ApiProviderType::Openai, + api_host: "https://api.groq.com/openai", + group: ProviderGroup::Mainstream, + sort_order: 6, + api_version: None, + }, + SystemProviderDef { + id: "grok", + name: "Grok (xAI)", + provider_type: ApiProviderType::Openai, + api_host: "https://api.x.ai", + group: ProviderGroup::Mainstream, + sort_order: 7, + api_version: None, + }, + SystemProviderDef { + id: "mistral", + name: "Mistral", + provider_type: ApiProviderType::Openai, + api_host: "https://api.mistral.ai", + group: ProviderGroup::Mainstream, + sort_order: 8, + api_version: None, + }, + SystemProviderDef { + id: "perplexity", + name: "Perplexity", + provider_type: ApiProviderType::Openai, + api_host: "https://api.perplexity.ai/", + group: ProviderGroup::Mainstream, + sort_order: 9, + api_version: None, + }, + SystemProviderDef { + id: "cohere", + name: "Cohere", + provider_type: ApiProviderType::Openai, + api_host: "https://api.cohere.ai", + group: ProviderGroup::Mainstream, + sort_order: 10, + api_version: None, + }, + // ========================================================================= + // 国内 AI (15个) - Requirements 3.2 + // ========================================================================= + SystemProviderDef { + id: "zhipu", + name: "智谱 (ZhiPu)", + provider_type: ApiProviderType::Openai, + api_host: "https://open.bigmodel.cn/api/paas/v4/", + group: ProviderGroup::Chinese, + sort_order: 11, + api_version: None, + }, + SystemProviderDef { + id: "baichuan", + name: "百川 (Baichuan)", + provider_type: ApiProviderType::Openai, + api_host: "https://api.baichuan-ai.com", + group: ProviderGroup::Chinese, + sort_order: 12, + api_version: None, + }, + SystemProviderDef { + id: "dashscope", + name: "百炼/通义千问 (Dashscope)", + provider_type: ApiProviderType::Openai, + api_host: "https://dashscope.aliyuncs.com/compatible-mode/v1/", + group: ProviderGroup::Chinese, + sort_order: 13, + api_version: None, + }, + SystemProviderDef { + id: "stepfun", + name: "阶跃星辰 (StepFun)", + provider_type: ApiProviderType::Openai, + api_host: "https://api.stepfun.com", + group: ProviderGroup::Chinese, + sort_order: 14, + api_version: None, + }, + SystemProviderDef { + id: "doubao", + name: "豆包 (Doubao)", + provider_type: ApiProviderType::Openai, + api_host: "https://ark.cn-beijing.volces.com/api/v3/", + group: ProviderGroup::Chinese, + sort_order: 15, + api_version: None, + }, + SystemProviderDef { + id: "minimax", + name: "MiniMax", + provider_type: ApiProviderType::Openai, + api_host: "https://api.minimaxi.com/v1", + group: ProviderGroup::Chinese, + sort_order: 16, + api_version: None, + }, + SystemProviderDef { + id: "yi", + name: "零一万物 (Yi)", + provider_type: ApiProviderType::Openai, + api_host: "https://api.lingyiwanwu.com", + group: ProviderGroup::Chinese, + sort_order: 17, + api_version: None, + }, + SystemProviderDef { + id: "hunyuan", + name: "腾讯混元 (Hunyuan)", + provider_type: ApiProviderType::Openai, + api_host: "https://api.hunyuan.cloud.tencent.com", + group: ProviderGroup::Chinese, + sort_order: 18, + api_version: None, + }, + SystemProviderDef { + id: "tencent-cloud-ti", + name: "腾讯云 TI", + provider_type: ApiProviderType::Openai, + api_host: "https://api.lkeap.cloud.tencent.com", + group: ProviderGroup::Chinese, + sort_order: 19, + api_version: None, + }, + SystemProviderDef { + id: "baidu-cloud", + name: "百度云 (Baidu Cloud)", + provider_type: ApiProviderType::Openai, + api_host: "https://qianfan.baidubce.com/v2/", + group: ProviderGroup::Chinese, + sort_order: 20, + api_version: None, + }, + SystemProviderDef { + id: "infini", + name: "无问芯穹 (Infini)", + provider_type: ApiProviderType::Openai, + api_host: "https://cloud.infini-ai.com/maas", + group: ProviderGroup::Chinese, + sort_order: 21, + api_version: None, + }, + SystemProviderDef { + id: "modelscope", + name: "魔搭 (ModelScope)", + provider_type: ApiProviderType::Openai, + api_host: "https://api-inference.modelscope.cn/v1/", + group: ProviderGroup::Chinese, + sort_order: 22, + api_version: None, + }, + SystemProviderDef { + id: "xirang", + name: "息壤 (Xirang)", + provider_type: ApiProviderType::Openai, + api_host: "https://wishub-x1.ctyun.cn", + group: ProviderGroup::Chinese, + sort_order: 23, + api_version: None, + }, + SystemProviderDef { + id: "mimo", + name: "小米 MiMo", + provider_type: ApiProviderType::Openai, + api_host: "https://api.xiaomimimo.com", + group: ProviderGroup::Chinese, + sort_order: 24, + api_version: None, + }, + SystemProviderDef { + id: "zhinao", + name: "360 智脑 (Zhinao)", + provider_type: ApiProviderType::Openai, + api_host: "https://api.360.cn", + group: ProviderGroup::Chinese, + sort_order: 25, + api_version: None, + }, + // ========================================================================= + // 云服务 (5个) - Requirements 3.3 + // ========================================================================= + SystemProviderDef { + id: "azure-openai", + name: "Azure OpenAI", + provider_type: ApiProviderType::AzureOpenai, + api_host: "", + group: ProviderGroup::Cloud, + sort_order: 26, + api_version: Some("2024-02-15-preview"), + }, + SystemProviderDef { + id: "vertexai", + name: "VertexAI", + provider_type: ApiProviderType::Vertexai, + api_host: "", + group: ProviderGroup::Cloud, + sort_order: 27, + api_version: None, + }, + SystemProviderDef { + id: "aws-bedrock", + name: "AWS Bedrock", + provider_type: ApiProviderType::AwsBedrock, + api_host: "", + group: ProviderGroup::Cloud, + sort_order: 28, + api_version: None, + }, + SystemProviderDef { + id: "github", + name: "Github Models", + provider_type: ApiProviderType::Openai, + api_host: "https://models.github.ai/inference", + group: ProviderGroup::Cloud, + sort_order: 29, + api_version: None, + }, + SystemProviderDef { + id: "copilot", + name: "Github Copilot", + provider_type: ApiProviderType::Openai, + api_host: "https://api.githubcopilot.com/", + group: ProviderGroup::Cloud, + sort_order: 30, + api_version: None, + }, + // ========================================================================= + // API 聚合/中转服务 (25个) - Requirements 3.4 + // ========================================================================= + SystemProviderDef { + id: "silicon", + name: "Silicon Flow", + provider_type: ApiProviderType::Openai, + api_host: "https://api.siliconflow.cn", + group: ProviderGroup::Aggregator, + sort_order: 31, + api_version: None, + }, + SystemProviderDef { + id: "openrouter", + name: "OpenRouter", + provider_type: ApiProviderType::Openai, + api_host: "https://openrouter.ai/api/v1/", + group: ProviderGroup::Aggregator, + sort_order: 32, + api_version: None, + }, + SystemProviderDef { + id: "aihubmix", + name: "AiHubMix", + provider_type: ApiProviderType::Openai, + api_host: "https://aihubmix.com", + group: ProviderGroup::Aggregator, + sort_order: 33, + api_version: None, + }, + SystemProviderDef { + id: "302ai", + name: "302.AI", + provider_type: ApiProviderType::Openai, + api_host: "https://api.302.ai", + group: ProviderGroup::Aggregator, + sort_order: 34, + api_version: None, + }, + SystemProviderDef { + id: "together", + name: "Together", + provider_type: ApiProviderType::Openai, + api_host: "https://api.together.xyz", + group: ProviderGroup::Aggregator, + sort_order: 35, + api_version: None, + }, + SystemProviderDef { + id: "fireworks", + name: "Fireworks", + provider_type: ApiProviderType::Openai, + api_host: "https://api.fireworks.ai/inference", + group: ProviderGroup::Aggregator, + sort_order: 36, + api_version: None, + }, + SystemProviderDef { + id: "nvidia", + name: "NVIDIA", + provider_type: ApiProviderType::Openai, + api_host: "https://integrate.api.nvidia.com", + group: ProviderGroup::Aggregator, + sort_order: 37, + api_version: None, + }, + SystemProviderDef { + id: "hyperbolic", + name: "Hyperbolic", + provider_type: ApiProviderType::Openai, + api_host: "https://api.hyperbolic.xyz", + group: ProviderGroup::Aggregator, + sort_order: 38, + api_version: None, + }, + SystemProviderDef { + id: "cerebras", + name: "Cerebras", + provider_type: ApiProviderType::Openai, + api_host: "https://api.cerebras.ai/v1", + group: ProviderGroup::Aggregator, + sort_order: 39, + api_version: None, + }, + SystemProviderDef { + id: "ppio", + name: "PPIO", + provider_type: ApiProviderType::Openai, + api_host: "https://api.ppinfra.com/v3/openai/", + group: ProviderGroup::Aggregator, + sort_order: 40, + api_version: None, + }, + SystemProviderDef { + id: "qiniu", + name: "七牛 (Qiniu)", + provider_type: ApiProviderType::Openai, + api_host: "https://api.qnaigc.com", + group: ProviderGroup::Aggregator, + sort_order: 41, + api_version: None, + }, + SystemProviderDef { + id: "tokenflux", + name: "TokenFlux", + provider_type: ApiProviderType::Openai, + api_host: "https://api.tokenflux.ai/openai/v1", + group: ProviderGroup::Aggregator, + sort_order: 42, + api_version: None, + }, + SystemProviderDef { + id: "cephalon", + name: "Cephalon", + provider_type: ApiProviderType::Openai, + api_host: "https://cephalon.cloud/user-center/v1/model", + group: ProviderGroup::Aggregator, + sort_order: 43, + api_version: None, + }, + SystemProviderDef { + id: "lanyun", + name: "蓝云 (Lanyun)", + provider_type: ApiProviderType::Openai, + api_host: "https://maas-api.lanyun.net", + group: ProviderGroup::Aggregator, + sort_order: 44, + api_version: None, + }, + SystemProviderDef { + id: "ph8", + name: "PH8", + provider_type: ApiProviderType::Openai, + api_host: "https://ph8.co", + group: ProviderGroup::Aggregator, + sort_order: 45, + api_version: None, + }, + SystemProviderDef { + id: "sophnet", + name: "SophNet", + provider_type: ApiProviderType::Openai, + api_host: "https://www.sophnet.com/api/open-apis/v1", + group: ProviderGroup::Aggregator, + sort_order: 46, + api_version: None, + }, + SystemProviderDef { + id: "ocoolai", + name: "ocoolAI", + provider_type: ApiProviderType::Openai, + api_host: "https://api.ocoolai.com", + group: ProviderGroup::Aggregator, + sort_order: 47, + api_version: None, + }, + SystemProviderDef { + id: "dmxapi", + name: "DMXAPI", + provider_type: ApiProviderType::Openai, + api_host: "https://www.dmxapi.cn", + group: ProviderGroup::Aggregator, + sort_order: 48, + api_version: None, + }, + SystemProviderDef { + id: "aionly", + name: "AIOnly", + provider_type: ApiProviderType::Openai, + api_host: "https://api.aiionly.com", + group: ProviderGroup::Aggregator, + sort_order: 49, + api_version: None, + }, + SystemProviderDef { + id: "burncloud", + name: "BurnCloud", + provider_type: ApiProviderType::Openai, + api_host: "https://ai.burncloud.com", + group: ProviderGroup::Aggregator, + sort_order: 50, + api_version: None, + }, + SystemProviderDef { + id: "alayanew", + name: "AlayaNew", + provider_type: ApiProviderType::Openai, + api_host: "https://deepseek.alayanew.com", + group: ProviderGroup::Aggregator, + sort_order: 51, + api_version: None, + }, + SystemProviderDef { + id: "longcat", + name: "LongCat", + provider_type: ApiProviderType::Openai, + api_host: "https://api.longcat.chat/openai", + group: ProviderGroup::Aggregator, + sort_order: 52, + api_version: None, + }, + SystemProviderDef { + id: "poe", + name: "Poe", + provider_type: ApiProviderType::Openai, + api_host: "https://api.poe.com/v1/", + group: ProviderGroup::Aggregator, + sort_order: 53, + api_version: None, + }, + SystemProviderDef { + id: "huggingface", + name: "Hugging Face", + provider_type: ApiProviderType::OpenaiResponse, + api_host: "https://router.huggingface.co/v1/", + group: ProviderGroup::Aggregator, + sort_order: 54, + api_version: None, + }, + SystemProviderDef { + id: "vercel-gateway", + name: "Vercel AI Gateway", + provider_type: ApiProviderType::Gateway, + api_host: "https://ai-gateway.vercel.sh/v1/ai", + group: ProviderGroup::Aggregator, + sort_order: 55, + api_version: None, + }, + // ========================================================================= + // 本地/自托管服务 (5个) - Requirements 3.5 + // ========================================================================= + SystemProviderDef { + id: "ollama", + name: "Ollama", + provider_type: ApiProviderType::Ollama, + api_host: "http://localhost:11434", + group: ProviderGroup::Local, + sort_order: 56, + api_version: None, + }, + SystemProviderDef { + id: "lmstudio", + name: "LM Studio", + provider_type: ApiProviderType::Openai, + api_host: "http://localhost:1234", + group: ProviderGroup::Local, + sort_order: 57, + api_version: None, + }, + SystemProviderDef { + id: "new-api", + name: "New API", + provider_type: ApiProviderType::NewApi, + api_host: "http://localhost:3000", + group: ProviderGroup::Local, + sort_order: 58, + api_version: None, + }, + SystemProviderDef { + id: "gpustack", + name: "GPUStack", + provider_type: ApiProviderType::Openai, + api_host: "", + group: ProviderGroup::Local, + sort_order: 59, + api_version: None, + }, + SystemProviderDef { + id: "ovms", + name: "OpenVINO Model Server", + provider_type: ApiProviderType::Openai, + api_host: "http://localhost:8000/v3/", + group: ProviderGroup::Local, + sort_order: 60, + api_version: None, + }, + // ========================================================================= + // 专用服务 (3个) - Requirements 3.6 + // ========================================================================= + SystemProviderDef { + id: "jina", + name: "Jina (Embedding/Rerank)", + provider_type: ApiProviderType::Openai, + api_host: "https://api.jina.ai", + group: ProviderGroup::Specialized, + sort_order: 61, + api_version: None, + }, + SystemProviderDef { + id: "voyageai", + name: "VoyageAI (Embedding)", + provider_type: ApiProviderType::Openai, + api_host: "https://api.voyageai.com", + group: ProviderGroup::Specialized, + sort_order: 62, + api_version: None, + }, + SystemProviderDef { + id: "cherryin", + name: "CherryIN", + provider_type: ApiProviderType::Openai, + api_host: "https://open.cherryin.net", + group: ProviderGroup::Specialized, + sort_order: 63, + api_version: None, + }, + ] +} + +/// 将 SystemProviderDef 转换为 ApiKeyProvider +pub fn to_api_key_provider(def: &SystemProviderDef) -> ApiKeyProvider { + let now = Utc::now(); + ApiKeyProvider { + id: def.id.to_string(), + name: def.name.to_string(), + provider_type: def.provider_type, + api_host: def.api_host.to_string(), + is_system: true, + group: def.group, + enabled: false, + sort_order: def.sort_order, + api_version: def.api_version.map(|s| s.to_string()), + project: None, + location: None, + region: None, + created_at: now, + updated_at: now, + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 025342df8..b898cc8fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,11 @@ pub mod agent; +pub mod backends; pub mod browser_interceptor; mod commands; mod config; mod converter; pub mod credential; -mod database; +pub mod database; pub mod flow_monitor; pub mod injection; mod logger; @@ -18,9 +19,11 @@ pub mod resilience; pub mod router; mod server; mod server_utils; -mod services; +pub mod services; +pub mod stream; pub mod streaming; pub mod telemetry; +pub mod translator; pub mod tray; pub mod websocket; @@ -30,6 +33,7 @@ use tauri::{Manager, Runtime}; use tokio::sync::RwLock; use agent::NativeAgentState; +use commands::api_key_provider_cmd::ApiKeyProviderServiceState; use commands::browser_interceptor_cmd::BrowserInterceptorState; use commands::flow_monitor_cmd::{ BatchOperationsState, BookmarkManagerState, EnhancedStatsServiceState, FlowInterceptorState, @@ -48,6 +52,7 @@ use flow_monitor::{ FlowMonitor, FlowMonitorConfig, FlowQueryService, FlowReplayer, InterceptConfig, QuickFilterManager, SessionManager, }; +use services::api_key_provider_service::ApiKeyProviderService; use services::provider_pool_service::ProviderPoolService; use services::skill_service::SkillService; use services::token_cache_service::TokenCacheService; @@ -1528,6 +1533,11 @@ pub fn run() { let provider_pool_service = ProviderPoolService::new(); let provider_pool_service_state = ProviderPoolServiceState(Arc::new(provider_pool_service)); + // Initialize ApiKeyProviderService + let api_key_provider_service = ApiKeyProviderService::new(); + let api_key_provider_service_state = + ApiKeyProviderServiceState(Arc::new(api_key_provider_service)); + // Initialize CredentialSyncService (optional - only if config manager is available) // For now, we initialize it as None since ConfigManager requires async setup // This can be enhanced later to properly initialize with ConfigManager @@ -1775,6 +1785,7 @@ pub fn run() { .manage(db) .manage(skill_service_state) .manage(provider_pool_service_state) + .manage(api_key_provider_service_state) .manage(credential_sync_service_state) .manage(token_cache_service_state) .manage(machine_id_service_state) @@ -2170,6 +2181,24 @@ pub fn run() { commands::provider_pool_cmd::install_playwright, commands::provider_pool_cmd::start_kiro_playwright_login, commands::provider_pool_cmd::cancel_kiro_playwright_login, + // API Key Provider commands + commands::api_key_provider_cmd::get_api_key_providers, + commands::api_key_provider_cmd::get_api_key_provider, + commands::api_key_provider_cmd::add_custom_api_key_provider, + commands::api_key_provider_cmd::update_api_key_provider, + commands::api_key_provider_cmd::delete_custom_api_key_provider, + commands::api_key_provider_cmd::add_api_key, + commands::api_key_provider_cmd::delete_api_key, + commands::api_key_provider_cmd::toggle_api_key, + commands::api_key_provider_cmd::update_api_key_alias, + commands::api_key_provider_cmd::get_next_api_key, + commands::api_key_provider_cmd::record_api_key_usage, + commands::api_key_provider_cmd::record_api_key_error, + commands::api_key_provider_cmd::get_provider_ui_state, + commands::api_key_provider_cmd::set_provider_ui_state, + commands::api_key_provider_cmd::update_provider_sort_orders, + commands::api_key_provider_cmd::export_api_key_providers, + commands::api_key_provider_cmd::import_api_key_providers, // Route commands commands::route_cmd::get_available_routes, commands::route_cmd::get_route_curl_examples, diff --git a/src-tauri/src/providers/claude_custom.rs b/src-tauri/src/providers/claude_custom.rs index ed56b4691..50167bcd1 100644 --- a/src-tauri/src/providers/claude_custom.rs +++ b/src-tauri/src/providers/claude_custom.rs @@ -409,12 +409,36 @@ impl StreamingProvider for ClaudeCustomProvider { // 转换 OpenAI 请求为 Anthropic 格式 let mut anthropic_messages = Vec::new(); let mut system_content = None; + // 收集 tool 角色消息的 tool_result,稍后合并到 user 消息中 + let mut pending_tool_results: Vec = Vec::new(); for msg in &request.messages { let role = &msg.role; + // 处理 tool 角色消息(工具调用结果) + if role == "tool" { + // 转换为 Anthropic tool_result content block + let tool_call_id = msg.tool_call_id.clone().unwrap_or_default(); + let content = msg.get_content_text(); + pending_tool_results.push(serde_json::json!({ + "type": "tool_result", + "tool_use_id": tool_call_id, + "content": content + })); + continue; + } + + // 如果有待处理的 tool_results 且当前不是 assistant 消息,先添加一个 user 消息 + if !pending_tool_results.is_empty() && role != "assistant" { + anthropic_messages.push(serde_json::json!({ + "role": "user", + "content": pending_tool_results.clone() + })); + pending_tool_results.clear(); + } + // 提取消息内容,转换为 Anthropic 格式的 content 数组 - let content_blocks: Vec = match &msg.content { + let mut content_blocks: Vec = match &msg.content { Some(MessageContent::Text(text)) => { if text.is_empty() { vec![] @@ -443,6 +467,23 @@ impl StreamingProvider for ClaudeCustomProvider { None => vec![], }; + // 处理 assistant 消息中的 tool_calls + if role == "assistant" { + if let Some(ref tool_calls) = msg.tool_calls { + for tc in tool_calls { + // 解析 arguments JSON + let input: serde_json::Value = serde_json::from_str(&tc.function.arguments) + .unwrap_or(serde_json::json!({})); + content_blocks.push(serde_json::json!({ + "type": "tool_use", + "id": tc.id, + "name": tc.function.name, + "input": input + })); + } + } + } + if role == "system" { // system 消息只提取文本 let text = content_blocks @@ -464,6 +505,14 @@ impl StreamingProvider for ClaudeCustomProvider { } } + // 处理末尾的 tool_results + if !pending_tool_results.is_empty() { + anthropic_messages.push(serde_json::json!({ + "role": "user", + "content": pending_tool_results + })); + } + let mut anthropic_body = serde_json::json!({ "model": request.model, "max_tokens": request.max_tokens.unwrap_or(4096), @@ -475,6 +524,76 @@ impl StreamingProvider for ClaudeCustomProvider { anthropic_body["system"] = serde_json::json!(sys); } + // 转换 tools: OpenAI 格式 -> Anthropic 格式 + if let Some(ref tools) = request.tools { + let anthropic_tools: Vec = tools + .iter() + .filter_map(|tool| { + match tool { + crate::models::openai::Tool::Function { function } => { + Some(serde_json::json!({ + "name": function.name, + "description": function.description.clone().unwrap_or_default(), + "input_schema": function.parameters.clone().unwrap_or_else(|| serde_json::json!({"type": "object", "properties": {}})) + })) + } + // WebSearch 等其他工具类型暂不处理 + _ => None, + } + }) + .collect(); + + if !anthropic_tools.is_empty() { + anthropic_body["tools"] = serde_json::json!(anthropic_tools); + tracing::info!( + "[CLAUDE_STREAM] 添加 {} 个工具到请求", + anthropic_tools.len() + ); + } + } + + // 转换 tool_choice: OpenAI 格式 -> Anthropic 格式 + if let Some(ref tool_choice) = request.tool_choice { + let anthropic_tool_choice = match tool_choice { + serde_json::Value::String(s) => { + match s.as_str() { + "none" => Some(serde_json::json!({"type": "none"})), + "auto" => Some(serde_json::json!({"type": "auto"})), + "required" | "any" => Some(serde_json::json!({"type": "any"})), + _ => None, // 未知值,不设置 + } + } + serde_json::Value::Object(obj) => { + // 处理 {"type": "function", "function": {"name": "xxx"}} 格式 + if let Some(func) = obj.get("function") { + if let Some(name) = func.get("name").and_then(|n| n.as_str()) { + Some(serde_json::json!({"type": "tool", "name": name})) + } else { + None + } + } else if let Some(t) = obj.get("type").and_then(|t| t.as_str()) { + match t { + "any" | "tool" => Some(serde_json::json!({"type": "any"})), + "auto" => Some(serde_json::json!({"type": "auto"})), + "none" => Some(serde_json::json!({"type": "none"})), + _ => None, + } + } else { + None + } + } + _ => None, + }; + + if let Some(tc) = anthropic_tool_choice { + anthropic_body["tool_choice"] = tc; + tracing::info!( + "[CLAUDE_STREAM] 设置 tool_choice: {:?}", + anthropic_body["tool_choice"] + ); + } + } + let url = self.build_url("messages"); tracing::info!( diff --git a/src-tauri/src/providers/kiro.rs b/src-tauri/src/providers/kiro.rs index 0c74e2b9b..fc498ffc6 100644 --- a/src-tauri/src/providers/kiro.rs +++ b/src-tauri/src/providers/kiro.rs @@ -2,9 +2,12 @@ #![allow(dead_code)] -use crate::converter::openai_to_cw::convert_openai_to_codewhisperer; +// 使用新的 translator 模块替代旧的 converter +use crate::models::anthropic::AnthropicMessagesRequest; use crate::models::openai::*; use crate::providers::traits::{CredentialProvider, ProviderResult}; +use crate::translator::kiro::anthropic::request::convert_anthropic_to_codewhisperer; +use crate::translator::kiro::openai::request::convert_openai_to_codewhisperer; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -1226,3 +1229,90 @@ impl StreamingProvider for KiroProvider { StreamFormat::AwsEventStream } } + +// ============================================================================ +// Anthropic 格式直接支持 +// ============================================================================ + +impl KiroProvider { + /// 直接处理 Anthropic 格式的流式请求 + /// + /// 绕过 OpenAI 中间格式,直接从 Anthropic → CodeWhisperer + /// 这样可以保留 Anthropic 特有的字段(如 tool_choice) + pub async fn call_api_stream_anthropic( + &self, + request: &AnthropicMessagesRequest, + ) -> Result { + let token = self + .credentials + .access_token + .as_ref() + .ok_or_else(|| ProviderError::AuthenticationError("No access token".to_string()))?; + + let profile_arn = if self.credentials.auth_method.as_deref() == Some("social") { + self.credentials.profile_arn.clone() + } else { + None + }; + + // 直接转换 Anthropic → CodeWhisperer(不经过 OpenAI) + let cw_request = convert_anthropic_to_codewhisperer(request, profile_arn.clone()); + let url = self.get_base_url(); + + // 生成基于凭证的唯一 Machine ID + let machine_id = generate_machine_id_from_credentials( + profile_arn.as_deref(), + self.credentials.client_id.as_deref(), + ); + let kiro_version = get_kiro_version(); + let (os_name, node_version) = get_system_runtime_info(); + + tracing::info!( + "[KIRO_STREAM_ANTHROPIC] 直接 Anthropic→CodeWhisperer 流式请求: url={} machine_id={}...", + url, + &machine_id[..16] + ); + + let resp = self + .client + .post(&url) + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .header("Accept", "application/vnd.amazon.eventstream") + .header("amz-sdk-invocation-id", uuid::Uuid::new_v4().to_string()) + .header("amz-sdk-request", "attempt=1; max=1") + .header("x-amzn-kiro-agent-mode", "vibe") + .header( + "x-amz-user-agent", + format!("aws-sdk-js/1.0.0 KiroIDE-{kiro_version}-{machine_id}"), + ) + .header( + "user-agent", + format!( + "aws-sdk-js/1.0.0 ua/2.1 os/{os_name} lang/js md/nodejs#{node_version} api/codewhispererruntime#1.0.0 m/E KiroIDE-{kiro_version}-{machine_id}" + ), + ) + .json(&cw_request) + .send() + .await + .map_err(|e| { + tracing::error!("[KIRO_STREAM_ANTHROPIC] 请求发送失败: {}", e); + ProviderError::from_reqwest_error(&e) + })?; + + tracing::info!("[KIRO_STREAM_ANTHROPIC] 收到响应: status={}", resp.status()); + + // 检查响应状态 + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + tracing::error!("[KIRO_STREAM_ANTHROPIC] 请求失败: {} - {}", status, body); + return Err(ProviderError::from_http_status(status.as_u16(), &body)); + } + + tracing::info!("[KIRO_STREAM_ANTHROPIC] 流式响应开始: status={}", status); + + // 将 reqwest 响应转换为 StreamResponse + Ok(reqwest_stream_to_stream_response(resp)) + } +} diff --git a/src-tauri/src/server/handlers/provider_calls.rs b/src-tauri/src/server/handlers/provider_calls.rs index b8028e32d..e929c9299 100644 --- a/src-tauri/src/server/handlers/provider_calls.rs +++ b/src-tauri/src/server/handlers/provider_calls.rs @@ -66,10 +66,11 @@ use crate::server_utils::{ build_anthropic_response, build_anthropic_stream_response, parse_cw_response, safe_truncate, CWParsedResponse, }; +use crate::stream::{PipelineConfig, StreamPipeline}; use crate::streaming::traits::StreamingProvider; use crate::streaming::{ - AnthropicSseGenerator, AwsEvent, AwsEventStreamParser, StreamConfig, StreamContext, - StreamError, StreamFormat as StreamingFormat, StreamManager, StreamResponse, + StreamConfig, StreamContext, StreamError, StreamFormat as StreamingFormat, StreamManager, + StreamResponse, }; /// 根据凭证调用 Provider (Anthropic 格式) @@ -765,6 +766,27 @@ pub async fn call_provider_openai( _flow_id: Option<&str>, ) -> Response { let _start_time = std::time::Instant::now(); + + // 调试:打印凭证类型 + let cred_type = match &credential.credential { + CredentialData::KiroOAuth { .. } => "KiroOAuth", + CredentialData::ClaudeKey { .. } => "ClaudeKey", + CredentialData::OpenAIKey { .. } => "OpenAIKey", + CredentialData::GeminiOAuth { .. } => "GeminiOAuth", + CredentialData::GeminiApiKey { .. } => "GeminiApiKey", + CredentialData::VertexKey { .. } => "VertexKey", + CredentialData::QwenOAuth { .. } => "QwenOAuth", + CredentialData::AntigravityOAuth { .. } => "AntigravityOAuth", + _ => "Other", + }; + tracing::info!( + "[CALL_PROVIDER_OPENAI] 凭证类型={}, 凭证名称={:?}, provider_type={}, uuid={}", + cred_type, + credential.name, + credential.provider_type, + &credential.uuid[..8] + ); + match &credential.credential { CredentialData::KiroOAuth { creds_file_path } => { // 优先使用 token cache,避免每次都刷新 token @@ -842,17 +864,15 @@ pub async fn call_provider_openai( tracing::info!("[OPENAI_STREAM] 开始转换流式响应"); - // 创建 StreamConverter 将 AWS Event Stream 转换为 OpenAI SSE - let converter = std::sync::Arc::new(tokio::sync::Mutex::new( - crate::streaming::converter::StreamConverter::with_model( - crate::streaming::converter::StreamFormat::AwsEventStream, - crate::streaming::converter::StreamFormat::OpenAiSse, - &request.model, - ), + // 使用新的统一流处理管道 (Kiro → OpenAI) + let config = PipelineConfig::kiro_to_openai(request.model.clone()); + let pipeline = std::sync::Arc::new(tokio::sync::Mutex::new( + StreamPipeline::new(config), )); // 创建转换流 - let converter_for_stream = converter.clone(); + let pipeline_for_stream = pipeline.clone(); + let pipeline_for_finalize = pipeline.clone(); let final_stream = async_stream::stream! { use futures::StreamExt; @@ -866,10 +886,10 @@ pub async fn call_provider_openai( bytes.len() ); - // 转换 chunk + // 使用 Pipeline 处理 chunk let sse_events = { - let mut converter_guard = converter_for_stream.lock().await; - converter_guard.convert(&bytes) + let mut pipeline_guard = pipeline_for_stream.lock().await; + pipeline_guard.process_chunk(&bytes) }; tracing::debug!( @@ -879,7 +899,7 @@ pub async fn call_provider_openai( // yield 每个 SSE 事件 for sse_str in sse_events { - yield Ok::(sse_str); + yield Ok::(sse_str); } } Err(e) => { @@ -892,16 +912,16 @@ pub async fn call_provider_openai( tracing::info!("[OPENAI_STREAM] 流结束,生成 finalize 事件"); - // 流结束,生成结束事件 + // 流结束,使用 Pipeline 生成结束事件 let final_events = { - let mut converter_guard = converter_for_stream.lock().await; - converter_guard.finish() + let mut pipeline_guard = pipeline_for_finalize.lock().await; + pipeline_guard.finish() }; tracing::info!("[OPENAI_STREAM] finalize 生成 {} 个事件", final_events.len()); for sse_str in final_events { - yield Ok::(sse_str); + yield Ok::(sse_str); } }; @@ -1954,13 +1974,10 @@ pub async fn handle_kiro_stream( // 使用缓存的 token 覆盖文件中的 token(缓存的 token 更新) kiro.credentials.access_token = Some(token); - // 转换请求格式 - let openai_request = convert_anthropic_to_openai(request); + tracing::info!("[KIRO_STREAM] 准备调用 call_api_stream_anthropic (直接转换)"); - tracing::info!("[KIRO_STREAM] 准备调用 call_api_stream"); - - // 调用流式 API(需求 4.1, 4.2, 4.3: 401/403 错误重试逻辑) - let stream_response = match kiro.call_api_stream(&openai_request).await { + // 调用流式 API - 直接使用 Anthropic 格式(需求 4.1, 4.2, 4.3: 401/403 错误重试逻辑) + let stream_response = match kiro.call_api_stream_anthropic(request).await { Ok(stream) => { tracing::info!("[KIRO_STREAM] call_api_stream 成功返回流"); stream @@ -2009,7 +2026,7 @@ pub async fn handle_kiro_stream( // 使用新 token 重试(需求 4.2) kiro.credentials.access_token = Some(new_token); - match kiro.call_api_stream(&openai_request).await { + match kiro.call_api_stream_anthropic(request).await { Ok(stream) => stream, Err(retry_err) => { let _ = state.pool_service.mark_unhealthy( @@ -2060,24 +2077,20 @@ pub async fn handle_kiro_stream( flow_id ); - // 创建 AWS Event Stream 解析器和 Anthropic SSE 生成器 - let parser = std::sync::Arc::new(tokio::sync::Mutex::new(AwsEventStreamParser::new())); - let generator = std::sync::Arc::new(tokio::sync::Mutex::new(AnthropicSseGenerator::new( - &request.model, - ))); + // 使用新的统一流处理管道 (Kiro → Anthropic) + let config = PipelineConfig::kiro_to_anthropic(request.model.clone()); + let pipeline = std::sync::Arc::new(tokio::sync::Mutex::new(StreamPipeline::new(config))); // 获取 flow_id 的克隆用于回调 let flow_id_owned = flow_id.map(|s| s.to_string()); let flow_monitor = state.flow_monitor.clone(); - // 创建转换流 - 使用 map 而不是 then,避免异步闭包的复杂性 - let parser_clone = parser.clone(); - let generator_clone = generator.clone(); + // 创建转换流 + let pipeline_clone = pipeline.clone(); let flow_id_for_stream = flow_id_owned.clone(); let flow_monitor_for_stream = flow_monitor.clone(); - // 使用 async_stream 直接处理整个流 - let generator_for_finalize = generator.clone(); + let pipeline_for_finalize = pipeline.clone(); let flow_id_for_finalize = flow_id_owned.clone(); let flow_monitor_for_finalize = flow_monitor.clone(); @@ -2089,112 +2102,60 @@ pub async fn handle_kiro_stream( while let Some(chunk_result) = stream_response.next().await { match chunk_result { Ok(bytes) => { - // 调试日志:记录接收到的字节数和原始数据预览 - let bytes_preview = if bytes.len() > 200 { - format!("{}...", String::from_utf8_lossy(&bytes[..200])) - } else { - String::from_utf8_lossy(&bytes).to_string() - }; - tracing::info!( - "[KIRO_STREAM] 收到 {} 字节数据, 预览: {}", - bytes.len(), - bytes_preview.replace('\n', "\\n") + // 调试日志:记录接收到的字节数 + tracing::debug!( + "[KIRO_STREAM] 收到 {} 字节数据", + bytes.len() ); - // 解析 AWS Event Stream - let events = { - let mut parser_guard = parser_clone.lock().await; - parser_guard.process(&bytes) + // 使用 Pipeline 处理字节块 + let sse_strings = { + let mut pipeline_guard = pipeline_clone.lock().await; + pipeline_guard.process_chunk(&bytes) }; - // 调试日志:记录解析出的事件数量 - tracing::info!( - "[KIRO_STREAM] 解析出 {} 个事件", - events.len() + // 调试日志:记录生成的 SSE 事件数量 + tracing::debug!( + "[KIRO_STREAM] 生成 {} 个 SSE 事件", + sse_strings.len() ); - // 转换为 Anthropic SSE 事件 - for event in events { - // 需求 5.2: 当 AWS Event Stream 解析失败时记录警告,跳过无效数据继续处理 - if let AwsEvent::ParseError { message, raw_data } = &event { - tracing::warn!( - "[KIRO_STREAM] AWS Event Stream 解析错误: {}, 原始数据: {:?}", - message, - raw_data.as_ref().map(|s| if s.len() > 100 { &s[..100] } else { s }) - ); - // 跳过无效数据,继续处理后续 chunks - continue; - } + for sse_str in sse_strings { + // 调用 FlowMonitor.process_chunk()(需求 3.2) + if let Some(ref fid) = flow_id_for_stream { + // 解析 SSE 事件类型和数据 + let lines: Vec<&str> = sse_str.lines().collect(); + let mut event_type: Option<&str> = None; + let mut data: Option<&str> = None; - // 调试日志:记录事件类型 - tracing::info!( - "[KIRO_STREAM] 处理事件: {:?}", - match &event { - AwsEvent::Content { text } => format!("Content({}字符): {}", text.len(), if text.len() > 50 { &text[..50] } else { text }), - AwsEvent::ToolUseStart { id, name } => format!("ToolUseStart({}, {})", id, name), - AwsEvent::ToolUseInput { id, input } => format!("ToolUseInput({}, {}字符)", id, input.len()), - AwsEvent::ToolUseStop { id } => format!("ToolUseStop({})", id), - AwsEvent::Stop => "Stop".to_string(), - AwsEvent::Usage { credits, context_percentage } => format!("Usage({}, {})", credits, context_percentage), - AwsEvent::FollowupPrompt { content } => format!("FollowupPrompt({}字符)", content.len()), - AwsEvent::ParseError { message, .. } => format!("ParseError({})", message), - } - ); - - let sse_strings = { - let mut generator_guard = generator_clone.lock().await; - generator_guard.process_event(event) - }; - - // 调试日志:记录生成的 SSE 事件数量和内容预览 - tracing::info!( - "[KIRO_STREAM] 生成 {} 个 SSE 事件", - sse_strings.len() - ); - - for sse_str in sse_strings { - let preview = if sse_str.len() > 200 { &sse_str[..200] } else { &sse_str }; - tracing::info!( - "[KIRO_STREAM] SSE 事件: {}", - preview.replace('\n', "\\n") - ); - - // 调用 FlowMonitor.process_chunk()(需求 3.2) - if let Some(ref fid) = flow_id_for_stream { - // 解析 SSE 事件类型和数据 - let lines: Vec<&str> = sse_str.lines().collect(); - let mut event_type: Option<&str> = None; - let mut data: Option<&str> = None; - - for line in &lines { - if line.starts_with("event: ") { - event_type = Some(&line[7..]); - } else if line.starts_with("data: ") { - data = Some(&line[6..]); - } - } - - if let Some(d) = data { - let flow_monitor_clone = flow_monitor_for_stream.clone(); - let fid_clone = fid.clone(); - let event_type_owned = event_type.map(|s| s.to_string()); - let data_owned = d.to_string(); - - tokio::spawn(async move { - flow_monitor_clone - .process_chunk( - &fid_clone, - event_type_owned.as_deref(), - &data_owned, - ) - .await; - }); + for line in &lines { + if line.starts_with("event: ") { + event_type = Some(&line[7..]); + } else if line.starts_with("data: ") { + data = Some(&line[6..]); } } - // 立即 yield SSE 事件 - yield Ok::(sse_str); + if let Some(d) = data { + let flow_monitor_clone = flow_monitor_for_stream.clone(); + let fid_clone = fid.clone(); + let event_type_owned = event_type.map(|s| s.to_string()); + let data_owned = d.to_string(); + + tokio::spawn(async move { + flow_monitor_clone + .process_chunk( + &fid_clone, + event_type_owned.as_deref(), + &data_owned, + ) + .await; + }); + } } + + // 立即 yield SSE 事件 + yield Ok::(sse_str); } } Err(e) => { @@ -2232,19 +2193,15 @@ pub async fn handle_kiro_stream( tracing::info!("[KIRO_STREAM] 流结束,生成 finalize 事件"); - // 流结束,生成 finalize 事件 + // 流结束,使用 Pipeline 生成 finalize 事件 let final_events = { - let mut generator_guard = generator_for_finalize.lock().await; - generator_guard.finalize() + let mut pipeline_guard = pipeline_for_finalize.lock().await; + pipeline_guard.finish() }; - tracing::info!("[KIRO_STREAM] finalize 生成 {} 个事件", final_events.len()); + tracing::debug!("[KIRO_STREAM] finalize 生成 {} 个事件", final_events.len()); for sse_str in final_events { - tracing::info!( - "[KIRO_STREAM] finalize 事件: {}", - if sse_str.len() > 200 { &sse_str[..200] } else { &sse_str }.replace('\n', "\\n") - ); // 调用 FlowMonitor.process_chunk() if let Some(ref fid) = flow_id_for_finalize { let lines: Vec<&str> = sse_str.lines().collect(); diff --git a/src-tauri/src/services/api_key_provider_service.rs b/src-tauri/src/services/api_key_provider_service.rs new file mode 100644 index 000000000..a07ba6755 --- /dev/null +++ b/src-tauri/src/services/api_key_provider_service.rs @@ -0,0 +1,650 @@ +//! API Key Provider 管理服务 +//! +//! 提供 API Key Provider 的 CRUD 操作、加密存储和轮询负载均衡功能。 +//! +//! **Feature: provider-ui-refactor** +//! **Validates: Requirements 7.3, 9.1, 9.2, 9.3** + +use crate::database::dao::api_key_provider::{ + ApiKeyEntry, ApiKeyProvider, ApiKeyProviderDao, ApiProviderType, ProviderGroup, + ProviderWithKeys, +}; +use crate::database::system_providers::{get_system_providers, to_api_key_provider}; +use crate::database::DbConnection; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use chrono::Utc; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::RwLock; + +// ============================================================================ +// 加密服务 +// ============================================================================ + +/// 简单的 API Key 加密服务 +/// 使用 XOR 加密 + Base64 编码 +/// 注意:这是一个简单的混淆方案,不是强加密 +struct EncryptionService { + /// 加密密钥(从机器 ID 派生) + key: Vec, +} + +impl EncryptionService { + /// 创建新的加密服务 + fn new() -> Self { + // 使用机器特定信息生成密钥 + let machine_id = Self::get_machine_id(); + let mut hasher = Sha256::new(); + hasher.update(machine_id.as_bytes()); + hasher.update(b"proxycast-api-key-encryption-salt"); + let key = hasher.finalize().to_vec(); + + Self { key } + } + + /// 获取机器 ID + fn get_machine_id() -> String { + // 尝试获取机器 ID,失败则使用默认值 + if let Ok(id) = std::fs::read_to_string("/etc/machine-id") { + return id.trim().to_string(); + } + if let Ok(id) = std::fs::read_to_string("/var/lib/dbus/machine-id") { + return id.trim().to_string(); + } + // macOS: 使用 IOPlatformUUID + #[cfg(target_os = "macos")] + { + if let Ok(output) = std::process::Command::new("ioreg") + .args(["-rd1", "-c", "IOPlatformExpertDevice"]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.contains("IOPlatformUUID") { + if let Some(uuid) = line.split('"').nth(3) { + return uuid.to_string(); + } + } + } + } + } + // 默认值 + "proxycast-default-machine-id".to_string() + } + + /// 加密 API Key + fn encrypt(&self, plaintext: &str) -> String { + let encrypted: Vec = plaintext + .as_bytes() + .iter() + .enumerate() + .map(|(i, b)| b ^ self.key[i % self.key.len()]) + .collect(); + BASE64.encode(encrypted) + } + + /// 解密 API Key + fn decrypt(&self, ciphertext: &str) -> Result { + let encrypted = BASE64 + .decode(ciphertext) + .map_err(|e| format!("Base64 解码失败: {}", e))?; + let decrypted: Vec = encrypted + .iter() + .enumerate() + .map(|(i, b)| b ^ self.key[i % self.key.len()]) + .collect(); + String::from_utf8(decrypted).map_err(|e| format!("UTF-8 解码失败: {}", e)) + } + + /// 检查是否为加密后的值(非明文) + fn is_encrypted(&self, value: &str) -> bool { + // 加密后的值是 Base64 编码的,通常不包含常见的 API Key 前缀 + !value.starts_with("sk-") + && !value.starts_with("pk-") + && !value.starts_with("api-") + && BASE64.decode(value).is_ok() + } +} + +// ============================================================================ +// API Key Provider 服务 +// ============================================================================ + +/// API Key Provider 管理服务 +pub struct ApiKeyProviderService { + /// 加密服务 + encryption: EncryptionService, + /// 轮询索引(按 provider_id 分组) + round_robin_index: RwLock>, +} + +impl Default for ApiKeyProviderService { + fn default() -> Self { + Self::new() + } +} + +impl ApiKeyProviderService { + /// 创建新的服务实例 + pub fn new() -> Self { + Self { + encryption: EncryptionService::new(), + round_robin_index: RwLock::new(HashMap::new()), + } + } + + // ==================== Provider 操作 ==================== + + /// 初始化系统 Provider + /// 检查数据库中是否存在系统 Provider,如果不存在则插入 + /// **Validates: Requirements 9.3** + pub fn initialize_system_providers(&self, db: &DbConnection) -> Result { + let conn = db.lock().map_err(|e| e.to_string())?; + let system_providers = get_system_providers(); + let mut inserted_count = 0; + + for def in &system_providers { + // 检查是否已存在 + let existing = + ApiKeyProviderDao::get_provider_by_id(&conn, def.id).map_err(|e| e.to_string())?; + + if existing.is_none() { + // 插入新的系统 Provider + let provider = to_api_key_provider(def); + ApiKeyProviderDao::insert_provider(&conn, &provider).map_err(|e| e.to_string())?; + inserted_count += 1; + } + } + + if inserted_count > 0 { + tracing::info!("初始化了 {} 个系统 Provider", inserted_count); + } + + Ok(inserted_count) + } + + /// 获取所有 Provider(包含 API Keys) + /// 首次调用时会自动初始化系统 Provider + pub fn get_all_providers(&self, db: &DbConnection) -> Result, String> { + // 首先确保系统 Provider 已初始化 + self.initialize_system_providers(db)?; + + let conn = db.lock().map_err(|e| e.to_string())?; + let mut providers = + ApiKeyProviderDao::get_all_providers_with_keys(&conn).map_err(|e| e.to_string())?; + + // 解密 API Keys(用于前端显示掩码) + for provider in &mut providers { + for _key in &mut provider.api_keys { + // 保持加密状态,前端会显示掩码 + } + } + + Ok(providers) + } + + /// 获取单个 Provider(包含 API Keys) + pub fn get_provider( + &self, + db: &DbConnection, + id: &str, + ) -> Result, String> { + let conn = db.lock().map_err(|e| e.to_string())?; + let provider = + ApiKeyProviderDao::get_provider_by_id(&conn, id).map_err(|e| e.to_string())?; + + match provider { + Some(p) => { + let api_keys = ApiKeyProviderDao::get_api_keys_by_provider(&conn, id) + .map_err(|e| e.to_string())?; + Ok(Some(ProviderWithKeys { + provider: p, + api_keys, + })) + } + None => Ok(None), + } + } + + /// 添加自定义 Provider + pub fn add_custom_provider( + &self, + db: &DbConnection, + name: String, + provider_type: ApiProviderType, + api_host: String, + api_version: Option, + project: Option, + location: Option, + region: Option, + ) -> Result { + let now = Utc::now(); + let id = format!("custom-{}", uuid::Uuid::new_v4()); + + let provider = ApiKeyProvider { + id: id.clone(), + name, + provider_type, + api_host, + is_system: false, + group: ProviderGroup::Custom, + enabled: true, + sort_order: 9999, // 自定义 Provider 排在最后 + api_version, + project, + location, + region, + created_at: now, + updated_at: now, + }; + + let conn = db.lock().map_err(|e| e.to_string())?; + ApiKeyProviderDao::insert_provider(&conn, &provider).map_err(|e| e.to_string())?; + + Ok(provider) + } + + /// 更新 Provider 配置 + pub fn update_provider( + &self, + db: &DbConnection, + id: &str, + name: Option, + api_host: Option, + enabled: Option, + sort_order: Option, + api_version: Option, + project: Option, + location: Option, + region: Option, + ) -> Result { + let conn = db.lock().map_err(|e| e.to_string())?; + let mut provider = ApiKeyProviderDao::get_provider_by_id(&conn, id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Provider not found: {}", id))?; + + // 更新字段 + if let Some(n) = name { + provider.name = n; + } + if let Some(h) = api_host { + provider.api_host = h; + } + if let Some(e) = enabled { + provider.enabled = e; + } + if let Some(s) = sort_order { + provider.sort_order = s; + } + if let Some(v) = api_version { + provider.api_version = if v.is_empty() { None } else { Some(v) }; + } + if let Some(p) = project { + provider.project = if p.is_empty() { None } else { Some(p) }; + } + if let Some(l) = location { + provider.location = if l.is_empty() { None } else { Some(l) }; + } + if let Some(r) = region { + provider.region = if r.is_empty() { None } else { Some(r) }; + } + provider.updated_at = Utc::now(); + + ApiKeyProviderDao::update_provider(&conn, &provider).map_err(|e| e.to_string())?; + + Ok(provider) + } + + /// 删除自定义 Provider + /// 系统 Provider 不允许删除 + pub fn delete_custom_provider(&self, db: &DbConnection, id: &str) -> Result { + let conn = db.lock().map_err(|e| e.to_string())?; + + // 检查是否为系统 Provider + let provider = ApiKeyProviderDao::get_provider_by_id(&conn, id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Provider not found: {}", id))?; + + if provider.is_system { + return Err("不允许删除系统 Provider".to_string()); + } + + ApiKeyProviderDao::delete_provider(&conn, id).map_err(|e| e.to_string()) + } + + // ==================== API Key 操作 ==================== + + /// 添加 API Key + pub fn add_api_key( + &self, + db: &DbConnection, + provider_id: &str, + api_key: &str, + alias: Option, + ) -> Result { + // 验证 Provider 存在 + let conn = db.lock().map_err(|e| e.to_string())?; + let _ = ApiKeyProviderDao::get_provider_by_id(&conn, provider_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Provider not found: {}", provider_id))?; + + // 加密 API Key + let encrypted_key = self.encryption.encrypt(api_key); + + let now = Utc::now(); + let key = ApiKeyEntry { + id: uuid::Uuid::new_v4().to_string(), + provider_id: provider_id.to_string(), + api_key_encrypted: encrypted_key, + alias, + enabled: true, + usage_count: 0, + error_count: 0, + last_used_at: None, + created_at: now, + }; + + ApiKeyProviderDao::insert_api_key(&conn, &key).map_err(|e| e.to_string())?; + + Ok(key) + } + + /// 删除 API Key + pub fn delete_api_key(&self, db: &DbConnection, key_id: &str) -> Result { + let conn = db.lock().map_err(|e| e.to_string())?; + ApiKeyProviderDao::delete_api_key(&conn, key_id).map_err(|e| e.to_string()) + } + + /// 切换 API Key 启用状态 + pub fn toggle_api_key( + &self, + db: &DbConnection, + key_id: &str, + enabled: bool, + ) -> Result { + let conn = db.lock().map_err(|e| e.to_string())?; + let mut key = ApiKeyProviderDao::get_api_key_by_id(&conn, key_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("API Key not found: {}", key_id))?; + + key.enabled = enabled; + ApiKeyProviderDao::update_api_key(&conn, &key).map_err(|e| e.to_string())?; + + Ok(key) + } + + /// 更新 API Key 别名 + pub fn update_api_key_alias( + &self, + db: &DbConnection, + key_id: &str, + alias: Option, + ) -> Result { + let conn = db.lock().map_err(|e| e.to_string())?; + let mut key = ApiKeyProviderDao::get_api_key_by_id(&conn, key_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("API Key not found: {}", key_id))?; + + key.alias = alias; + ApiKeyProviderDao::update_api_key(&conn, &key).map_err(|e| e.to_string())?; + + Ok(key) + } + + // ==================== 轮询负载均衡 ==================== + + /// 获取下一个可用的 API Key(轮询负载均衡) + /// **Validates: Requirements 7.3** + pub fn get_next_api_key( + &self, + db: &DbConnection, + provider_id: &str, + ) -> Result, String> { + let conn = db.lock().map_err(|e| e.to_string())?; + + // 获取所有启用的 API Keys + let keys = ApiKeyProviderDao::get_enabled_api_keys_by_provider(&conn, provider_id) + .map_err(|e| e.to_string())?; + + if keys.is_empty() { + return Ok(None); + } + + // 获取或创建轮询索引 + let index = { + let mut indices = self.round_robin_index.write().map_err(|e| e.to_string())?; + indices + .entry(provider_id.to_string()) + .or_insert_with(|| AtomicUsize::new(0)) + .fetch_add(1, Ordering::SeqCst) + }; + + // 选择 API Key + let selected_key = &keys[index % keys.len()]; + + // 解密并返回 + let decrypted = self.encryption.decrypt(&selected_key.api_key_encrypted)?; + Ok(Some(decrypted)) + } + + /// 获取下一个可用的 API Key 条目(包含 ID,用于记录使用) + pub fn get_next_api_key_entry( + &self, + db: &DbConnection, + provider_id: &str, + ) -> Result, String> { + let conn = db.lock().map_err(|e| e.to_string())?; + + // 获取所有启用的 API Keys + let keys = ApiKeyProviderDao::get_enabled_api_keys_by_provider(&conn, provider_id) + .map_err(|e| e.to_string())?; + + if keys.is_empty() { + return Ok(None); + } + + // 获取或创建轮询索引 + let index = { + let mut indices = self.round_robin_index.write().map_err(|e| e.to_string())?; + indices + .entry(provider_id.to_string()) + .or_insert_with(|| AtomicUsize::new(0)) + .fetch_add(1, Ordering::SeqCst) + }; + + // 选择 API Key + let selected_key = &keys[index % keys.len()]; + + // 解密并返回 + let decrypted = self.encryption.decrypt(&selected_key.api_key_encrypted)?; + Ok(Some((selected_key.id.clone(), decrypted))) + } + + /// 记录 API Key 使用 + pub fn record_usage(&self, db: &DbConnection, key_id: &str) -> Result<(), String> { + let conn = db.lock().map_err(|e| e.to_string())?; + let key = ApiKeyProviderDao::get_api_key_by_id(&conn, key_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("API Key not found: {}", key_id))?; + + ApiKeyProviderDao::update_api_key_usage(&conn, key_id, key.usage_count + 1, Utc::now()) + .map_err(|e| e.to_string()) + } + + /// 记录 API Key 错误 + pub fn record_error(&self, db: &DbConnection, key_id: &str) -> Result<(), String> { + let conn = db.lock().map_err(|e| e.to_string())?; + ApiKeyProviderDao::increment_api_key_error(&conn, key_id).map_err(|e| e.to_string()) + } + + // ==================== 加密相关 ==================== + + /// 检查 API Key 是否已加密 + pub fn is_encrypted(&self, value: &str) -> bool { + self.encryption.is_encrypted(value) + } + + /// 解密 API Key(用于 API 调用) + pub fn decrypt_api_key(&self, encrypted: &str) -> Result { + self.encryption.decrypt(encrypted) + } + + /// 加密 API Key(用于存储) + pub fn encrypt_api_key(&self, plaintext: &str) -> String { + self.encryption.encrypt(plaintext) + } + + // ==================== UI 状态 ==================== + + /// 获取 UI 状态 + pub fn get_ui_state(&self, db: &DbConnection, key: &str) -> Result, String> { + let conn = db.lock().map_err(|e| e.to_string())?; + ApiKeyProviderDao::get_ui_state(&conn, key).map_err(|e| e.to_string()) + } + + /// 设置 UI 状态 + pub fn set_ui_state(&self, db: &DbConnection, key: &str, value: &str) -> Result<(), String> { + let conn = db.lock().map_err(|e| e.to_string())?; + ApiKeyProviderDao::set_ui_state(&conn, key, value).map_err(|e| e.to_string()) + } + + /// 批量更新 Provider 排序顺序 + /// **Validates: Requirements 8.4** + pub fn update_provider_sort_orders( + &self, + db: &DbConnection, + sort_orders: Vec<(String, i32)>, + ) -> Result<(), String> { + let conn = db.lock().map_err(|e| e.to_string())?; + ApiKeyProviderDao::update_provider_sort_orders(&conn, &sort_orders) + .map_err(|e| e.to_string()) + } + + // ==================== 导入导出 ==================== + + /// 导出配置 + pub fn export_config( + &self, + db: &DbConnection, + include_keys: bool, + ) -> Result { + let conn = db.lock().map_err(|e| e.to_string())?; + let providers = + ApiKeyProviderDao::get_all_providers_with_keys(&conn).map_err(|e| e.to_string())?; + + let export_data = if include_keys { + // 包含 API Keys(但不包含实际的 key 值) + let providers_json: Vec = providers + .iter() + .map(|p| { + let keys: Vec = p + .api_keys + .iter() + .map(|k| { + serde_json::json!({ + "id": k.id, + "alias": k.alias, + "enabled": k.enabled, + }) + }) + .collect(); + serde_json::json!({ + "provider": p.provider, + "api_keys": keys, + }) + }) + .collect(); + serde_json::json!({ + "version": "1.0", + "exported_at": Utc::now().to_rfc3339(), + "providers": providers_json, + }) + } else { + // 不包含 API Keys + let providers_json: Vec = providers + .iter() + .map(|p| serde_json::json!(p.provider)) + .collect(); + serde_json::json!({ + "version": "1.0", + "exported_at": Utc::now().to_rfc3339(), + "providers": providers_json, + }) + }; + + Ok(export_data) + } + + /// 导入配置 + pub fn import_config( + &self, + db: &DbConnection, + config_json: &str, + ) -> Result { + let config: serde_json::Value = + serde_json::from_str(config_json).map_err(|e| format!("JSON 解析失败: {}", e))?; + + let providers = config["providers"] + .as_array() + .ok_or_else(|| "配置格式错误: 缺少 providers 数组".to_string())?; + + let conn = db.lock().map_err(|e| e.to_string())?; + let mut imported_providers = 0; + let mut skipped_providers = 0; + let mut errors = Vec::new(); + + for provider_json in providers { + let provider_data = if provider_json.get("provider").is_some() { + &provider_json["provider"] + } else { + provider_json + }; + + let id = provider_data["id"] + .as_str() + .ok_or_else(|| "Provider 缺少 id".to_string())?; + + // 检查是否已存在 + if ApiKeyProviderDao::get_provider_by_id(&conn, id) + .map_err(|e| e.to_string())? + .is_some() + { + skipped_providers += 1; + continue; + } + + // 解析 Provider + let provider: ApiKeyProvider = serde_json::from_value(provider_data.clone()) + .map_err(|e| format!("Provider 解析失败: {}", e))?; + + // 插入 Provider + if let Err(e) = ApiKeyProviderDao::insert_provider(&conn, &provider) { + errors.push(format!("导入 Provider {} 失败: {}", id, e)); + continue; + } + + imported_providers += 1; + } + + Ok(ImportResult { + success: errors.is_empty(), + imported_providers, + imported_api_keys: 0, // API Keys 不在导入中包含实际值 + skipped_providers, + errors, + }) + } +} + +/// 导入结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportResult { + pub success: bool, + pub imported_providers: usize, + pub imported_api_keys: usize, + pub skipped_providers: usize, + pub errors: Vec, +} + +use serde::{Deserialize, Serialize}; diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index b34c9bbed..e6f5d5229 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,3 +1,4 @@ +pub mod api_key_provider_service; pub mod backup_service; pub mod kiro_event_service; pub mod live_sync; diff --git a/src-tauri/src/stream/events.rs b/src-tauri/src/stream/events.rs new file mode 100644 index 000000000..504ab32fb --- /dev/null +++ b/src-tauri/src/stream/events.rs @@ -0,0 +1,317 @@ +//! 统一流事件类型 +//! +//! 定义流式传输的中间表示 (Intermediate Representation), +//! 用于解耦解析器 (parsers) 和生成器 (generators)。 +//! +//! # 设计原则 +//! +//! - Parsers 输出 `StreamEvent` +//! - Generators 消费 `StreamEvent` 生成目标格式 +//! - 不同后端的解析器都输出相同的 `StreamEvent` 类型 +//! - 不同前端的生成器都消费相同的 `StreamEvent` 类型 + +use serde::{Deserialize, Serialize}; + +/// 统一流事件类型 +/// +/// 作为不同协议之间的中间表示,解耦: +/// - 后端流格式解析 (AWS Event Stream, OpenAI SSE, Anthropic SSE) +/// - 前端流格式生成 (OpenAI SSE, Anthropic SSE, Gemini SSE) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum StreamEvent { + /// 消息开始 + /// + /// 表示一个新的消息/响应开始 + MessageStart { + /// 消息 ID + id: String, + /// 模型名称 + model: String, + }, + + /// 内容块开始 + /// + /// 表示一个新的内容块开始(文本或工具调用) + ContentBlockStart { + /// 内容块索引 + index: u32, + /// 内容块类型 + block_type: ContentBlockType, + }, + + /// 文本内容增量 + /// + /// 对应文本内容的增量输出 + TextDelta { + /// 文本内容 + text: String, + }, + + /// 工具调用开始 + /// + /// 表示一个新的工具调用开始 + ToolUseStart { + /// 工具调用 ID + id: String, + /// 工具名称 + name: String, + }, + + /// 工具调用参数增量 + /// + /// 工具调用参数的增量输出(部分 JSON) + ToolUseInputDelta { + /// 工具调用 ID + id: String, + /// 参数增量(部分 JSON 字符串) + partial_json: String, + }, + + /// 工具调用结束 + /// + /// 表示工具调用参数传输完成 + ToolUseStop { + /// 工具调用 ID + id: String, + }, + + /// 内容块结束 + /// + /// 表示一个内容块传输完成 + ContentBlockStop { + /// 内容块索引 + index: u32, + }, + + /// 消息结束 + /// + /// 表示整个消息/响应结束 + MessageStop { + /// 停止原因 + stop_reason: StopReason, + }, + + /// 使用量信息 + /// + /// Token 使用统计 + Usage { + /// 输入 token 数 + input_tokens: u32, + /// 输出 token 数 + output_tokens: u32, + /// 缓存读取 token 数(可选) + cache_read_input_tokens: Option, + /// 缓存创建 token 数(可选) + cache_creation_input_tokens: Option, + }, + + /// 后端特定使用量(如 CodeWhisperer credits) + /// + /// 用于传递后端特定的使用量信息 + BackendUsage { + /// 消耗的 credits + credits: f64, + /// 上下文使用百分比 + context_percentage: f64, + }, + + /// 错误事件 + /// + /// 流处理过程中的错误 + Error { + /// 错误类型 + error_type: String, + /// 错误消息 + message: String, + }, + + /// Ping/心跳事件 + /// + /// 保持连接活跃 + Ping, +} + +/// 内容块类型 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ContentBlockType { + /// 文本内容 + Text, + /// 工具调用 + ToolUse { + /// 工具调用 ID + id: String, + /// 工具名称 + name: String, + }, +} + +/// 停止原因 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum StopReason { + /// 正常结束 + EndTurn, + /// 达到最大 token 数 + MaxTokens, + /// 需要工具调用 + ToolUse, + /// 用户停止 + StopSequence, + /// 其他原因 + Other(String), +} + +impl Default for StopReason { + fn default() -> Self { + Self::EndTurn + } +} + +impl StopReason { + /// 从字符串解析停止原因 + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "end_turn" | "stop" => Self::EndTurn, + "max_tokens" | "length" => Self::MaxTokens, + "tool_use" | "tool_calls" => Self::ToolUse, + "stop_sequence" => Self::StopSequence, + _ => Self::Other(s.to_string()), + } + } + + /// 转换为 OpenAI 格式的字符串 + pub fn to_openai_str(&self) -> &str { + match self { + Self::EndTurn => "stop", + Self::MaxTokens => "length", + Self::ToolUse => "tool_calls", + Self::StopSequence => "stop", + Self::Other(_) => "stop", + } + } + + /// 转换为 Anthropic 格式的字符串 + pub fn to_anthropic_str(&self) -> &str { + match self { + Self::EndTurn => "end_turn", + Self::MaxTokens => "max_tokens", + Self::ToolUse => "tool_use", + Self::StopSequence => "stop_sequence", + Self::Other(s) => s, + } + } +} + +/// 流事件上下文 +/// +/// 用于在流处理过程中跟踪状态 +#[derive(Debug, Clone, Default)] +pub struct StreamContext { + /// 消息 ID + pub message_id: Option, + /// 模型名称 + pub model: Option, + /// 当前内容块索引 + pub current_block_index: u32, + /// 活跃的工具调用 ID 列表 + pub active_tool_calls: Vec, + /// 累计输入 tokens + pub input_tokens: u32, + /// 累计输出 tokens + pub output_tokens: u32, +} + +impl StreamContext { + /// 创建新的上下文 + pub fn new() -> Self { + Self::default() + } + + /// 使用消息 ID 和模型创建上下文 + pub fn with_message(id: String, model: String) -> Self { + Self { + message_id: Some(id), + model: Some(model), + ..Default::default() + } + } + + /// 获取下一个内容块索引 + pub fn next_block_index(&mut self) -> u32 { + let index = self.current_block_index; + self.current_block_index += 1; + index + } + + /// 添加活跃的工具调用 + pub fn add_tool_call(&mut self, id: String) { + if !self.active_tool_calls.contains(&id) { + self.active_tool_calls.push(id); + } + } + + /// 移除工具调用 + pub fn remove_tool_call(&mut self, id: &str) { + self.active_tool_calls.retain(|x| x != id); + } + + /// 检查是否有活跃的工具调用 + pub fn has_active_tool_calls(&self) -> bool { + !self.active_tool_calls.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stop_reason_from_str() { + assert_eq!(StopReason::from_str("end_turn"), StopReason::EndTurn); + assert_eq!(StopReason::from_str("stop"), StopReason::EndTurn); + assert_eq!(StopReason::from_str("max_tokens"), StopReason::MaxTokens); + assert_eq!(StopReason::from_str("length"), StopReason::MaxTokens); + assert_eq!(StopReason::from_str("tool_use"), StopReason::ToolUse); + assert_eq!(StopReason::from_str("tool_calls"), StopReason::ToolUse); + } + + #[test] + fn test_stop_reason_to_openai() { + assert_eq!(StopReason::EndTurn.to_openai_str(), "stop"); + assert_eq!(StopReason::MaxTokens.to_openai_str(), "length"); + assert_eq!(StopReason::ToolUse.to_openai_str(), "tool_calls"); + } + + #[test] + fn test_stop_reason_to_anthropic() { + assert_eq!(StopReason::EndTurn.to_anthropic_str(), "end_turn"); + assert_eq!(StopReason::MaxTokens.to_anthropic_str(), "max_tokens"); + assert_eq!(StopReason::ToolUse.to_anthropic_str(), "tool_use"); + } + + #[test] + fn test_stream_context_block_index() { + let mut ctx = StreamContext::new(); + assert_eq!(ctx.next_block_index(), 0); + assert_eq!(ctx.next_block_index(), 1); + assert_eq!(ctx.next_block_index(), 2); + } + + #[test] + fn test_stream_context_tool_calls() { + let mut ctx = StreamContext::new(); + assert!(!ctx.has_active_tool_calls()); + + ctx.add_tool_call("tool_1".to_string()); + assert!(ctx.has_active_tool_calls()); + + ctx.add_tool_call("tool_2".to_string()); + assert_eq!(ctx.active_tool_calls.len(), 2); + + ctx.remove_tool_call("tool_1"); + assert_eq!(ctx.active_tool_calls.len(), 1); + assert!(ctx.has_active_tool_calls()); + + ctx.remove_tool_call("tool_2"); + assert!(!ctx.has_active_tool_calls()); + } +} diff --git a/src-tauri/src/stream/generators/anthropic_sse.rs b/src-tauri/src/stream/generators/anthropic_sse.rs new file mode 100644 index 000000000..0269ea231 --- /dev/null +++ b/src-tauri/src/stream/generators/anthropic_sse.rs @@ -0,0 +1,465 @@ +//! Anthropic SSE 生成器 +//! +//! 将 `StreamEvent` 转换为 Anthropic Messages API SSE 格式。 +//! +//! # 格式说明 +//! +//! Anthropic SSE 格式: +//! ```text +//! event: message_start +//! data: {"type":"message_start","message":{...}} +//! +//! event: content_block_start +//! data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} +//! +//! event: content_block_delta +//! data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} +//! +//! event: content_block_stop +//! data: {"type":"content_block_stop","index":0} +//! +//! event: message_delta +//! data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}} +//! +//! event: message_stop +//! data: {"type":"message_stop"} +//! ``` + +use crate::stream::events::{ContentBlockType, StopReason, StreamEvent}; +use std::collections::HashMap; +use uuid::Uuid; + +/// 工具调用状态 +#[derive(Debug, Clone, Default)] +struct ToolCallState { + /// 工具调用 ID + id: String, + /// 工具名称 + name: String, + /// 累积的输入 JSON + input: String, + /// 内容块索引 + index: u32, +} + +/// Anthropic SSE 生成器 +#[derive(Debug)] +pub struct AnthropicSseGenerator { + /// 消息 ID + message_id: String, + /// 模型名称 + model: String, + /// 是否已发送 message_start 事件 + message_started: bool, + /// 工具调用状态映射 + tool_calls: HashMap, + /// 输入 token 数量 + input_tokens: u32, + /// 输出 token 数量 + output_tokens: u32, + /// 缓存读取 token 数 + cache_read_input_tokens: u32, + /// 缓存创建 token 数 + cache_creation_input_tokens: u32, + /// 累积的停止原因 + stop_reason: Option, +} + +impl Default for AnthropicSseGenerator { + fn default() -> Self { + Self::new("unknown".to_string()) + } +} + +impl AnthropicSseGenerator { + /// 创建新的生成器 + pub fn new(model: String) -> Self { + Self { + message_id: format!("msg_{}", Uuid::new_v4().simple()), + model, + message_started: false, + tool_calls: HashMap::new(), + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + stop_reason: None, + } + } + + /// 使用指定的消息 ID 创建生成器 + pub fn with_id(id: String, model: String) -> Self { + Self { + message_id: id, + model, + message_started: false, + tool_calls: HashMap::new(), + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + stop_reason: None, + } + } + + /// 将 StreamEvent 转换为 Anthropic SSE 字符串列表 + /// + /// # 返回 + /// + /// SSE 事件字符串列表,每个字符串都是完整的 SSE 事件(包含 `event:` 和 `data:` 行) + pub fn generate(&mut self, event: &StreamEvent) -> Vec { + let mut sse_events = Vec::new(); + + // 确保发送 message_start + if !self.message_started { + match event { + StreamEvent::MessageStart { id, model } => { + self.message_id = id.clone(); + self.model = model.clone(); + } + _ => {} + } + sse_events.push(self.create_message_start()); + self.message_started = true; + } + + match event { + StreamEvent::MessageStart { .. } => { + // 已经在上面处理了 + } + + StreamEvent::ContentBlockStart { index, block_type } => { + match block_type { + ContentBlockType::Text => { + sse_events.push(self.create_content_block_start_text(*index)); + } + ContentBlockType::ToolUse { id, name } => { + // 记录工具调用状态 + self.tool_calls.insert( + id.clone(), + ToolCallState { + id: id.clone(), + name: name.clone(), + input: String::new(), + index: *index, + }, + ); + sse_events.push(self.create_content_block_start_tool(*index, id, name)); + } + } + } + + StreamEvent::TextDelta { text } => { + // 假设文本块索引为 0(大多数情况下) + sse_events.push(self.create_text_delta(0, text)); + } + + StreamEvent::ToolUseStart { id, name } => { + // 如果还没有对应的 ContentBlockStart,创建工具调用状态 + if !self.tool_calls.contains_key(id) { + let index = self.tool_calls.len() as u32; + self.tool_calls.insert( + id.clone(), + ToolCallState { + id: id.clone(), + name: name.clone(), + input: String::new(), + index, + }, + ); + } + // ToolUseStart 已经在 ContentBlockStart 中处理 + } + + StreamEvent::ToolUseInputDelta { id, partial_json } => { + if let Some(state) = self.tool_calls.get_mut(id) { + state.input.push_str(partial_json); + let index = state.index; + sse_events.push(self.create_input_json_delta(index, partial_json)); + } + } + + StreamEvent::ToolUseStop { id } => { + // 工具调用结束,但保留状态直到 ContentBlockStop + let _ = id; + } + + StreamEvent::ContentBlockStop { index } => { + sse_events.push(self.create_content_block_stop(*index)); + } + + StreamEvent::MessageStop { stop_reason } => { + self.stop_reason = Some(stop_reason.clone()); + // 移除所有工具调用状态 + self.tool_calls.clear(); + sse_events.push(self.create_message_delta(stop_reason)); + sse_events.push(self.create_message_stop()); + } + + StreamEvent::Usage { + input_tokens, + output_tokens, + cache_read_input_tokens, + cache_creation_input_tokens, + } => { + self.input_tokens = *input_tokens; + self.output_tokens = *output_tokens; + if let Some(cache_read) = cache_read_input_tokens { + self.cache_read_input_tokens = *cache_read; + } + if let Some(cache_creation) = cache_creation_input_tokens { + self.cache_creation_input_tokens = *cache_creation; + } + // Anthropic 在流中发送 ping 事件而不是 usage 事件 + } + + StreamEvent::BackendUsage { .. } => { + // 后端特定的使用量信息,不转换 + } + + StreamEvent::Error { + error_type, + message, + } => { + sse_events.push(self.create_error(error_type, message)); + } + + StreamEvent::Ping => { + sse_events.push(self.create_ping()); + } + } + + sse_events + } + + /// 获取消息 ID + pub fn message_id(&self) -> &str { + &self.message_id + } + + /// 获取模型名称 + pub fn model(&self) -> &str { + &self.model + } + + // ======================================================================== + // SSE 事件创建方法 + // ======================================================================== + + fn create_message_start(&self) -> String { + let event = serde_json::json!({ + "type": "message_start", + "message": { + "id": self.message_id, + "type": "message", + "role": "assistant", + "model": self.model, + "content": [], + "stop_reason": serde_json::Value::Null, + "stop_sequence": serde_json::Value::Null, + "usage": { + "input_tokens": self.input_tokens, + "output_tokens": self.output_tokens, + "cache_read_input_tokens": self.cache_read_input_tokens, + "cache_creation_input_tokens": self.cache_creation_input_tokens + } + } + }); + format!("event: message_start\ndata: {}\n\n", event) + } + + fn create_content_block_start_text(&self, index: u32) -> String { + let event = serde_json::json!({ + "type": "content_block_start", + "index": index, + "content_block": { + "type": "text", + "text": "" + } + }); + format!("event: content_block_start\ndata: {}\n\n", event) + } + + fn create_content_block_start_tool(&self, index: u32, id: &str, name: &str) -> String { + let event = serde_json::json!({ + "type": "content_block_start", + "index": index, + "content_block": { + "type": "tool_use", + "id": id, + "name": name, + "input": {} + } + }); + format!("event: content_block_start\ndata: {}\n\n", event) + } + + fn create_text_delta(&self, index: u32, text: &str) -> String { + let event = serde_json::json!({ + "type": "content_block_delta", + "index": index, + "delta": { + "type": "text_delta", + "text": text + } + }); + format!("event: content_block_delta\ndata: {}\n\n", event) + } + + fn create_input_json_delta(&self, index: u32, partial_json: &str) -> String { + let event = serde_json::json!({ + "type": "content_block_delta", + "index": index, + "delta": { + "type": "input_json_delta", + "partial_json": partial_json + } + }); + format!("event: content_block_delta\ndata: {}\n\n", event) + } + + fn create_content_block_stop(&self, index: u32) -> String { + let event = serde_json::json!({ + "type": "content_block_stop", + "index": index + }); + format!("event: content_block_stop\ndata: {}\n\n", event) + } + + fn create_message_delta(&self, stop_reason: &StopReason) -> String { + let event = serde_json::json!({ + "type": "message_delta", + "delta": { + "stop_reason": stop_reason.to_anthropic_str(), + "stop_sequence": serde_json::Value::Null + }, + "usage": { + "output_tokens": self.output_tokens + } + }); + format!("event: message_delta\ndata: {}\n\n", event) + } + + fn create_message_stop(&self) -> String { + let event = serde_json::json!({ + "type": "message_stop" + }); + format!("event: message_stop\ndata: {}\n\n", event) + } + + fn create_ping(&self) -> String { + let event = serde_json::json!({ + "type": "ping" + }); + format!("event: ping\ndata: {}\n\n", event) + } + + fn create_error(&self, error_type: &str, message: &str) -> String { + let event = serde_json::json!({ + "type": "error", + "error": { + "type": error_type, + "message": message + } + }); + format!("event: error\ndata: {}\n\n", event) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_message_start() { + let mut generator = AnthropicSseGenerator::new("claude-3-sonnet".to_string()); + let event = StreamEvent::MessageStart { + id: "msg_123".to_string(), + model: "claude-3-sonnet".to_string(), + }; + + let sse = generator.generate(&event); + assert_eq!(sse.len(), 1); + assert!(sse[0].starts_with("event: message_start\ndata: ")); + assert!(sse[0].contains("\"id\":\"msg_123\"")); + } + + #[test] + fn test_generate_text_content() { + let mut generator = AnthropicSseGenerator::new("claude-3-sonnet".to_string()); + + // 先发送 message_start + let _ = generator.generate(&StreamEvent::MessageStart { + id: "msg_123".to_string(), + model: "claude-3-sonnet".to_string(), + }); + + // 内容块开始 + let sse = generator.generate(&StreamEvent::ContentBlockStart { + index: 0, + block_type: ContentBlockType::Text, + }); + assert!(sse[0].contains("content_block_start")); + assert!(sse[0].contains("\"type\":\"text\"")); + + // 文本增量 + let sse = generator.generate(&StreamEvent::TextDelta { + text: "Hello".to_string(), + }); + assert!(sse[0].contains("content_block_delta")); + assert!(sse[0].contains("text_delta")); + assert!(sse[0].contains("Hello")); + } + + #[test] + fn test_generate_tool_use() { + let mut generator = AnthropicSseGenerator::new("claude-3-sonnet".to_string()); + + // 发送 message_start + let _ = generator.generate(&StreamEvent::MessageStart { + id: "msg_123".to_string(), + model: "claude-3-sonnet".to_string(), + }); + + // 工具调用内容块开始 + let sse = generator.generate(&StreamEvent::ContentBlockStart { + index: 1, + block_type: ContentBlockType::ToolUse { + id: "tool_abc".to_string(), + name: "read_file".to_string(), + }, + }); + assert!(sse[0].contains("content_block_start")); + assert!(sse[0].contains("\"type\":\"tool_use\"")); + assert!(sse[0].contains("\"id\":\"tool_abc\"")); + assert!(sse[0].contains("\"name\":\"read_file\"")); + + // 工具参数增量 + let sse = generator.generate(&StreamEvent::ToolUseInputDelta { + id: "tool_abc".to_string(), + partial_json: "{\"path\":".to_string(), + }); + assert!(sse[0].contains("content_block_delta")); + assert!(sse[0].contains("input_json_delta")); + } + + #[test] + fn test_generate_message_stop() { + let mut generator = AnthropicSseGenerator::new("claude-3-sonnet".to_string()); + + // 发送 message_start + let _ = generator.generate(&StreamEvent::MessageStart { + id: "msg_123".to_string(), + model: "claude-3-sonnet".to_string(), + }); + + // 消息结束 + let sse = generator.generate(&StreamEvent::MessageStop { + stop_reason: StopReason::EndTurn, + }); + assert_eq!(sse.len(), 2); + assert!(sse[0].contains("message_delta")); + assert!(sse[0].contains("end_turn")); + assert!(sse[1].contains("message_stop")); + } +} diff --git a/src-tauri/src/stream/generators/mod.rs b/src-tauri/src/stream/generators/mod.rs new file mode 100644 index 000000000..c92108e12 --- /dev/null +++ b/src-tauri/src/stream/generators/mod.rs @@ -0,0 +1,15 @@ +//! SSE 流生成器 +//! +//! 将统一的 `StreamEvent` 转换为不同前端协议的 SSE 格式。 +//! +//! # 支持的格式 +//! +//! - OpenAI SSE (data: {...}) +//! - Anthropic SSE (event: xxx\ndata: {...}) +//! - Gemini SSE (待实现) + +pub mod anthropic_sse; +pub mod openai_sse; + +pub use anthropic_sse::AnthropicSseGenerator; +pub use openai_sse::OpenAiSseGenerator; diff --git a/src-tauri/src/stream/generators/openai_sse.rs b/src-tauri/src/stream/generators/openai_sse.rs new file mode 100644 index 000000000..ffc59d0c7 --- /dev/null +++ b/src-tauri/src/stream/generators/openai_sse.rs @@ -0,0 +1,402 @@ +//! OpenAI SSE 生成器 +//! +//! 将 `StreamEvent` 转换为 OpenAI Chat Completions SSE 格式。 +//! +//! # 格式说明 +//! +//! OpenAI SSE 格式: +//! ```text +//! data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]} +//! +//! data: [DONE] +//! ``` + +use crate::stream::events::{ContentBlockType, StopReason, StreamEvent}; +use serde::Serialize; +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// OpenAI SSE 生成器 +#[derive(Debug)] +pub struct OpenAiSseGenerator { + /// 响应 ID + response_id: String, + /// 模型名称 + model: String, + /// 创建时间戳 + created: u64, + /// 工具调用状态 (tool_call_id -> (index, name, accumulated_args)) + tool_calls: HashMap, + /// 下一个工具调用索引 + next_tool_index: usize, +} + +#[derive(Debug, Clone)] +struct ToolCallState { + /// 工具调用在 tool_calls 数组中的索引 + index: usize, + /// 工具名称 + name: String, + /// 累积的参数 + arguments: String, +} + +impl Default for OpenAiSseGenerator { + fn default() -> Self { + Self::new("unknown".to_string()) + } +} + +impl OpenAiSseGenerator { + /// 创建新的生成器 + pub fn new(model: String) -> Self { + let created = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + Self { + response_id: format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()), + model, + created, + tool_calls: HashMap::new(), + next_tool_index: 0, + } + } + + /// 使用指定的响应 ID 创建生成器 + pub fn with_id(id: String, model: String) -> Self { + let created = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + Self { + response_id: id, + model, + created, + tool_calls: HashMap::new(), + next_tool_index: 0, + } + } + + /// 将 StreamEvent 转换为 OpenAI SSE 字符串 + /// + /// # 返回 + /// + /// - `Some(String)` - 生成的 SSE 字符串(包含 `data: ` 前缀和换行) + /// - `None` - 该事件不需要生成 SSE 输出 + pub fn generate(&mut self, event: &StreamEvent) -> Option { + match event { + StreamEvent::MessageStart { id, model } => { + self.response_id = id.clone(); + self.model = model.clone(); + // OpenAI 格式不需要单独的 message_start 事件 + None + } + + StreamEvent::ContentBlockStart { block_type, .. } => { + // OpenAI 格式不需要单独的 content_block_start 事件 + // 但我们需要跟踪工具调用 + if let ContentBlockType::ToolUse { id, name } = block_type { + let index = self.next_tool_index; + self.next_tool_index += 1; + self.tool_calls.insert( + id.clone(), + ToolCallState { + index, + name: name.clone(), + arguments: String::new(), + }, + ); + } + None + } + + StreamEvent::TextDelta { text } => { + let chunk = OpenAiStreamChunk { + id: &self.response_id, + object: "chat.completion.chunk", + created: self.created, + model: &self.model, + choices: vec![OpenAiChoice { + index: 0, + delta: OpenAiDelta { + role: None, + content: Some(text.as_str()), + tool_calls: None, + }, + finish_reason: None, + }], + }; + Some(format!("data: {}\n\n", serde_json::to_string(&chunk).ok()?)) + } + + StreamEvent::ToolUseStart { id, name } => { + // 确保工具调用状态存在 + let index = if let Some(state) = self.tool_calls.get(id) { + state.index + } else { + let index = self.next_tool_index; + self.next_tool_index += 1; + self.tool_calls.insert( + id.clone(), + ToolCallState { + index, + name: name.clone(), + arguments: String::new(), + }, + ); + index + }; + + let chunk = OpenAiStreamChunk { + id: &self.response_id, + object: "chat.completion.chunk", + created: self.created, + model: &self.model, + choices: vec![OpenAiChoice { + index: 0, + delta: OpenAiDelta { + role: None, + content: None, + tool_calls: Some(vec![OpenAiToolCallDelta { + index, + id: Some(id.as_str()), + r#type: Some("function"), + function: Some(OpenAiFunctionDelta { + name: Some(name.as_str()), + arguments: None, + }), + }]), + }, + finish_reason: None, + }], + }; + Some(format!("data: {}\n\n", serde_json::to_string(&chunk).ok()?)) + } + + StreamEvent::ToolUseInputDelta { id, partial_json } => { + let index = self.tool_calls.get(id)?.index; + + // 累积参数 + if let Some(state) = self.tool_calls.get_mut(id) { + state.arguments.push_str(partial_json); + } + + let chunk = OpenAiStreamChunk { + id: &self.response_id, + object: "chat.completion.chunk", + created: self.created, + model: &self.model, + choices: vec![OpenAiChoice { + index: 0, + delta: OpenAiDelta { + role: None, + content: None, + tool_calls: Some(vec![OpenAiToolCallDelta { + index, + id: None, + r#type: None, + function: Some(OpenAiFunctionDelta { + name: None, + arguments: Some(partial_json.as_str()), + }), + }]), + }, + finish_reason: None, + }], + }; + Some(format!("data: {}\n\n", serde_json::to_string(&chunk).ok()?)) + } + + StreamEvent::ToolUseStop { id } => { + // OpenAI 格式不需要单独的工具调用结束事件 + self.tool_calls.remove(id); + None + } + + StreamEvent::ContentBlockStop { .. } => { + // OpenAI 格式不需要单独的 content_block_stop 事件 + None + } + + StreamEvent::MessageStop { stop_reason } => { + let finish_reason = stop_reason.to_openai_str(); + + let chunk = OpenAiStreamChunk { + id: &self.response_id, + object: "chat.completion.chunk", + created: self.created, + model: &self.model, + choices: vec![OpenAiChoice { + index: 0, + delta: OpenAiDelta { + role: None, + content: None, + tool_calls: None, + }, + finish_reason: Some(finish_reason), + }], + }; + + let chunk_str = format!("data: {}\n\n", serde_json::to_string(&chunk).ok()?); + Some(format!("{}data: [DONE]\n\n", chunk_str)) + } + + StreamEvent::Usage { + input_tokens, + output_tokens, + .. + } => { + // OpenAI 在流式响应中通常不发送 usage + // 但某些实现可能需要,这里可以选择性地生成 + let _ = (input_tokens, output_tokens); + None + } + + StreamEvent::BackendUsage { .. } => { + // 后端特定的使用量信息,不转换为 OpenAI 格式 + None + } + + StreamEvent::Error { + error_type, + message, + } => { + // 生成错误响应 + let error_obj = serde_json::json!({ + "error": { + "type": error_type, + "message": message, + } + }); + Some(format!("data: {}\n\n", error_obj)) + } + + StreamEvent::Ping => { + // 心跳事件,生成空的 SSE 注释 + Some(": ping\n\n".to_string()) + } + } + } + + /// 生成 [DONE] 事件 + pub fn generate_done(&self) -> String { + "data: [DONE]\n\n".to_string() + } + + /// 获取响应 ID + pub fn response_id(&self) -> &str { + &self.response_id + } +} + +// ============================================================================ +// OpenAI SSE 数据结构 +// ============================================================================ + +#[derive(Debug, Serialize)] +struct OpenAiStreamChunk<'a> { + id: &'a str, + object: &'a str, + created: u64, + model: &'a str, + choices: Vec>, +} + +#[derive(Debug, Serialize)] +struct OpenAiChoice<'a> { + index: usize, + delta: OpenAiDelta<'a>, + #[serde(skip_serializing_if = "Option::is_none")] + finish_reason: Option<&'a str>, +} + +#[derive(Debug, Serialize)] +struct OpenAiDelta<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + role: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + tool_calls: Option>>, +} + +#[derive(Debug, Serialize)] +struct OpenAiToolCallDelta<'a> { + index: usize, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + r#type: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + function: Option>, +} + +#[derive(Debug, Serialize)] +struct OpenAiFunctionDelta<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + name: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + arguments: Option<&'a str>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_text_delta() { + let mut generator = OpenAiSseGenerator::new("gpt-4".to_string()); + let event = StreamEvent::TextDelta { + text: "Hello".to_string(), + }; + + let sse = generator.generate(&event); + assert!(sse.is_some()); + let sse = sse.unwrap(); + assert!(sse.starts_with("data: ")); + assert!(sse.contains("\"content\":\"Hello\"")); + } + + #[test] + fn test_generate_tool_call() { + let mut generator = OpenAiSseGenerator::new("gpt-4".to_string()); + + // 工具调用开始 + let event = StreamEvent::ToolUseStart { + id: "call_123".to_string(), + name: "read_file".to_string(), + }; + let sse = generator.generate(&event); + assert!(sse.is_some()); + let sse = sse.unwrap(); + assert!(sse.contains("\"tool_calls\"")); + assert!(sse.contains("\"name\":\"read_file\"")); + + // 工具参数增量 + let event = StreamEvent::ToolUseInputDelta { + id: "call_123".to_string(), + partial_json: "{\"path\":".to_string(), + }; + let sse = generator.generate(&event); + assert!(sse.is_some()); + let sse = sse.unwrap(); + assert!(sse.contains("\"arguments\":\"{\\\"path\\\":\"")); + } + + #[test] + fn test_generate_message_stop() { + let mut generator = OpenAiSseGenerator::new("gpt-4".to_string()); + let event = StreamEvent::MessageStop { + stop_reason: StopReason::EndTurn, + }; + + let sse = generator.generate(&event); + assert!(sse.is_some()); + let sse = sse.unwrap(); + assert!(sse.contains("\"finish_reason\":\"stop\"")); + assert!(sse.contains("[DONE]")); + } +} diff --git a/src-tauri/src/stream/mod.rs b/src-tauri/src/stream/mod.rs new file mode 100644 index 000000000..9c730f174 --- /dev/null +++ b/src-tauri/src/stream/mod.rs @@ -0,0 +1,36 @@ +//! 流式处理层 +//! +//! 提供统一的流式数据处理能力,包括: +//! - 事件类型定义 (events) +//! - 后端流格式解析 (parsers) +//! - 前端流格式生成 (generators) +//! +//! # 架构设计 +//! +//! ```text +//! 后端响应流 ──> [Parser] ──> StreamEvent ──> [Generator] ──> 前端 SSE +//! +//! 例如: +//! AWS Event Stream ──> [AwsEventStreamParser] ──> StreamEvent ──> [AnthropicSseGenerator] ──> Anthropic SSE +//! AWS Event Stream ──> [AwsEventStreamParser] ──> StreamEvent ──> [OpenAiSseGenerator] ──> OpenAI SSE +//! ``` +//! +//! # 模块结构 +//! +//! - `events`: 统一的流事件类型定义 (`StreamEvent`) +//! - `parsers`: 后端流格式解析器 +//! - `aws_event_stream`: AWS Event Stream 解析器 (Kiro/CodeWhisperer) +//! - `generators`: 前端流格式生成器 +//! - `openai_sse`: OpenAI SSE 格式生成器 +//! - `anthropic_sse`: Anthropic SSE 格式生成器 + +pub mod events; +pub mod generators; +pub mod parsers; +pub mod pipeline; + +// 重新导出核心类型 +pub use events::{ContentBlockType, StopReason, StreamContext, StreamEvent}; +pub use generators::{AnthropicSseGenerator, OpenAiSseGenerator}; +pub use parsers::{AwsEventStreamParser, ParserState}; +pub use pipeline::{create_sse_stream, BackendType, FrontendType, PipelineConfig, StreamPipeline}; diff --git a/src-tauri/src/stream/parsers/aws_event_stream.rs b/src-tauri/src/stream/parsers/aws_event_stream.rs new file mode 100644 index 000000000..01eb2266f --- /dev/null +++ b/src-tauri/src/stream/parsers/aws_event_stream.rs @@ -0,0 +1,500 @@ +//! AWS Event Stream 解析器 +//! +//! 解析 Kiro/CodeWhisperer 的 AWS Event Stream 二进制格式, +//! 输出统一的 `StreamEvent` 类型。 +//! +//! # 协议格式 +//! +//! CodeWhisperer 使用 AWS Event Stream 二进制格式,每个事件包含: +//! - `{"content": "文本内容"}` - 文本增量 +//! - `{"toolUseId": "id", "name": "tool_name"}` - 工具调用开始 +//! - `{"toolUseId": "id", "input": "部分JSON"}` - 工具参数增量 +//! - `{"toolUseId": "id", "stop": true}` - 工具调用结束 +//! - `{"stop": true}` - 流结束 +//! - `{"usage": 0.34}` - Credits 使用量 +//! - `{"contextUsagePercentage": 54.36}` - 上下文使用百分比 + +use crate::stream::events::{ContentBlockType, StopReason, StreamContext, StreamEvent}; +use std::collections::HashMap; + +/// 解析器状态 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParserState { + /// 等待数据 + Idle, + /// 正在解析 + Parsing, + /// 已完成 + Completed, + /// 错误状态 + Error(String), +} + +impl Default for ParserState { + fn default() -> Self { + Self::Idle + } +} + +/// 工具调用累积器 +#[derive(Debug, Clone, Default)] +struct ToolAccumulator { + /// 工具名称 + name: String, + /// 累积的输入 + input: String, + /// 内容块索引 + block_index: u32, +} + +/// AWS Event Stream 解析器 +/// +/// 解析 CodeWhisperer 的 AWS Event Stream 格式,输出统一的 `StreamEvent`。 +#[derive(Debug)] +pub struct AwsEventStreamParser { + /// 缓冲区(用于处理部分 chunk) + buffer: Vec, + /// 当前状态 + state: ParserState, + /// 工具调用累积器 + tool_accumulators: HashMap, + /// 解析错误计数 + parse_error_count: u32, + /// 最大缓冲区大小(防止内存耗尽) + max_buffer_size: usize, + /// 流上下文 + context: StreamContext, + /// 是否已发送消息开始事件 + message_started: bool, + /// 是否在文本块中 + in_text_block: bool, + /// 当前文本块索引 + text_block_index: Option, +} + +impl Default for AwsEventStreamParser { + fn default() -> Self { + Self::new() + } +} + +impl AwsEventStreamParser { + /// 默认最大缓冲区大小 (1MB) + pub const DEFAULT_MAX_BUFFER_SIZE: usize = 1024 * 1024; + + /// 创建新的解析器 + pub fn new() -> Self { + Self { + buffer: Vec::new(), + state: ParserState::Idle, + tool_accumulators: HashMap::new(), + parse_error_count: 0, + max_buffer_size: Self::DEFAULT_MAX_BUFFER_SIZE, + context: StreamContext::new(), + message_started: false, + in_text_block: false, + text_block_index: None, + } + } + + /// 创建带模型名称的解析器 + pub fn with_model(model: String) -> Self { + let mut parser = Self::new(); + parser.context.model = Some(model); + parser + } + + /// 获取当前状态 + pub fn state(&self) -> &ParserState { + &self.state + } + + /// 获取解析错误计数 + pub fn parse_error_count(&self) -> u32 { + self.parse_error_count + } + + /// 获取缓冲区大小 + pub fn buffer_size(&self) -> usize { + self.buffer.len() + } + + /// 重置解析器状态 + pub fn reset(&mut self) { + self.buffer.clear(); + self.state = ParserState::Idle; + self.tool_accumulators.clear(); + self.parse_error_count = 0; + self.context = StreamContext::new(); + self.message_started = false; + self.in_text_block = false; + self.text_block_index = None; + } + + /// 处理接收到的字节 + /// + /// # 返回 + /// + /// 解析出的 `StreamEvent` 列表 + pub fn process(&mut self, bytes: &[u8]) -> Vec { + if bytes.is_empty() { + return Vec::new(); + } + + // 更新状态 + if self.state == ParserState::Idle { + self.state = ParserState::Parsing; + } + + // 检查缓冲区大小限制 + if self.buffer.len() + bytes.len() > self.max_buffer_size { + self.parse_error_count += 1; + return vec![StreamEvent::Error { + error_type: "buffer_overflow".to_string(), + message: "缓冲区溢出".to_string(), + }]; + } + + // 将新数据添加到缓冲区 + self.buffer.extend_from_slice(bytes); + + // 解析缓冲区中的所有完整 JSON 对象 + self.parse_buffer() + } + + /// 完成解析 + /// + /// 处理缓冲区中剩余的数据,并完成所有未完成的工具调用。 + pub fn finish(&mut self) -> Vec { + let mut events = Vec::new(); + + // 尝试解析缓冲区中剩余的数据 + events.extend(self.parse_buffer()); + + // 完成所有未完成的工具调用 + for (id, accumulator) in self.tool_accumulators.drain() { + if !accumulator.name.is_empty() { + events.push(StreamEvent::ToolUseStop { id: id.clone() }); + events.push(StreamEvent::ContentBlockStop { + index: accumulator.block_index, + }); + } + } + + // 关闭文本块(如果有) + if let Some(index) = self.text_block_index.take() { + events.push(StreamEvent::ContentBlockStop { index }); + } + + // 更新状态 + self.state = ParserState::Completed; + + events + } + + /// 解析缓冲区中的数据 + fn parse_buffer(&mut self) -> Vec { + let mut events = Vec::new(); + let mut pos = 0; + + while pos < self.buffer.len() { + // 查找下一个 JSON 对象的开始位置 + let start = match self.find_json_start(pos) { + Some(s) => s, + None => break, + }; + + // 提取 JSON 对象 + match self.extract_json(start) { + Some((json_str, end_pos)) => { + // 解析 JSON 并生成事件 + match self.parse_json_event(&json_str) { + Ok(event_list) => events.extend(event_list), + Err(e) => { + self.parse_error_count += 1; + events.push(StreamEvent::Error { + error_type: "parse_error".to_string(), + message: e, + }); + } + } + pos = end_pos; + } + None => { + // JSON 对象不完整,等待更多数据 + break; + } + } + } + + // 移除已处理的数据 + if pos > 0 { + self.buffer.drain(..pos); + } + + events + } + + /// 查找 JSON 对象的开始位置 + fn find_json_start(&self, from: usize) -> Option { + self.buffer[from..] + .iter() + .position(|&b| b == b'{') + .map(|p| from + p) + } + + /// 从缓冲区中提取完整的 JSON 对象 + fn extract_json(&self, start: usize) -> Option<(String, usize)> { + if start >= self.buffer.len() || self.buffer[start] != b'{' { + return None; + } + + let mut brace_count = 0; + let mut in_string = false; + let mut escape_next = false; + + for (i, &b) in self.buffer[start..].iter().enumerate() { + if escape_next { + escape_next = false; + continue; + } + + match b { + b'\\' if in_string => escape_next = true, + b'"' => in_string = !in_string, + b'{' if !in_string => brace_count += 1, + b'}' if !in_string => { + brace_count -= 1; + if brace_count == 0 { + let end = start + i + 1; + let json_bytes = &self.buffer[start..end]; + if let Ok(json_str) = String::from_utf8(json_bytes.to_vec()) { + return Some((json_str, end)); + } else { + return None; + } + } + } + _ => {} + } + } + + None + } + + /// 解析 JSON 事件并生成 StreamEvent + fn parse_json_event(&mut self, json_str: &str) -> Result, String> { + let value: serde_json::Value = + serde_json::from_str(json_str).map_err(|e| format!("JSON 解析错误: {}", e))?; + + let mut events = Vec::new(); + + // 如果还没发送消息开始事件,先发送 + if !self.message_started { + self.message_started = true; + let msg_id = format!("msg_{}", uuid::Uuid::new_v4().simple()); + self.context.message_id = Some(msg_id.clone()); + events.push(StreamEvent::MessageStart { + id: msg_id, + model: self + .context + .model + .clone() + .unwrap_or_else(|| "unknown".to_string()), + }); + } + + // 处理 content 事件 + if let Some(content) = value.get("content").and_then(|v| v.as_str()) { + // 跳过 followupPrompt + if value.get("followupPrompt").is_none() { + // 如果还没有文本块,创建一个 + if !self.in_text_block { + self.in_text_block = true; + let index = self.context.next_block_index(); + self.text_block_index = Some(index); + events.push(StreamEvent::ContentBlockStart { + index, + block_type: ContentBlockType::Text, + }); + } + + events.push(StreamEvent::TextDelta { + text: content.to_string(), + }); + } + } + // 处理 tool use 事件 (包含 toolUseId) + else if let Some(tool_use_id) = value.get("toolUseId").and_then(|v| v.as_str()) { + // 如果有文本块,先关闭它 + if let Some(index) = self.text_block_index.take() { + self.in_text_block = false; + events.push(StreamEvent::ContentBlockStop { index }); + } + + let name = value + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let input_chunk = value + .get("input") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let is_stop = value.get("stop").and_then(|v| v.as_bool()).unwrap_or(false); + + let tool_id = tool_use_id.to_string(); + + // 获取或创建工具累积器 + let accumulator = self.tool_accumulators.entry(tool_id.clone()).or_default(); + + // 如果有名称,这是工具调用开始 + if !name.is_empty() && accumulator.name.is_empty() { + accumulator.name = name.clone(); + accumulator.block_index = self.context.next_block_index(); + self.context.add_tool_call(tool_id.clone()); + + events.push(StreamEvent::ContentBlockStart { + index: accumulator.block_index, + block_type: ContentBlockType::ToolUse { + id: tool_id.clone(), + name: name.clone(), + }, + }); + + events.push(StreamEvent::ToolUseStart { + id: tool_id.clone(), + name, + }); + } + + // 如果有输入增量 + if !input_chunk.is_empty() { + accumulator.input.push_str(&input_chunk); + events.push(StreamEvent::ToolUseInputDelta { + id: tool_id.clone(), + partial_json: input_chunk, + }); + } + + // 如果是 stop 事件 + if is_stop { + if let Some(acc) = self.tool_accumulators.remove(&tool_id) { + self.context.remove_tool_call(&tool_id); + events.push(StreamEvent::ToolUseStop { id: tool_id }); + events.push(StreamEvent::ContentBlockStop { + index: acc.block_index, + }); + } + } + } + // 处理独立的 stop 事件 + else if value.get("stop").and_then(|v| v.as_bool()).unwrap_or(false) { + // 关闭文本块(如果有) + if let Some(index) = self.text_block_index.take() { + self.in_text_block = false; + events.push(StreamEvent::ContentBlockStop { index }); + } + + // 确定停止原因 + let stop_reason = if self.context.has_active_tool_calls() { + StopReason::ToolUse + } else { + StopReason::EndTurn + }; + + events.push(StreamEvent::MessageStop { stop_reason }); + } + // 处理 usage 事件 + else if let Some(usage) = value.get("usage").and_then(|v| v.as_f64()) { + events.push(StreamEvent::BackendUsage { + credits: usage, + context_percentage: 0.0, + }); + } + // 处理 contextUsagePercentage 事件 + else if let Some(ctx_usage) = value.get("contextUsagePercentage").and_then(|v| v.as_f64()) + { + events.push(StreamEvent::BackendUsage { + credits: 0.0, + context_percentage: ctx_usage, + }); + } + + Ok(events) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_content_event() { + let mut parser = AwsEventStreamParser::with_model("test-model".to_string()); + let events = parser.process(br#"{"content":"Hello"}"#); + + assert!(events.len() >= 2); + assert!( + matches!(&events[0], StreamEvent::MessageStart { model, .. } if model == "test-model") + ); + assert!(matches!( + &events[1], + StreamEvent::ContentBlockStart { + block_type: ContentBlockType::Text, + .. + } + )); + assert!(matches!(&events[2], StreamEvent::TextDelta { text } if text == "Hello")); + } + + #[test] + fn test_parse_tool_use_event() { + let mut parser = AwsEventStreamParser::new(); + + // 工具调用开始 + let events = parser.process(br#"{"toolUseId":"tool_123","name":"read_file"}"#); + assert!(events.iter().any(|e| matches!(e, StreamEvent::ToolUseStart { id, name } if id == "tool_123" && name == "read_file"))); + + // 工具参数增量 + let events = parser.process(br#"{"toolUseId":"tool_123","input":"{\"path\":"}"#); + assert!(events.iter().any(|e| matches!(e, StreamEvent::ToolUseInputDelta { id, partial_json } if id == "tool_123" && partial_json == "{\"path\":"))); + + // 工具调用结束 + let events = parser.process(br#"{"toolUseId":"tool_123","stop":true}"#); + assert!(events + .iter() + .any(|e| matches!(e, StreamEvent::ToolUseStop { id } if id == "tool_123"))); + } + + #[test] + fn test_parse_stop_event() { + let mut parser = AwsEventStreamParser::new(); + + // 先发送一些内容 + let _ = parser.process(br#"{"content":"test"}"#); + + // 发送 stop 事件 + let events = parser.process(br#"{"stop":true}"#); + assert!(events.iter().any(|e| matches!( + e, + StreamEvent::MessageStop { + stop_reason: StopReason::EndTurn + } + ))); + } + + #[test] + fn test_incremental_parsing() { + let mut parser = AwsEventStreamParser::new(); + + // 发送部分数据 + let events1 = parser.process(br#"{"con"#); + assert!(events1.is_empty()); // 不完整,没有事件 + + // 发送剩余数据 + let events2 = parser.process(br#"tent":"Hello"}"#); + assert!(!events2.is_empty()); // 现在有事件了 + } +} diff --git a/src-tauri/src/stream/parsers/mod.rs b/src-tauri/src/stream/parsers/mod.rs new file mode 100644 index 000000000..c84f9a0f6 --- /dev/null +++ b/src-tauri/src/stream/parsers/mod.rs @@ -0,0 +1,13 @@ +//! 流式数据解析器 +//! +//! 解析不同后端的流式响应格式,输出统一的 `StreamEvent`。 +//! +//! # 支持的格式 +//! +//! - AWS Event Stream (Kiro/CodeWhisperer) +//! - OpenAI SSE (待实现) +//! - Anthropic SSE (待实现) + +pub mod aws_event_stream; + +pub use aws_event_stream::{AwsEventStreamParser, ParserState}; diff --git a/src-tauri/src/stream/pipeline.rs b/src-tauri/src/stream/pipeline.rs new file mode 100644 index 000000000..79b7b96a3 --- /dev/null +++ b/src-tauri/src/stream/pipeline.rs @@ -0,0 +1,327 @@ +//! 统一流处理管道 +//! +//! 封装完整的流式处理流程:后端字节流 → 解析 → 转换 → 前端 SSE +//! +//! # 使用示例 +//! +//! ```ignore +//! use proxycast::stream::pipeline::{StreamPipeline, PipelineConfig}; +//! +//! let config = PipelineConfig::kiro_to_anthropic("claude-sonnet-4-5".to_string()); +//! let pipeline = StreamPipeline::new(config); +//! +//! // 处理字节流 +//! let sse_stream = pipeline.process_stream(byte_stream); +//! ``` + +use crate::stream::events::StreamEvent; +use crate::stream::generators::{AnthropicSseGenerator, OpenAiSseGenerator}; +use crate::stream::parsers::AwsEventStreamParser; +use bytes::Bytes; +use futures::{Stream, StreamExt}; + +/// 后端类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackendType { + /// Kiro/CodeWhisperer (AWS Event Stream) + Kiro, + /// OpenAI (SSE) + OpenAi, + /// Anthropic (SSE) + Anthropic, +} + +/// 前端类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrontendType { + /// OpenAI SSE 格式 + OpenAi, + /// Anthropic SSE 格式 + Anthropic, +} + +/// 流处理管道配置 +#[derive(Debug, Clone)] +pub struct PipelineConfig { + /// 后端类型 + pub backend: BackendType, + /// 前端类型 + pub frontend: FrontendType, + /// 模型名称 + pub model: String, + /// 消息 ID(可选) + pub message_id: Option, +} + +impl PipelineConfig { + /// 创建 Kiro → Anthropic 配置 + pub fn kiro_to_anthropic(model: String) -> Self { + Self { + backend: BackendType::Kiro, + frontend: FrontendType::Anthropic, + model, + message_id: None, + } + } + + /// 创建 Kiro → OpenAI 配置 + pub fn kiro_to_openai(model: String) -> Self { + Self { + backend: BackendType::Kiro, + frontend: FrontendType::OpenAi, + model, + message_id: None, + } + } + + /// 设置消息 ID + pub fn with_message_id(mut self, id: String) -> Self { + self.message_id = Some(id); + self + } +} + +/// SSE 生成器封装 +enum SseGenerator { + Anthropic(AnthropicSseGenerator), + OpenAi(OpenAiSseGenerator), +} + +impl SseGenerator { + fn generate(&mut self, event: &StreamEvent) -> Vec { + match self { + SseGenerator::Anthropic(g) => g.generate(event), + SseGenerator::OpenAi(g) => g.generate(event).into_iter().collect(), + } + } +} + +/// 统一流处理管道 +/// +/// 将后端字节流转换为前端 SSE 字符串流 +pub struct StreamPipeline { + /// 配置 + config: PipelineConfig, + /// AWS Event Stream 解析器(用于 Kiro 后端) + aws_parser: Option, + /// SSE 生成器 + generator: SseGenerator, +} + +impl StreamPipeline { + /// 创建新的管道 + pub fn new(config: PipelineConfig) -> Self { + let aws_parser = match config.backend { + BackendType::Kiro => Some(AwsEventStreamParser::with_model(config.model.clone())), + _ => None, + }; + + let generator = match config.frontend { + FrontendType::Anthropic => { + if let Some(id) = &config.message_id { + SseGenerator::Anthropic(AnthropicSseGenerator::with_id( + id.clone(), + config.model.clone(), + )) + } else { + SseGenerator::Anthropic(AnthropicSseGenerator::new(config.model.clone())) + } + } + FrontendType::OpenAi => { + if let Some(id) = &config.message_id { + SseGenerator::OpenAi(OpenAiSseGenerator::with_id( + id.clone(), + config.model.clone(), + )) + } else { + SseGenerator::OpenAi(OpenAiSseGenerator::new(config.model.clone())) + } + } + }; + + Self { + config, + aws_parser, + generator, + } + } + + /// 处理单个字节块 + /// + /// # 返回 + /// + /// 生成的 SSE 字符串列表 + pub fn process_chunk(&mut self, bytes: &[u8]) -> Vec { + let events = self.parse_bytes(bytes); + self.generate_sse(&events) + } + + /// 完成处理 + /// + /// # 返回 + /// + /// 最终的 SSE 字符串列表 + pub fn finish(&mut self) -> Vec { + let events = self.finish_parsing(); + self.generate_sse(&events) + } + + /// 解析字节为 StreamEvent + fn parse_bytes(&mut self, bytes: &[u8]) -> Vec { + match &mut self.aws_parser { + Some(parser) => parser.process(bytes), + None => Vec::new(), // TODO: 支持其他后端格式的解析 + } + } + + /// 完成解析 + fn finish_parsing(&mut self) -> Vec { + match &mut self.aws_parser { + Some(parser) => parser.finish(), + None => Vec::new(), + } + } + + /// 将 StreamEvent 转换为 SSE 字符串 + fn generate_sse(&mut self, events: &[StreamEvent]) -> Vec { + let mut result = Vec::new(); + for event in events { + let sse_strings = self.generator.generate(event); + result.extend(sse_strings); + } + result + } + + /// 获取配置 + pub fn config(&self) -> &PipelineConfig { + &self.config + } + + /// 重置管道状态 + pub fn reset(&mut self) { + if let Some(ref mut parser) = self.aws_parser { + parser.reset(); + } + self.generator = match self.config.frontend { + FrontendType::Anthropic => { + SseGenerator::Anthropic(AnthropicSseGenerator::new(self.config.model.clone())) + } + FrontendType::OpenAi => { + SseGenerator::OpenAi(OpenAiSseGenerator::new(self.config.model.clone())) + } + }; + } +} + +/// 创建流式处理的异步流 +/// +/// 将字节流转换为 SSE 字符串流 +pub fn create_sse_stream( + byte_stream: S, + config: PipelineConfig, +) -> impl Stream> +where + S: Stream> + Send + 'static, + E: Send + 'static, +{ + async_stream::stream! { + let mut pipeline = StreamPipeline::new(config); + let mut byte_stream = std::pin::pin!(byte_stream); + + while let Some(result) = byte_stream.next().await { + match result { + Ok(bytes) => { + let sse_strings = pipeline.process_chunk(&bytes); + for sse in sse_strings { + yield Ok(sse); + } + } + Err(e) => { + yield Err(e); + return; + } + } + } + + // 完成处理 + let final_sse = pipeline.finish(); + for sse in final_sse { + yield Ok(sse); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pipeline_config_kiro_to_anthropic() { + let config = PipelineConfig::kiro_to_anthropic("claude-sonnet-4-5".to_string()); + assert_eq!(config.backend, BackendType::Kiro); + assert_eq!(config.frontend, FrontendType::Anthropic); + assert_eq!(config.model, "claude-sonnet-4-5"); + } + + #[test] + fn test_pipeline_config_kiro_to_openai() { + let config = PipelineConfig::kiro_to_openai("gpt-4".to_string()); + assert_eq!(config.backend, BackendType::Kiro); + assert_eq!(config.frontend, FrontendType::OpenAi); + } + + #[test] + fn test_pipeline_process_content() { + let config = PipelineConfig::kiro_to_anthropic("claude-sonnet-4-5".to_string()); + let mut pipeline = StreamPipeline::new(config); + + // 模拟 Kiro 内容事件 + let bytes = br#"{"content":"Hello"}"#; + let sse = pipeline.process_chunk(bytes); + + // 应该生成 message_start, content_block_start, content_block_delta + assert!(!sse.is_empty()); + assert!(sse.iter().any(|s| s.contains("message_start"))); + assert!(sse.iter().any(|s| s.contains("content_block_start"))); + assert!(sse.iter().any(|s| s.contains("Hello"))); + } + + #[test] + fn test_pipeline_process_tool_use() { + let config = PipelineConfig::kiro_to_anthropic("claude-sonnet-4-5".to_string()); + let mut pipeline = StreamPipeline::new(config); + + // 工具调用开始 + let bytes = br#"{"toolUseId":"tool_123","name":"read_file"}"#; + let sse = pipeline.process_chunk(bytes); + + assert!(!sse.is_empty()); + assert!(sse.iter().any(|s| s.contains("tool_use"))); + assert!(sse.iter().any(|s| s.contains("read_file"))); + + // 工具参数 + let bytes = br#"{"toolUseId":"tool_123","input":"{\"path\":"}"#; + let sse = pipeline.process_chunk(bytes); + assert!(sse.iter().any(|s| s.contains("input_json_delta"))); + + // 工具结束 + let bytes = br#"{"toolUseId":"tool_123","stop":true}"#; + let sse = pipeline.process_chunk(bytes); + assert!(sse.iter().any(|s| s.contains("content_block_stop"))); + } + + #[test] + fn test_pipeline_openai_output() { + let config = PipelineConfig::kiro_to_openai("gpt-4".to_string()); + let mut pipeline = StreamPipeline::new(config); + + // 模拟 Kiro 内容事件 + let bytes = br#"{"content":"Hello"}"#; + let sse = pipeline.process_chunk(bytes); + + assert!(!sse.is_empty()); + // OpenAI 格式应该包含 data: 前缀和 choices + assert!(sse.iter().any(|s| s.starts_with("data: "))); + assert!(sse.iter().any(|s| s.contains("\"content\":\"Hello\""))); + } +} diff --git a/src-tauri/src/translator/kiro/anthropic/mod.rs b/src-tauri/src/translator/kiro/anthropic/mod.rs new file mode 100644 index 000000000..430de7866 --- /dev/null +++ b/src-tauri/src/translator/kiro/anthropic/mod.rs @@ -0,0 +1,13 @@ +//! Anthropic 协议 → Kiro 后端转换 +//! +//! 处理 Anthropic Messages API 格式与 CodeWhisperer 格式之间的转换。 +//! +//! # 重要说明 +//! +//! 这是 Claude Code 使用的协议,是最核心的转换路径。 + +pub mod request; +pub mod response; + +pub use request::AnthropicRequestTranslator; +pub use response::AnthropicResponseTranslator; diff --git a/src-tauri/src/translator/kiro/anthropic/request.rs b/src-tauri/src/translator/kiro/anthropic/request.rs new file mode 100644 index 000000000..58296ae44 --- /dev/null +++ b/src-tauri/src/translator/kiro/anthropic/request.rs @@ -0,0 +1,677 @@ +//! Anthropic 请求直接转换为 CodeWhisperer 请求 +//! +//! 直接将 Anthropic MessagesRequest 转换为 CodeWhisperer API 格式, +//! 无需经过 OpenAI 中间格式,减少转换开销。 + +use crate::models::anthropic::*; +use crate::models::codewhisperer::*; +use crate::translator::kiro::openai::request::{get_model_map, DEFAULT_MODEL}; +use crate::translator::traits::{RequestTranslator, TranslateError}; +use std::collections::HashSet; +use uuid::Uuid; + +/// Anthropic 到 Kiro 请求转换器 +#[derive(Debug, Clone)] +pub struct AnthropicRequestTranslator { + /// 可选的 Profile ARN (AWS CodeWhisperer) + pub profile_arn: Option, +} + +impl Default for AnthropicRequestTranslator { + fn default() -> Self { + Self::new() + } +} + +impl AnthropicRequestTranslator { + /// 创建新的转换器 + pub fn new() -> Self { + Self { profile_arn: None } + } + + /// 使用 Profile ARN 创建转换器 + pub fn with_profile_arn(profile_arn: String) -> Self { + Self { + profile_arn: Some(profile_arn), + } + } +} + +impl RequestTranslator for AnthropicRequestTranslator { + type Input = AnthropicMessagesRequest; + type Output = CodeWhispererRequest; + type Error = TranslateError; + + fn translate_request(&self, request: Self::Input) -> Result { + Ok(convert_anthropic_to_codewhisperer( + &request, + self.profile_arn.clone(), + )) + } +} + +// ============================================================================ +// 内部类型 +// ============================================================================ + +#[derive(Debug, Clone)] +struct ProcessedMessage { + role: String, + content: String, + tool_uses: Option>, + tool_results: Option>, +} + +// ============================================================================ +// 转换函数 +// ============================================================================ + +/// 将 Anthropic MessagesRequest 直接转换为 CodeWhisperer 请求 +pub fn convert_anthropic_to_codewhisperer( + request: &AnthropicMessagesRequest, + profile_arn: Option, +) -> CodeWhispererRequest { + let model_map = get_model_map(); + let cw_model = model_map + .get(request.model.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| DEFAULT_MODEL.to_string()); + + let conversation_id = Uuid::new_v4().to_string(); + + // 提取 system prompt + let mut system_prompt = extract_system_text(&request.system); + + // 处理 tool_choice: required - CodeWhisperer 不支持此参数,通过 prompt 注入强制 + if is_tool_choice_required(&request.tool_choice) && request.tools.is_some() { + let tool_instruction = "\n\n[CRITICAL INSTRUCTION] You MUST use one of the provided tools to respond. Do NOT respond with plain text. Call a tool function immediately."; + system_prompt.push_str(tool_instruction); + tracing::info!("[KIRO_TRANSLATE] tool_choice=required detected in Anthropic request, injected tool instruction"); + } + + // 预处理消息 + let messages = preprocess_anthropic_messages(&request.messages); + + // 构建历史记录 + let mut history: Vec = Vec::new(); + let mut start_idx = 0; + + // 处理 system prompt - 合并到第一条用户消息 + if !system_prompt.is_empty() && !messages.is_empty() && messages[0].role == "user" { + let first_content = &messages[0].content; + let combined = format!("{system_prompt}\n\n{first_content}"); + + let mut user_msg = UserInputMessage { + content: combined, + model_id: cw_model.clone(), + origin: "AI_EDITOR".to_string(), + images: None, + user_input_message_context: None, + }; + + if let Some(ref tool_results) = messages[0].tool_results { + user_msg.user_input_message_context = Some(UserInputMessageContext { + tools: None, + tool_results: Some(tool_results.clone()), + }); + } + + history.push(HistoryItem::User(UserHistoryItem { + user_input_message: user_msg, + })); + start_idx = 1; + } + + // 处理历史消息(除最后一条) + for msg in messages + .iter() + .take(messages.len().saturating_sub(1)) + .skip(start_idx) + { + match msg.role.as_str() { + "user" => { + let content = if msg.content.is_empty() { + if msg.tool_results.is_some() { + "Tool results provided.".to_string() + } else { + "Continue".to_string() + } + } else { + msg.content.clone() + }; + + let mut user_msg = UserInputMessage { + content, + model_id: cw_model.clone(), + origin: "AI_EDITOR".to_string(), + images: None, + user_input_message_context: None, + }; + + if let Some(ref tool_results) = msg.tool_results { + user_msg.user_input_message_context = Some(UserInputMessageContext { + tools: None, + tool_results: Some(tool_results.clone()), + }); + } + + history.push(HistoryItem::User(UserHistoryItem { + user_input_message: user_msg, + })); + } + "assistant" => { + let content = if msg.content.is_empty() { + "I understand.".to_string() + } else { + msg.content.clone() + }; + + history.push(HistoryItem::Assistant(AssistantHistoryItem { + assistant_response_message: AssistantResponseMessage { + content, + tool_uses: msg.tool_uses.clone(), + }, + })); + } + _ => {} + } + } + + // 修复历史记录交替顺序 + let history = fix_history_alternation(history, &cw_model); + + // 构建当前消息 + let (current_content, current_tool_results) = if let Some(last_msg) = messages.last() { + if last_msg.role == "assistant" { + ("Continue".to_string(), None) + } else { + let content = if last_msg.content.is_empty() { + if last_msg.tool_results.is_some() { + "Tool results provided.".to_string() + } else { + "Continue".to_string() + } + } else { + last_msg.content.clone() + }; + (content, last_msg.tool_results.clone()) + } + } else { + ("Continue".to_string(), None) + }; + + // 构建 tools + let tools = convert_anthropic_tools(&request.tools); + + let user_input_message_context = if tools.is_some() || current_tool_results.is_some() { + Some(UserInputMessageContext { + tools, + tool_results: current_tool_results, + }) + } else { + None + }; + + CodeWhispererRequest { + conversation_state: ConversationState { + chat_trigger_type: "MANUAL".to_string(), + conversation_id, + current_message: CurrentMessage { + user_input_message: UserInputMessage { + content: current_content, + model_id: cw_model, + origin: "AI_EDITOR".to_string(), + images: None, + user_input_message_context, + }, + }, + history: if history.is_empty() { + None + } else { + Some(history) + }, + }, + profile_arn, + } +} + +/// 提取 system prompt 文本 +fn extract_system_text(system: &Option) -> String { + match system { + Some(serde_json::Value::String(s)) => s.clone(), + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|item| { + if item.get("type") == Some(&serde_json::Value::String("text".to_string())) { + item.get("text") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) + } else { + None + } + }) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +/// 预处理 Anthropic 消息 +fn preprocess_anthropic_messages(messages: &[AnthropicMessage]) -> Vec { + let mut result: Vec = Vec::new(); + + for msg in messages { + let processed = convert_anthropic_message(msg); + result.extend(processed); + } + + // 合并连续的 user 消息中的 tool_results + let mut merged: Vec = Vec::new(); + let mut pending_tool_results: Vec = Vec::new(); + + for msg in result { + if msg.role == "user" { + if let Some(ref tr) = msg.tool_results { + pending_tool_results.extend(tr.clone()); + } + if !msg.content.is_empty() || msg.tool_results.is_none() { + // 去重 tool_results + let mut seen_ids = HashSet::new(); + pending_tool_results.retain(|tr| seen_ids.insert(tr.tool_use_id.clone())); + + merged.push(ProcessedMessage { + role: msg.role, + content: if msg.content.is_empty() && !pending_tool_results.is_empty() { + "Tool results provided.".to_string() + } else { + msg.content + }, + tool_uses: None, + tool_results: if pending_tool_results.is_empty() { + None + } else { + Some(pending_tool_results.clone()) + }, + }); + pending_tool_results.clear(); + } + } else { + // 如果有待处理的 tool_results,先创建 user 消息 + if !pending_tool_results.is_empty() { + let mut seen_ids = HashSet::new(); + pending_tool_results.retain(|tr| seen_ids.insert(tr.tool_use_id.clone())); + + merged.push(ProcessedMessage { + role: "user".to_string(), + content: "Tool results provided.".to_string(), + tool_uses: None, + tool_results: Some(pending_tool_results.clone()), + }); + pending_tool_results.clear(); + } + merged.push(msg); + } + } + + // 处理末尾的 tool_results + if !pending_tool_results.is_empty() { + let mut seen_ids = HashSet::new(); + pending_tool_results.retain(|tr| seen_ids.insert(tr.tool_use_id.clone())); + + merged.push(ProcessedMessage { + role: "user".to_string(), + content: "Tool results provided.".to_string(), + tool_uses: None, + tool_results: Some(pending_tool_results), + }); + } + + merged +} + +/// 转换单条 Anthropic 消息 +fn convert_anthropic_message(msg: &AnthropicMessage) -> Vec { + let mut result: Vec = Vec::new(); + + match &msg.content { + serde_json::Value::String(s) => { + result.push(ProcessedMessage { + role: msg.role.clone(), + content: s.clone(), + tool_uses: None, + tool_results: None, + }); + } + serde_json::Value::Array(parts) => { + let mut text_parts: Vec = Vec::new(); + let mut tool_uses: Vec = Vec::new(); + let mut tool_results: Vec = Vec::new(); + + for part in parts { + let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match part_type { + "text" => { + if let Some(text) = part.get("text").and_then(|t| t.as_str()) { + text_parts.push(text.to_string()); + } + } + "tool_use" => { + let default_id = format!("toolu_{}", &Uuid::new_v4().to_string()[..8]); + let id = part + .get("id") + .and_then(|i| i.as_str()) + .unwrap_or(&default_id); + let name = part.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let input = part.get("input").cloned().unwrap_or(serde_json::json!({})); + + tool_uses.push(CWToolUse { + tool_use_id: id.to_string(), + name: name.to_string(), + input, + }); + } + "tool_result" => { + let tool_use_id = part + .get("tool_use_id") + .and_then(|i| i.as_str()) + .unwrap_or(""); + let content_text = extract_tool_result_content(part.get("content")); + let is_error = part + .get("is_error") + .and_then(|e| e.as_bool()) + .unwrap_or(false); + + tool_results.push(CWToolResult { + tool_use_id: tool_use_id.to_string(), + content: vec![CWTextContent { text: content_text }], + status: if is_error { + "error".to_string() + } else { + "success".to_string() + }, + }); + } + _ => {} + } + } + + // 处理 assistant 消息 + if msg.role == "assistant" { + result.push(ProcessedMessage { + role: "assistant".to_string(), + content: text_parts.join(""), + tool_uses: if tool_uses.is_empty() { + None + } else { + Some(tool_uses) + }, + tool_results: None, + }); + } + // 处理 user 消息 + else if msg.role == "user" { + // 先添加 tool results + if !tool_results.is_empty() { + result.push(ProcessedMessage { + role: "user".to_string(), + content: String::new(), + tool_uses: None, + tool_results: Some(tool_results), + }); + } + + // 添加文本内容 + if !text_parts.is_empty() { + result.push(ProcessedMessage { + role: "user".to_string(), + content: text_parts.join(""), + tool_uses: None, + tool_results: None, + }); + } + } + } + _ => {} + } + + result +} + +/// 提取 tool_result 内容 +fn extract_tool_result_content(content: Option<&serde_json::Value>) -> String { + match content { + Some(serde_json::Value::String(s)) => s.clone(), + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|item| { + if item.get("type") == Some(&serde_json::Value::String("text".to_string())) { + item.get("text") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) + } else { + None + } + }) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +/// 转换 Anthropic tools 为 CodeWhisperer tools +fn convert_anthropic_tools(tools: &Option>) -> Option> { + tools.as_ref().map(|tools| { + let mut cw_tools: Vec = Vec::new(); + let mut function_count = 0; + + for t in tools.iter() { + // 处理特殊工具类型 + if t.name == "web_search" || t.name == "web_search_20250305" { + cw_tools.push(CWToolItem::WebSearch(CWWebSearchTool { + tool_type: "web_search".to_string(), + })); + continue; + } + + // 限制最多 50 个函数工具 + if function_count >= 50 { + continue; + } + function_count += 1; + + let params = t + .input_schema + .clone() + .unwrap_or_else(|| serde_json::json!({"type": "object", "properties": {}})); + + let desc = t + .description + .clone() + .unwrap_or_else(|| format!("Tool: {}", t.name)); + + cw_tools.push(CWToolItem::Standard(CWTool { + tool_specification: ToolSpecification { + name: t.name.clone(), + description: if desc.len() > 500 { + let truncated: String = desc.chars().take(497).collect(); + format!("{}...", truncated) + } else { + desc + }, + input_schema: InputSchema { json: params }, + }, + })); + } + + cw_tools + }) +} + +/// 修复历史记录,确保 user/assistant 严格交替 +fn fix_history_alternation(history: Vec, model_id: &str) -> Vec { + if history.is_empty() { + return history; + } + + let mut fixed: Vec = Vec::new(); + + for item in history { + match &item { + HistoryItem::User(user_item) => { + if let Some(HistoryItem::User(last_user)) = fixed.last_mut() { + let has_tool_results = user_item + .user_input_message + .user_input_message_context + .as_ref() + .map(|ctx| ctx.tool_results.is_some()) + .unwrap_or(false); + + if has_tool_results { + let new_results = user_item + .user_input_message + .user_input_message_context + .as_ref() + .and_then(|ctx| ctx.tool_results.clone()) + .unwrap_or_default(); + + if let Some(ref mut ctx) = + last_user.user_input_message.user_input_message_context + { + if let Some(ref mut existing) = ctx.tool_results { + existing.extend(new_results); + } else { + ctx.tool_results = Some(new_results); + } + } else { + last_user.user_input_message.user_input_message_context = + Some(UserInputMessageContext { + tools: None, + tool_results: Some(new_results), + }); + } + continue; + } else { + fixed.push(HistoryItem::Assistant(AssistantHistoryItem { + assistant_response_message: AssistantResponseMessage { + content: "I understand.".to_string(), + tool_uses: None, + }, + })); + } + } + fixed.push(item); + } + HistoryItem::Assistant(_) => { + if let Some(HistoryItem::Assistant(_)) = fixed.last() { + fixed.push(HistoryItem::User(UserHistoryItem { + user_input_message: UserInputMessage { + content: "Continue".to_string(), + model_id: model_id.to_string(), + origin: "AI_EDITOR".to_string(), + images: None, + user_input_message_context: None, + }, + })); + } + if fixed.is_empty() { + fixed.push(HistoryItem::User(UserHistoryItem { + user_input_message: UserInputMessage { + content: "Continue".to_string(), + model_id: model_id.to_string(), + origin: "AI_EDITOR".to_string(), + images: None, + user_input_message_context: None, + }, + })); + } + fixed.push(item); + } + } + } + + // 确保以 assistant 结尾 + if let Some(HistoryItem::User(_)) = fixed.last() { + fixed.push(HistoryItem::Assistant(AssistantHistoryItem { + assistant_response_message: AssistantResponseMessage { + content: "I understand.".to_string(), + tool_uses: None, + }, + })); + } + + fixed +} + +/// 检查 tool_choice 是否为 required +/// +/// Anthropic tool_choice 可以是: +/// - {"type": "any"} - 必须调用工具 +/// - {"type": "tool", "name": "xxx"} - 必须调用指定工具 +fn is_tool_choice_required(tool_choice: &Option) -> bool { + match tool_choice { + Some(serde_json::Value::Object(obj)) => { + if let Some(serde_json::Value::String(t)) = obj.get("type") { + t == "any" || t == "tool" + } else { + false + } + } + // OpenAI 风格的 "required" 字符串 + Some(serde_json::Value::String(s)) => s == "required" || s == "any", + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_simple_request() { + let request = AnthropicMessagesRequest { + model: "claude-sonnet-4-5".to_string(), + messages: vec![AnthropicMessage { + role: "user".to_string(), + content: serde_json::json!("Hello"), + }], + system: None, + max_tokens: Some(1024), + stream: true, + temperature: None, + tools: None, + tool_choice: None, + }; + + let translator = AnthropicRequestTranslator::new(); + let result = translator.translate_request(request); + assert!(result.is_ok()); + + let cw_request = result.unwrap(); + assert_eq!( + cw_request + .conversation_state + .current_message + .user_input_message + .model_id, + "CLAUDE_SONNET_4_5_20250929_V1_0" + ); + } + + #[test] + fn test_extract_system_text_string() { + let system = Some(serde_json::json!("You are a helpful assistant.")); + let text = extract_system_text(&system); + assert_eq!(text, "You are a helpful assistant."); + } + + #[test] + fn test_extract_system_text_array() { + let system = Some(serde_json::json!([ + {"type": "text", "text": "Line 1"}, + {"type": "text", "text": "Line 2"} + ])); + let text = extract_system_text(&system); + assert_eq!(text, "Line 1\nLine 2"); + } +} diff --git a/src-tauri/src/translator/kiro/anthropic/response.rs b/src-tauri/src/translator/kiro/anthropic/response.rs new file mode 100644 index 000000000..0b952b067 --- /dev/null +++ b/src-tauri/src/translator/kiro/anthropic/response.rs @@ -0,0 +1,189 @@ +//! Kiro 响应转换为 Anthropic SSE 格式 +//! +//! 将 `StreamEvent` 转换为 Anthropic Messages API 流式响应格式。 +//! 这是 Claude Code 使用的协议。 + +use crate::stream::{AnthropicSseGenerator, StreamEvent}; +use crate::translator::traits::{ResponseTranslator, SseResponseTranslator}; + +/// Anthropic 响应转换器 +/// +/// 将 `StreamEvent` 转换为 Anthropic SSE 格式 +#[derive(Debug)] +pub struct AnthropicResponseTranslator { + /// SSE 生成器 + generator: AnthropicSseGenerator, +} + +impl Default for AnthropicResponseTranslator { + fn default() -> Self { + Self::new("unknown".to_string()) + } +} + +impl AnthropicResponseTranslator { + /// 创建新的转换器 + pub fn new(model: String) -> Self { + Self { + generator: AnthropicSseGenerator::new(model), + } + } + + /// 使用指定的消息 ID 创建转换器 + pub fn with_id(id: String, model: String) -> Self { + Self { + generator: AnthropicSseGenerator::with_id(id, model), + } + } + + /// 获取消息 ID + pub fn message_id(&self) -> &str { + self.generator.message_id() + } + + /// 获取模型名称 + pub fn model(&self) -> &str { + self.generator.model() + } +} + +impl ResponseTranslator for AnthropicResponseTranslator { + type Output = Vec; + + fn translate_event(&mut self, event: &StreamEvent) -> Option { + let events = self.generator.generate(event); + if events.is_empty() { + None + } else { + Some(events) + } + } + + fn finalize(&mut self) -> Vec { + Vec::new() // Anthropic 生成器在 MessageStop 时已经发送了所有结束事件 + } + + fn reset(&mut self) { + self.generator = AnthropicSseGenerator::new("unknown".to_string()); + } +} + +impl SseResponseTranslator for AnthropicResponseTranslator { + fn translate_to_sse(&mut self, event: &StreamEvent) -> Vec { + self.generator.generate(event) + } + + fn finalize_sse(&mut self) -> Vec { + Vec::new() // Anthropic 生成器在 MessageStop 时已经发送了所有结束事件 + } + + fn reset(&mut self) { + self.generator = AnthropicSseGenerator::new("unknown".to_string()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stream::{ContentBlockType, StopReason}; + + #[test] + fn test_translate_message_start() { + let mut translator = AnthropicResponseTranslator::new("claude-3-sonnet".to_string()); + + let event = StreamEvent::MessageStart { + id: "msg_123".to_string(), + model: "claude-3-sonnet".to_string(), + }; + + let sse = translator.translate_event(&event); + assert!(sse.is_some()); + let sse = sse.unwrap(); + assert!(!sse.is_empty()); + assert!(sse[0].starts_with("event: message_start\ndata: ")); + } + + #[test] + fn test_translate_text_content() { + let mut translator = AnthropicResponseTranslator::new("claude-3-sonnet".to_string()); + + // 先发送 message_start + let _ = translator.translate_event(&StreamEvent::MessageStart { + id: "msg_123".to_string(), + model: "claude-3-sonnet".to_string(), + }); + + // 内容块开始 + let sse = translator.translate_event(&StreamEvent::ContentBlockStart { + index: 0, + block_type: ContentBlockType::Text, + }); + assert!(sse.is_some()); + let sse = sse.unwrap(); + assert!(sse[0].contains("content_block_start")); + + // 文本增量 + let sse = translator.translate_event(&StreamEvent::TextDelta { + text: "Hello".to_string(), + }); + assert!(sse.is_some()); + let sse = sse.unwrap(); + assert!(sse[0].contains("content_block_delta")); + assert!(sse[0].contains("Hello")); + } + + #[test] + fn test_translate_tool_use() { + let mut translator = AnthropicResponseTranslator::new("claude-3-sonnet".to_string()); + + // 发送 message_start + let _ = translator.translate_event(&StreamEvent::MessageStart { + id: "msg_123".to_string(), + model: "claude-3-sonnet".to_string(), + }); + + // 工具调用内容块开始 + let sse = translator.translate_event(&StreamEvent::ContentBlockStart { + index: 1, + block_type: ContentBlockType::ToolUse { + id: "tool_abc".to_string(), + name: "read_file".to_string(), + }, + }); + assert!(sse.is_some()); + let sse = sse.unwrap(); + assert!(sse[0].contains("content_block_start")); + assert!(sse[0].contains("tool_use")); + + // 工具参数增量 + let sse = translator.translate_event(&StreamEvent::ToolUseInputDelta { + id: "tool_abc".to_string(), + partial_json: "{\"path\":".to_string(), + }); + assert!(sse.is_some()); + let sse = sse.unwrap(); + assert!(sse[0].contains("input_json_delta")); + } + + #[test] + fn test_translate_message_stop() { + let mut translator = AnthropicResponseTranslator::new("claude-3-sonnet".to_string()); + + // 发送 message_start + let _ = translator.translate_event(&StreamEvent::MessageStart { + id: "msg_123".to_string(), + model: "claude-3-sonnet".to_string(), + }); + + // 消息结束 + let sse = translator.translate_event(&StreamEvent::MessageStop { + stop_reason: StopReason::EndTurn, + }); + assert!(sse.is_some()); + let sse = sse.unwrap(); + assert_eq!(sse.len(), 2); + assert!(sse[0].contains("message_delta")); + assert!(sse[0].contains("end_turn")); + assert!(sse[1].contains("message_stop")); + } +} diff --git a/src-tauri/src/translator/kiro/mod.rs b/src-tauri/src/translator/kiro/mod.rs new file mode 100644 index 000000000..590b4c6bf --- /dev/null +++ b/src-tauri/src/translator/kiro/mod.rs @@ -0,0 +1,43 @@ +//! Kiro/CodeWhisperer 后端协议转换 +//! +//! 处理与 AWS CodeWhisperer (Kiro) 后端的协议转换。 +//! +//! # 子模块 +//! +//! - `openai`: OpenAI 前端协议支持 +//! - `anthropic`: Anthropic 前端协议支持 +//! +//! # 调用链 +//! +//! ## OpenAI 协议 +//! ```text +//! OpenAI ChatCompletionRequest +//! → [openai/request.rs] translate_request +//! → CodeWhispererRequest +//! → [backends/kiro.rs] call_stream +//! → AWS Event Stream bytes +//! → [stream/parsers/aws_event_stream.rs] parse +//! → StreamEvent +//! → [openai/response.rs] translate_event +//! → OpenAI SSE +//! ``` +//! +//! ## Anthropic 协议 +//! ```text +//! Anthropic MessagesRequest +//! → [anthropic/request.rs] translate_request +//! → CodeWhispererRequest +//! → [backends/kiro.rs] call_stream +//! → AWS Event Stream bytes +//! → [stream/parsers/aws_event_stream.rs] parse +//! → StreamEvent +//! → [anthropic/response.rs] translate_event +//! → Anthropic SSE +//! ``` + +pub mod anthropic; +pub mod openai; + +// 重新导出常用类型 +pub use anthropic::{AnthropicRequestTranslator, AnthropicResponseTranslator}; +pub use openai::{OpenAiRequestTranslator, OpenAiResponseTranslator}; diff --git a/src-tauri/src/translator/kiro/openai/mod.rs b/src-tauri/src/translator/kiro/openai/mod.rs new file mode 100644 index 000000000..6bd8bc2e4 --- /dev/null +++ b/src-tauri/src/translator/kiro/openai/mod.rs @@ -0,0 +1,9 @@ +//! OpenAI 协议 → Kiro 后端转换 +//! +//! 处理 OpenAI ChatCompletion API 格式与 CodeWhisperer 格式之间的转换。 + +pub mod request; +pub mod response; + +pub use request::OpenAiRequestTranslator; +pub use response::OpenAiResponseTranslator; diff --git a/src-tauri/src/translator/kiro/openai/request.rs b/src-tauri/src/translator/kiro/openai/request.rs new file mode 100644 index 000000000..60aa5ebd8 --- /dev/null +++ b/src-tauri/src/translator/kiro/openai/request.rs @@ -0,0 +1,620 @@ +//! OpenAI 请求转换为 CodeWhisperer 请求 +//! +//! 将 OpenAI ChatCompletionRequest 转换为 CodeWhisperer API 格式。 +//! +//! # 模型映射 +//! +//! - claude-opus-4-5 → claude-opus-4.5 +//! - claude-sonnet-4-5 → CLAUDE_SONNET_4_5_20250929_V1_0 +//! - claude-sonnet-4-20250514 → CLAUDE_SONNET_4_20250514_V1_0 +//! - claude-haiku-4-5 → claude-haiku-4.5 + +use crate::models::codewhisperer::*; +use crate::models::openai::*; +use crate::translator::traits::{RequestTranslator, TranslateError}; +use std::collections::{HashMap, HashSet}; +use uuid::Uuid; + +/// OpenAI 到 Kiro 请求转换器 +#[derive(Debug, Clone)] +pub struct OpenAiRequestTranslator { + /// 可选的 Profile ARN (AWS CodeWhisperer) + pub profile_arn: Option, +} + +impl Default for OpenAiRequestTranslator { + fn default() -> Self { + Self::new() + } +} + +impl OpenAiRequestTranslator { + /// 创建新的转换器 + pub fn new() -> Self { + Self { profile_arn: None } + } + + /// 使用 Profile ARN 创建转换器 + pub fn with_profile_arn(profile_arn: String) -> Self { + Self { + profile_arn: Some(profile_arn), + } + } +} + +impl RequestTranslator for OpenAiRequestTranslator { + type Input = ChatCompletionRequest; + type Output = CodeWhispererRequest; + type Error = TranslateError; + + fn translate_request(&self, request: Self::Input) -> Result { + Ok(convert_openai_to_codewhisperer( + &request, + self.profile_arn.clone(), + )) + } +} + +// ============================================================================ +// 模型映射 +// ============================================================================ + +/// 模型映射表 +pub fn get_model_map() -> HashMap<&'static str, &'static str> { + let mut map = HashMap::new(); + // Opus 4.5 系列 + map.insert("claude-opus-4-5", "claude-opus-4.5"); + map.insert("claude-opus-4-5-20251101", "claude-opus-4.5"); + // Haiku 4.5 系列 + map.insert("claude-haiku-4-5", "claude-haiku-4.5"); + map.insert("claude-haiku-4-5-20251001", "claude-haiku-4.5"); + // Sonnet 4.5 系列 + map.insert("claude-sonnet-4-5", "CLAUDE_SONNET_4_5_20250929_V1_0"); + map.insert( + "claude-sonnet-4-5-20250929", + "CLAUDE_SONNET_4_5_20250929_V1_0", + ); + // Sonnet 4 系列 + map.insert("claude-sonnet-4-20250514", "CLAUDE_SONNET_4_20250514_V1_0"); + // Sonnet 3.7/3.5 系列(兼容旧版本) + map.insert( + "claude-3-7-sonnet-20250219", + "CLAUDE_3_7_SONNET_20250219_V1_0", + ); + map.insert( + "claude-3-5-sonnet-20241022", + "CLAUDE_3_7_SONNET_20250219_V1_0", + ); + map.insert( + "claude-3-5-sonnet-latest", + "CLAUDE_3_7_SONNET_20250219_V1_0", + ); + map +} + +/// 获取支持的模型列表 +pub fn get_supported_models() -> Vec<&'static str> { + vec![ + "claude-opus-4-5", + "claude-opus-4-5-20251101", + "claude-haiku-4-5", + "claude-haiku-4-5-20251001", + "claude-sonnet-4-5", + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-20250514", + "claude-3-7-sonnet-20250219", + ] +} + +/// 默认模型 +pub const DEFAULT_MODEL: &str = "CLAUDE_SONNET_4_5_20250929_V1_0"; + +// ============================================================================ +// 内部类型 +// ============================================================================ + +#[derive(Debug, Clone)] +struct ProcessedMessage { + role: String, + content: String, + tool_calls: Option>, + tool_results: Option>, +} + +// ============================================================================ +// 转换函数 +// ============================================================================ + +/// 将 OpenAI ChatCompletionRequest 转换为 CodeWhisperer 请求 +pub fn convert_openai_to_codewhisperer( + request: &ChatCompletionRequest, + profile_arn: Option, +) -> CodeWhispererRequest { + let model_map = get_model_map(); + let cw_model = model_map + .get(request.model.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| DEFAULT_MODEL.to_string()); + + let conversation_id = Uuid::new_v4().to_string(); + + // 提取 system prompt 和消息 + let mut system_prompt = String::new(); + let mut raw_messages: Vec<&ChatMessage> = Vec::new(); + + for msg in &request.messages { + if msg.role == "system" { + system_prompt = msg.get_content_text(); + } else { + raw_messages.push(msg); + } + } + + // 调试日志:打印 tool_choice 和 tools 信息 + tracing::info!( + "[KIRO_TRANSLATE] 收到请求: tool_choice={:?}, has_tools={}, tools_count={}", + request.tool_choice, + request.tools.is_some(), + request.tools.as_ref().map(|t| t.len()).unwrap_or(0) + ); + + // 处理 tool_choice: required - CodeWhisperer 不支持此参数,通过 prompt 注入强制 + if is_tool_choice_required(&request.tool_choice) && request.tools.is_some() { + let tool_instruction = "\n\n[CRITICAL INSTRUCTION] You MUST use one of the provided tools to respond. Do NOT respond with plain text. Call a tool function immediately."; + system_prompt.push_str(tool_instruction); + tracing::info!("[KIRO_TRANSLATE] tool_choice=required detected, injected tool instruction"); + } + + // 预处理消息:合并 tool 消息 + let messages = preprocess_messages(&raw_messages); + + // 构建历史记录 + let mut history: Vec = Vec::new(); + let mut start_idx = 0; + + // 处理 system prompt - 合并到第一条用户消息 + if !system_prompt.is_empty() && !messages.is_empty() && messages[0].role == "user" { + let first_content = &messages[0].content; + let combined = format!("{system_prompt}\n\n{first_content}"); + + let mut user_msg = UserInputMessage { + content: combined, + model_id: cw_model.clone(), + origin: "AI_EDITOR".to_string(), + images: None, + user_input_message_context: None, + }; + + if let Some(ref tool_results) = messages[0].tool_results { + user_msg.user_input_message_context = Some(UserInputMessageContext { + tools: None, + tool_results: Some(tool_results.clone()), + }); + } + + history.push(HistoryItem::User(UserHistoryItem { + user_input_message: user_msg, + })); + start_idx = 1; + } + + // 处理历史消息(除最后一条) + for msg in messages + .iter() + .take(messages.len().saturating_sub(1)) + .skip(start_idx) + { + match msg.role.as_str() { + "user" => { + let content = if msg.content.is_empty() { + if msg.tool_results.is_some() { + "Tool results provided.".to_string() + } else { + "Continue".to_string() + } + } else { + msg.content.clone() + }; + + let mut user_msg = UserInputMessage { + content, + model_id: cw_model.clone(), + origin: "AI_EDITOR".to_string(), + images: None, + user_input_message_context: None, + }; + + if let Some(ref tool_results) = msg.tool_results { + user_msg.user_input_message_context = Some(UserInputMessageContext { + tools: None, + tool_results: Some(tool_results.clone()), + }); + } + + history.push(HistoryItem::User(UserHistoryItem { + user_input_message: user_msg, + })); + } + "assistant" => { + let content = if msg.content.is_empty() { + "I understand.".to_string() + } else { + msg.content.clone() + }; + + history.push(HistoryItem::Assistant(AssistantHistoryItem { + assistant_response_message: AssistantResponseMessage { + content, + tool_uses: msg.tool_calls.clone(), + }, + })); + } + _ => {} + } + } + + // 修复历史记录交替顺序 + let history = fix_history_alternation(history, &cw_model); + + // 构建当前消息 + let (current_content, current_tool_results) = if let Some(last_msg) = messages.last() { + if last_msg.role == "assistant" { + ("Continue".to_string(), None) + } else { + let content = if last_msg.content.is_empty() { + if last_msg.tool_results.is_some() { + "Tool results provided.".to_string() + } else { + "Continue".to_string() + } + } else { + last_msg.content.clone() + }; + (content, last_msg.tool_results.clone()) + } + } else { + ("Continue".to_string(), None) + }; + + // 构建 tools + let tools = convert_tools(&request.tools); + + let user_input_message_context = if tools.is_some() || current_tool_results.is_some() { + Some(UserInputMessageContext { + tools, + tool_results: current_tool_results, + }) + } else { + None + }; + + CodeWhispererRequest { + conversation_state: ConversationState { + chat_trigger_type: "MANUAL".to_string(), + conversation_id, + current_message: CurrentMessage { + user_input_message: UserInputMessage { + content: current_content, + model_id: cw_model, + origin: "AI_EDITOR".to_string(), + images: None, + user_input_message_context, + }, + }, + history: if history.is_empty() { + None + } else { + Some(history) + }, + }, + profile_arn, + } +} + +/// 预处理消息:合并连续的 tool 消息到前一个 assistant 消息后的 user 消息 +fn preprocess_messages(messages: &[&ChatMessage]) -> Vec { + let mut result: Vec = Vec::new(); + let mut pending_tool_results: Vec = Vec::new(); + + for msg in messages { + match msg.role.as_str() { + "tool" => { + let content = msg.get_content_text(); + let tool_id = msg.tool_call_id.clone().unwrap_or_default(); + pending_tool_results.push(CWToolResult { + content: vec![CWTextContent { text: content }], + status: "success".to_string(), + tool_use_id: tool_id, + }); + } + "user" => { + let content = msg.get_content_text(); + let mut tool_results = pending_tool_results.clone(); + pending_tool_results.clear(); + + // 去重 tool_results + let mut seen_ids = HashSet::new(); + tool_results.retain(|tr| seen_ids.insert(tr.tool_use_id.clone())); + + result.push(ProcessedMessage { + role: "user".to_string(), + content, + tool_calls: None, + tool_results: if tool_results.is_empty() { + None + } else { + Some(tool_results) + }, + }); + } + "assistant" => { + // 如果有待处理的 tool results,先创建一个 user 消息 + if !pending_tool_results.is_empty() { + let mut tool_results = pending_tool_results.clone(); + pending_tool_results.clear(); + + let mut seen_ids = HashSet::new(); + tool_results.retain(|tr| seen_ids.insert(tr.tool_use_id.clone())); + + result.push(ProcessedMessage { + role: "user".to_string(), + content: "Tool results provided.".to_string(), + tool_calls: None, + tool_results: Some(tool_results), + }); + } + + let content = msg.get_content_text(); + let tool_calls = msg.tool_calls.as_ref().map(|calls| { + calls + .iter() + .map(|tc| CWToolUse { + input: serde_json::from_str(&tc.function.arguments) + .unwrap_or(serde_json::json!({})), + name: tc.function.name.clone(), + tool_use_id: tc.id.clone(), + }) + .collect() + }); + + result.push(ProcessedMessage { + role: "assistant".to_string(), + content, + tool_calls, + tool_results: None, + }); + } + _ => {} + } + } + + // 处理末尾的 tool results + if !pending_tool_results.is_empty() { + let mut tool_results = pending_tool_results; + let mut seen_ids = HashSet::new(); + tool_results.retain(|tr| seen_ids.insert(tr.tool_use_id.clone())); + + result.push(ProcessedMessage { + role: "user".to_string(), + content: "Tool results provided.".to_string(), + tool_calls: None, + tool_results: Some(tool_results), + }); + } + + result +} + +/// 转换工具列表 +fn convert_tools(tools: &Option>) -> Option> { + tools.as_ref().map(|tools| { + let mut cw_tools: Vec = Vec::new(); + let mut function_count = 0; + + for t in tools.iter() { + match t { + Tool::Function { function } => { + // 限制最多 50 个函数工具 + if function_count >= 50 { + continue; + } + function_count += 1; + + let params = function + .parameters + .clone() + .unwrap_or_else(|| serde_json::json!({"type": "object", "properties": {}})); + + let desc = function + .description + .clone() + .unwrap_or_else(|| format!("Tool: {}", function.name)); + + cw_tools.push(CWToolItem::Standard(CWTool { + tool_specification: ToolSpecification { + name: function.name.clone(), + description: if desc.len() > 500 { + let truncated: String = desc.chars().take(497).collect(); + format!("{}...", truncated) + } else { + desc + }, + input_schema: InputSchema { json: params }, + }, + })); + } + Tool::WebSearch | Tool::WebSearch20250305 => { + cw_tools.push(CWToolItem::WebSearch(CWWebSearchTool { + tool_type: "web_search".to_string(), + })); + } + } + } + + cw_tools + }) +} + +/// 修复历史记录,确保 user/assistant 严格交替 +fn fix_history_alternation(history: Vec, model_id: &str) -> Vec { + if history.is_empty() { + return history; + } + + let mut fixed: Vec = Vec::new(); + + for item in history { + match &item { + HistoryItem::User(user_item) => { + if let Some(HistoryItem::User(last_user)) = fixed.last_mut() { + let has_tool_results = user_item + .user_input_message + .user_input_message_context + .as_ref() + .map(|ctx| ctx.tool_results.is_some()) + .unwrap_or(false); + + if has_tool_results { + let new_results = user_item + .user_input_message + .user_input_message_context + .as_ref() + .and_then(|ctx| ctx.tool_results.clone()) + .unwrap_or_default(); + + if let Some(ref mut ctx) = + last_user.user_input_message.user_input_message_context + { + if let Some(ref mut existing) = ctx.tool_results { + existing.extend(new_results); + } else { + ctx.tool_results = Some(new_results); + } + } else { + last_user.user_input_message.user_input_message_context = + Some(UserInputMessageContext { + tools: None, + tool_results: Some(new_results), + }); + } + continue; + } else { + fixed.push(HistoryItem::Assistant(AssistantHistoryItem { + assistant_response_message: AssistantResponseMessage { + content: "I understand.".to_string(), + tool_uses: None, + }, + })); + } + } + fixed.push(item); + } + HistoryItem::Assistant(_) => { + if let Some(HistoryItem::Assistant(_)) = fixed.last() { + fixed.push(HistoryItem::User(UserHistoryItem { + user_input_message: UserInputMessage { + content: "Continue".to_string(), + model_id: model_id.to_string(), + origin: "AI_EDITOR".to_string(), + images: None, + user_input_message_context: None, + }, + })); + } + if fixed.is_empty() { + fixed.push(HistoryItem::User(UserHistoryItem { + user_input_message: UserInputMessage { + content: "Continue".to_string(), + model_id: model_id.to_string(), + origin: "AI_EDITOR".to_string(), + images: None, + user_input_message_context: None, + }, + })); + } + fixed.push(item); + } + } + } + + // 确保以 assistant 结尾 + if let Some(HistoryItem::User(_)) = fixed.last() { + fixed.push(HistoryItem::Assistant(AssistantHistoryItem { + assistant_response_message: AssistantResponseMessage { + content: "I understand.".to_string(), + tool_uses: None, + }, + })); + } + + fixed +} + +/// 检查 tool_choice 是否为 required +/// +/// tool_choice 可以是: +/// - "required" 字符串 +/// - {"type": "any"} 或类似结构 +fn is_tool_choice_required(tool_choice: &Option) -> bool { + match tool_choice { + Some(serde_json::Value::String(s)) => s == "required" || s == "any", + Some(serde_json::Value::Object(obj)) => { + // 检查 {"type": "any"} 或 {"type": "tool", ...} + if let Some(serde_json::Value::String(t)) = obj.get("type") { + t == "any" || t == "tool" + } else { + false + } + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_model_mapping() { + let map = get_model_map(); + assert_eq!(map.get("claude-opus-4-5"), Some(&"claude-opus-4.5")); + assert_eq!( + map.get("claude-sonnet-4-5"), + Some(&"CLAUDE_SONNET_4_5_20250929_V1_0") + ); + } + + #[test] + fn test_convert_simple_request() { + let request = ChatCompletionRequest { + model: "claude-sonnet-4-5".to_string(), + messages: vec![ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::Text("Hello".to_string())), + tool_calls: None, + tool_call_id: None, + }], + tools: None, + stream: false, + max_tokens: None, + temperature: None, + top_p: None, + tool_choice: None, + reasoning_effort: None, + }; + + let translator = OpenAiRequestTranslator::new(); + let result = translator.translate_request(request); + assert!(result.is_ok()); + + let cw_request = result.unwrap(); + assert_eq!( + cw_request + .conversation_state + .current_message + .user_input_message + .model_id, + "CLAUDE_SONNET_4_5_20250929_V1_0" + ); + } +} diff --git a/src-tauri/src/translator/kiro/openai/response.rs b/src-tauri/src/translator/kiro/openai/response.rs new file mode 100644 index 000000000..66539d6d9 --- /dev/null +++ b/src-tauri/src/translator/kiro/openai/response.rs @@ -0,0 +1,130 @@ +//! Kiro 响应转换为 OpenAI SSE 格式 +//! +//! 将 `StreamEvent` 转换为 OpenAI Chat Completions 流式响应格式。 + +use crate::stream::{OpenAiSseGenerator, StreamEvent}; +use crate::translator::traits::{ResponseTranslator, SseResponseTranslator}; + +/// OpenAI 响应转换器 +/// +/// 将 `StreamEvent` 转换为 OpenAI SSE 格式 +#[derive(Debug)] +pub struct OpenAiResponseTranslator { + /// SSE 生成器 + generator: OpenAiSseGenerator, +} + +impl Default for OpenAiResponseTranslator { + fn default() -> Self { + Self::new("unknown".to_string()) + } +} + +impl OpenAiResponseTranslator { + /// 创建新的转换器 + pub fn new(model: String) -> Self { + Self { + generator: OpenAiSseGenerator::new(model), + } + } + + /// 使用指定的响应 ID 创建转换器 + pub fn with_id(id: String, model: String) -> Self { + Self { + generator: OpenAiSseGenerator::with_id(id, model), + } + } + + /// 获取响应 ID + pub fn response_id(&self) -> &str { + self.generator.response_id() + } +} + +impl ResponseTranslator for OpenAiResponseTranslator { + type Output = String; + + fn translate_event(&mut self, event: &StreamEvent) -> Option { + self.generator.generate(event) + } + + fn finalize(&mut self) -> Vec { + vec![self.generator.generate_done()] + } + + fn reset(&mut self) { + self.generator = OpenAiSseGenerator::new("unknown".to_string()); + } +} + +impl SseResponseTranslator for OpenAiResponseTranslator { + fn translate_to_sse(&mut self, event: &StreamEvent) -> Vec { + self.generator.generate(event).into_iter().collect() + } + + fn finalize_sse(&mut self) -> Vec { + vec![self.generator.generate_done()] + } + + fn reset(&mut self) { + self.generator = OpenAiSseGenerator::new("unknown".to_string()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stream::{ContentBlockType, StopReason}; + + #[test] + fn test_translate_text_delta() { + let mut translator = OpenAiResponseTranslator::new("gpt-4".to_string()); + + let event = StreamEvent::TextDelta { + text: "Hello".to_string(), + }; + + let sse = translator.translate_event(&event); + assert!(sse.is_some()); + let sse = sse.unwrap(); + assert!(sse.starts_with("data: ")); + assert!(sse.contains("\"content\":\"Hello\"")); + } + + #[test] + fn test_translate_tool_use() { + let mut translator = OpenAiResponseTranslator::new("gpt-4".to_string()); + + // 工具调用开始 + let event = StreamEvent::ToolUseStart { + id: "call_123".to_string(), + name: "read_file".to_string(), + }; + let sse = translator.translate_event(&event); + assert!(sse.is_some()); + assert!(sse.unwrap().contains("\"tool_calls\"")); + + // 工具参数增量 + let event = StreamEvent::ToolUseInputDelta { + id: "call_123".to_string(), + partial_json: "{\"path\":".to_string(), + }; + let sse = translator.translate_event(&event); + assert!(sse.is_some()); + } + + #[test] + fn test_translate_message_stop() { + let mut translator = OpenAiResponseTranslator::new("gpt-4".to_string()); + + let event = StreamEvent::MessageStop { + stop_reason: StopReason::EndTurn, + }; + + let sse = translator.translate_event(&event); + assert!(sse.is_some()); + let sse = sse.unwrap(); + assert!(sse.contains("\"finish_reason\":\"stop\"")); + assert!(sse.contains("[DONE]")); + } +} diff --git a/src-tauri/src/translator/mod.rs b/src-tauri/src/translator/mod.rs new file mode 100644 index 000000000..e79d0789a --- /dev/null +++ b/src-tauri/src/translator/mod.rs @@ -0,0 +1,53 @@ +//! 协议转换层 +//! +//! 处理不同前端协议(OpenAI、Anthropic、Gemini CLI)与不同后端(Kiro、Codex、Claude) +//! 之间的请求和响应格式转换。 +//! +//! # 架构设计 +//! +//! ```text +//! translator/ +//! ├── traits.rs # 转换器 trait 定义 +//! └── kiro/ # Kiro/CodeWhisperer 后端 +//! ├── openai/ # OpenAI 前端协议 +//! │ ├── request.rs # OpenAI → Kiro 请求 +//! │ └── response.rs # StreamEvent → OpenAI SSE +//! └── anthropic/ # Anthropic 前端协议 +//! ├── request.rs # Anthropic → Kiro 请求 +//! └── response.rs # StreamEvent → Anthropic SSE +//! ``` +//! +//! # 使用示例 +//! +//! ```ignore +//! use proxycast::translator::kiro::{ +//! AnthropicRequestTranslator, AnthropicResponseTranslator, +//! }; +//! use proxycast::translator::traits::RequestTranslator; +//! +//! // 请求转换 +//! let translator = AnthropicRequestTranslator::new(); +//! let cw_request = translator.translate_request(anthropic_request)?; +//! +//! // 响应转换 +//! let mut response_translator = AnthropicResponseTranslator::new(model); +//! for event in stream_events { +//! let sse_events = response_translator.translate_to_sse(&event); +//! for sse in sse_events { +//! // 发送 SSE 到客户端 +//! } +//! } +//! ``` + +pub mod kiro; +pub mod traits; + +// 重新导出核心类型 +pub use kiro::{ + AnthropicRequestTranslator, AnthropicResponseTranslator, OpenAiRequestTranslator, + OpenAiResponseTranslator, +}; +pub use traits::{ + RequestTranslator, ResponseTranslator, SseResponseTranslator, TranslateError, + TranslateErrorKind, +}; diff --git a/src-tauri/src/translator/traits.rs b/src-tauri/src/translator/traits.rs new file mode 100644 index 000000000..9ff4fe5c5 --- /dev/null +++ b/src-tauri/src/translator/traits.rs @@ -0,0 +1,215 @@ +//! 协议转换器 Trait 定义 +//! +//! 定义请求和响应转换器的核心接口,用于在不同协议之间进行转换。 +//! +//! # 设计原则 +//! +//! - `RequestTranslator`: 将前端协议请求转换为后端协议请求 +//! - `ResponseTranslator`: 将后端响应/事件转换为前端格式 +//! - 使用 `StreamEvent` 作为流式响应的中间表示 + +use crate::stream::StreamEvent; + +/// 请求转换器 Trait +/// +/// 将前端协议的请求转换为后端协议的请求格式。 +/// +/// # 类型参数 +/// +/// - `Input`: 前端请求类型(如 OpenAI ChatCompletionRequest) +/// - `Output`: 后端请求类型(如 CodeWhispererRequest) +/// - `Error`: 转换错误类型 +pub trait RequestTranslator { + /// 前端请求类型 + type Input; + /// 后端请求类型 + type Output; + /// 转换错误类型 + type Error: std::error::Error + Send + Sync + 'static; + + /// 转换请求 + /// + /// # 参数 + /// + /// - `request`: 前端协议请求 + /// + /// # 返回 + /// + /// 转换后的后端协议请求 + fn translate_request(&self, request: Self::Input) -> Result; +} + +/// 响应转换器 Trait +/// +/// 将 `StreamEvent` 转换为目标前端协议的响应格式。 +/// +/// # 类型参数 +/// +/// - `Output`: 目标响应类型或 SSE 字符串 +pub trait ResponseTranslator { + /// 目标响应类型 + type Output; + + /// 转换单个流事件 + /// + /// # 参数 + /// + /// - `event`: 统一的流事件 + /// + /// # 返回 + /// + /// - `Some(output)`: 生成的响应数据 + /// - `None`: 该事件不需要生成输出 + fn translate_event(&mut self, event: &StreamEvent) -> Option; + + /// 完成转换 + /// + /// 用于生成流结束时的最终事件(如果需要) + fn finalize(&mut self) -> Vec { + Vec::new() + } + + /// 重置转换器状态 + fn reset(&mut self); +} + +/// SSE 响应转换器 Trait +/// +/// 专门用于将 `StreamEvent` 转换为 SSE 字符串格式。 +/// 这是 `ResponseTranslator` 的一个常见特化。 +pub trait SseResponseTranslator { + /// 将流事件转换为 SSE 字符串 + /// + /// # 返回 + /// + /// SSE 格式的字符串列表,每个字符串都是完整的 SSE 事件 + fn translate_to_sse(&mut self, event: &StreamEvent) -> Vec; + + /// 生成结束 SSE 事件 + fn finalize_sse(&mut self) -> Vec { + Vec::new() + } + + /// 重置状态 + fn reset(&mut self); +} + +/// 转换错误类型 +#[derive(Debug, Clone)] +pub struct TranslateError { + /// 错误类型 + pub kind: TranslateErrorKind, + /// 错误消息 + pub message: String, + /// 原始数据(用于调试) + pub source_data: Option, +} + +impl std::fmt::Display for TranslateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.kind, self.message) + } +} + +impl std::error::Error for TranslateError {} + +/// 转换错误类型枚举 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TranslateErrorKind { + /// 无效的请求格式 + InvalidRequest, + /// 不支持的功能 + UnsupportedFeature, + /// 缺少必要字段 + MissingField, + /// 数据验证失败 + ValidationFailed, + /// 序列化/反序列化错误 + SerializationError, + /// 其他错误 + Other, +} + +impl std::fmt::Display for TranslateErrorKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidRequest => write!(f, "InvalidRequest"), + Self::UnsupportedFeature => write!(f, "UnsupportedFeature"), + Self::MissingField => write!(f, "MissingField"), + Self::ValidationFailed => write!(f, "ValidationFailed"), + Self::SerializationError => write!(f, "SerializationError"), + Self::Other => write!(f, "Other"), + } + } +} + +impl TranslateError { + /// 创建新的转换错误 + pub fn new(kind: TranslateErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + source_data: None, + } + } + + /// 带原始数据创建错误 + pub fn with_source( + kind: TranslateErrorKind, + message: impl Into, + source: impl Into, + ) -> Self { + Self { + kind, + message: message.into(), + source_data: Some(source.into()), + } + } + + /// 创建无效请求错误 + pub fn invalid_request(message: impl Into) -> Self { + Self::new(TranslateErrorKind::InvalidRequest, message) + } + + /// 创建不支持功能错误 + pub fn unsupported(message: impl Into) -> Self { + Self::new(TranslateErrorKind::UnsupportedFeature, message) + } + + /// 创建缺少字段错误 + pub fn missing_field(field: &str) -> Self { + Self::new( + TranslateErrorKind::MissingField, + format!("Missing required field: {}", field), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_translate_error_display() { + let err = TranslateError::new(TranslateErrorKind::InvalidRequest, "test error"); + assert_eq!(format!("{}", err), "InvalidRequest: test error"); + } + + #[test] + fn test_translate_error_with_source() { + let err = TranslateError::with_source( + TranslateErrorKind::SerializationError, + "failed to parse", + "{invalid json}", + ); + assert!(err.source_data.is_some()); + assert_eq!(err.source_data.unwrap(), "{invalid json}"); + } + + #[test] + fn test_missing_field_error() { + let err = TranslateError::missing_field("model"); + assert_eq!(err.kind, TranslateErrorKind::MissingField); + assert!(err.message.contains("model")); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2e081bfa7..be1163fa1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.26.0", + "version": "0.27.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tests/api_key_provider_tests.rs b/src-tauri/tests/api_key_provider_tests.rs new file mode 100644 index 000000000..fb8d17785 --- /dev/null +++ b/src-tauri/tests/api_key_provider_tests.rs @@ -0,0 +1,813 @@ +//! API Key Provider 属性测试 +//! +//! 使用 proptest 进行属性测试,验证 API Key Provider 服务的正确性。 +//! +//! **Feature: provider-ui-refactor** + +use proptest::prelude::*; +use std::collections::HashSet; +use std::sync::Arc; +use tempfile::TempDir; + +use proxycast_lib::database::dao::api_key_provider::{ + ApiKeyEntry, ApiKeyProvider, ApiKeyProviderDao, ApiProviderType, ProviderGroup, +}; +use proxycast_lib::database::{init_database, DbConnection}; +use proxycast_lib::services::api_key_provider_service::ApiKeyProviderService; +use rusqlite::Connection; + +/// 测试上下文 +struct TestContext { + pub temp_dir: TempDir, + pub db: DbConnection, + pub service: ApiKeyProviderService, +} + +impl TestContext { + /// 创建测试上下文 + pub fn new() -> Result> { + let temp_dir = TempDir::new()?; + let db_path = temp_dir.path().join("test.db"); + let conn = Connection::open(&db_path)?; + + // 创建表结构 + conn.execute( + "CREATE TABLE IF NOT EXISTS api_key_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + api_host TEXT NOT NULL, + is_system INTEGER NOT NULL DEFAULT 0, + group_name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + api_version TEXT, + project TEXT, + location TEXT, + region TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + api_key_encrypted TEXT NOT NULL, + alias TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + usage_count INTEGER NOT NULL DEFAULT 0, + error_count INTEGER NOT NULL DEFAULT 0, + last_used_at TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (provider_id) REFERENCES api_key_providers(id) ON DELETE CASCADE + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS provider_ui_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )", + [], + )?; + + let db = Arc::new(std::sync::Mutex::new(conn)); + let service = ApiKeyProviderService::new(); + + Ok(Self { + temp_dir, + db, + service, + }) + } + + /// 创建测试 Provider + pub fn create_test_provider(&self, id: &str) -> Result { + let now = chrono::Utc::now(); + let provider = ApiKeyProvider { + id: id.to_string(), + name: format!("Test Provider {}", id), + provider_type: ApiProviderType::Openai, + api_host: "https://api.test.com".to_string(), + is_system: false, + group: ProviderGroup::Custom, + enabled: true, + sort_order: 0, + api_version: None, + project: None, + location: None, + region: None, + created_at: now, + updated_at: now, + }; + + let conn = self.db.lock().map_err(|e| e.to_string())?; + ApiKeyProviderDao::insert_provider(&conn, &provider).map_err(|e| e.to_string())?; + + Ok(provider) + } + + /// 添加测试 API Key + pub fn add_test_api_key( + &self, + provider_id: &str, + api_key: &str, + ) -> Result { + self.service + .add_api_key(&self.db, provider_id, api_key, None) + } +} + +// ============================================================================ +// Property 12: 轮询负载均衡正确性 +// **Validates: Requirements 7.3** +// ============================================================================ + +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property 12: 轮询负载均衡正确性 + /// + /// *对于任意* 拥有 N 个启用的 API Key 的 Provider,连续 N 次获取 API Key 应各返回不同的 Key + /// + /// **Feature: provider-ui-refactor, Property 12: 轮询负载均衡正确性** + /// **Validates: Requirements 7.3** + #[test] + fn test_round_robin_load_balancing(num_keys in 2usize..10) { + let ctx = TestContext::new().expect("Failed to create test context"); + + // 创建测试 Provider + let provider_id = format!("test-provider-{}", uuid::Uuid::new_v4()); + ctx.create_test_provider(&provider_id).expect("Failed to create provider"); + + // 添加 N 个 API Keys + let mut expected_keys = Vec::new(); + for i in 0..num_keys { + let api_key = format!("sk-test-key-{}-{}", provider_id, i); + ctx.add_test_api_key(&provider_id, &api_key).expect("Failed to add API key"); + expected_keys.push(api_key); + } + + // 连续获取 N 次 API Key + let mut retrieved_keys = Vec::new(); + for _ in 0..num_keys { + let key = ctx.service + .get_next_api_key(&ctx.db, &provider_id) + .expect("Failed to get next API key") + .expect("No API key returned"); + retrieved_keys.push(key); + } + + // 验证:连续 N 次获取应返回 N 个不同的 Key + let unique_keys: HashSet<_> = retrieved_keys.iter().collect(); + prop_assert_eq!( + unique_keys.len(), + num_keys, + "Expected {} unique keys, but got {}. Keys: {:?}", + num_keys, + unique_keys.len(), + retrieved_keys + ); + + // 验证:所有返回的 Key 都在预期列表中 + for key in &retrieved_keys { + prop_assert!( + expected_keys.contains(key), + "Unexpected key returned: {}", + key + ); + } + } + + /// Property 12 补充测试:轮询循环性 + /// + /// *对于任意* 拥有 N 个启用的 API Key 的 Provider,获取 2N 次应该循环使用所有 Key + /// + /// **Feature: provider-ui-refactor, Property 12: 轮询负载均衡正确性** + /// **Validates: Requirements 7.3** + #[test] + fn test_round_robin_cycling(num_keys in 2usize..8) { + let ctx = TestContext::new().expect("Failed to create test context"); + + // 创建测试 Provider + let provider_id = format!("test-provider-cycle-{}", uuid::Uuid::new_v4()); + ctx.create_test_provider(&provider_id).expect("Failed to create provider"); + + // 添加 N 个 API Keys + for i in 0..num_keys { + let api_key = format!("sk-cycle-key-{}-{}", provider_id, i); + ctx.add_test_api_key(&provider_id, &api_key).expect("Failed to add API key"); + } + + // 获取 2N 次 API Key + let mut first_cycle = Vec::new(); + let mut second_cycle = Vec::new(); + + for i in 0..(num_keys * 2) { + let key = ctx.service + .get_next_api_key(&ctx.db, &provider_id) + .expect("Failed to get next API key") + .expect("No API key returned"); + + if i < num_keys { + first_cycle.push(key); + } else { + second_cycle.push(key); + } + } + + // 验证:第一轮和第二轮应该返回相同的 Key 序列 + prop_assert_eq!( + first_cycle, + second_cycle, + "Round robin should cycle through keys in the same order" + ); + } +} + +// ============================================================================ +// Property 13: API Key 使用统计正确性 +// **Validates: Requirements 7.4** +// ============================================================================ + +proptest! { + #![proptest_config(ProptestConfig::with_cases(20))] + + /// Property 13: API Key 使用统计正确性 + /// + /// *对于任意* API Key 使用记录操作,使用次数应正确递增 + /// + /// **Feature: provider-ui-refactor, Property 13: API Key 使用统计正确性** + /// **Validates: Requirements 7.4** + #[test] + fn test_usage_count_increment(num_usages in 1usize..10) { + let ctx = TestContext::new().expect("Failed to create test context"); + + // 创建测试 Provider + let provider_id = format!("test-provider-usage-{}", uuid::Uuid::new_v4()); + ctx.create_test_provider(&provider_id).expect("Failed to create provider"); + + // 添加 API Key + let api_key = format!("sk-usage-test-{}", provider_id); + let entry = ctx.add_test_api_key(&provider_id, &api_key) + .expect("Failed to add API key"); + + // 初始使用次数应为 0 + prop_assert_eq!(entry.usage_count, 0, "Initial usage count should be 0"); + + // 记录 N 次使用 + for _ in 0..num_usages { + ctx.service.record_usage(&ctx.db, &entry.id) + .expect("Failed to record usage"); + } + + // 获取更新后的 API Key + let conn = ctx.db.lock().expect("Failed to lock db"); + let updated = ApiKeyProviderDao::get_api_key_by_id(&conn, &entry.id) + .expect("Failed to get API key") + .expect("API key not found"); + + // 验证:使用次数应等于记录次数 + prop_assert_eq!( + updated.usage_count as usize, + num_usages, + "Usage count should equal number of record_usage calls" + ); + + // 验证:最后使用时间应被更新 + prop_assert!( + updated.last_used_at.is_some(), + "last_used_at should be set after usage" + ); + } + + /// Property 13 补充测试:错误次数递增 + /// + /// *对于任意* API Key 错误记录操作,错误次数应正确递增 + /// + /// **Feature: provider-ui-refactor, Property 13: API Key 使用统计正确性** + /// **Validates: Requirements 7.4** + #[test] + fn test_error_count_increment(num_errors in 1usize..10) { + let ctx = TestContext::new().expect("Failed to create test context"); + + // 创建测试 Provider + let provider_id = format!("test-provider-error-{}", uuid::Uuid::new_v4()); + ctx.create_test_provider(&provider_id).expect("Failed to create provider"); + + // 添加 API Key + let api_key = format!("sk-error-test-{}", provider_id); + let entry = ctx.add_test_api_key(&provider_id, &api_key) + .expect("Failed to add API key"); + + // 初始错误次数应为 0 + prop_assert_eq!(entry.error_count, 0, "Initial error count should be 0"); + + // 记录 N 次错误 + for _ in 0..num_errors { + ctx.service.record_error(&ctx.db, &entry.id) + .expect("Failed to record error"); + } + + // 获取更新后的 API Key + let conn = ctx.db.lock().expect("Failed to lock db"); + let updated = ApiKeyProviderDao::get_api_key_by_id(&conn, &entry.id) + .expect("Failed to get API key") + .expect("API key not found"); + + // 验证:错误次数应等于记录次数 + prop_assert_eq!( + updated.error_count as usize, + num_errors, + "Error count should equal number of record_error calls" + ); + } + + /// Property 13 补充测试:使用和错误统计独立 + /// + /// *对于任意* API Key,使用次数和错误次数应独立递增 + /// + /// **Feature: provider-ui-refactor, Property 13: API Key 使用统计正确性** + /// **Validates: Requirements 7.4** + #[test] + fn test_usage_and_error_independent( + num_usages in 1usize..5, + num_errors in 1usize..5 + ) { + let ctx = TestContext::new().expect("Failed to create test context"); + + // 创建测试 Provider + let provider_id = format!("test-provider-mixed-{}", uuid::Uuid::new_v4()); + ctx.create_test_provider(&provider_id).expect("Failed to create provider"); + + // 添加 API Key + let api_key = format!("sk-mixed-test-{}", provider_id); + let entry = ctx.add_test_api_key(&provider_id, &api_key) + .expect("Failed to add API key"); + + // 交替记录使用和错误 + for i in 0..(num_usages + num_errors) { + if i < num_usages { + ctx.service.record_usage(&ctx.db, &entry.id) + .expect("Failed to record usage"); + } + if i < num_errors { + ctx.service.record_error(&ctx.db, &entry.id) + .expect("Failed to record error"); + } + } + + // 获取更新后的 API Key + let conn = ctx.db.lock().expect("Failed to lock db"); + let updated = ApiKeyProviderDao::get_api_key_by_id(&conn, &entry.id) + .expect("Failed to get API key") + .expect("API key not found"); + + // 验证:使用次数和错误次数应独立 + prop_assert_eq!( + updated.usage_count as usize, + num_usages, + "Usage count should equal number of record_usage calls" + ); + prop_assert_eq!( + updated.error_count as usize, + num_errors, + "Error count should equal number of record_error calls" + ); + } +} + +// ============================================================================ +// Property 16: 数据持久化 Round-Trip +// **Validates: Requirements 9.1** +// ============================================================================ + +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property 16: 数据持久化 Round-Trip + /// + /// *对于任意* Provider 配置,保存后重新加载应得到等价的配置数据 + /// + /// **Feature: provider-ui-refactor, Property 16: 数据持久化 Round-Trip** + /// **Validates: Requirements 9.1** + #[test] + fn test_provider_persistence_round_trip( + name in "[a-zA-Z0-9 ]{3,30}", + api_host in "https://[a-z]{3,10}\\.[a-z]{2,5}/[a-z]{0,10}" + ) { + let ctx = TestContext::new().expect("Failed to create test context"); + + // 创建 Provider + let provider = ctx.service + .add_custom_provider( + &ctx.db, + name.clone(), + ApiProviderType::Openai, + api_host.clone(), + None, + None, + None, + None, + ) + .expect("Failed to create provider"); + + // 重新加载 Provider + let loaded = ctx.service + .get_provider(&ctx.db, &provider.id) + .expect("Failed to get provider") + .expect("Provider not found"); + + // 验证:加载的数据应与保存的数据等价 + prop_assert_eq!(&loaded.provider.id, &provider.id, "ID should match"); + prop_assert_eq!(&loaded.provider.name, &name, "Name should match"); + prop_assert_eq!(&loaded.provider.api_host, &api_host, "API host should match"); + prop_assert_eq!(loaded.provider.is_system, false, "Should not be system provider"); + prop_assert_eq!(loaded.provider.group, ProviderGroup::Custom, "Group should be Custom"); + } + + /// Property 16 补充测试:UI 状态持久化 Round-Trip + /// + /// *对于任意* UI 状态键值对,保存后重新加载应得到相同的值 + /// + /// **Feature: provider-ui-refactor, Property 16: 数据持久化 Round-Trip** + /// **Validates: Requirements 9.1, 8.4** + #[test] + fn test_ui_state_persistence_round_trip( + key in "[a-z_]{3,20}", + value in "[a-zA-Z0-9_,\\[\\]\"{}:]{1,100}" + ) { + let ctx = TestContext::new().expect("Failed to create test context"); + + // 保存 UI 状态 + ctx.service + .set_ui_state(&ctx.db, &key, &value) + .expect("Failed to set UI state"); + + // 重新加载 UI 状态 + let loaded = ctx.service + .get_ui_state(&ctx.db, &key) + .expect("Failed to get UI state") + .expect("UI state not found"); + + // 验证:加载的值应与保存的值相同 + prop_assert_eq!(&loaded, &value, "UI state value should match"); + } + + /// Property 16 补充测试:Provider 排序持久化 Round-Trip + /// + /// *对于任意* Provider 排序顺序,保存后重新加载应保持相同的顺序 + /// + /// **Feature: provider-ui-refactor, Property 16: 数据持久化 Round-Trip** + /// **Validates: Requirements 9.1, 8.4** + #[test] + fn test_provider_sort_order_persistence(num_providers in 2usize..6) { + let ctx = TestContext::new().expect("Failed to create test context"); + + // 创建多个 Provider + let mut provider_ids = Vec::new(); + for i in 0..num_providers { + let provider = ctx.service + .add_custom_provider( + &ctx.db, + format!("Provider {}", i), + ApiProviderType::Openai, + format!("https://api{}.test.com", i), + None, + None, + None, + None, + ) + .expect("Failed to create provider"); + provider_ids.push(provider.id); + } + + // 反转排序顺序 + let reversed_ids: Vec<_> = provider_ids.iter().rev().cloned().collect(); + let sort_orders: Vec<(String, i32)> = reversed_ids + .iter() + .enumerate() + .map(|(i, id)| (id.clone(), i as i32)) + .collect(); + + // 更新排序顺序 + ctx.service + .update_provider_sort_orders(&ctx.db, sort_orders) + .expect("Failed to update sort orders"); + + // 重新加载所有 Provider + let loaded = ctx.service + .get_all_providers(&ctx.db) + .expect("Failed to get providers"); + + // 过滤出我们创建的 Provider + let our_providers: Vec<_> = loaded + .iter() + .filter(|p| provider_ids.contains(&p.provider.id)) + .collect(); + + // 验证:排序顺序应与更新后的顺序一致 + for (i, expected_id) in reversed_ids.iter().enumerate() { + let provider = our_providers + .iter() + .find(|p| &p.provider.id == expected_id) + .expect("Provider not found"); + prop_assert_eq!( + provider.provider.sort_order, + i as i32, + "Sort order should match for provider {}", + expected_id + ); + } + } + + /// Property 16 补充测试:API Key 持久化 Round-Trip + /// + /// *对于任意* API Key,保存后重新加载应得到等价的数据(除了加密的 key) + /// + /// **Feature: provider-ui-refactor, Property 16: 数据持久化 Round-Trip** + /// **Validates: Requirements 9.1** + #[test] + fn test_api_key_persistence_round_trip( + api_key in "[a-zA-Z0-9_-]{20,50}", + alias in proptest::option::of("[a-zA-Z0-9 ]{3,20}") + ) { + let ctx = TestContext::new().expect("Failed to create test context"); + + // 创建 Provider + let provider_id = format!("test-provider-key-rt-{}", uuid::Uuid::new_v4()); + ctx.create_test_provider(&provider_id).expect("Failed to create provider"); + + // 添加 API Key + let entry = ctx.service + .add_api_key(&ctx.db, &provider_id, &api_key, alias.clone()) + .expect("Failed to add API key"); + + // 重新加载 Provider(包含 API Keys) + let loaded = ctx.service + .get_provider(&ctx.db, &provider_id) + .expect("Failed to get provider") + .expect("Provider not found"); + + // 找到我们添加的 API Key + let loaded_key = loaded.api_keys + .iter() + .find(|k| k.id == entry.id) + .expect("API Key not found"); + + // 验证:加载的数据应与保存的数据等价 + prop_assert_eq!(&loaded_key.id, &entry.id, "ID should match"); + prop_assert_eq!(&loaded_key.provider_id, &provider_id, "Provider ID should match"); + prop_assert_eq!(&loaded_key.alias, &alias, "Alias should match"); + prop_assert_eq!(loaded_key.enabled, true, "Should be enabled by default"); + prop_assert_eq!(loaded_key.usage_count, 0, "Usage count should be 0"); + prop_assert_eq!(loaded_key.error_count, 0, "Error count should be 0"); + + // 验证:解密后的 API Key 应与原始值相同 + let decrypted = ctx.service + .decrypt_api_key(&loaded_key.api_key_encrypted) + .expect("Failed to decrypt"); + prop_assert_eq!(&decrypted, &api_key, "Decrypted API key should match original"); + } +} + +// ============================================================================ +// Property 17: API Key 加密存储 +// **Validates: Requirements 9.2** +// ============================================================================ + +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property 17: API Key 加密存储 + /// + /// *对于任意* 存储的 API Key,数据库中的值不应为明文 + /// + /// **Feature: provider-ui-refactor, Property 17: API Key 加密存储** + /// **Validates: Requirements 9.2** + #[test] + fn test_api_key_encryption(api_key in "[a-zA-Z0-9_-]{20,50}") { + let ctx = TestContext::new().expect("Failed to create test context"); + + // 创建测试 Provider + let provider_id = format!("test-provider-enc-{}", uuid::Uuid::new_v4()); + ctx.create_test_provider(&provider_id).expect("Failed to create provider"); + + // 添加 API Key + let entry = ctx.add_test_api_key(&provider_id, &api_key) + .expect("Failed to add API key"); + + // 验证:存储的值不是明文 + prop_assert_ne!( + &entry.api_key_encrypted, + &api_key, + "API Key should be encrypted, not stored as plaintext" + ); + + // 验证:加密后的值看起来像 Base64 + prop_assert!( + entry.api_key_encrypted.chars().all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '='), + "Encrypted value should be Base64 encoded" + ); + + // 验证:可以正确解密 + let decrypted = ctx.service.decrypt_api_key(&entry.api_key_encrypted) + .expect("Failed to decrypt API key"); + prop_assert_eq!( + &decrypted, + &api_key, + "Decrypted key should match original" + ); + } + + /// Property 17 补充测试:加密 Round-Trip + /// + /// *对于任意* API Key,加密后解密应得到原始值 + /// + /// **Feature: provider-ui-refactor, Property 17: API Key 加密存储** + /// **Validates: Requirements 9.2** + #[test] + fn test_encryption_round_trip(api_key in "[a-zA-Z0-9_-]{10,100}") { + let service = ApiKeyProviderService::new(); + + // 加密 + let encrypted = service.encrypt_api_key(&api_key); + + // 验证:加密后不等于原文 + prop_assert_ne!( + &encrypted, + &api_key, + "Encrypted value should differ from original" + ); + + // 解密 + let decrypted = service.decrypt_api_key(&encrypted) + .expect("Failed to decrypt"); + + // 验证:解密后等于原文 + prop_assert_eq!( + &decrypted, + &api_key, + "Decrypted value should match original" + ); + } +} + +#[cfg(test)] +mod unit_tests { + use super::*; + + /// 单元测试:基本的 Provider CRUD 操作 + #[test] + fn test_provider_crud() { + let ctx = TestContext::new().expect("Failed to create test context"); + + // 创建 Provider + let provider = ctx + .service + .add_custom_provider( + &ctx.db, + "Test Provider".to_string(), + ApiProviderType::Openai, + "https://api.test.com".to_string(), + None, + None, + None, + None, + ) + .expect("Failed to create provider"); + + assert!(provider.id.starts_with("custom-")); + assert_eq!(provider.name, "Test Provider"); + assert!(!provider.is_system); + + // 获取 Provider + let retrieved = ctx + .service + .get_provider(&ctx.db, &provider.id) + .expect("Failed to get provider") + .expect("Provider not found"); + + assert_eq!(retrieved.provider.id, provider.id); + + // 更新 Provider + let updated = ctx + .service + .update_provider( + &ctx.db, + &provider.id, + Some("Updated Name".to_string()), + None, + Some(false), + None, + None, + None, + None, + None, + ) + .expect("Failed to update provider"); + + assert_eq!(updated.name, "Updated Name"); + assert!(!updated.enabled); + + // 删除 Provider + let deleted = ctx + .service + .delete_custom_provider(&ctx.db, &provider.id) + .expect("Failed to delete provider"); + + assert!(deleted); + + // 验证已删除 + let not_found = ctx + .service + .get_provider(&ctx.db, &provider.id) + .expect("Failed to get provider"); + + assert!(not_found.is_none()); + } + + /// 单元测试:API Key CRUD 操作 + #[test] + fn test_api_key_crud() { + let ctx = TestContext::new().expect("Failed to create test context"); + + // 创建 Provider + let provider_id = "test-provider-key-crud"; + ctx.create_test_provider(provider_id) + .expect("Failed to create provider"); + + // 添加 API Key + let key = ctx + .add_test_api_key(provider_id, "sk-test-key-123") + .expect("Failed to add API key"); + + assert!(!key.id.is_empty()); + assert_eq!(key.provider_id, provider_id); + assert!(key.enabled); + + // 切换启用状态 + let toggled = ctx + .service + .toggle_api_key(&ctx.db, &key.id, false) + .expect("Failed to toggle API key"); + + assert!(!toggled.enabled); + + // 更新别名 + let aliased = ctx + .service + .update_api_key_alias(&ctx.db, &key.id, Some("My Key".to_string())) + .expect("Failed to update alias"); + + assert_eq!(aliased.alias, Some("My Key".to_string())); + + // 删除 API Key + let deleted = ctx + .service + .delete_api_key(&ctx.db, &key.id) + .expect("Failed to delete API key"); + + assert!(deleted); + } + + /// 单元测试:系统 Provider 不能删除 + #[test] + fn test_system_provider_cannot_be_deleted() { + let ctx = TestContext::new().expect("Failed to create test context"); + + // 创建系统 Provider + let now = chrono::Utc::now(); + let provider = ApiKeyProvider { + id: "system-openai".to_string(), + name: "OpenAI".to_string(), + provider_type: ApiProviderType::Openai, + api_host: "https://api.openai.com".to_string(), + is_system: true, // 系统 Provider + group: ProviderGroup::Mainstream, + enabled: true, + sort_order: 1, + api_version: None, + project: None, + location: None, + region: None, + created_at: now, + updated_at: now, + }; + + { + let conn = ctx.db.lock().expect("Failed to lock db"); + ApiKeyProviderDao::insert_provider(&conn, &provider).expect("Failed to insert"); + } + + // 尝试删除系统 Provider + let result = ctx.service.delete_custom_provider(&ctx.db, "system-openai"); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("不允许删除系统 Provider")); + } +} diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index 21fd92a57..9828a0ec6 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -1,16 +1,6 @@ import React, { useState, useRef, useEffect } from "react"; -import { - User, - Bot, - Copy, - Edit2, - Trash2, - Lightbulb, - ChevronDown, - Check, -} from "lucide-react"; +import { User, Bot, Copy, Edit2, Trash2, Check } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { MessageListContainer, @@ -23,9 +13,6 @@ import { TimeStamp, MessageBubble, MessageActions, - ThinkingBox, - ThinkingHeader, - ThinkingContent, } from "../styles"; import { MarkdownRenderer } from "./MarkdownRenderer"; import { StreamingRenderer } from "./StreamingRenderer"; @@ -44,9 +31,6 @@ export const MessageList: React.FC = ({ onEditMessage, }) => { const scrollRef = useRef(null); - const [expandedThinking, setExpandedThinking] = useState< - Record - >({}); const [copiedId, setCopiedId] = useState(null); const [editingId, setEditingId] = useState(null); const [editContent, setEditContent] = useState(""); @@ -57,10 +41,6 @@ export const MessageList: React.FC = ({ } }, [messages]); - const toggleThinking = (id: string) => { - setExpandedThinking((prev) => ({ ...prev, [id]: !prev[id] })); - }; - const formatTime = (date: Date) => { return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }; @@ -128,25 +108,6 @@ export const MessageList: React.FC = ({ - {msg.isThinking && ( - - toggleThinking(msg.id)}> - - {msg.thinkingContent} - - - {expandedThinking[msg.id] && ( - 正在深度思考... - )} - - )} - {editingId === msg.id ? (