From b9de3665d9796254dd78ab0cbb65a23d98bccb03 Mon Sep 17 00:00:00 2001 From: wunianxiao Date: Mon, 1 Jun 2026 17:21:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20iPet=20=E2=86=92=20iAs=20=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E8=BF=81=E7=A7=BB=EF=BC=8CRust=20=E7=89=88=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=20AI=20=E5=8A=A9=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 ✅ CLI 框架 + 配置系统 - clap 子命令: login / listen / send / whoami / usage - config.json + env var 替换 - tracing 日志系统 - state 持久化(auth/runtime 文件存 + PostgreSQL) Phase 2 ✅ 微信通道 - wechat::client — 完整 iLink Bot HTTP API 实现 - 扫码登录(终端二维码 + 轮询状态) - 长轮询 getupdates / 消息收发 / 监听注册 Phase 3 ✅ AI 对话(纯文本 + function calling) - LlmProvider trait: DeepSeek + LM Studio 实现 - SSE 流式解析(text / reasoning / tool_calls delta / usage) - Conversation: 消息历史 + chat / chat_with_tools 工具循环 Phase 4 ✅ PostgreSQL 集成 - app_state(认证 KV 存储) - chat_records(消息收发记录) - llm_usage(Token 用量统计缓存命中率) - user_memories(长期记忆持久化) - pending_approvals(审批确认码) - scheduled_tasks(定时任务表) Phase 5 ✅ 一切皆 Skill(工具系统) - SkillRegistry: 系统 + 用户 skills 双目录合并 - SKILL.md 解析器 + 子进程执行器(stdin JSON → stdout) - 9 个系统 Skills: datetime / weather / search / email / shell / schedule / memos / read_memories / read_summaries - ApprovalManager: High 风险技能 → 确认码审批(透明模式) - High 风险技能:确认码审批(透明模式) Phase 6 ✅ 定时任务调度器 上下文管理 - ChatSession: checkpoint + token budget (28K) + summaries - Token 估算器(中英文自适应) - 12h 空闲 → trigger_idle_summary(不入会话) - Budget 溢出 → trigger_overflow_summary(入会话 + drain 旧消息) - Summarizer: LLM 生成自然语言摘要(fallback 简单截断) - 长期记忆 / 摘要 通过 read_memories / read_summaries 工具按需读取 工具调用日志 + Token 统计 - INFO: 工具名 + 参数 + 结果摘要 - DEBUG: 子进程 exit/stdout/stderr - ias usage --since --until --model 查看用量和缓存命中率 --- .gitignore | 25 + Cargo.lock | 3817 +++++++++++++++++ Cargo.toml | 33 + MIGRATION_PLAN.md | 151 + config.json | 19 + migrations/20260601000001_init.sql | 29 + .../20260601000002_pending_approvals.sql | 17 + migrations/20260601000003_scheduled_tasks.sql | 20 + migrations/20260601000004_llm_usage.sql | 15 + migrations/20260601000005_user_memories.sql | 9 + resources/skills/email/SKILL.md | 31 + resources/skills/email/scripts/email.sh | 90 + resources/skills/execute_shell/SKILL.md | 30 + .../skills/execute_shell/scripts/shell.sh | 36 + .../skills/get_current_datetime/SKILL.md | 26 + .../get_current_datetime/scripts/datetime.sh | 16 + .../skills/list_scheduled_tasks/SKILL.md | 19 + .../list_scheduled_tasks/scripts/list.sh | 27 + resources/skills/manage_memos/SKILL.md | 36 + resources/skills/manage_memos/scripts/memo.sh | 58 + .../skills/manage_scheduled_task/SKILL.md | 42 + .../manage_scheduled_task/scripts/manage.sh | 48 + resources/skills/query_weather/SKILL.md | 30 + .../skills/query_weather/scripts/weather.sh | 160 + resources/skills/search_web/SKILL.md | 30 + resources/skills/search_web/scripts/search.sh | 54 + src/cli.rs | 63 + src/config.rs | 218 + src/context/DESIGN.md | 193 + src/context/builder.rs | 266 ++ src/context/mod.rs | 6 + src/context/tools.rs | 92 + src/context/types.rs | 111 + src/db/mod.rs | 49 + src/db/models.rs | 193 + src/llm/conversation.rs | 242 ++ src/llm/deepseek.rs | 195 + src/llm/lmstudio.rs | 157 + src/llm/mod.rs | 8 + src/llm/provider.rs | 187 + src/llm/types.rs | 125 + src/main.rs | 637 ++- src/scheduler.rs | 167 + src/state.rs | 96 + src/tools/DESIGN.md | 328 ++ src/tools/approval.rs | 154 + src/tools/mod.rs | 4 + src/tools/parser.rs | 279 ++ src/tools/registry.rs | 311 ++ src/tools/skill.rs | 156 + src/wechat/client.rs | 436 ++ src/wechat/mod.rs | 2 + src/wechat/types.rs | 333 ++ 53 files changed, 9874 insertions(+), 2 deletions(-) create mode 100644 Cargo.lock create mode 100644 MIGRATION_PLAN.md create mode 100644 config.json create mode 100644 migrations/20260601000001_init.sql create mode 100644 migrations/20260601000002_pending_approvals.sql create mode 100644 migrations/20260601000003_scheduled_tasks.sql create mode 100644 migrations/20260601000004_llm_usage.sql create mode 100644 migrations/20260601000005_user_memories.sql create mode 100644 resources/skills/email/SKILL.md create mode 100755 resources/skills/email/scripts/email.sh create mode 100644 resources/skills/execute_shell/SKILL.md create mode 100755 resources/skills/execute_shell/scripts/shell.sh create mode 100644 resources/skills/get_current_datetime/SKILL.md create mode 100755 resources/skills/get_current_datetime/scripts/datetime.sh create mode 100644 resources/skills/list_scheduled_tasks/SKILL.md create mode 100755 resources/skills/list_scheduled_tasks/scripts/list.sh create mode 100644 resources/skills/manage_memos/SKILL.md create mode 100755 resources/skills/manage_memos/scripts/memo.sh create mode 100644 resources/skills/manage_scheduled_task/SKILL.md create mode 100755 resources/skills/manage_scheduled_task/scripts/manage.sh create mode 100644 resources/skills/query_weather/SKILL.md create mode 100755 resources/skills/query_weather/scripts/weather.sh create mode 100644 resources/skills/search_web/SKILL.md create mode 100755 resources/skills/search_web/scripts/search.sh create mode 100644 src/cli.rs create mode 100644 src/config.rs create mode 100644 src/context/DESIGN.md create mode 100644 src/context/builder.rs create mode 100644 src/context/mod.rs create mode 100644 src/context/tools.rs create mode 100644 src/context/types.rs create mode 100644 src/db/mod.rs create mode 100644 src/db/models.rs create mode 100644 src/llm/conversation.rs create mode 100644 src/llm/deepseek.rs create mode 100644 src/llm/lmstudio.rs create mode 100644 src/llm/mod.rs create mode 100644 src/llm/provider.rs create mode 100644 src/llm/types.rs create mode 100644 src/scheduler.rs create mode 100644 src/state.rs create mode 100644 src/tools/DESIGN.md create mode 100644 src/tools/approval.rs create mode 100644 src/tools/mod.rs create mode 100644 src/tools/parser.rs create mode 100644 src/tools/registry.rs create mode 100644 src/tools/skill.rs create mode 100644 src/wechat/client.rs create mode 100644 src/wechat/mod.rs create mode 100644 src/wechat/types.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..9bb529a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,26 @@ +# Rust 构建产物 /target + +# 运行时数据(状态文件、缓存、聊天记录本地副本等) +.data/ +.pi + +# 环境配置(含密钥,不入库) +.env +.env.local +.env.*.local + +# 密钥文件 +*.pem +*.key +qweather/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# 操作系统 +.DS_Store +Thumbs.db diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d73eebb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3817 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iAs" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64", + "chrono", + "clap", + "dotenvy", + "futures-util", + "hex", + "image", + "qrcode", + "rand 0.9.4", + "reqwest", + "rustyline", + "serde", + "serde_json", + "sha2", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.8.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.52.0", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "native-tls", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 2901afa..3a12129 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,36 @@ version = "0.1.0" edition = "2024" [dependencies] +# 异步运行时 +tokio = { version = "1.0", features = ["full"] } +# HTTP 客户端 +reqwest = { version = "0.12", features = ["json", "stream"] } +# 序列化 +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +# 环境变量 +dotenvy = "0.15" +# CLI 参数解析 +clap = { version = "4", features = ["derive"] } +# 终端输入 +rustyline = "14.0" +# UUID +uuid = { version = "1", features = ["v4", "serde"] } +# 时间 +chrono = { version = "0.4", features = ["serde"] } +# 加密 +base64 = "0.22" +sha2 = "0.10" +hex = "0.4" +rand = "0.9" +# 日志 +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# 二维码终端显示 +qrcode = "0.14" +image = "0.25" +# 异步 trait +async-trait = "0.1" +# 流式处理 +futures-util = "0.3" +sqlx = { version = "0.8.6", features = ["runtime-tokio-native-tls", "postgres", "uuid", "chrono", "derive", "migrate"] } diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md new file mode 100644 index 0000000..2598b96 --- /dev/null +++ b/MIGRATION_PLAN.md @@ -0,0 +1,151 @@ +# iPet → Rust (iAs) 迁移计划 + +## 项目定位 +将 iPet(Bun/JS 微信 AI 聊天机器人)迁移到 Rust 实现的 `iAs` 项目。 + +## 技术栈 + +| 模块 | Rust 方案 | +|------|-----------| +| 运行时 | `tokio`(full) | +| HTTP 客户端 | `reqwest`(json + stream) | +| 序列化 | `serde` / `serde_json` | +| CLI | `clap`(derive) | +| 数据库 | `sqlx`(postgres + uuid + chrono) | +| 邮箱 | `async-imap` + `mailparse` | +| JWT | `jsonwebtoken` | +| 定时任务 | `tokio-cron-scheduler` | +| 流式 | `tokio-stream` + `futures` | +| 加密 | `sha2` + `hex` + `uuid` | + +## 分阶段计划 + +### Phase 1 ✅ CLI 框架 + 配置系统 +- [x] clap 子命令:login, listen, send, whoami +- [x] 配置文件读取(config.json + env var 替换) +- [x] 日志系统(tracing + EnvFilter) +- [x] 状态管理(auth.json / runtime.json 持久化) + +### Phase 2 🔜 微信通道(当前阶段) +- [x] API 类型定义 +- [ ] 扫码登录(get_bot_qrcode + get_qrcode_status) +- [ ] 长轮询收消息(getupdates) +- [ ] 发送消息(sendmessage) +- [ ] 注册/注销监听器(notifystart/notifystop) +- [ ] 身份和 token 持久化 + +### Phase 3 ✅ AI 对话(纯对话,无工具) +- [x] DeepSeek 流式 API(SSE + thinking 模式) +- [x] LM Studio 兼容(OpenAI 接口) +- [x] Provider 抽象(LlmProvider trait) +- [x] 对话管理器(Conversation) + - 消息历史管理 + - 系统提示词 + - 自动修剪历史 + - 流式消费 + 自动记录 +- [x] listen 命令集成 LLM 回复 + +### Phase 4 ✅ PostgreSQL 集成(认证+聊天记录入库) +- [x] sqlx 迁移框架(`migrations/` 目录) +- [x] `app_state` 表:认证信息 Key-Value 存储 +- [x] `chat_records` 表:消息收发记录 +- [x] 数据库连接池管理(`Database` 结构体) +- [x] 文件存储 → 数据库的自动迁移 +- [x] 文件存储降级(无 DB 时保持可用) +- [x] 入库时机: + - login → auth 入库 + - 收到消息 → inbound 记录 + - echo/LLM 回复 → outbound 记录 + - send 命令 → outbound 记录 + +### Phase 5 🎯 一切皆 Skill(工具系统) + +**设计文档:** `src/tools/DESIGN.md` + +**核心思想:** 没有"内置工具"和"外部技能"的区别——所有工具都是 SKILL.md。 +- 系统 skills — `resources/skills/`,随 iAs 分发 +- 用户 skills — `skills/`,用户自行添加 +- 加载时合并,同名用户覆盖系统 + +**安全模型(仅两级):** +| 等级 | 行为 | +|------|------| +| Low | 自动执行 | +| High | 微信确认码审批(6位码,5分钟过期)| + +**系统 Skills(7个):** +| Skill | 风险 | 说明 | +|-------|------|------| +| `get_current_datetime` | Low | 获取时间 | +| `query_weather` | Low | 和风天气 | +| `search_web` | Low | Tavily 搜索 | +| `email` | High | 收发邮件 | +| `execute_shell` | High | 执行 shell | +| `list_scheduled_tasks` | Low | 列定时任务 | +| `manage_scheduled_task` | High | 管理定时任务 | + +**去掉的模块:** +- ~~MCP 桥接~~(由 Skills 替代) +- ~~Shell 能力~~(由 Skills 替代) +- ~~Medium 风险层~~(仅保留 Low/High) + +**实施步骤:** +- [x] 设计文档(DESIGN.md) +- [x] Step 1: SkillSpec/Skill/SkillRegistry 基础设施 +- [x] Step 2: SKILL.md 解析器 + 目录扫描(system + user 合并) +- [x] Step 3: 技能执行器(子进程 + stdin 参数 + 结果解析) +- [x] Step 4: 系统 Skills 脚本实现(7 个) + - get_current_datetime ✅ / query_weather ✅ / search_web ✅ + - email ✅ / execute_shell ✅ / list_scheduled_tasks ✅ / manage_scheduled_task ✅ +- [x] Step 5: 审批系统(pending_approvals + 确认码 + oneshot 通道) + - approval.rs: ApprovalManager(内存 + 数据库双存储) + - 确认码生成/验证(SHA-256 哈希) + - 取消支持(0 / 取消) + - 3 次重试机制 + - 透明模式(LLM 不感知审批过程,确认码回复被 consume 不传 LLM) +- [x] Step 6: LLM function calling 集成 + - ToolCall 类型 + StreamChunk::ToolCalls + - 流式解析器支持 tool_calls delta + - Conversation::chat_with_tools() 工具循环 + - SkillSpec → LLM tools JSON 转换 + - ToolExecutor 回调桥接 SkillRegistry + - 审批回复拦截(handle_reply 优先于 LLM) + - WeChatClient 可克隆(用于 send_wechat 闭包) + +### Phase 6 定时任务(PostgreSQL 调度) +- [ ] 基于 PostgreSQL 的定时任务调度 + +### Phase 7 上下文管理 + 信任系统 +- [ ] 长短期记忆 +- [ ] 滚动摘要 +- [ ] 用户验证/授权 + +### Phase 8 优化、测试、部署 +- [ ] 错误处理完善 +- [ ] 日志优化 +- [ ] systemd 服务 + +## 微信通道 API 参考 + +Base URL: `https://ilinkai.weixin.qq.com` + +### 认证 +headers: +- `Authorization: Bearer ` +- `AuthorizationType: ilink_bot_token` +- `Content-Type: application/json` +- `iLink-App-Id: ` +- `iLink-App-ClientVersion: ` +- `X-WECHAT-UIN: ` + +### 端点 +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/ilink/bot/get_bot_qrcode?bot_type=3` | 获取登录二维码 | +| GET | `/ilink/bot/get_qrcode_status?qrcode=` | 轮询扫码状态 | +| POST | `/ilink/bot/msg/notifystart` | 注册监听器 | +| POST | `/ilink/bot/msg/notifystop` | 注销监听器 | +| POST | `/ilink/bot/getupdates` | 长轮询消息 | +| POST | `/ilink/bot/sendmessage` | 发送消息 | +| POST | `/ilink/bot/getconfig` | 获取配置 | +| POST | `/ilink/bot/sendtyping` | 发送输入状态 | diff --git a/config.json b/config.json new file mode 100644 index 0000000..1056b9f --- /dev/null +++ b/config.json @@ -0,0 +1,19 @@ +{ + "storage": { + "state_dir": ".data/weixin-ilink", + "database_url": "" + }, + "llm": { + "provider": "${WEIXIN_LLM_PROVIDER}", + "deepseek": { + "api_key": "${DEEPSEEK_API_KEY}", + "model": "${DEEPSEEK_MODEL}", + "base_url": "${DEEPSEEK_BASE_URL}" + }, + "lmstudio": { + "base_url": "${LM_STUDIO_BASE_URL}", + "model": "${LM_STUDIO_MODEL}", + "api_key": "${LM_STUDIO_API_KEY}" + } + } +} diff --git a/migrations/20260601000001_init.sql b/migrations/20260601000001_init.sql new file mode 100644 index 0000000..9e3c22b --- /dev/null +++ b/migrations/20260601000001_init.sql @@ -0,0 +1,29 @@ +-- iAs 初始数据库迁移 +-- 认证信息和聊天记录存储 + +-- 应用状态表(key-value 存储,用于认证信息等) +CREATE TABLE IF NOT EXISTS app_state ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 聊天记录表 +CREATE TABLE IF NOT EXISTS chat_records ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + direction TEXT NOT NULL, -- 'inbound' | 'outbound' + user_id TEXT NOT NULL, -- 微信用户 ID + account_id TEXT NOT NULL, -- 机器人账号 ID + text TEXT NOT NULL, -- 消息内容 + source TEXT NOT NULL DEFAULT '', -- 消息来源 + context_token TEXT NOT NULL DEFAULT '', -- 上下文令牌 + message_id TEXT NOT NULL DEFAULT '', + meta JSONB NOT NULL DEFAULT '{}'::JSONB +); + +CREATE INDEX IF NOT EXISTS idx_chat_records_user_created + ON chat_records (user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_chat_records_account_created + ON chat_records (account_id, created_at DESC); diff --git a/migrations/20260601000002_pending_approvals.sql b/migrations/20260601000002_pending_approvals.sql new file mode 100644 index 0000000..6904e3e --- /dev/null +++ b/migrations/20260601000002_pending_approvals.sql @@ -0,0 +1,17 @@ +-- 待审批的工具调用 +CREATE TABLE IF NOT EXISTS pending_approvals ( + id UUID PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + user_id TEXT NOT NULL, + skill_name TEXT NOT NULL, + params JSONB NOT NULL, + code_hash TEXT NOT NULL, + attempts_left INTEGER NOT NULL DEFAULT 3, + status TEXT NOT NULL DEFAULT 'pending', + result JSONB, + consumed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_pending_approvals_user_status + ON pending_approvals (user_id, status); diff --git a/migrations/20260601000003_scheduled_tasks.sql b/migrations/20260601000003_scheduled_tasks.sql new file mode 100644 index 0000000..7801670 --- /dev/null +++ b/migrations/20260601000003_scheduled_tasks.sql @@ -0,0 +1,20 @@ +-- 定时任务表(Phase 6) +CREATE TABLE IF NOT EXISTS scheduled_tasks ( + id UUID PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + user_id TEXT NOT NULL, + command TEXT NOT NULL, + shell TEXT NOT NULL DEFAULT '/bin/bash', + cwd TEXT NOT NULL DEFAULT '', + interval_seconds INTEGER NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + next_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_run_at TIMESTAMPTZ, + last_status TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due + ON scheduled_tasks (enabled, next_run_at); diff --git a/migrations/20260601000004_llm_usage.sql b/migrations/20260601000004_llm_usage.sql new file mode 100644 index 0000000..3bb60e8 --- /dev/null +++ b/migrations/20260601000004_llm_usage.sql @@ -0,0 +1,15 @@ +-- LLM Token 用量统计 +CREATE TABLE IF NOT EXISTS llm_usage ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + user_id TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + provider TEXT NOT NULL DEFAULT 'deepseek', + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, + cache_hit_tokens INTEGER NOT NULL DEFAULT 0, + cache_miss_tokens INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_llm_usage_created ON llm_usage (created_at DESC); diff --git a/migrations/20260601000005_user_memories.sql b/migrations/20260601000005_user_memories.sql new file mode 100644 index 0000000..0195ae6 --- /dev/null +++ b/migrations/20260601000005_user_memories.sql @@ -0,0 +1,9 @@ +-- 长期记忆表 +CREATE TABLE IF NOT EXISTS user_memories ( + id SERIAL PRIMARY KEY, + user_id TEXT NOT NULL DEFAULT 'default', + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_memories_user ON user_memories (user_id); diff --git a/resources/skills/email/SKILL.md b/resources/skills/email/SKILL.md new file mode 100644 index 0000000..6570697 --- /dev/null +++ b/resources/skills/email/SKILL.md @@ -0,0 +1,31 @@ +# email + +收发邮件(IMAP)。 + +## Risk Level +High + +## Parameters +```json +{ + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "操作类型", + "enum": ["list_unread", "read", "mark_read"] + }, + "limit": { + "type": "integer", + "description": "列出最近几封邮件,默认5", + "default": 5 + } + }, + "required": ["action"] +} +``` + +## Execute +```bash +scripts/email.sh +``` diff --git a/resources/skills/email/scripts/email.sh b/resources/skills/email/scripts/email.sh new file mode 100755 index 0000000..d57bca9 --- /dev/null +++ b/resources/skills/email/scripts/email.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env node +// 邮件工具(IMAP 收发) +// 通过 stdin 接收 JSON 参数 +let input = ''; +process.stdin.on('data', (chunk) => input += chunk); +process.stdin.on('end', async () => { + try { + const params = JSON.parse(input.trim() || '{}'); + const action = params.action || 'list_unread'; + const limit = params.limit || 5; + + const host = process.env.EMAIL_IMAP_HOST; + const port = parseInt(process.env.EMAIL_IMAP_PORT || '993'); + const user = process.env.EMAIL_IMAP_USER; + const password = process.env.EMAIL_IMAP_PASSWORD; + + if (!host || !user || !password) { + console.log('请设置 EMAIL_IMAP_HOST, EMAIL_IMAP_USER, EMAIL_IMAP_PASSWORD'); + process.exit(1); + } + + // 使用 openssl s_client 连接 IMAP over SSL + // 简单实现:列出收件箱最近 N 封邮件摘要 + const net = require('net'); + const tls = require('tls'); + + const socket = tls.connect(port, host, { rejectUnauthorized: false }, () => { + let buf = ''; + let step = 0; + let tag = 0; + + function cmd(line) { + tag++; + socket.write(`A${tag} ${line}\r\n`); + return `A${tag}`; + } + + socket.on('data', (data) => { + buf += data.toString('utf-8'); + // 简化处理:等服务器就绪后依次发命令 + if (step === 0 && buf.includes('OK')) { + step = 1; + cmd(`LOGIN "${user}" "${password}"`); + } else if (step === 1 && buf.includes(`A1 OK`)) { + step = 2; + cmd('SELECT INBOX'); + } else if (step === 2 && buf.includes(`A2 OK`)) { + step = 3; + cmd(`FETCH 1:${limit} (FLAGS INTERNALDATE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)])`); + } else if (step === 3 && buf.includes(`A3 OK`)) { + step = 4; + // 解析结果 + const lines = buf.split('\n').filter(l => l.trim()); + const results = []; + let current = {}; + for (const line of lines) { + if (line.match(/^\* \d+ FETCH/)) { + if (current.subject) results.push(current); + current = {}; + } + const subjMatch = line.match(/^SUBJECT: (.+)$/i); + const fromMatch = line.match(/^FROM: (.+)$/i); + const dateMatch = line.match(/^DATE: (.+)$/i); + if (subjMatch) current.subject = subjMatch[1].trim(); + if (fromMatch) current.from = fromMatch[1].trim(); + if (dateMatch) current.date = dateMatch[1].trim(); + } + if (current.subject) results.push(current); + + console.log(`最近 ${results.length} 封邮件:`); + results.forEach((r, i) => { + console.log(`\n${i+1}. 主题: ${r.subject || '(无主题)'}`); + console.log(` 发件人: ${r.from || '未知'}`); + console.log(` 时间: ${r.date || '未知'}`); + }); + cmd('LOGOUT'); + } + }); + }); + + socket.setTimeout(15000, () => { + console.log('连接 IMAP 超时'); + process.exit(1); + }); + + } catch (e) { + console.log(`邮件查询失败: ${e.message}`); + process.exit(1); + } +}); diff --git a/resources/skills/execute_shell/SKILL.md b/resources/skills/execute_shell/SKILL.md new file mode 100644 index 0000000..8da32e0 --- /dev/null +++ b/resources/skills/execute_shell/SKILL.md @@ -0,0 +1,30 @@ +# execute_shell + +在服务器上执行 shell 命令。 + +## Risk Level +High + +## Parameters +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "要执行的 shell 命令" + }, + "timeout": { + "type": "integer", + "description": "超时秒数,默认30", + "default": 30 + } + }, + "required": ["command"] +} +``` + +## Execute +```bash +scripts/shell.sh +``` diff --git a/resources/skills/execute_shell/scripts/shell.sh b/resources/skills/execute_shell/scripts/shell.sh new file mode 100755 index 0000000..0214770 --- /dev/null +++ b/resources/skills/execute_shell/scripts/shell.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# 执行 shell 命令 +# 高风险:需要确认码审批 +set -e + +read -r input +CMD=$(echo "$input" | python3 -c " +import sys, json +d = json.load(sys.stdin) +print(d.get('command', '')) +" 2>/dev/null) + +TIMEOUT=$(echo "$input" | python3 -c " +import sys, json +d = json.load(sys.stdin) +print(d.get('timeout', 30)) +" 2>/dev/null) + +if [ -z "$CMD" ]; then + echo "请提供 command 参数" + exit 1 +fi + +# 安全限制:禁止危险命令 +LOWER_CMD=$(echo "$CMD" | tr '[:upper:]' '[:lower:]') +for DANGEROUS in "rm -rf /" "mkfs" "dd if=" ":(){ :|:& };:" "> /dev/" "wget http" "curl http"; do + if echo "$LOWER_CMD" | grep -q "$DANGEROUS"; then + echo "禁止执行危险命令: $DANGEROUS" + exit 1 + fi +done + +# 输出前 N 行防止刷屏 +OUTPUT=$(timeout "$TIMEOUT" bash -c "$CMD" 2>&1 || echo "命令执行失败(退出码: $?)") +echo "$OUTPUT" | head -50 +[ "$(echo "$OUTPUT" | wc -l)" -gt 50 ] && echo "...(输出已截断,共 $(echo "$OUTPUT" | wc -l) 行)" diff --git a/resources/skills/get_current_datetime/SKILL.md b/resources/skills/get_current_datetime/SKILL.md new file mode 100644 index 0000000..7296481 --- /dev/null +++ b/resources/skills/get_current_datetime/SKILL.md @@ -0,0 +1,26 @@ +# get_current_datetime + +获取当前日期时间(北京时间 UTC+8)。 + +## Risk Level +Low + +## Parameters +```json +{ + "type": "object", + "properties": { + "format": { + "type": "string", + "description": "时间格式: full | date | time", + "enum": ["full", "date", "time"], + "default": "full" + } + } +} +``` + +## Execute +```bash +scripts/datetime.sh +``` diff --git a/resources/skills/get_current_datetime/scripts/datetime.sh b/resources/skills/get_current_datetime/scripts/datetime.sh new file mode 100755 index 0000000..c9b5747 --- /dev/null +++ b/resources/skills/get_current_datetime/scripts/datetime.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# 读取 stdin JSON 参数 +read -r input +format=$(echo "$input" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + print(d.get('format', 'full')) +except: print('full') +" 2>/dev/null || echo "full") + +case "$format" in + date) TZ='Asia/Shanghai' date '+%Y-%m-%d' ;; + time) TZ='Asia/Shanghai' date '+%H:%M:%S' ;; + *) TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M:%S' ;; +esac diff --git a/resources/skills/list_scheduled_tasks/SKILL.md b/resources/skills/list_scheduled_tasks/SKILL.md new file mode 100644 index 0000000..0c1566b --- /dev/null +++ b/resources/skills/list_scheduled_tasks/SKILL.md @@ -0,0 +1,19 @@ +# list_scheduled_tasks + +列出所有已配置的定时任务。 + +## Risk Level +Low + +## Parameters +```json +{ + "type": "object", + "properties": {} +} +``` + +## Execute +```bash +scripts/list.sh +``` diff --git a/resources/skills/list_scheduled_tasks/scripts/list.sh b/resources/skills/list_scheduled_tasks/scripts/list.sh new file mode 100755 index 0000000..149cfa5 --- /dev/null +++ b/resources/skills/list_scheduled_tasks/scripts/list.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# 列出定时任务(从 PostgreSQL) +set -e + +DB_URL="${DATABASE_URL}" +if [ -z "$DB_URL" ]; then + echo "数据库未配置(DATABASE_URL)" + exit 0 +fi + +# 使用 psql 查询 +psql "$DB_URL" -t -A -F '|' -c " + SELECT name, description, enabled, next_run_at, interval_seconds + FROM scheduled_tasks + ORDER BY name +" 2>/dev/null | while IFS='|' read -r name desc enabled next_run interval; do + if [ -n "$name" ]; then + echo "- $name${desc:+ ($desc)}" + echo " 启用: $enabled | 间隔: ${interval}s | 下次: ${next_run:-无}" + fi +done + +# 检查是否有任何任务 +COUNT=$(psql "$DB_URL" -t -A -c "SELECT count(*) FROM scheduled_tasks" 2>/dev/null || echo "0") +if [ "$COUNT" = "0" ] || [ -z "$COUNT" ]; then + echo "暂无定时任务" +fi diff --git a/resources/skills/manage_memos/SKILL.md b/resources/skills/manage_memos/SKILL.md new file mode 100644 index 0000000..3fad833 --- /dev/null +++ b/resources/skills/manage_memos/SKILL.md @@ -0,0 +1,36 @@ +# manage_memos + +管理备忘录:添加、列出、删除。 + +备忘录存储在 `.data/memos.json` 文件中。 + +## Risk Level +Low + +## Parameters +```json +{ + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "操作类型", + "enum": ["add", "list", "delete"] + }, + "content": { + "type": "string", + "description": "备忘内容(add 时需要)" + }, + "id": { + "type": "integer", + "description": "备忘编号(delete 时需要)" + } + }, + "required": ["action"] +} +``` + +## Execute +```bash +scripts/memo.sh +``` diff --git a/resources/skills/manage_memos/scripts/memo.sh b/resources/skills/manage_memos/scripts/memo.sh new file mode 100755 index 0000000..8092d11 --- /dev/null +++ b/resources/skills/manage_memos/scripts/memo.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# 备忘录管理 — 存储 .data/memos.json +MEMO_FILE=".data/memos.json" +mkdir -p "$(dirname "$MEMO_FILE")" +[ ! -f "$MEMO_FILE" ] && echo '[]' > "$MEMO_FILE" + +read -r INPUT +export MEMO_INPUT="$INPUT" + +python3 - "$MEMO_FILE" << 'PYEOF' +import sys, json, os, datetime + +path = sys.argv[1] +input_raw = os.environ.get('MEMO_INPUT', '{}') + +try: req = json.loads(input_raw) +except: + print('无效的 JSON 输入') + sys.exit(1) + +action = req.get('action', 'list') +content = req.get('content', '') +memo_id = req.get('id', '') + +data = json.load(open(path)) if os.path.exists(path) and os.path.getsize(path) > 0 else [] + +if action == 'add': + if not content: + print('请提供 content 参数') + sys.exit(1) + next_id = max([m.get('id', 0) for m in data], default=0) + 1 + data.append({'id': next_id, 'content': content, 'created_at': datetime.datetime.now().isoformat()}) + json.dump(data, open(path, 'w'), ensure_ascii=False, indent=2) + print(f'已添加备忘录 #{next_id}') + +elif action == 'list': + if not data: + print('暂无备忘录') + else: + for m in data: + ts = m.get('created_at', '?')[:16] + print(f"#{m['id']} [{ts}] {m['content']}") + +elif action == 'delete': + if not memo_id: + print('请提供 id 参数') + sys.exit(1) + new = [m for m in data if str(m.get('id', '')) != str(memo_id)] + if len(new) == len(data): + print(f'未找到备忘 #{memo_id}') + else: + json.dump(new, open(path, 'w'), ensure_ascii=False, indent=2) + print(f'已删除备忘 #{memo_id}') + +else: + print(f'未知操作: {action}(支持: add, list, delete)') + sys.exit(1) +PYEOF diff --git a/resources/skills/manage_scheduled_task/SKILL.md b/resources/skills/manage_scheduled_task/SKILL.md new file mode 100644 index 0000000..0e4efb9 --- /dev/null +++ b/resources/skills/manage_scheduled_task/SKILL.md @@ -0,0 +1,42 @@ +# manage_scheduled_task + +创建、修改、删除或启用/禁用定时任务。 + +## Risk Level +High + +## Parameters +```json +{ + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "操作: add | delete | enable | disable", + "enum": ["add", "delete", "enable", "disable"] + }, + "name": { + "type": "string", + "description": "任务名称" + }, + "command": { + "type": "string", + "description": "要执行的命令(add 时需要)" + }, + "interval_seconds": { + "type": "integer", + "description": "执行间隔秒数(add 时需要)" + }, + "description": { + "type": "string", + "description": "任务描述" + } + }, + "required": ["action", "name"] +} +``` + +## Execute +```bash +scripts/manage.sh +``` diff --git a/resources/skills/manage_scheduled_task/scripts/manage.sh b/resources/skills/manage_scheduled_task/scripts/manage.sh new file mode 100755 index 0000000..81c7d54 --- /dev/null +++ b/resources/skills/manage_scheduled_task/scripts/manage.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# 管理定时任务 +set -e + +read -r input +ACTION=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('action',''))" 2>/dev/null) +NAME=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('name',''))" 2>/dev/null) +CMD=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('command',''))" 2>/dev/null) +INTERVAL=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('interval_seconds',3600))" 2>/dev/null) +DESC=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('description',''))" 2>/dev/null) + +DB_URL="${DATABASE_URL}" + +if [ -z "$DB_URL" ]; then + echo "数据库未配置(DATABASE_URL)" + exit 1 +fi + +case "$ACTION" in + add) + if [ -z "$CMD" ]; then + echo "add 操作需要 command 参数" + exit 1 + fi + psql "$DB_URL" -c " + INSERT INTO scheduled_tasks (id, name, description, command, interval_seconds, enabled, next_run_at) + VALUES (gen_random_uuid(), '$NAME', '$DESC', '$CMD', $INTERVAL, true, NOW() + interval '$INTERVAL seconds') + ON CONFLICT (name) DO UPDATE + SET command = EXCLUDED.command, interval_seconds = EXCLUDED.interval_seconds, enabled = true, description = EXCLUDED.description + " 2>/dev/null && echo "✅ 已添加/更新定时任务: $NAME" || echo "❌ 添加失败(表 scheduled_tasks 可能未创建)" + ;; + delete) + psql "$DB_URL" -c "DELETE FROM scheduled_tasks WHERE name = '$NAME'" 2>/dev/null + echo "✅ 已删除定时任务: $NAME" + ;; + enable) + psql "$DB_URL" -c "UPDATE scheduled_tasks SET enabled = true WHERE name = '$NAME'" 2>/dev/null + echo "✅ 已启用定时任务: $NAME" + ;; + disable) + psql "$DB_URL" -c "UPDATE scheduled_tasks SET enabled = false WHERE name = '$NAME'" 2>/dev/null + echo "✅ 已禁用定时任务: $NAME" + ;; + *) + echo "未知操作: $ACTION(支持: add, delete, enable, disable)" + exit 1 + ;; +esac diff --git a/resources/skills/query_weather/SKILL.md b/resources/skills/query_weather/SKILL.md new file mode 100644 index 0000000..5faa3e6 --- /dev/null +++ b/resources/skills/query_weather/SKILL.md @@ -0,0 +1,30 @@ +# query_weather + +查询天气信息(和风天气 API)。 + +## Risk Level +Low + +## Parameters +```json +{ + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "城市名,如 北京、上海、合肥" + }, + "days": { + "type": "integer", + "description": "预报天数,默认3", + "default": 3 + } + }, + "required": ["location"] +} +``` + +## Execute +```bash +scripts/weather.sh +``` diff --git a/resources/skills/query_weather/scripts/weather.sh b/resources/skills/query_weather/scripts/weather.sh new file mode 100755 index 0000000..b950463 --- /dev/null +++ b/resources/skills/query_weather/scripts/weather.sh @@ -0,0 +1,160 @@ +#!/bin/bash +# 和风天气查询脚本 +# 读取 stdin JSON: {"location":"北京","days":3} + +read -r input + +LOCATION=$(echo "$input" | python3 -c " +import sys, json +d = json.load(sys.stdin) +print(d.get('location', '')) +" 2>/dev/null) + +DAYS=$(echo "$input" | python3 -c " +import sys, json +d = json.load(sys.stdin) +print(d.get('days', 3)) +" 2>/dev/null) + +if [ -z "$LOCATION" ]; then + echo "请提供 location 参数" + exit 1 +fi + +# 从环境变量读取配置 +API_HOST="${QWEATHER_API_HOST}" +KEY_ID="${QWEATHER_JWT_KEY_ID}" +PROJECT_ID="${QWEATHER_JWT_PROJECT_ID}" +KEY_FILE="${QWEATHER_JWT_PRIVATE_KEY_FILE:-qweather/ed25519-private.pem}" +TTL="${QWEATHER_JWT_TTL_SECONDS:-3600}" + +if [ -z "$API_HOST" ]; then + echo "请设置 QWEATHER_API_HOST 环境变量" + exit 1 +fi + +# 生成 JWT(使用 python3) +JWT=$(python3 -c " +import json, time, base64, struct, sys +from pathlib import Path + +key_id = '$KEY_ID' +project_id = '$PROJECT_ID' +key_file = '$KEY_FILE' +ttl = int('$TTL') + +if not key_id or not project_id: + sys.exit(0) + +# Read Ed25519 private key +key_path = Path(key_file) +if not key_path.exists(): + sys.exit(0) + +key_data = key_path.read_text() + +# Simple base64url +def b64url(data): + return base64.urlsafe_b64encode(data).rstrip(b'=').decode() + +# Extract raw key bytes from PEM +import re +pem_match = re.search(r'-----BEGIN PRIVATE KEY-----\\s*(.*?)\\s*-----END PRIVATE KEY-----', key_data, re.DOTALL) +if not pem_match: + sys.exit(0) + +der = base64.b64decode(pem_match.group(1)) +# PKCS8 Ed25519 private key: last 32 bytes are the seed +seed = der[-32:] + +from hashlib import sha512 +h = sha512(seed).digest() +a = bytearray(h[:32]) +a[0] &= 248 +a[31] &= 127 +a[31] |= 64 + +# Sign using RFC 8032 +def ed25519_sign(message, seed_bytes): + h = sha512(seed_bytes).digest() + a = bytearray(h[:32]) + a[0] &= 248 + a[31] &= 127 + a[31] |= 64 + r = sha512(bytes(a[32:]) + message).digest() + R = _sc_reduce(r) + R_bytes = _sc_mul_base(R) + S = _sc_add(R, _sc_mul(_sc_reduce(sha512(bytes(R_bytes) + bytes(a) + message).digest()), _sc_reduce(bytes(a)))) + return bytes(R_bytes) + _sc_encode(S) + +# Skipping full impl - use openssl instead +print('') +" 2>/dev/null) + +# If python3 JWT failed, try openssl +if [ -z "$JWT" ]; then + NOW=$(date +%s) + IAT=$((NOW - 30)) + EXP=$((NOW + TTL)) + + HEADER=$(echo -n '{"alg":"EdDSA","kid":"'"$KEY_ID"'"}' | base64 -w0 | tr '+/' '-_' | tr -d '=') + PAYLOAD=$(echo -n '{"sub":"'"$PROJECT_ID"'","iat":'"$IAT"',"exp":'"$EXP"'}' | base64 -w0 | tr '+/' '-_' | tr -d '=') + SIGNING_INPUT="${HEADER}.${PAYLOAD}" + + # Try openssl pkeyutl (OpenSSL >= 3.0, needs temp file for -rawin) + TMPFILE=$(mktemp) + echo -n "$SIGNING_INPUT" > "$TMPFILE" + SIGNATURE=$(openssl pkeyutl -sign -inkey "$KEY_FILE" -rawin -in "$TMPFILE" 2>/dev/null | base64 -w0 | tr '+/' '-_' | tr -d '=') || true + rm -f "$TMPFILE" + if [ -z "$SIGNATURE" ]; then + # Fallback: try older openssl + SIGNATURE=$(echo -n "$SIGNING_INPUT" | openssl dgst -sign "$KEY_FILE" -keyform PEM -sha512 2>/dev/null | base64 -w0 | tr '+/' '-_' | tr -d '=') || true + fi + + if [ -n "$SIGNATURE" ]; then + JWT="${SIGNING_INPUT}.${SIGNATURE}" + fi +fi + +# Try env var JWT as fallback +if [ -z "$JWT" ]; then + JWT="${QWEATHER_JWT}" +fi + +if [ -z "$JWT" ]; then + echo "无法生成 QWeather JWT,请检查 QWEATHER_JWT_KEY_ID/QWEATHER_JWT_PROJECT_ID/QWEATHER_JWT_PRIVATE_KEY_FILE 配置" + exit 1 +fi + +# 查询城市 ID +CITY_RESP=$(curl -s --compressed "https://geoapi.qweather.com/v2/city/lookup?location=$(echo -n "$LOCATION" | python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()))")" -H "Authorization: Bearer $JWT") +CITY_ID=$(echo "$CITY_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['location'][0]['id'] if d.get('location') else '')" 2>/dev/null) + +if [ -z "$CITY_ID" ]; then + echo "未找到城市: $LOCATION" + exit 1 +fi + +# 查询实况天气 +NOW_RESP=$(curl -s --compressed "https://${API_HOST}/v7/weather/now?location=${CITY_ID}&lang=zh&unit=m" -H "Authorization: Bearer $JWT") +NOW_TEMP=$(echo "$NOW_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f\"{d['now']['temp']}°C, {d['now']['text']}\")" 2>/dev/null) + +# 查询预报(最多7天) +ENDPOINT_DAYS=$DAYS +[ "$DAYS" -gt 3 ] && ENDPOINT_DAYS=7 +[ "$DAYS" -le 3 ] && ENDPOINT_DAYS=3 + +DAILY_RESP=$(curl -s --compressed "https://${API_HOST}/v7/weather/${ENDPOINT_DAYS}d?location=${CITY_ID}&lang=zh&unit=m" -H "Authorization: Bearer $JWT") +FORECAST=$(echo "$DAILY_RESP" | python3 -c " +import sys, json +d = json.load(sys.stdin) +days = d.get('daily', [])[:int('$DAYS')] +lines = ['未来天气:'] +for day in days: + lines.append(f\" {day['fxDate']}: {day['textDay']} {day['tempMin']}~{day['tempMax']}°C\") +print('\\n'.join(lines)) +" 2>/dev/null) + +echo "${LOCATION}天气: 当前 ${NOW_TEMP}" +echo "" +echo "$FORECAST" diff --git a/resources/skills/search_web/SKILL.md b/resources/skills/search_web/SKILL.md new file mode 100644 index 0000000..6383423 --- /dev/null +++ b/resources/skills/search_web/SKILL.md @@ -0,0 +1,30 @@ +# search_web + +通过网络搜索获取最新信息(Tavily Search API)。 + +## Risk Level +Low + +## Parameters +```json +{ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "搜索关键词" + }, + "count": { + "type": "integer", + "description": "返回结果数量,默认5", + "default": 5 + } + }, + "required": ["query"] +} +``` + +## Execute +```bash +scripts/search.sh +``` diff --git a/resources/skills/search_web/scripts/search.sh b/resources/skills/search_web/scripts/search.sh new file mode 100755 index 0000000..95a0fc0 --- /dev/null +++ b/resources/skills/search_web/scripts/search.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Tavily 搜索 + +read -r input +QUERY=$(echo "$input" | python3 -c " +import sys, json +d = json.load(sys.stdin) +print(d.get('query', '')) +" 2>/dev/null) + +COUNT=$(echo "$input" | python3 -c " +import sys, json +d = json.load(sys.stdin) +print(d.get('count', 5)) +" 2>/dev/null) + +if [ -z "$QUERY" ]; then + echo "请提供 query 参数" + exit 1 +fi + +API_KEY="${TAVILY_API_KEY}" +if [ -z "$API_KEY" ]; then + echo "请设置 TAVILY_API_KEY 环境变量" + exit 1 +fi + +RESP=$(curl -s -X POST "https://api.tavily.com/search" \ + -H "Content-Type: application/json" \ + -d "{\"api_key\":\"$API_KEY\",\"query\":\"$QUERY\",\"max_results\":$COUNT,\"include_answer\":true}") + +# 解析并输出 +ANSWER=$(echo "$RESP" | python3 -c " +import sys, json +d = json.load(sys.stdin) +answer = d.get('answer', '') +results = d.get('results', []) +lines = [] +if answer: + lines.append(f'总结: {answer}') + lines.append('') +for i, r in enumerate(results[:$COUNT], 1): + title = r.get('title', '无标题') + url = r.get('url', '') + content = r.get('content', '')[:200] + lines.append(f'{i}. {title}') + lines.append(f' {url}') + if content: + lines.append(f' {content}') + lines.append('') +print('\\n'.join(lines)) +" 2>/dev/null) + +echo "$ANSWER" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..4eeaa8d --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,63 @@ +use clap::{Parser, Subcommand}; + +/// iAs - 微信 AI 智能助手 +#[derive(Parser, Debug)] +#[command(name = "ias", version, about = "微信 AI 智能助手 (Rust)")] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// 扫码登录微信 + Login { + /// 登录超时秒数 + #[arg(long, default_value = "480")] + timeout: u64, + }, + + /// 监听消息(长轮询) + Listen { + /// 启用 AI 自动回复 + #[arg(long)] + llm: bool, + + /// 仅回显消息(不调用 AI) + #[arg(long)] + echo: bool, + }, + + /// 发送文本消息 + Send { + /// 目标用户 ID + #[arg(long)] + to: String, + + /// 消息内容 + #[arg(long)] + text: String, + + /// 上下文令牌 + #[arg(long)] + context_token: Option, + }, + + /// 查看当前登录状态 + Whoami, + + /// 查看 LLM Token 使用统计 + Usage { + /// 起始时间(ISO 格式,默认7天前) + #[arg(long)] + since: Option, + + /// 结束时间(ISO 格式,默认现在) + #[arg(long)] + until: Option, + + /// 模型名称过滤 + #[arg(long)] + model: Option, + }, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e6fcfb7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,218 @@ +// 允许未来阶段使用的字段 +#![allow(dead_code)] + +use serde::Deserialize; +use std::path::Path; + +/// iAs 全局配置 +#[derive(Debug, Clone, Deserialize)] +pub struct AppConfig { + #[serde(default)] + pub llm: LlmConfig, + #[serde(default)] + pub storage: StorageConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LlmConfig { + /// LLM 提供商:deepseek | lmstudio + #[serde(default = "default_llm_provider")] + pub provider: String, + + /// DeepSeek 配置 + #[serde(default)] + pub deepseek: DeepSeekConfig, + + /// LM Studio 配置 + #[serde(default)] + pub lmstudio: LmStudioConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DeepSeekConfig { + #[serde(default = "default_deepseek_api_key")] + pub api_key: String, + #[serde(default = "default_deepseek_model")] + pub model: String, + #[serde(default = "default_deepseek_base_url")] + pub base_url: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LmStudioConfig { + #[serde(default = "default_lmstudio_base_url")] + pub base_url: String, + #[serde(default = "default_lmstudio_model")] + pub model: String, + #[serde(default)] + pub api_key: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct StorageConfig { + /// 状态文件目录 + #[serde(default = "default_state_dir")] + pub state_dir: String, + /// PostgreSQL 连接串(可选,默认为文件存储) + #[serde(default)] + pub database_url: String, +} + +// ─── 默认值 ─── + +fn default_llm_provider() -> String { + "deepseek".to_string() +} + +fn default_deepseek_api_key() -> String { + env_or_default("DEEPSEEK_API_KEY", "") +} + +fn default_deepseek_model() -> String { + env_or_default("DEEPSEEK_MODEL", "deepseek-v4-flash") +} + +fn default_deepseek_base_url() -> String { + env_or_default("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1") +} + +fn default_lmstudio_base_url() -> String { + env_or_default("LM_STUDIO_BASE_URL", "http://localhost:1234/v1") +} + +fn default_lmstudio_model() -> String { + env_or_default("LM_STUDIO_MODEL", "local-model") +} + +fn default_state_dir() -> String { + ".data/weixin-ilink".to_string() +} + +fn env_or_default(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +impl Default for LlmConfig { + fn default() -> Self { + Self { + provider: default_llm_provider(), + deepseek: DeepSeekConfig::default(), + lmstudio: LmStudioConfig::default(), + } + } +} + +impl Default for DeepSeekConfig { + fn default() -> Self { + Self { + api_key: default_deepseek_api_key(), + model: default_deepseek_model(), + base_url: default_deepseek_base_url(), + } + } +} + +impl Default for LmStudioConfig { + fn default() -> Self { + Self { + base_url: default_lmstudio_base_url(), + model: default_lmstudio_model(), + api_key: String::new(), + } + } +} + +impl Default for StorageConfig { + fn default() -> Self { + Self { + state_dir: default_state_dir(), + database_url: String::new(), + } + } +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + llm: LlmConfig::default(), + storage: StorageConfig::default(), + } + } +} + +impl AppConfig { + /// 从文件加载配置,缺失字段回退到环境变量 + pub fn from_file(path: impl AsRef) -> Self { + let path = path.as_ref(); + if path.exists() { + match std::fs::read_to_string(path) { + Ok(content) => { + // 先做环境变量替换 + let resolved = resolve_env_vars(&content); + match serde_json::from_str(&resolved) { + Ok(cfg) => return cfg, + Err(e) => { + tracing::warn!("解析配置文件失败 ({}): {},使用默认配置", path.display(), e); + } + } + } + Err(e) => { + tracing::warn!("读取配置文件失败 ({}): {},使用默认配置", path.display(), e); + } + } + } + Self::default() + } +} + +/// 替换字符串中的 `${VAR_NAME}` 为环境变量值 +fn resolve_env_vars(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '$' && chars.peek() == Some(&'{') { + chars.next(); // 跳过 '{' + let mut var_name = String::new(); + while let Some(&ch) = chars.peek() { + if ch == '}' { + chars.next(); // 跳过 '}' + break; + } + var_name.push(ch); + chars.next(); + } + let value = std::env::var(&var_name).unwrap_or_default(); + result.push_str(&value); + } else { + result.push(c); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_env_vars() { + // set_var is unsafe in edition 2024; skip env test + let result = resolve_env_vars("prefix_${TEST_VAR}_suffix"); + // TEST_VAR might not be set, so result could be "prefix__suffix" + assert_eq!(result, "prefix__suffix"); + } + + #[test] + fn test_resolve_missing_env_var() { + let result = resolve_env_vars("prefix_${MISSING_VAR}_suffix"); + assert_eq!(result, "prefix__suffix"); + } + + #[test] + fn test_resolve_no_vars() { + let result = resolve_env_vars("plain text"); + assert_eq!(result, "plain text"); + } +} diff --git a/src/context/DESIGN.md b/src/context/DESIGN.md new file mode 100644 index 0000000..3471c06 --- /dev/null +++ b/src/context/DESIGN.md @@ -0,0 +1,193 @@ +# iAs 上下文管理系统设计 v2 + +## 核心理念 + +1. **长期记忆不固定注入** — 通过工具让 LLM 按需读写 +2. **摘要不入上下文** — 除非因上下文空间不足被迫压缩 +3. **摘要总结点** — 记录压缩位置,之后的消息始终完整保留 +4. **12 小时空闲触发** — 超过 12h 自动压缩,但不插入会话 +5. **Token Budget 驱动** — 达到上限才压缩,保持消息完整度优先 + +--- + +## 状态模型 + +``` +ChatSession +├── checkpoint: usize = 0 ← 摘要总结点(消息列表中的位置) +├── messages: Vec ← 全部消息(checkpoint 之前的是历史) +├── summaries: Vec ← 所有摘要 +│ ├── pos: usize ← 对应消息列表中的位置 +│ ├── text: String ← 摘要内容 +│ └── reason: "overflow"|"timeout" ← 生成原因 +└── last_user_at: Option ← 上一条用户消息时间 +``` + +### 消息列表视图(LLM 实际看到的内容) + +``` +TOOL: read_memories / write_memory / read_summaries + ↑ LLM 按需调用,不自动注入 + +VIEW: [system_prompt] + [overflow_summary] ← 仅当因 context 满产生时注入 + [messages from checkpoint..] ← 始终完整保留 +``` + +--- + +## 核心类型 + +```rust +pub struct SummaryEntry { + pub checkpoint: usize, // 对应 messages 中的位置 + pub text: String, + pub reason: SummaryReason, +} + +pub enum SummaryReason { + Overflow, // 上下文满了自动压缩 + Timeout, // 超过 12 小时空闲 +} + +pub struct ChatSession { + checkpoint: usize, + messages: Vec, + summaries: Vec, + last_user_at: Option>, + token_budget: u32, // 默认 32000 +} +``` + +--- + +## 核心流程 + +``` +用户发消息 "合肥天气" + │ + ▼ +1. 距离检查 + ├── last_user_at 超过 12h? + │ └── YES → summarize_all(reason=timeout) + │ ├── 生成摘要存入 summaries(不插入会话) + │ └── checkpoint = messages.len() + │ + ▼ +2. 构建 LLM 请求 + ├── system_prompt + ├── 【如果有 overflow_summary 且 checkpoint > 0】 + │ └── { role: "system", content: "历史摘要:..." } + └── messages[checkpoint..] + │ + ▼ +3. Token 估算 + ├── 在 budget 内 → 直接发送 + │ + └── 超出 budget → summarize_all(reason=overflow) + ├── 生成摘要存入 summaries + ├── checkpoint = messages.len() + ├── 摘要作为 system message 注入本次请求 + └── 重新构建请求(现在只有摘要 + 当前消息) + │ + ▼ +4. 发送给 LLM + ├── LLM 回复(可能包含工具调用) + │ ├── read_memories → 返回长期记忆 + │ ├── write_memory → 保存到长期记忆 + │ └── read_summaries → 返回 summaries 列表 + │ + └── LLM 最终回复 → record_turn(user_msg, assistant_msg) + ├── messages.push(user_msg, assistant_msg) + └── last_user_at = now +``` + +--- + +## 摘要触发条件对比 + +| 原因 | 触发条件 | 是否插入会话 | 用途 | +|------|---------|------------|------| +| **Overflow** | token 超 budget | ✅ 自动插入 | 保持上下文不超限 | +| **Timeout** | 距上条消息 >12h | ❌ 不插入(工具可读) | 跨天会话,LLM 自行决定是否查看 | + +--- + +## 长期记忆工具(LLM 按需调用) + +```json +{ + "name": "read_memories", + "description": "读取用户的长期记忆(跨会话持久保存的个人信息)", + "parameters": { "type": "object", "properties": {} } +} + +{ + "name": "write_memory", + "description": "写入一条用户的长期记忆", + "parameters": { + "type": "object", + "properties": { + "content": { "type": "string", "description": "记忆内容" } + }, + "required": ["content"] + } +} + +{ + "name": "read_summaries", + "description": "读取历史会话摘要(包括空闲超时产生的摘要)", + "parameters": { + "type": "object", + "properties": {} + } +} +``` + +这些工具注册为系统 skills(只对 `chat_with_tools` 启用,不暴露给用户)。 + +## 数据库存储 + +```sql +-- 长期记忆(跨会话持久) +CREATE TABLE user_memories ( + user_id TEXT PRIMARY KEY, + content JSONB NOT NULL DEFAULT '[]', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +摘要和消息会话不持久化(会话消息保存到 `chat_records` 表,但摘要只存在于运行时的 `ChatSession`)。 + +## 与 iPet 的区别 + +| 特性 | iPet | iAs v2 | +|------|------|--------| +| 长期记忆 | 自动注入每个请求 | 通过工具按需读写 | +| 滚动摘要 | 自动注入每个请求 | 不注入,工具可读 | +| 会话摘要 | 自动注入 | 不注入,overflow 时例外 | +| 摘要触发 | 定期 + 按消息数 | Token budget + 12h 空闲 | +| 历史完整保留 | ❌ 始终压缩 | ✅ checkpoint 后完整保留 | + +## Token 估算 + +```rust +pub fn estimate_tokens(text: &str) -> u32 { + let cjk = text.chars().filter(|c| c >= '\u{4e00}' && c <= '\u{9fff}').count() as u32; + let other = text.chars().count() as u32 - cjk; + cjk * 15 / 10 + other / 4 + 8 // +8 消息 overhead +} +``` + +## 实施步骤 + +| 步骤 | 内容 | +|------|------| +| 1 | ChatSession + SummaryEntry 核心类型 | +| 2 | build_context() — 构建 LLM 消息数组 | +| 3 | record_turn() — 记录对话轮次 | +| 4 | summarize_all() — 摘要生成 + checkpoint 更新 | +| 5 | token_estimate() — Token 估算 | +| 6 | 长期记忆工具(read_memories / write_memory)| +| 7 | 摘要读取工具(read_summaries)| +| 8 | 集成到 Conversation + main.rs | diff --git a/src/context/builder.rs b/src/context/builder.rs new file mode 100644 index 0000000..d5dfd7f --- /dev/null +++ b/src/context/builder.rs @@ -0,0 +1,266 @@ +use crate::context::types::ChatSession; +use crate::llm::conversation::Summarizer; +use crate::llm::types::Message; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// 估算消息的 token 数 +pub fn estimate_tokens(msg: &Message) -> u32 { + let text = msg.content.as_str(); + estimate_text_tokens(text) + 8 +} + +/// 估算纯文本的 token 数 +pub fn estimate_text_tokens(text: &str) -> u32 { + if text.is_empty() { + return 0; + } + let cjk_count = text.chars().filter(|c| is_cjk(*c)).count() as u32; + let other_count = text.chars().count() as u32 - cjk_count; + // 中文 ≈ 1.5 token/字,英文 ≈ 0.25 token/字符 + cjk_count * 15 / 10 + other_count / 4 +} + +fn is_cjk(c: char) -> bool { + ('\u{4e00}'..='\u{9fff}').contains(&c) + || ('\u{3400}'..='\u{4dbf}').contains(&c) + || ('\u{f900}'..='\u{faff}').contains(&c) +} + +/// 构建给 LLM 的消息数组(带 token budget 管理) +/// +/// 返回消息数组和是否需要摘要的信息 +pub async fn build_context( + session: &Arc>, + system_prompt: &str, +) -> Vec { + let mut s = session.lock().await; + + // ── 1. 空闲超时检查(消息到达前由调用方检查)── + // 这里只做构建,超时触发在上层 + + // ── 2. 构建消息 ── + let mut messages = Vec::new(); + let mut used = 0u32; + + // 系统提示词 + let sys_tokens = estimate_text_tokens(system_prompt); + messages.push(Message::system(system_prompt)); + used += sys_tokens + 8; + + // overflow 摘要(如果有) + if let Some(summary) = s.latest_overflow_summary() { + let text = format!("【历史对话摘要】\n{}", summary.text); + let tokens = estimate_text_tokens(&text); + if used + tokens < s.token_budget { + messages.push(Message::system(text)); + used += tokens + 8; + } + } + + // 近期消息(从 checkpoint 开始,倒序添加直到接近 budget) + let recent = s.recent_messages().to_vec(); + let mut included = Vec::new(); + let mut tail_used = 0u32; + + for msg in recent.iter().rev() { + let t = estimate_tokens(msg); + if used + tail_used + t > s.token_budget - 500 { + break; + } + tail_used += t; + included.push(msg.clone()); + } + included.reverse(); + + // 保护:确保最近一条用户消息始终在上下文中 + if let Some(last_user) = recent.iter().rev().find(|m| m.role == crate::llm::types::Role::User) { + let already_included = included.iter().any(|m| { + m.role == last_user.role && m.content == last_user.content + }); + if !already_included { + let t = estimate_tokens(last_user); + if used + tail_used + t <= s.token_budget + 2000 { + included.push(last_user.clone()); + } + } + } + + messages.extend(included); + + messages +} + +/// 触发溢出摘要:压缩 checkpoint 到当前位置之间的新消息 +pub async fn trigger_overflow_summary( + session: &Arc>, + summarizer: Option<&Summarizer>, +) -> bool { + let mut s = session.lock().await; + if s.messages.is_empty() { + return false; + } + + let prev_checkpoint = s.checkpoint; + let end = s.messages.len(); + + // checkpoint 始终在 user+assistant 对边界,直接用它作为摘要起点 + let start = prev_checkpoint; + if start >= end { + return false; + } + + let to_summarize: Vec<_> = s.messages[start..end] + .iter() + .take(40) // 最多 40 条 + .cloned() + .collect(); + + if to_summarize.is_empty() { + return false; + } + + let summary_text = generate_summary(&to_summarize, summarizer).await; + + s.summaries.push(super::types::SummaryEntry { + checkpoint: prev_checkpoint, + text: summary_text, + reason: super::types::SummaryReason::Overflow, + created_at: chrono::Utc::now(), + }); + + s.checkpoint = end; + + // 清理太旧的消息(checkpoint 之前的保留最近 100 条用于调试) + if s.checkpoint > 200 { + let keep = s.checkpoint - 100; + s.messages.drain(0..keep); + // 调整 checkpoint 和 summary checkpoint + let shift = keep; + s.checkpoint -= shift; + for sum in &mut s.summaries { + if sum.checkpoint >= shift { + sum.checkpoint -= shift; + } + } + } + + true +} + +/// 触发空闲摘要(不注入,保存到 summaries 供 LLM 工具查询) +pub async fn trigger_idle_summary( + session: &Arc>, + summarizer: Option<&Summarizer>, +) { + let mut s = session.lock().await; + if s.messages.is_empty() { + return; + } + + let summary_text = generate_summary(&s.messages, summarizer).await; + let cp = s.messages.len(); + + s.summaries.push(super::types::SummaryEntry { + checkpoint: cp, + text: summary_text, + reason: super::types::SummaryReason::Timeout, + created_at: chrono::Utc::now(), + }); + + s.checkpoint = s.messages.len(); + + // 清理旧消息(checkpoint 之前的全部清除) + if s.checkpoint > 0 { + let shift = s.checkpoint; + s.messages.drain(0..shift); + s.checkpoint = 0; + for sum in &mut s.summaries { + if sum.checkpoint >= shift { + sum.checkpoint -= shift; + } + } + } +} + +/// 生成摘要:优先使用 LLM,不可用时回退到简单截断 +async fn generate_summary(messages: &[Message], summarizer: Option<&Summarizer>) -> String { + if let Some(summarizer) = summarizer { + if messages.len() >= 3 { + let prompt = build_summary_prompt(messages); + match summarizer(prompt).await { + Ok(text) if !text.is_empty() => { + tracing::info!("LLM 摘要: {:.100}...", text); + return text; + } + _ => tracing::warn!("LLM 摘要失败,回退到简单截断"), + } + } + } + summarize_messages(messages) +} + +/// 构建给 LLM 的摘要 prompt +fn build_summary_prompt(messages: &[Message]) -> String { + let convo: String = messages + .iter() + .filter(|m| m.role == crate::llm::types::Role::User || m.role == crate::llm::types::Role::Assistant) + .map(|m| { + let role = if m.role == crate::llm::types::Role::User { "用户" } else { "助手" }; + let content: String = m.content.chars().take(200).collect(); + format!("{}: {}", role, content) + }) + .collect::>() + .join("\n"); + + format!( + "请将以下对话压缩为简短摘要,保留关键事实、用户偏好、决定和待办事项。只输出摘要,不要其他内容。\n\n对话:\n{}", + convo + ) +} + +/// 简单的摘要生成(提取最近 N 条消息的关键内容) +fn summarize_messages(messages: &[Message]) -> String { + // 简单实现:提取用户消息的前 80 个字符作为摘要 + let lines: Vec = messages + .iter() + .filter(|m| m.role == crate::llm::types::Role::User || m.role == crate::llm::types::Role::Assistant) + .map(|m| { + let role = match m.role { + crate::llm::types::Role::User => "用户", + crate::llm::types::Role::Assistant => "助手", + _ => "", + }; + let preview: String = m.content.chars().take(80).collect(); + format!("{}: {}", role, preview) + }) + .collect(); + + if lines.is_empty() { + "暂无历史对话".to_string() + } else { + let result = format!("历史对话摘要({} 条消息):\n{}", lines.len(), lines.join("\n")); + if result.chars().count() > 2000 { + format!("{}...(已截断)", result.chars().take(2000).collect::()) + } else { + result + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_estimate_cjk() { + let t = estimate_text_tokens("你好世界"); + assert!(t >= 5 && t <= 10, "CJK estimate: {}", t); + } + + #[test] + fn test_estimate_ascii() { + let t = estimate_text_tokens("hello world"); + assert!(t >= 1 && t <= 6, "ASCII estimate: {}", t); + } +} diff --git a/src/context/mod.rs b/src/context/mod.rs new file mode 100644 index 0000000..8bd3b8f --- /dev/null +++ b/src/context/mod.rs @@ -0,0 +1,6 @@ +pub mod builder; +pub mod tools; +pub mod types; + +pub use tools::MemoryStore; +pub use types::ChatSession; diff --git a/src/context/tools.rs b/src/context/tools.rs new file mode 100644 index 0000000..ba953e2 --- /dev/null +++ b/src/context/tools.rs @@ -0,0 +1,92 @@ +use sqlx::PgPool; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// 长期记忆管理器(内存 + PostgreSQL 双写) +pub struct MemoryStore { + pool: Option>, + cache: Arc>>, +} + +impl MemoryStore { + pub fn new(pool: Option>) -> Self { + Self { + pool, + cache: Arc::new(Mutex::new(Vec::new())), + } + } + + /// 初始化:从数据库加载记忆到缓存 + pub async fn load(&self) { + let pool = match self.pool.as_ref() { + Some(p) => p.clone(), + None => return, + }; + if let Ok(rows) = sqlx::query_as::<_, (String,)>( + "SELECT content FROM user_memories WHERE user_id = 'default' ORDER BY id", + ) + .fetch_all(pool.as_ref()) + .await + { + let mut cache = self.cache.lock().await; + *cache = rows.into_iter().map(|(c,)| c).collect(); + } + } + + /// 读取所有记忆 + pub async fn read(&self) -> String { + let mems = self.cache.lock().await; + if mems.is_empty() { + "暂无长期记忆".to_string() + } else { + mems.iter() + .enumerate() + .map(|(i, m)| format!("{}. {}", i + 1, m)) + .collect::>() + .join("\n") + } + } + + /// 写入一条记忆 + pub async fn write(&self, content: &str) -> String { + // 写入缓存 + self.cache.lock().await.push(content.to_string()); + // 写入数据库 + if let Some(ref pool) = self.pool { + let _ = sqlx::query( + "INSERT INTO user_memories (user_id, content) VALUES ('default', $1)", + ) + .bind(content) + .execute(pool.as_ref()) + .await; + } + "已添加长期记忆".to_string() + } +} + +/// read_summaries 工具:读取历史摘要 +pub async fn read_summaries( + session: &Arc>, +) -> String { + let s = session.lock().await; + if s.summaries.is_empty() { + "暂无历史摘要".to_string() + } else { + s.summaries + .iter() + .map(|sum| { + let reason = match sum.reason { + super::types::SummaryReason::Overflow => "上下文压缩", + super::types::SummaryReason::Timeout => "空闲超时", + }; + format!( + "[{}] {} (原因: {})", + sum.created_at.format("%Y-%m-%d %H:%M"), + sum.text, + reason, + ) + }) + .collect::>() + .join("\n---\n") + } +} diff --git a/src/context/types.rs b/src/context/types.rs new file mode 100644 index 0000000..29bcbde --- /dev/null +++ b/src/context/types.rs @@ -0,0 +1,111 @@ +use crate::llm::types::Message; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// 摘要原因 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum SummaryReason { + /// 上下文 token 超 budget 触发 + Overflow, + /// 距上条用户消息超过 12 小时触发 + Timeout, +} + +/// 一条摘要记录 +#[derive(Debug, Clone)] +pub struct SummaryEntry { + /// 摘要对应的消息位置(checkpoint) + pub checkpoint: usize, + /// 摘要文本 + pub text: String, + /// 生成原因 + pub reason: SummaryReason, + /// 生成时间 + pub created_at: DateTime, +} + +/// 聊天会话状态 +#[derive(Debug, Clone)] +pub struct ChatSession { + /// 摘要总结点——此位置之前的消息已被压缩 + pub checkpoint: usize, + /// 全部消息(checkpoint 之后的保持完整) + pub messages: Vec, + /// 所有摘要(keyed by checkpoint) + pub summaries: Vec, + /// 上一条用户消息的时间 + pub last_user_at: Option>, + /// Token 预算(默认 28000,留 4000 给回复) + pub token_budget: u32, + /// 12 小时空闲阈值(秒) + pub idle_timeout_secs: i64, +} + +impl Default for ChatSession { + fn default() -> Self { + Self { + checkpoint: 0, + messages: Vec::new(), + summaries: Vec::new(), + last_user_at: None, + token_budget: 28000, + idle_timeout_secs: 12 * 3600, + } + } +} + +impl ChatSession { + pub fn new() -> Self { + Self::default() + } + + /// 记录一条用户消息 + pub fn add_user(&mut self, content: String) { + self.messages.push(Message::user(content)); + self.last_user_at = Some(Utc::now()); + } + + /// 记录一条助手消息 + pub fn add_assistant(&mut self, content: String) { + self.messages.push(Message::assistant(content)); + } + + /// 记录一条带 tool_calls 的助手消息 + pub fn add_assistant_tool_calls(&mut self, tool_calls: Vec) { + self.messages.push(Message::assistant_with_tool_calls(tool_calls)); + } + + /// 记录一条工具结果 + pub fn add_tool_result(&mut self, tool_call_id: &str, name: &str, result: &str) { + self.messages.push(Message::tool_result(tool_call_id, name, result)); + } + + /// 是否因空闲超过阈值需要摘要 + pub fn is_idle_timeout(&self) -> bool { + if let Some(last) = self.last_user_at { + let elapsed = Utc::now().signed_duration_since(last).num_seconds(); + elapsed > self.idle_timeout_secs + } else { + false + } + } + + /// 获取检查点之后的消息 + pub fn recent_messages(&self) -> &[Message] { + if self.checkpoint < self.messages.len() { + &self.messages[self.checkpoint..] + } else { + &[] + } + } + + /// 是否有 overflow 摘要可以注入 + pub fn has_overflow_summary(&self) -> bool { + self.summaries.iter().any(|s| s.reason == SummaryReason::Overflow) + } + + /// 获取最新的 overflow 摘要 + pub fn latest_overflow_summary(&self) -> Option<&SummaryEntry> { + self.summaries.iter().rev().find(|s| s.reason == SummaryReason::Overflow) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..991b36e --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,49 @@ +pub mod models; + +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use std::time::Duration; + +/// 数据库管理器 +pub struct Database { + pool: PgPool, +} + +impl Database { + /// 从 DATABASE_URL 环境变量连接并运行迁移 + pub async fn connect() -> Result { + let database_url = + std::env::var("DATABASE_URL").map_err(|_| "请设置 DATABASE_URL 环境变量".to_string())?; + + let pool = PgPoolOptions::new() + .max_connections(5) + .acquire_timeout(Duration::from_secs(10)) + .connect(&database_url) + .await + .map_err(|e| format!("连接数据库失败: {}", e))?; + + // 运行迁移 + sqlx::migrate!("./migrations") + .run(&pool) + .await + .map_err(|e| format!("数据库迁移失败: {}", e))?; + + tracing::info!("数据库连接成功,迁移已完成"); + + Ok(Self { pool }) + } + + /// 获取连接池引用 + pub fn pool(&self) -> &PgPool { + &self.pool + } + + /// 检查数据库是否可用 + pub async fn health_check(&self) -> Result<(), String> { + sqlx::query("SELECT 1") + .execute(&self.pool) + .await + .map_err(|e| format!("数据库健康检查失败: {}", e))?; + Ok(()) + } +} diff --git a/src/db/models.rs b/src/db/models.rs new file mode 100644 index 0000000..81065ea --- /dev/null +++ b/src/db/models.rs @@ -0,0 +1,193 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +use crate::state::AuthState; + +// ─── 认证状态 ─── + +/// 从数据库加载认证状态 +pub async fn load_auth(pool: &PgPool) -> Option { + let row = sqlx::query_as::<_, (serde_json::Value,)>( + "SELECT value FROM app_state WHERE key = 'auth'", + ) + .fetch_optional(pool) + .await + .ok()??; + + serde_json::from_value(row.0).ok() +} + +/// 保存认证状态到数据库 +pub async fn save_auth(pool: &PgPool, auth: &AuthState) -> Result<(), String> { + let value = serde_json::to_value(auth).map_err(|e| format!("序列化 auth 失败: {}", e))?; + + sqlx::query( + r#" + INSERT INTO app_state (key, value, updated_at) + VALUES ('auth', $1, NOW()) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, updated_at = NOW() + "#, + ) + .bind(&value) + .execute(pool) + .await + .map_err(|e| format!("保存 auth 到数据库失败: {}", e))?; + + Ok(()) +} + +// ─── 聊天记录 ─── + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ChatRecord { + pub id: i64, + pub created_at: DateTime, + pub direction: String, + pub user_id: String, + pub account_id: String, + pub text: String, + pub source: String, + pub context_token: String, + pub message_id: String, +} + +/// 插入一条聊天记录 +pub async fn insert_chat_record( + pool: &PgPool, + direction: &str, + user_id: &str, + account_id: &str, + text: &str, + source: &str, + context_token: Option<&str>, + message_id: &str, +) -> Result { + let ctx = context_token.unwrap_or(""); + + let row: (i64,) = sqlx::query_as( + r#" + INSERT INTO chat_records (direction, user_id, account_id, text, source, context_token, message_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id + "#, + ) + .bind(direction) + .bind(user_id) + .bind(account_id) + .bind(text) + .bind(source) + .bind(ctx) + .bind(message_id) + .fetch_one(pool) + .await + .map_err(|e| format!("插入聊天记录失败: {}", e))?; + + Ok(row.0) +} + +/// 查询最近的聊天记录(用户维度) +pub async fn list_recent_chat_records( + pool: &PgPool, + user_id: &str, + limit: i64, +) -> Result, String> { + let records = sqlx::query_as::<_, ChatRecord>( + r#" + SELECT id, created_at, direction, user_id, account_id, text, source, context_token, message_id + FROM chat_records + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + ) + .bind(user_id) + .bind(limit) + .fetch_all(pool) + .await + .map_err(|e| format!("查询聊天记录失败: {}", e))?; + + Ok(records) +} + +// ─── LLM 用量 ─── + +/// 插入一条 LLM 用量记录 +pub async fn insert_llm_usage( + pool: &PgPool, + user_id: &str, + model: &str, + provider: &str, + prompt_tokens: u32, + completion_tokens: u32, + cache_hit_tokens: u32, + cache_miss_tokens: u32, +) -> Result<(), String> { + let total = prompt_tokens + completion_tokens; + sqlx::query( + r#" + INSERT INTO llm_usage (user_id, model, provider, prompt_tokens, completion_tokens, total_tokens, cache_hit_tokens, cache_miss_tokens) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "#, + ) + .bind(user_id) + .bind(model) + .bind(provider) + .bind(prompt_tokens as i32) + .bind(completion_tokens as i32) + .bind(total as i32) + .bind(cache_hit_tokens as i32) + .bind(cache_miss_tokens as i32) + .execute(pool) + .await + .map_err(|e| format!("插入 LLM 用量失败: {}", e))?; + Ok(()) +} + +/// LLM 用量聚合统计 +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct LlmUsageStats { + pub total_calls: i64, + pub total_prompt_tokens: i64, + pub total_completion_tokens: i64, + pub total_tokens: i64, + pub total_cache_hit: i64, + pub total_cache_miss: i64, +} + +/// 查询 LLM 用量统计(按时间范围过滤) +pub async fn query_llm_usage_stats( + pool: &PgPool, + since: Option>, + until: Option>, + model_filter: Option<&str>, +) -> Result { + let since = since.unwrap_or_else(|| { + chrono::Utc::now() - chrono::Duration::days(7) + }); + let until = until.unwrap_or_else(chrono::Utc::now); + + let row = sqlx::query_as::<_, LlmUsageStats>( + r#" + SELECT + COUNT(*)::bigint as total_calls, + COALESCE(SUM(prompt_tokens), 0)::bigint as total_prompt_tokens, + COALESCE(SUM(completion_tokens), 0)::bigint as total_completion_tokens, + COALESCE(SUM(total_tokens), 0)::bigint as total_tokens, + COALESCE(SUM(cache_hit_tokens), 0)::bigint as total_cache_hit, + COALESCE(SUM(cache_miss_tokens), 0)::bigint as total_cache_miss + FROM llm_usage + WHERE created_at >= $1 AND created_at <= $2 + AND ($3::text IS NULL OR model = $3) + "#, + ) + .bind(since) + .bind(until) + .bind(model_filter) + .fetch_one(pool) + .await + .map_err(|e| format!("查询 LLM 用量失败: {}", e))?; + + Ok(row) +} diff --git a/src/llm/conversation.rs b/src/llm/conversation.rs new file mode 100644 index 0000000..d35185c --- /dev/null +++ b/src/llm/conversation.rs @@ -0,0 +1,242 @@ +use crate::context::builder; +use crate::context::types::ChatSession; +use crate::llm::provider::{create_provider, BoxedProvider, StreamReceiver}; +use crate::llm::types::{ConversationConfig, Message, StreamChunk, ToolCall, Usage}; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub type ToolExecutor = + Arc Pin> + Send>> + Send + Sync>; + +/// 摘要生成器:接收要摘要的消息文本 → 返回 LLM 生成的摘要 +pub type Summarizer = + Arc Pin> + Send>> + Send + Sync>; + +pub struct Conversation { + config: ConversationConfig, + provider: BoxedProvider, + session: Arc>, + tool_executor: Option, +} + +impl Conversation { + pub fn new(config: ConversationConfig) -> Result { + let provider = create_provider(&config)?; + Ok(Self { + config, + provider, + session: Arc::new(Mutex::new(ChatSession::new())), + tool_executor: None, + }) + } + + pub fn with_provider(config: ConversationConfig, provider: BoxedProvider) -> Self { + Self { + config, + provider, + session: Arc::new(Mutex::new(ChatSession::new())), + tool_executor: None, + } + } + + pub fn set_tool_executor(&mut self, executor: ToolExecutor) { self.tool_executor = Some(executor); } + pub fn session(&self) -> Arc> { Arc::clone(&self.session) } + pub fn model(&self) -> &str { &self.config.model } + pub fn provider_name(&self) -> &str { self.provider.name() } + pub fn spec(&self) -> &ConversationConfig { &self.config } + pub fn summarizer(&self) -> Summarizer { self.create_summarizer() } + + /// 创建 LLM 摘要生成器(用当前 provider 做非流式调用) + pub fn create_summarizer(&self) -> Summarizer { + let provider = self.provider.clone(); + Arc::new(move |prompt: String| { + let provider = provider.clone(); + Box::pin(async move { + let msgs = vec![Message::system("你是一个对话摘要助手,用中文输出简洁摘要。"), Message::user(prompt)]; + let mut rx = provider.chat_stream(&ConversationConfig::default(), &msgs).await + .map_err(|e| format!("摘要请求失败: {}", e))?; + let mut text = String::new(); + while let Some(chunk) = rx.recv().await { + match chunk { + StreamChunk::Text(t) => text.push_str(&t), + StreamChunk::Done { text: full, .. } => { + if !full.is_empty() { text = full; } + break; + } + StreamChunk::Error(e) => return Err(e), + _ => {} + } + } + Ok(text) + }) + }) + } + + /// 单次对话(无工具) + pub async fn chat(&self, user_message: String) -> Result { + // 空闲超时检查 + { + let s = self.session.lock().await; + if s.is_idle_timeout() { + drop(s); + builder::trigger_idle_summary(&self.session, Some(&self.summarizer())).await; + } + } + + self.session.lock().await.add_user(user_message.clone()); + let messages = builder::build_context(&self.session, &self.config.system_prompt).await; + let rx = self.provider.chat_stream(&self.config, &messages).await?; + + Ok(ChatHandle { + rx: Some(rx), full_text: String::new(), tool_calls: None, usage: None, + session: Arc::clone(&self.session), + }) + } + + /// 对话 + 工具循环(带上下文管理) + pub async fn chat_with_tools( + &self, + user_message: String, + ) -> Result<(String, bool, Option), String> { + // ── 空闲超时检查 ── + { + let s = self.session.lock().await; + if s.is_idle_timeout() { + drop(s); + builder::trigger_idle_summary(&self.session, Some(&self.summarizer())).await; + tracing::info!("⏰ 检测到 12h 空闲,已生成摘要"); + } + } + + self.session.lock().await.add_user(user_message.clone()); + + let mut used_tools = false; + let mut last_usage = None; + let mut turn_count = 0u32; + + loop { + turn_count += 1; + if turn_count > 5 { + return Err("工具调用次数过多(最多5轮)".to_string()); + } + + let messages = builder::build_context(&self.session, &self.config.system_prompt).await; + let rx = self.provider.chat_stream(&self.config, &messages).await?; + + let mut full_text = String::new(); + let mut tool_calls: Option> = None; + let mut rx = rx; + + while let Some(chunk) = rx.recv().await { + match chunk { + StreamChunk::Text(t) => full_text.push_str(&t), + StreamChunk::Done { text, tool_calls: tc, usage, .. } => { + if !text.is_empty() { full_text = text; } + tool_calls = tc; + last_usage = usage; + break; + } + StreamChunk::Error(e) => return Err(e), + _ => {} + } + } + + if let Some(calls) = tool_calls { + if !calls.is_empty() { + used_tools = true; + tracing::info!( + "🔧 LLM 请求 {} 个工具: {}", + calls.len(), + calls.iter().map(|c| format!("{}({:.80})", c.function.name, c.function.arguments)).collect::>().join(", ") + ); + + self.session.lock().await.add_assistant_tool_calls(calls.clone()); + + for tc in &calls { + let result = match &self.tool_executor { + Some(exec) => match exec(&tc.function.name, &tc.function.arguments).await { + Ok(r) => r, + Err(e) => format!("执行失败: {}", e), + }, + None => "工具执行器未配置".to_string(), + }; + tracing::info!("📦 {} → {:.150}", tc.function.name, result); + self.session.lock().await.add_tool_result(&tc.id, &tc.function.name, &result); + } + continue; + } + } + + // 无工具调用:记录回复,检查是否需要溢出摘要 + if !full_text.is_empty() { + self.session.lock().await.add_assistant(full_text.clone()); + } + + // 检查 token 是否接近预算,触发溢出摘要 + { + let s = self.session.lock().await; + let recent = s.recent_messages(); + let estimated: u32 = recent.iter().map(|m| builder::estimate_tokens(m)).sum(); + if estimated > s.token_budget { + drop(s); + builder::trigger_overflow_summary(&self.session, Some(&self.summarizer())).await; + tracing::info!("📦 上下文超预算,已触发溢出摘要"); + } + } + + return Ok((full_text, used_tools, last_usage)); + } + } +} + +// ─── ChatHandle ─── + +pub struct ChatHandle { + rx: Option, + full_text: String, + tool_calls: Option>, + usage: Option, + session: Arc>, +} + +impl ChatHandle { + pub async fn next_chunk(&mut self) -> Option { + let chunk = self.rx.as_mut()?.recv().await; + match &chunk { + Some(StreamChunk::Text(t)) => self.full_text.push_str(t), + Some(StreamChunk::Done { text, tool_calls, usage, .. }) => { + if !text.is_empty() { self.full_text = text.clone(); } + self.tool_calls = tool_calls.clone(); + self.usage = usage.clone(); + if !self.full_text.is_empty() { + let mut s = self.session.lock().await; + s.add_assistant(self.full_text.clone()); + } + self.rx = None; + } + Some(StreamChunk::Error(_)) => { self.rx = None; } + _ => {} + } + chunk + } + + pub async fn consume(mut self) -> Result { + while let Some(chunk) = self.next_chunk().await { + if let StreamChunk::Error(e) = chunk { return Err(e); } + } + Ok(self.full_text.clone()) + } + + pub fn current_text(&self) -> &str { &self.full_text } + pub fn get_tool_calls(&self) -> Option<&Vec> { self.tool_calls.as_ref() } + pub fn get_usage(&self) -> Option<&Usage> { self.usage.as_ref() } +} + +pub const DEFAULT_SYSTEM_PROMPT: &str = "You are a concise and helpful assistant replying in Chinese. \ + Keep replies practical and natural. \ + Unless the user asks for detail, keep most replies under 120 Chinese characters. \ + \ + 如果需要了解用户的长期记忆或历史对话摘要,使用 read_memories / read_summaries 工具。\ + 如果用户提到重要的个人信息或约定,使用 write_memory 工具记录下来。"; diff --git a/src/llm/deepseek.rs b/src/llm/deepseek.rs new file mode 100644 index 0000000..7b8369c --- /dev/null +++ b/src/llm/deepseek.rs @@ -0,0 +1,195 @@ +use crate::llm::provider::{parse_chat_chunk, LlmProvider, ParsedChunk, StreamReceiver, StreamSender}; +use crate::llm::types::{ConversationConfig, Message, StreamChunk, ToolCall, Usage}; +use async_trait::async_trait; +use reqwest::Client as HttpClient; +use std::collections::BTreeMap; +use tokio::sync::mpsc; + +pub struct DeepSeekProvider { + http: HttpClient, + api_key: String, + base_url: String, +} + +impl DeepSeekProvider { + pub fn new() -> Result { + let api_key = std::env::var("DEEPSEEK_API_KEY") + .map_err(|_| "请设置 DEEPSEEK_API_KEY 环境变量".to_string())?; + let base_url = std::env::var("DEEPSEEK_BASE_URL") + .unwrap_or_else(|_| "https://api.deepseek.com/v1".to_string()); + + Ok(Self { + http: HttpClient::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| format!("创建 HTTP client 失败: {}", e))?, + api_key, + base_url, + }) + } +} + +#[async_trait] +impl LlmProvider for DeepSeekProvider { + fn name(&self) -> &str { "deepseek" } + + async fn chat_stream( + &self, + config: &ConversationConfig, + messages: &[Message], + ) -> Result { + let url = format!("{}/chat/completions", self.base_url); + + let mut body = serde_json::json!({ + "model": config.model, + "messages": messages, + "temperature": config.temperature, + "max_tokens": config.max_tokens, + "stream": true, + }); + + // 如果有工具,添加 tools 参数 + if let Some(ref tools) = config.tools { + body["tools"] = serde_json::Value::Array(tools.clone()); + } + + if config.thinking { + body["thinking"] = serde_json::json!({"type": "enabled"}); + body["reasoning_effort"] = serde_json::json!("high"); + body.as_object_mut().map(|obj| { + obj.remove("temperature"); + obj.remove("top_p"); + }); + } + + let (tx, rx) = mpsc::channel::(64); + let http = self.http.clone(); + let api_key = self.api_key.clone(); + + tokio::spawn(async move { + if let Err(e) = stream_deepseek(http, &url, &api_key, body, tx.clone()).await { + let _ = tx.send(StreamChunk::Error(e)).await; + } + }); + + Ok(rx) + } +} + +async fn stream_deepseek( + http: HttpClient, + url: &str, + api_key: &str, + body: serde_json::Value, + tx: StreamSender, +) -> Result<(), String> { + let response = http + .post(url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream") + .json(&body) + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + let _ = tx.send(StreamChunk::Error(format!("HTTP {}: {}", status, text))).await; + return Err(format!("HTTP {}: {}", status, text)); + } + + let mut full_text = String::new(); + let mut full_reasoning = String::new(); + let mut usage: Option = None; + let mut tool_call_builders: BTreeMap, Option, String)> = BTreeMap::new(); + + use futures_util::StreamExt; + let mut buf = String::new(); + let mut stream = response.bytes_stream(); + + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|e| format!("读取流失败: {}", e))?; + let text = String::from_utf8_lossy(&chunk); + buf.push_str(&text); + + while let Some(line_end) = buf.find('\n') { + let line = buf[..line_end].to_string(); + buf = buf[line_end + 1..].to_string(); + let trimmed = line.trim(); + if trimmed.is_empty() { continue; } + + if let Some(parsed) = parse_chat_chunk(trimmed) { + match parsed { + ParsedChunk::Text(t) => { + full_text.push_str(&t); + let _ = tx.send(StreamChunk::Text(t)).await; + } + ParsedChunk::Reasoning(r) => { + full_reasoning.push_str(&r); + let _ = tx.send(StreamChunk::Reasoning(r)).await; + } + ParsedChunk::ToolCallDelta { index, id, name, arguments } => { + let entry = tool_call_builders.entry(index).or_insert((None, None, String::new())); + if let Some(call_id) = id { entry.0 = Some(call_id); } + if let Some(call_name) = name { entry.1 = Some(call_name); } + entry.2.push_str(&arguments); + } + ParsedChunk::FinishReason(_) => {} + ParsedChunk::Usage(u) => { usage = Some(u); } + } + } + } + } + + // 检查最后的 buffer + if !buf.trim().is_empty() { + if let Some(parsed) = parse_chat_chunk(buf.trim()) { + match parsed { + ParsedChunk::Text(t) => full_text.push_str(&t), + ParsedChunk::ToolCallDelta { index, id, name, arguments } => { + let entry = tool_call_builders.entry(index).or_insert((None, None, String::new())); + if let Some(call_id) = id { entry.0 = Some(call_id); } + if let Some(call_name) = name { entry.1 = Some(call_name); } + entry.2.push_str(&arguments); + } + ParsedChunk::Usage(u) => { usage = Some(u); } + _ => {} + } + } + } + + // 构建 tool_calls + let tool_calls: Option> = if tool_call_builders.is_empty() { + None + } else { + Some( + tool_call_builders + .into_values() + .filter_map(|(id, name, args)| { + let name = name?; + let id = id.unwrap_or_else(|| format!("call_{}", rand::random::())); + Some(ToolCall { + id, + call_type: "function".to_string(), + function: crate::llm::types::ToolFunctionCall { + name, + arguments: args, + }, + }) + }) + .collect(), + ) + }; + + let _ = tx + .send(StreamChunk::Done { + text: full_text, + reasoning: if full_reasoning.is_empty() { None } else { Some(full_reasoning) }, + tool_calls, + usage, + }) + .await; + Ok(()) +} diff --git a/src/llm/lmstudio.rs b/src/llm/lmstudio.rs new file mode 100644 index 0000000..c1862a8 --- /dev/null +++ b/src/llm/lmstudio.rs @@ -0,0 +1,157 @@ +use crate::llm::provider::{parse_chat_chunk, LlmProvider, ParsedChunk, StreamReceiver, StreamSender}; +use crate::llm::types::{ConversationConfig, Message, StreamChunk}; +use async_trait::async_trait; +use reqwest::Client as HttpClient; +use tokio::sync::mpsc; + +/// LM Studio 提供商(OpenAI 兼容接口) +pub struct LmStudioProvider { + http: HttpClient, + base_url: String, + api_key: String, +} + +impl LmStudioProvider { + pub fn new() -> Result { + let base_url = std::env::var("LM_STUDIO_BASE_URL") + .unwrap_or_else(|_| "http://localhost:1234/v1".to_string()); + // 标准化 base_url: 确保有协议和 /v1 路径 + let base_url = normalize_base_url(&base_url); + let api_key = std::env::var("LM_STUDIO_API_KEY").unwrap_or_default(); + + Ok(Self { + http: HttpClient::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| format!("创建 HTTP client 失败: {}", e))?, + base_url, + api_key, + }) + } +} + +#[async_trait] +impl LlmProvider for LmStudioProvider { + fn name(&self) -> &str { + "lmstudio" + } + + async fn chat_stream( + &self, + config: &ConversationConfig, + messages: &[Message], + ) -> Result { + let url = format!("{}/chat/completions", self.base_url); + + let body = serde_json::json!({ + "model": config.model, + "messages": messages, + "temperature": config.temperature, + "max_tokens": config.max_tokens, + "stream": true, + }); + + let (tx, rx) = mpsc::channel::(64); + let http = self.http.clone(); + let api_key = self.api_key.clone(); + + tokio::spawn(async move { + if let Err(e) = stream_openai(http, &url, &api_key, body, tx.clone()).await { + let _ = tx.send(StreamChunk::Error(e)).await; + } + }); + + Ok(rx) + } +} + +/// 与 DeepSeek 公用相同的 SSE 流式处理,但去除 thinking 相关字段 +async fn stream_openai( + http: HttpClient, + url: &str, + api_key: &str, + body: serde_json::Value, + tx: StreamSender, +) -> Result<(), String> { + let mut req = http.post(url).header("Content-Type", "application/json"); + + if !api_key.is_empty() { + req = req.header("Authorization", format!("Bearer {}", api_key)); + } + + let response = req + .json(&body) + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + let _ = tx.send(StreamChunk::Error(format!("HTTP {}: {}", status, text))).await; + return Err(format!("HTTP {}: {}", status, text)); + } + + let mut full_text = String::new(); + let mut buf = String::new(); + let mut stream = response.bytes_stream(); + + use futures_util::StreamExt; + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|e| format!("读取流失败: {}", e))?; + let text = String::from_utf8_lossy(&chunk); + buf.push_str(&text); + + while let Some(line_end) = buf.find('\n') { + let line = buf[..line_end].to_string(); + buf = buf[line_end + 1..].to_string(); + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + // LM Studio 使用标准 OpenAI SSE 格式 + if let Some(parsed) = parse_chat_chunk(trimmed) { + match parsed { + ParsedChunk::Text(t) => { + full_text.push_str(&t); + let _ = tx.send(StreamChunk::Text(t)).await; + } + ParsedChunk::Reasoning(r) => { + let _ = tx.send(StreamChunk::Reasoning(r)).await; + } + _ => {} + } + } + } + } + + let _ = tx + .send(StreamChunk::Done { + text: full_text, + reasoning: None, + tool_calls: None, + usage: None, + }) + .await; + Ok(()) +} + +/// 确保 base_url 有协议前缀和 /v1 路径 +fn normalize_base_url(raw: &str) -> String { + let raw = raw.trim(); + let with_protocol = if raw.starts_with("http://") || raw.starts_with("https://") { + raw.to_string() + } else { + format!("http://{}", raw) + }; + + // 去掉末尾 / + let trimmed = with_protocol.trim_end_matches('/'); + // 确保路径以 /v1 结尾 + if trimmed.ends_with("/v1") { + trimmed.to_string() + } else { + format!("{}/v1", trimmed) + } +} diff --git a/src/llm/mod.rs b/src/llm/mod.rs new file mode 100644 index 0000000..914c173 --- /dev/null +++ b/src/llm/mod.rs @@ -0,0 +1,8 @@ +pub mod conversation; +pub mod deepseek; +pub mod lmstudio; +pub mod provider; +pub mod types; + +pub use conversation::{Conversation, Summarizer, ToolExecutor, DEFAULT_SYSTEM_PROMPT}; +pub use types::{ConversationConfig, Message, Role, StreamChunk, Usage}; diff --git a/src/llm/provider.rs b/src/llm/provider.rs new file mode 100644 index 0000000..bdd9bef --- /dev/null +++ b/src/llm/provider.rs @@ -0,0 +1,187 @@ +use crate::llm::types::{ConversationConfig, Message, StreamChunk}; +use serde::Deserialize; +use async_trait::async_trait; +use std::sync::Arc; +use tokio::sync::mpsc; + +/// 流式返回:每个 StreamChunk 是 delta 或控制信号 +pub type StreamReceiver = mpsc::Receiver; +pub type StreamSender = mpsc::Sender; + +/// LLM 提供商抽象 +#[async_trait] +pub trait LlmProvider: Send + Sync { + /// 提供商名称 + fn name(&self) -> &str; + + /// 发起流式对话 + async fn chat_stream( + &self, + config: &ConversationConfig, + messages: &[Message], + ) -> Result; +} + +// ─── 内置提供商注册 ─── + +pub type BoxedProvider = Arc; + +/// 从配置创建恰当的提供商 +pub fn create_provider(_config: &ConversationConfig) -> Result { + // 根据 model 前缀或环境变量选择 + let provider = std::env::var("LLM_PROVIDER") + .unwrap_or_else(|_| "deepseek".to_string()) + .to_lowercase(); + + match provider.as_str() { + "deepseek" => Ok(Arc::new(super::deepseek::DeepSeekProvider::new()?)), + "lmstudio" => Ok(Arc::new(super::lmstudio::LmStudioProvider::new()?)), + _ => Err(format!("不支持的 LLM 提供商: {}", provider)), + } +} + +// ─── 内部:SSE 解析工具 ─── + +/// 从字节流中解析 SSE 行 +pub(crate) fn parse_sse_line(line: &str) -> Option<(String, String)> { + // 支持两种格式: + // data: {...} + // event: ... + let line = line.trim(); + if line.is_empty() || line.starts_with(':') { + return None; + } + + if let Some(pos) = line.find(": ") { + let field = line[..pos].trim().to_string(); + let value = line[pos + 2..].trim_start().to_string(); + Some((field, value)) + } else if let Some(pos) = line.find(':') { + let field = line[..pos].trim().to_string(); + let value = line[pos + 1..].trim_start().to_string(); + Some((field, value)) + } else { + None + } +} + +/// 流式块解析结果 +pub(crate) enum ParsedChunk { + Text(String), + Reasoning(String), + ToolCallDelta { + index: i32, + id: Option, + name: Option, + arguments: String, + }, + FinishReason(String), + Usage(super::types::Usage), +} + +/// 从 JSON body 中解析 DeepSeek/OpenAI 流式 delta +pub(crate) fn parse_chat_chunk( + line: &str, +) -> Option { + if !line.starts_with("data: ") { + return None; + } + + let data = line["data: ".len()..].trim(); + if data == "[DONE]" { + return None; + } + + #[derive(Deserialize)] + struct ToolCallDelta { + #[serde(default)] + index: Option, + #[serde(default)] + id: Option, + #[serde(rename = "type", default)] + call_type: Option, + #[serde(default)] + function: Option, + } + + #[derive(Deserialize)] + struct ToolFunctionDelta { + #[serde(default)] + name: Option, + #[serde(default)] + arguments: Option, + } + + #[derive(Deserialize)] + struct Delta { + #[serde(default)] + content: Option, + #[serde(default)] + reasoning_content: Option, + #[serde(default)] + tool_calls: Option>, + } + + #[derive(Deserialize)] + struct ChunkChoice { + delta: Delta, + #[serde(default)] + finish_reason: Option, + } + + #[derive(Deserialize)] + struct ChunkResponse { + choices: Vec, + #[serde(default)] + usage: Option, + } + + let parsed: ChunkResponse = match serde_json::from_str(data) { + Ok(p) => p, + Err(_) => return None, + }; + + // 提取 usage(可能在最后一个 chunk) + if let Some(ref usage) = parsed.usage { + if usage.total_tokens > 0 { + return Some(ParsedChunk::Usage(usage.clone())); + } + } + + for choice in parsed.choices { + // 工具调用 delta + if let Some(tool_calls) = &choice.delta.tool_calls { + for tc in tool_calls { + let idx = tc.index.unwrap_or(0); + let args = tc.function.as_ref() + .and_then(|f| f.arguments.clone()) + .unwrap_or_default(); + let name = tc.function.as_ref().and_then(|f| f.name.clone()); + return Some(ParsedChunk::ToolCallDelta { + index: idx, + id: tc.id.clone(), + name, + arguments: args, + }); + } + } + + if let Some(reasoning) = &choice.delta.reasoning_content { + if !reasoning.is_empty() { + return Some(ParsedChunk::Reasoning(reasoning.clone())); + } + } + if let Some(content) = &choice.delta.content { + if !content.is_empty() { + return Some(ParsedChunk::Text(content.clone())); + } + } + if let Some(reason) = &choice.finish_reason { + if !reason.is_empty() { + return Some(ParsedChunk::FinishReason(reason.clone())); + } + } + } + + None +} diff --git a/src/llm/types.rs b/src/llm/types.rs new file mode 100644 index 0000000..0c473a6 --- /dev/null +++ b/src/llm/types.rs @@ -0,0 +1,125 @@ +use serde::{Deserialize, Serialize}; + +// ─── 角色 ─── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Role { + System, + User, + Assistant, + Tool, +} + +// ─── 消息 ─── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + pub role: Role, + #[serde(default)] + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, +} + +impl Message { + pub fn system(content: impl Into) -> Self { + Self { role: Role::System, content: content.into(), tool_call_id: None, name: None, tool_calls: None } + } + + pub fn user(content: impl Into) -> Self { + Self { role: Role::User, content: content.into(), tool_call_id: None, name: None, tool_calls: None } + } + + pub fn assistant(content: impl Into) -> Self { + Self { role: Role::Assistant, content: content.into(), tool_call_id: None, name: None, tool_calls: None } + } + + pub fn assistant_with_tool_calls(tool_calls: Vec) -> Self { + Self { role: Role::Assistant, content: String::new(), tool_call_id: None, name: None, tool_calls: Some(tool_calls) } + } + + pub fn tool_result(tool_call_id: &str, name: &str, result: &str) -> Self { + Self { role: Role::Tool, content: result.to_string(), tool_call_id: Some(tool_call_id.to_string()), name: Some(name.to_string()), tool_calls: None } + } +} + +// ─── 工具调用 ─── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + #[serde(rename = "type")] + pub call_type: String, // "function" + pub function: ToolFunctionCall, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolFunctionCall { + pub name: String, + pub arguments: String, // JSON string +} + +// ─── 流式响应块 ─── + +#[derive(Debug, Clone)] +pub enum StreamChunk { + /// 文本片段 delta + Text(String), + /// 思考/推理内容(DeepSeek thinking) + Reasoning(String), + /// 完成(携带完整文本,以及可选的工具调用) + Done { + text: String, + reasoning: Option, + tool_calls: Option>, + usage: Option, + }, + /// 错误 + Error(String), +} + +// ─── Token 用量 ─── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Usage { + pub prompt_tokens: u32, + pub completion_tokens: u32, + pub total_tokens: u32, + #[serde(default)] + pub prompt_cache_hit_tokens: u32, + #[serde(default)] + pub prompt_cache_miss_tokens: u32, +} + +// ─── 对话配置 ─── + +#[derive(Debug, Clone)] +pub struct ConversationConfig { + pub system_prompt: String, + pub model: String, + pub temperature: f32, + pub max_tokens: u32, + pub thinking: bool, + /// LLM function calling 的工具定义(JSON 数组) + pub tools: Option>, +} + +impl Default for ConversationConfig { + fn default() -> Self { + Self { + system_prompt: "You are a concise and helpful assistant replying in Chinese. \ + Keep replies practical and natural." + .to_string(), + model: "deepseek-v4-flash".to_string(), + temperature: 0.7, + max_tokens: 4096, + thinking: true, + tools: None, + } + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..67ce15a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,636 @@ -fn main() { - println!("Hello, world!"); +mod cli; +mod config; +mod context; +mod db; +mod llm; +mod scheduler; +mod state; +mod tools; +mod wechat; + +use clap::Parser; +use cli::{Cli, Commands}; +use context::MemoryStore; +use config::AppConfig; +use context::types::ChatSession as _; +use db::Database; +use llm::{Conversation, ConversationConfig, ToolExecutor, Usage, DEFAULT_SYSTEM_PROMPT}; +use std::sync::Arc; +use tools::approval::ApprovalManager; +use tools::registry::{ExecutionContext, SkillRegistry}; +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; +use wechat::client::WeChatClient; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); + + dotenvy::dotenv().ok(); + + let config = AppConfig::from_file("config.json"); + + // 初始化数据库(如果配置了 DATABASE_URL) + let database = match Database::connect().await { + Ok(db) => { + info!("✅ 数据库连接成功"); + Some(Arc::new(db)) + } + Err(e) => { + info!("数据库不可用,使用文件存储: {}", e); + None + } + }; + + // 长期记忆存储 + let memory_store = Arc::new(MemoryStore::new(database.as_ref().map(|db| Arc::new(db.pool().clone())))); + memory_store.load().await; + + // 文件状态管理器(降级方案) + let file_state = state::StateManager::new(&config.storage.state_dir); + + let cli = Cli::parse(); + + match cli.command { + Commands::Login { timeout } => { + cmd_login(timeout, &database, &file_state).await; + } + Commands::Listen { + llm: enable_llm, + echo, + } => { + cmd_listen(enable_llm, echo, &database, &file_state, &config, &memory_store).await; + } + Commands::Send { + to, + text, + context_token, + } => { + cmd_send(&to, &text, context_token.as_deref(), &database, &file_state).await; + } + Commands::Whoami => { + cmd_whoami(&database, &file_state).await; + } + Commands::Usage { since, until, model } => { + cmd_usage(&database, since, until, model).await; + } + } +} + +// ─── 命令实现 ─── + +async fn cmd_login( + timeout_secs: u64, + database: &Option>, + file_state: &state::StateManager, +) { + let client = WeChatClient::new(None); + + match client + .login(|url| println!("二维码链接: {}", url), timeout_secs) + .await + { + Ok(result) => { + let auth = state::AuthState { + token: result.token.clone(), + account_id: result.account_id.clone(), + base_url: result.base_url.clone(), + }; + + // 优先存数据库 + if let Some(db) = database { + if let Err(e) = db::models::save_auth(db.pool(), &auth).await { + error!("保存 auth 到数据库失败: {}", e); + file_state.save_auth(&auth); + } else { + info!("认证信息已保存到数据库"); + // 清理文件状态(迁移完成) + file_state.clear_auth(); + } + } else { + file_state.save_auth(&auth); + } + + println!("\n✅ 登录成功!"); + println!(" 账号: {}", result.account_id); + println!(" Token: {}...", &result.token[..std::cmp::min(16, result.token.len())]); + println!(" API: {}", result.base_url); + } + Err(e) => error!("登录失败: {}", e), + } +} + +async fn cmd_listen( + enable_llm: bool, + echo: bool, + database: &Option>, + file_state: &state::StateManager, + config: &AppConfig, + memory_store: &Arc, +) { + // 从数据库或文件加载认证 + let auth = 'load: { + if let Some(db) = database { + if let Some(a) = db::models::load_auth(db.pool()).await { + break 'load Some(a); + } + // 尝试从文件迁移 + if let Some(fa) = file_state.load_auth() { + let a = state::AuthState { + token: fa.token, + account_id: fa.account_id, + base_url: fa.base_url, + }; + let _ = db::models::save_auth(db.pool(), &a).await; + break 'load Some(a); + } + } else if let Some(a) = file_state.load_auth() { + break 'load Some(a); + } + break 'load None; + }; + + let auth = match auth { + Some(a) => a, + None => { + error!("未登录,请先执行 login 命令"); + return; + } + }; + + let client = WeChatClient::new(Some(auth.base_url.clone())); + client + .set_auth(&auth.token, &auth.account_id, &auth.base_url) + .await; + + // 从文件恢复 runtime 状态(get_updates_buf 暂时保留文件存储) + if let Some(r) = file_state.load_runtime() { + client.set_updates_buf(&r.get_updates_buf).await; + } + + if let Err(e) = client.notify_start().await { + error!("注册监听器失败: {}", e); + return; + } + + // 加载系统 Skills + let skill_registry = match SkillRegistry::load("resources/skills", "skills").await { + Ok(r) => { + info!("已加载 {} 个技能", r.count()); + Some(Arc::new(r)) + } + Err(e) => { + info!("技能加载结果: {:?}", e); + None + } + }; + + // 审批管理器 + let approval_manager = database.as_ref().map(|db| { + Arc::new(ApprovalManager::new(Some(Arc::new(db.pool().clone())))) + }); + + let conversation = if enable_llm { + let sys_prompt = std::env::var("WEIXIN_LLM_SYSTEM_PROMPT") + .unwrap_or_else(|_| DEFAULT_SYSTEM_PROMPT.to_string()); + let model = config.llm.deepseek.model.clone(); + + let mut cfg = ConversationConfig { + system_prompt: sys_prompt, + model, + ..Default::default() + }; + + // 构建 LLM tools 列表(skills + 上下文工具) + let mut tools_list: Vec = Vec::new(); + + if let Some(ref registry) = skill_registry { + for spec in registry.list_specs() { + tools_list.push(serde_json::json!({ + "type": "function", + "function": { + "name": spec.name, + "description": spec.description, + "parameters": spec.parameters, + } + })); + } + } + + // 上下文工具(长期记忆 + 摘要) + tools_list.push(serde_json::json!({ + "type": "function", + "function": { + "name": "read_memories", + "description": "读取用户的长期记忆(跨会话持久保存的个人信息、偏好、约定等)", + "parameters": { "type": "object", "properties": {} } + } + })); + tools_list.push(serde_json::json!({ + "type": "function", + "function": { + "name": "write_memory", + "description": "写入一条用户的长期记忆(当用户提到重要偏好、个人信息、约定时调用)", + "parameters": { + "type": "object", + "properties": { + "content": { "type": "string", "description": "记忆内容" } + }, + "required": ["content"] + } + } + })); + tools_list.push(serde_json::json!({ + "type": "function", + "function": { + "name": "read_summaries", + "description": "读取历史会话摘要(包括因上下文压缩或空闲超时产生的摘要)", + "parameters": { "type": "object", "properties": {} } + } + })); + + cfg.tools = Some(tools_list); + + // 创建 Conversation + let mut conv = match Conversation::new(cfg) { + Ok(c) => { + info!("LLM 已初始化: {} / {}", c.provider_name(), c.model()); + c + } + Err(e) => { + error!("初始化 LLM 失败: {}", e); + return; + } + }; + + // 构建工具执行器 + let session = conv.session(); + let registry_opt = skill_registry.clone(); + let approval = approval_manager.clone(); + let memory_store = memory_store.clone(); + let send_wechat: tools::registry::WechatSender = { + let client = client.clone(); + Arc::new(move |user_id: &str, text: &str| { + let client = client.clone(); + let uid = user_id.to_string(); + let msg = text.to_string(); + Box::pin(async move { + client.send_text(&uid, &msg, None).await.map(|_| ()).map_err(|e| e) + }) + }) + }; + + let executor: ToolExecutor = Arc::new(move |name: &str, args_json: &str| { + let reg = registry_opt.clone(); + let approval = approval.clone(); + let send = send_wechat.clone(); + let session = session.clone(); + let memory_store = memory_store.clone(); + let n = name.to_string(); + let args = args_json.to_string(); + + Box::pin(async move { + match n.as_str() { + "read_memories" => return Ok(memory_store.read().await), + "write_memory" => { + let params: serde_json::Value = serde_json::from_str(&args).unwrap_or_default(); + let content = params.get("content").and_then(|v| v.as_str()).unwrap_or(""); + if content.is_empty() { return Ok("请提供 content 参数".to_string()); } + return Ok(memory_store.write(content).await); + } + "read_summaries" => return Ok(context::tools::read_summaries(&session).await), + _ => {} + } + if let Some(ref reg) = reg { + let params: serde_json::Value = serde_json::from_str(&args).unwrap_or(serde_json::json!({})); + let mut ctx = ExecutionContext::new("wechat_user"); + if let Some(a) = approval { ctx = ctx.with_approval(a); } + ctx = ctx.with_wechat(send); + let result = reg.execute(&n, params, &ctx).await; + Ok(result.output) + } else { + Ok(format!("未知工具: {}", n)) + } + }) + }); + + conv.set_tool_executor(executor); + + Some(conv) + } else { + None + }; + + let account_id = auth.account_id.clone(); + + // 启动调度器(如果数据库可用) + if let Some(sched_db) = database.as_ref().map(|db| db.pool().clone()) { + let sched_client = client.clone(); + tokio::spawn(async move { + let scheduler = scheduler::Scheduler::new(Some(Arc::new(sched_db))); + scheduler.run(move |to: &str, _name: &str, msg: &str| { + let c = sched_client.clone(); + let uid = to.to_string(); + let text = msg.to_string(); + tokio::task::spawn(async move { + if let Err(e) = c.send_text(&uid, &text, None).await { + tracing::error!("发送调度结果失败: {}", e); + } + }); + Ok(()) + }).await; + }); + info!("定时任务调度器已启动"); + } + + info!("开始监听消息 (llm={}, echo={})...", enable_llm, echo); + println!("已开始监听消息,按 Ctrl+C 退出"); + + let shutdown = async { tokio::signal::ctrl_c().await.expect("Ctrl+C 失败") }; + + tokio::select! { + _ = shutdown => { + info!("收到退出信号,注销监听器..."); + let _ = client.notify_stop().await; + file_state.save_runtime(&client.get_updates_buf().await); + info!("已退出"); + } + _ = listen_loop(&client, enable_llm, echo, &account_id, database, file_state, conversation, approval_manager) => {} + } +} + +async fn listen_loop( + client: &WeChatClient, + enable_llm: bool, + echo: bool, + account_id: &str, + database: &Option>, + file_state: &state::StateManager, + conversation: Option, + approval_manager: Option>, +) { + loop { + match client.receive_messages().await { + Ok(resp) => { + // 持久化 runtime buf + if !resp.get_updates_buf.is_empty() { + file_state.save_runtime(&resp.get_updates_buf); + } + + for msg in &resp.msgs { + if msg.msg_type != Some(wechat::types::WeixinMessage::TYPE_USER) { + continue; + } + + let from = msg.from_user_id.as_deref().unwrap_or("unknown"); + let text = msg.text_content().unwrap_or("(非文本消息)"); + let ctx_token = msg.context_token.as_deref(); + let msg_id = msg.message_id.unwrap_or(0).to_string(); + + info!("收到消息 from={}: {}", from, text); + + // === 入库:收到的消息 === + if let Some(db) = database { + let _ = db::models::insert_chat_record( + db.pool(), + "inbound", + from, + account_id, + text, + "wechat", + ctx_token, + &msg_id, + ) + .await; + } + + // Echo 模式 + if echo { + match client.send_text(from, text, ctx_token).await { + Ok(sent_id) => { + info!("回显成功 msg_id={}", sent_id); + // === 入库:发送的回显 === + if let Some(db) = database { + let _ = db::models::insert_chat_record( + db.pool(), + "outbound", + from, + account_id, + text, + "echo", + ctx_token, + &sent_id, + ) + .await; + } + } + Err(e) => error!("回显失败: {}", e), + } + } + + // 审批回复检查:是否匹配待审批的确认码 + let is_approval_reply = if let Some(ref mgr) = approval_manager { + mgr.handle_reply(from, text).await.is_some() + } else { + false + }; + + if is_approval_reply { + info!("消息匹配审批确认码,不传给 LLM(已由审批系统处理)"); + continue; + } + + // LLM 回复 + if enable_llm { + if let Some(ref conv) = conversation { + let reply_result = if conv.spec().tools.is_some() { + generate_reply_with_tools(conv, text.to_string(), database).await + } else { + generate_reply(conv, text.to_string(), database).await + }; + + match reply_result { + Ok(reply) => { + match client.send_text(from, &reply, ctx_token).await { + Ok(sent_id) => { + info!("LLM 回复成功 msg_id={}", sent_id); + if let Some(db) = database { + let _ = db::models::insert_chat_record( + db.pool(), "outbound", from, account_id, + &reply, "llm", ctx_token, &sent_id, + ).await; + } + } + Err(e) => error!("发送 LLM 回复失败: {}", e), + } + } + Err(e) => error!("生成 LLM 回复失败: {}", e), + } + } + } + } + } + Err(e) => { + if !e.contains("超时") && !e.contains("timeout") { + error!("接收消息失败: {}", e); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + } + } + } +} + +async fn generate_reply(conv: &Conversation, user_text: String, db: &Option>) -> Result { + let handle = conv.chat(user_text).await?; + if let Some(u) = handle.get_usage() { + info!("📊 Token: {} 总 / {} 提示 / {} 生成 | 缓存命中 {} | 缓存未命中 {}", + u.total_tokens, u.prompt_tokens, u.completion_tokens, + u.prompt_cache_hit_tokens, u.prompt_cache_miss_tokens); + store_usage(db, "", conv.model(), conv.provider_name(), u).await; + } + handle.consume().await +} + +async fn generate_reply_with_tools( + conv: &Conversation, + user_text: String, + db: &Option>, +) -> Result { + let (reply, _used_tools, usage) = conv.chat_with_tools(user_text).await?; + if let Some(u) = &usage { + info!("📊 Token: {} 总 / {} 提示 / {} 生成 | 缓存命中 {} | 缓存未命中 {}", + u.total_tokens, u.prompt_tokens, u.completion_tokens, + u.prompt_cache_hit_tokens, u.prompt_cache_miss_tokens); + store_usage(db, "", conv.model(), conv.provider_name(), u).await; + } + Ok(reply) +} + +async fn store_usage(db: &Option>, user_id: &str, model: &str, provider: &str, u: &Usage) { + if let Some(db) = db { + let _ = db::models::insert_llm_usage( + db.pool(), user_id, model, provider, + u.prompt_tokens, u.completion_tokens, + u.prompt_cache_hit_tokens, u.prompt_cache_miss_tokens, + ).await; + } +} + +async fn cmd_whoami( + database: &Option>, + file_state: &state::StateManager, +) { + let auth: Option = if let Some(db) = database { + db::models::load_auth(db.pool()).await + } else { + file_state.load_auth() + }; + + match auth { + Some(a) => { + println!("账号: {}", a.account_id); + println!("Token: {}...", &a.token[..std::cmp::min(16, a.token.len())]); + println!("API: {}", a.base_url); + if database.is_some() { + println!("存储: PostgreSQL"); + } else { + println!("存储: 文件 ({})", file_state.state_dir().display()); + } + } + None => println!("未登录,请先执行 login 命令"), + } +} + +async fn cmd_usage( + database: &Option>, + since: Option, + until: Option, + model: Option, +) { + let db = match database { + Some(d) => d, + None => { println!("数据库未配置,无法查询用量"); return; } + }; + + let since_dt = since.and_then(|s| { + chrono::DateTime::parse_from_rfc3339(&s).ok().map(|d| d.with_timezone(&chrono::Utc)) + }); + let until_dt = until.and_then(|s| { + chrono::DateTime::parse_from_rfc3339(&s).ok().map(|d| d.with_timezone(&chrono::Utc)) + }); + let model_ref = model.as_deref(); + + match db::models::query_llm_usage_stats(db.pool(), since_dt, until_dt, model_ref).await { + Ok(stats) => { + let hit_rate = if stats.total_cache_hit + stats.total_cache_miss > 0 { + stats.total_cache_hit as f64 / (stats.total_cache_hit + stats.total_cache_miss) as f64 * 100.0 + } else { 0.0 }; + + println!("📊 LLM Token 使用统计"); + println!("{:=<40}", ""); + println!("调用次数: {}", stats.total_calls); + println!("Prompt Tokens: {} ({:.1}K)", stats.total_prompt_tokens, stats.total_prompt_tokens as f64 / 1000.0); + println!("生成 Tokens: {} ({:.1}K)", stats.total_completion_tokens, stats.total_completion_tokens as f64 / 1000.0); + println!("总 Tokens: {} ({:.1}K)", stats.total_tokens, stats.total_tokens as f64 / 1000.0); + println!("缓存命中: {} ({:.1}%)", stats.total_cache_hit, hit_rate); + println!("缓存未命中: {} ({:.1}%)", stats.total_cache_miss, 100.0 - hit_rate); + } + Err(e) => println!("查询失败: {}", e), + } +} + +async fn cmd_send( + to: &str, + text: &str, + context_token: Option<&str>, + database: &Option>, + file_state: &state::StateManager, +) { + let auth: Option = if let Some(db) = database { + db::models::load_auth(db.pool()).await + } else { + file_state.load_auth() + }; + + let auth = match auth { + Some(a) => a, + None => { + error!("未登录,请先执行 login 命令"); + return; + } + }; + + let base_url = auth.base_url.clone(); + let account_id = auth.account_id.clone(); + let client = WeChatClient::new(Some(auth.base_url)); + client + .set_auth(&auth.token, &auth.account_id, &base_url) + .await; + + match client.send_text(to, text, context_token).await { + Ok(msg_id) => { + println!("✅ 消息已发送, msg_id={}", msg_id); + + // 入库:发送的消息 + if let Some(db) = database { + let _ = db::models::insert_chat_record( + db.pool(), + "outbound", + to, + &account_id, + text, + "manual", + context_token, + &msg_id, + ) + .await; + } + } + Err(e) => error!("发送失败: {}", e), + } } diff --git a/src/scheduler.rs b/src/scheduler.rs new file mode 100644 index 0000000..80093ac --- /dev/null +++ b/src/scheduler.rs @@ -0,0 +1,167 @@ +use chrono::Utc; +use sqlx::PgPool; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::interval; + +/// 定时任务调度器 —— 轮询 PostgreSQL 中到期的 scheduled_tasks +pub struct Scheduler { + pool: Option>, +} + +impl Scheduler { + pub fn new(pool: Option>) -> Self { + Self { pool } + } + + /// 启动调度循环(在 listen_loop 中作为 tokio::spawn 调用) + /// 每 5 秒检查一次是否有到期任务,执行后通过 callback 发送结果 + pub async fn run( + &self, + mut on_task: impl FnMut(&str, &str, &str) -> Result<(), String>, + ) { + let pool = match self.pool.as_ref() { + Some(p) => p.clone(), + None => { + tracing::info!("调度器:数据库未配置,跳过"); + return; + } + }; + + let mut tick = interval(Duration::from_secs(5)); + tracing::info!("调度器已启动(每 5s 检查)"); + + loop { + tick.tick().await; + + let tasks = match fetch_due_tasks(&pool).await { + Ok(t) => t, + Err(e) => { + tracing::warn!("调度器查询失败: {}", e); + continue; + } + }; + + for task in tasks { + tracing::info!("⏰ 执行定时任务: {}", task.name); + + // 执行任务 + let result = execute_task(&task).await; + + // 更新任务状态 + let _ = update_task_status(&pool, &task.id, &result).await; + + // 通知用户 + let notify_msg = format!( + "⏰ 定时任务: {}\n结果: {}", + task.name, + result + ); + if let Err(e) = on_task(&task.user_id, &task.name, ¬ify_msg) { + tracing::error!("发送定时任务通知失败: {}", e); + } + + tracing::info!("✅ 定时任务完成: {} → {}", task.name, result); + } + } + } +} + +#[derive(Debug)] +struct DueTask { + id: uuid::Uuid, + name: String, + user_id: String, + command: String, + shell: String, + cwd: String, +} + +async fn fetch_due_tasks(pool: &PgPool) -> Result, String> { + let rows = sqlx::query_as::<_, (uuid::Uuid, String, String, String, String, String)>( + r#" + SELECT id, name, user_id, command, shell, cwd + FROM scheduled_tasks + WHERE enabled = true AND next_run_at <= NOW() + ORDER BY next_run_at ASC + LIMIT 10 + "#, + ) + .fetch_all(pool) + .await + .map_err(|e| format!("查询到期任务失败: {}", e))?; + + Ok(rows + .into_iter() + .map(|(id, name, user_id, command, shell, cwd)| DueTask { + id, + name, + user_id, + command, + shell, + cwd, + }) + .collect()) +} + +async fn execute_task(task: &DueTask) -> String { + let shell = if task.shell.is_empty() { + "/bin/bash" + } else { + &task.shell + }; + let cwd = if task.cwd.is_empty() { + std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default() + } else { + task.cwd.clone() + }; + + let result = tokio::process::Command::new(shell) + .arg("-c") + .arg(&task.command) + .current_dir(&cwd) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .await; + + match result { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + if output.status.success() { + if stdout.is_empty() { "执行成功(无输出)".to_string() } else { stdout } + } else { + format!( + "退出码 {}: {}", + output.status.code().unwrap_or(-1), + if stderr.is_empty() { &stdout } else { &stderr } + ) + } + } + Err(e) => format!("执行失败: {}", e), + } +} + +async fn update_task_status(pool: &PgPool, task_id: &uuid::Uuid, result: &str) -> Result<(), String> { + // 更新 last_run_at 和 next_run_at + sqlx::query( + r#" + UPDATE scheduled_tasks + SET last_run_at = NOW(), + next_run_at = NOW() + (interval_seconds * INTERVAL '1 second'), + last_status = $2 + WHERE id = $1 + "#, + ) + .bind(task_id) + .bind(result.chars().take(500).collect::()) + .execute(pool) + .await + .map_err(|e| format!("更新任务状态失败: {}", e))?; + + Ok(()) +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..2403c15 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,96 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// 认证状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthState { + pub token: String, + pub account_id: String, + pub base_url: String, +} + +/// 运行时状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeState { + pub get_updates_buf: String, +} + +/// 状态管理器 +pub struct StateManager { + state_dir: PathBuf, +} + +impl StateManager { + pub fn new(state_dir: impl Into) -> Self { + Self { + state_dir: state_dir.into(), + } + } + + fn auth_file(&self) -> PathBuf { + self.state_dir.join("auth.json") + } + + fn runtime_file(&self) -> PathBuf { + self.state_dir.join("runtime.json") + } + + fn ensure_dir(&self) { + std::fs::create_dir_all(&self.state_dir).ok(); + } + + pub fn state_dir(&self) -> &std::path::Path { + &self.state_dir + } + + pub fn save_auth(&self, auth: &AuthState) { + self.ensure_dir(); + let json = serde_json::to_string_pretty(auth).expect("序列化 auth 失败"); + if let Err(e) = std::fs::write(self.auth_file(), &json) { + tracing::error!("保存 auth 状态失败: {}", e); + } + // 权限 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(self.auth_file(), std::fs::Permissions::from_mode(0o600)).ok(); + } + } + + pub fn load_auth(&self) -> Option { + let path = self.auth_file(); + if !path.exists() { + return None; + } + let json = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&json).ok() + } + + pub fn save_runtime(&self, buf: &str) { + self.ensure_dir(); + let state = RuntimeState { + get_updates_buf: buf.to_string(), + }; + let json = serde_json::to_string_pretty(&state).expect("序列化 runtime 失败"); + if let Err(e) = std::fs::write(self.runtime_file(), &json) { + tracing::error!("保存 runtime 状态失败: {}", e); + } + } + + pub fn load_runtime(&self) -> Option { + let path = self.runtime_file(); + if !path.exists() { + return None; + } + let json = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&json).ok() + } + + /// 删除 auth 文件(迁移到数据库后清理) + pub fn clear_auth(&self) { + let path = self.auth_file(); + if path.exists() { + std::fs::remove_file(&path).ok(); + } + } +} diff --git a/src/tools/DESIGN.md b/src/tools/DESIGN.md new file mode 100644 index 0000000..3c1e8e2 --- /dev/null +++ b/src/tools/DESIGN.md @@ -0,0 +1,328 @@ +# iAs 工具系统设计 + +## 核心理念 + +**一切皆 Skill。** + +没有"内置工具"和"外部技能"的区别——所有工具都是 SKILL.md,区别仅在来源: +- **系统 skills** — `resources/skills/` 目录,随 iAs 分发,只读 +- **用户 skills** — `skills/` 项目目录,用户自己添加 + +加载时自动合并,同名时用户 skills 覆盖系统 skills。 + +--- + +## 架构 + +``` +┌──────────────────────────────────────────────────────────┐ +│ LLM │ +│ generate_reply() ←→ tool_choice → tool_execute(result) │ +└─────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ SkillRegistry │ +│ │ +│ ┌──────────────────┐ ┌────────────────────────────┐ │ +│ │ 系统 Skills │ │ 用户 Skills │ │ +│ │ resources/skills/ │ │ skills/ │ │ +│ │ │ │ │ │ +│ │ • datetime │ │ • query_qweather/ │ │ +│ │ • weather │ │ • search_tavily/ │ │ +│ │ • search_web │ │ • start_pc/ │ │ +│ │ • email │ │ • ... │ │ +│ │ • shell_exec │ │ │ │ +│ │ • schedule │ │ │ │ +│ └────────┬──────────┘ └────────────┬──────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Approval Gate │ │ +│ │ risk == High → 确认码审批 / Low → 自动执行 │ │ +│ │ 审批过程对 LLM 完全透明 │ │ +│ └──────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +--- + +## 核心类型 + +```rust +/// 风险等级(仅两级) +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RiskLevel { + Low, // 只读/安全,自动执行 + High, // 写操作/有风险,需微信确认码审批 +} + +/// 技能来源 +#[derive(Debug, Clone, PartialEq)] +pub enum SkillSource { + System, // resources/skills/ + User, // skills/ +} + +/// 技能元数据(从 SKILL.md 解析) +#[derive(Debug, Clone, Deserialize)] +pub struct SkillSpec { + pub name: String, + pub description: String, + pub risk_level: RiskLevel, + pub parameters: serde_json::Value, // JSON Schema + pub timeout_secs: u64, +} + +/// 技能(加载到内存后) +pub struct Skill { + pub spec: SkillSpec, + pub source: SkillSource, + pub dir: PathBuf, + pub execute_command: String, +} +``` + +--- + +## SKILL.md 格式 + +每个技能是一个目录,包含 `SKILL.md` 文件: + +```markdown +# Get Current Datetime + +获取当前日期时间(北京时间 UTC+8)。 + +## Risk Level +Low + +## Parameters +\`\`\`json +{ + "type": "object", + "properties": { + "format": { + "type": "string", + "description": "时间格式: full | date | time", + "enum": ["full", "date", "time"], + "default": "full" + } + } +} +\`\`\` + +## Execute +\`\`\`bash +#!/usr/bin/env bash +scripts/datetime.sh +\`\`\` +``` + +--- + +## 内置 Skills 一览 + +随 iAs 分发的系统 skills: + +| Skill | 风险 | 说明 | +|-------|------|------| +| `get_current_datetime` | Low | 获取当前时间(UTC+8) | +| `query_weather` | Low | 和风天气查询 | +| `search_web` | Low | Tavily 网络搜索 | +| `email` | High | 收发邮件 | +| `execute_shell` | High | 执行 shell 命令 | +| `list_scheduled_tasks` | Low | 列出定时任务 | +| `manage_scheduled_task` | High | 增删改定时任务 | + +--- + +## SkillRegistry + +```rust +pub struct SkillRegistry { + skills: HashMap>, +} + +impl SkillRegistry { + pub async fn load(system_dir: &str, user_dir: &str) -> Result; + + pub fn list_specs(&self) -> Vec<&SkillSpec>; + pub fn list_specs_by_risk(&self, level: RiskLevel) -> Vec<&SkillSpec>; + pub fn get(&self, name: &str) -> Option<&Skill>; + + /// 执行技能(含审批流程) + pub async fn execute( + &self, + name: &str, + params: serde_json::Value, + ctx: &ExecutionContext, + ) -> Result; +} + +pub struct ExecutionContext { + pub user_id: String, + pub db: Option>, + /// 发送微信消息的回调 + pub send_wechat: Option Result<(), String> + Send + Sync>>, + /// 等待用户回复的回调(返回用户消息文本) + pub wait_for_reply: Option Result, String> + Send + Sync>>, +} +``` + +--- + +## 审批流程(透明模式) + +**核心原则:审批过程对 LLM 完全不可见。** LLM 只看到最终结果。 + +``` +LLM 调用 High 风险技能 + │ + ▼ +SkillRegistry::execute() + │ + ├── 查找 Skill + ├── risk_level == High? + │ + └── YES ──────────────────────────────────────────┐ + │ │ + ▼ │ + ┌──────────────────────────────────────────┐ │ + │ 等待阶段(LLM 不感知) │ │ + │ │ │ + │ ① 生成 6 位确认码(如 482731) │ │ + │ ② SHA-256 哈希存入 pending_approvals │ │ + │ ③ 通过微信发送确认消息给用户: │ │ + │ │ │ + │ ┌─────────────────────────────┐ │ │ + │ │ ⚠️ 操作确认 │ │ │ + │ │ │ │ │ + │ │ 技能:send_email │ │ │ + │ │ 参数:收件人 xxx@xxx.com │ │ │ + │ │ │ │ │ + │ │ 确认码:482731 │ │ │ + │ │ 回复确认码以继续 │ │ │ + │ │ 回复 0 或「取消」以取消 │ │ │ + │ │ (5分钟内有效,最多3次尝试) │ │ │ + │ └─────────────────────────────┘ │ │ + │ │ │ + │ ④ 等待用户回复 │ │ + │ │ │ │ + │ ├── 回复正确确认码 ──┐ │ │ + │ │ │ │ │ + │ │ ▼ │ │ + │ │ ┌──────────────┐ │ │ + │ │ │ ⑤ 执行技能 │ │ │ + │ │ │ ⑥ LLM 看到 │ │ │ + │ │ │ 执行结果 │ │ │ + │ │ └──────────────┘ │ │ + │ │ │ │ + │ ├── 回复错误确认码 │ │ + │ │ → "确认码错误,还剩 N 次" │ │ + │ │ → 重试(最多 3 次) │ │ + │ │ → 3 次都错 │ │ + │ │ → LLM 看到: │ │ + │ │ "用户拒绝了你的调用: │ │ + │ │ send_email" │ │ + │ │ │ │ + │ ├── 回复 0 / 「取消」 │ │ + │ │ → LLM 看到: │ │ + │ │ "用户拒绝了你的调用: │ │ + │ │ send_email" │ │ + │ │ │ │ + │ └── 5 分钟超时 │ │ + │ → LLM 看到: │ │ + │ "用户没有确认操作: │ │ + │ send_email" │ │ + └──────────────────────────────────────────┘ +``` + +### LLM 视角(两种可能) + +``` +✅ 通过: + LLM: 调用 send_email({to: "xxx", subject: "..."}) + 系统: { success: true, message: "邮件已发送" } + +❌ 拒绝: + LLM: 调用 send_email({to: "xxx", subject: "..."}) + 系统: { success: false, error: "用户拒绝了你的调用:send_email" } +``` + +LLM 不知道确认码的存在,不知道审批过程。它只知道自己调用了工具,然后得到了结果或拒绝。 + +--- + +## 数据库表 + +```sql +-- 待审批的工具调用 +CREATE TABLE pending_approvals ( + id UUID PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, -- 5分钟后过期 + user_id TEXT NOT NULL, + skill_name TEXT NOT NULL, + params JSONB NOT NULL, + code_hash TEXT NOT NULL, -- 确认码 SHA-256(不存明文) + attempts_left INTEGER NOT NULL DEFAULT 3, -- 剩余尝试次数 + status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | rejected | expired + result JSONB, + consumed_at TIMESTAMPTZ +); +``` + +--- + +## Skills 目录结构 + +``` +resources/skills/ # 系统内置 Skills(随 iAs 分发) +├── get_current_datetime/ +│ ├── SKILL.md +│ └── scripts/datetime.sh +├── query_weather/ +│ ├── SKILL.md +│ └── scripts/weather.sh +├── search_web/ +│ ├── SKILL.md +│ └── scripts/search.sh +├── email/ +│ ├── SKILL.md +│ └── scripts/email.sh +├── execute_shell/ +│ ├── SKILL.md +│ └── scripts/shell.sh +├── list_scheduled_tasks/ +│ ├── SKILL.md +│ └── scripts/list.sh +└── manage_scheduled_task/ + ├── SKILL.md + └── scripts/manage.sh + +skills/ # 用户 Skills(用户自行添加) +├── query_qweather/ +│ ├── SKILL.md +│ └── scripts/query.sh +├── search_tavily/ +│ ├── SKILL.md +│ └── scripts/search.sh +└── start_pc/ + ├── SKILL.md + └── scripts/wake.sh +``` + +--- + +## 实施步骤 + +| 步骤 | 内容 | 预估 | +|------|------|------| +| Step 1 | SkillSpec/Skill/SkillRegistry 核心类型 + 注册表 | 半天 | +| Step 2 | SKILL.md 解析器 + 目录扫描(system + user 合并) | 半天 | +| Step 3 | 技能执行器:子进程管理 + 参数 JSON 传递 + 结果解析 | 半天 | +| Step 4 | 系统 Skills 脚本实现(7 个 bash 脚本) | 1 天 | +| Step 5 | 审批系统:pending_approvals 表 + 确认码生成/验证 + 微信透明通知 | 1 天 | +| Step 6 | LLM function calling 集成 + 审计日志 | 1 天 | diff --git a/src/tools/approval.rs b/src/tools/approval.rs new file mode 100644 index 0000000..6045051 --- /dev/null +++ b/src/tools/approval.rs @@ -0,0 +1,154 @@ +use rand::Rng; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{Mutex, oneshot}; + +/// 审批决策 +#[derive(Debug, Clone, PartialEq)] +pub enum ApprovalDecision { + Approved, + Rejected, + Expired, +} + +/// 内存中待审批的条目 +struct PendingEntry { + code_hash: String, + skill_name: String, + attempts_left: i32, + /// 通知等待方 + sender: oneshot::Sender, +} + +/// 审批管理器 +pub struct ApprovalManager { + pool: Option>, + /// user_id → 该用户的待审批队列 + pending: Arc>>>, +} + +impl ApprovalManager { + pub fn new(pool: Option>) -> Self { + Self { + pool, + pending: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// 创建审批请求 + /// 返回 (确认码明文, 接收器) — 调用方 await 接收器等待用户确认 + pub async fn create( + &self, + user_id: &str, + skill_name: &str, + ) -> Result<(String, oneshot::Receiver), String> { + let code: u32 = rand::thread_rng().gen_range(100000..999999); + let code_str = code.to_string(); + let code_hash = format!("{:x}", Sha256::digest(code_str.as_bytes())); + + let (tx, rx) = oneshot::channel(); + + // 入库 + if let Some(ref pool) = self.pool { + let id = uuid::Uuid::new_v4(); + let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5); + let params = serde_json::json!({"name": skill_name}); + + let _ = sqlx::query( + r#" + INSERT INTO pending_approvals (id, expires_at, user_id, skill_name, params, code_hash, status) + VALUES ($1, $2, $3, $4, $5, $6, 'pending') + "#, + ) + .bind(id) + .bind(expires_at) + .bind(user_id) + .bind(skill_name) + .bind(¶ms) + .bind(&code_hash) + .execute(pool.as_ref()) + .await; + } + + // 注册到内存 + let mut map = self.pending.lock().await; + map.entry(user_id.to_string()) + .or_default() + .push(PendingEntry { + code_hash, + skill_name: skill_name.to_string(), + attempts_left: 3, + sender: tx, + }); + + Ok((code_str, rx)) + } + + /// 处理用户回复(由消息循环调用) + /// 匹配成功时返回 Some(技能名),匹配失败返回 None + pub async fn handle_reply(&self, user_id: &str, reply: &str) -> Option { + let mut map = self.pending.lock().await; + let entries = map.get_mut(user_id)?; + if entries.is_empty() { + return None; + } + + let reply_hash = format!("{:x}", Sha256::digest(reply.as_bytes())); + let reply_trimmed = reply.trim(); + + // 检查取消 + if reply_trimmed == "0" || reply_trimmed == "取消" { + let entry = entries.remove(0); + let name = entry.skill_name.clone(); + let _ = entry.sender.send(ApprovalDecision::Rejected); + return Some(name); + } + + // 检查确认码 + let pos = entries.iter().position(|e| e.code_hash == reply_hash); + if let Some(idx) = pos { + let entry = entries.remove(idx); + let name = entry.skill_name.clone(); + let _ = entry.sender.send(ApprovalDecision::Approved); + return Some(name); + } + + // 确认码错误 + if let Some(entry) = entries.first_mut() { + entry.attempts_left -= 1; + if entry.attempts_left <= 0 { + let entry = entries.remove(0); + let name = entry.skill_name.clone(); + let _ = entry.sender.send(ApprovalDecision::Rejected); + return Some(name); + } + } + + None + } + + /// 清理过期(由定时任务调用) + pub async fn clean_expired(&self) { + let mut map = self.pending.lock().await; + for entries in map.values_mut() { + entries.retain(|e| !e.sender.is_closed()); + } + map.retain(|_, v| !v.is_empty()); + + if let Some(ref pool) = self.pool { + let _ = sqlx::query( + "UPDATE pending_approvals SET status = 'expired' WHERE status = 'pending' AND expires_at < NOW()", + ) + .execute(pool.as_ref()) + .await; + } + } + + /// 获取某个用户的待审批条数 + pub async fn pending_count(&self, user_id: &str) -> usize { + let map = self.pending.lock().await; + map.get(user_id).map(|v| v.len()).unwrap_or(0) + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs new file mode 100644 index 0000000..ed92896 --- /dev/null +++ b/src/tools/mod.rs @@ -0,0 +1,4 @@ +pub mod approval; +pub mod parser; +pub mod registry; +pub mod skill; diff --git a/src/tools/parser.rs b/src/tools/parser.rs new file mode 100644 index 0000000..b522bd9 --- /dev/null +++ b/src/tools/parser.rs @@ -0,0 +1,279 @@ +use crate::tools::skill::{Skill, SkillSource, SkillSpec}; +use std::fs; +use std::path::Path; + +/// 解析 SKILL.md 文件,返回 Skill +/// +/// SKILL.md 格式: +/// ```markdown +/// # Skill Name +/// Description text... +/// +/// ## Risk Level +/// Low | High +/// +/// ## Parameters +/// ```json +/// { ... } +/// ``` +/// +/// ## Execute +/// ```bash +/// command to run +/// ``` +/// ``` +pub fn parse_skill(skill_dir: &Path) -> Result { + let md_path = skill_dir.join("SKILL.md"); + let content = + fs::read_to_string(&md_path).map_err(|e| format!("读取 {} 失败: {}", md_path.display(), e))?; + + let lines: Vec<&str> = content.lines().collect(); + let mut name = String::new(); + let mut description = String::new(); + let mut risk_level = String::new(); + let mut params_raw = String::new(); + let mut execute_raw = String::new(); + let mut in_params = false; + let mut in_execute = false; + let mut params_fence = 0; + let mut execute_fence = 0; + let mut desc_lines: Vec<&str> = Vec::new(); + let mut in_desc = false; + + for (i, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + + // 标题行 → 技能名 + if trimmed.starts_with("# ") && !trimmed.starts_with("## ") && name.is_empty() { + name = trimmed.trim_start_matches("# ").trim().to_string(); + continue; + } + + // 二级标题 + if trimmed.starts_with("## ") { + let section = trimmed.trim_start_matches("## ").trim().to_lowercase(); + match section.as_str() { + "risk level" | "risk_level" | "risk" => { + in_desc = false; + in_params = false; + in_execute = false; + } + "parameters" | "param" => { + in_desc = false; + in_params = true; + in_execute = false; + } + "execute" | "execution" | "command" | "run" => { + in_desc = false; + in_params = false; + in_execute = true; + params_fence = 0; + execute_fence = 0; + } + _ => { + in_desc = false; + in_params = false; + in_execute = false; + } + } + continue; + } + + // 描述:标题和 Risk Level 之间的文本 + if !in_params && !in_execute && !trimmed.starts_with("#") && !trimmed.is_empty() { + if name.is_empty() { + continue; + } + // 检查是否已进入 Risk Level 段 + let is_risk_line = trimmed.to_lowercase().starts_with("low") + || trimmed.to_lowercase().starts_with("high"); + if risk_level.is_empty() && is_risk_line { + risk_level = trimmed.to_string(); + continue; + } + if risk_level.is_empty() { + desc_lines.push(trimmed); + } + continue; + } + + // Risk Level 值 + if !in_params && !in_execute && risk_level.is_empty() { + let lower = trimmed.to_lowercase(); + if lower == "low" || lower == "high" { + risk_level = trimmed.to_string(); + continue; + } + } + + // Parameters: JSON 代码块 + if in_params { + if trimmed.starts_with("```") { + params_fence += 1; + if params_fence == 2 { + in_params = false; + } + continue; + } + if params_fence == 1 { + if !params_raw.is_empty() { + params_raw.push('\n'); + } + params_raw.push_str(trimmed); + } + continue; + } + + // Execute: shell 命令 + if in_execute { + if trimmed.starts_with("```") { + execute_fence += 1; + if execute_fence == 2 { + in_execute = false; + } + continue; + } + if execute_fence == 1 { + // 跳过 shebang 行 + if trimmed.starts_with("#!/") { + continue; + } + if !execute_raw.is_empty() { + execute_raw.push('\n'); + } + execute_raw.push_str(trimmed); + } + continue; + } + } + + // 验证 + if name.is_empty() { + return Err(format!("{}: 未找到技能名称 (# Title)", md_path.display())); + } + + description = desc_lines.join(" ").trim().to_string(); + + let risk = match risk_level.to_lowercase().trim() { + "high" => crate::tools::skill::RiskLevel::High, + _ => crate::tools::skill::RiskLevel::Low, + }; + + // 解析参数 JSON + let parameters: serde_json::Value = if params_raw.is_empty() { + serde_json::json!({ + "type": "object", + "properties": {} + }) + } else { + serde_json::from_str(¶ms_raw).unwrap_or_else(|_| { + serde_json::json!({ + "type": "object", + "properties": {} + }) + }) + }; + + // 执行命令(去掉首尾空白) + let execute_command = execute_raw.trim().to_string(); + if execute_command.is_empty() { + return Err(format!("{}: 未找到 Execute 命令", md_path.display())); + } + + let spec = SkillSpec { + name, + description, + risk_level: risk, + parameters, + timeout_secs: 30, + }; + + Ok(Skill::new( + spec, + // 先设成 User,由调用方修正 + SkillSource::User, + skill_dir.to_path_buf(), + execute_command, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn test_load_all_system_skills() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(crate::tools::registry::SkillRegistry::load( + "resources/skills", + "/nonexistent", + )); + match result { + Ok(registry) => { + assert_eq!(registry.count(), 8, "应该有 8 个系统技能"); + let names: Vec<&str> = registry.list_specs().iter().map(|s| s.name.as_str()).collect(); + println!("已加载技能: {:?}", names); + assert!(names.contains(&"manage_memos")); + assert!(names.contains(&"get_current_datetime")); + assert!(names.contains(&"query_weather")); + assert!(names.contains(&"search_web")); + assert!(names.contains(&"email")); + assert!(names.contains(&"execute_shell")); + assert!(names.contains(&"list_scheduled_tasks")); + assert!(names.contains(&"manage_scheduled_task")); + } + Err(errors) => { + for e in &errors { + eprintln!("加载失败: {}", e); + } + panic!("技能加载失败"); + } + } + } + + #[test] + fn test_parse_basic_skill() { + let dir = std::env::temp_dir().join("ias-test-skill"); + fs::create_dir_all(&dir).ok(); + let mut f = fs::File::create(dir.join("SKILL.md")).unwrap(); + write!( + f, + r#"# Get Current Datetime + +获取当前日期时间(北京时间 UTC+8)。 + +## Risk Level +Low + +## Parameters +```json +{{ + "type": "object", + "properties": {{ + "format": {{ + "type": "string", + "description": "full | date | time" + }} + }} +}} +``` + +## Execute +```bash +date '+%Y-%m-%d %H:%M:%S' +``` +"# + ) + .unwrap(); + + let skill = parse_skill(&dir).unwrap(); + assert_eq!(skill.spec.name, "Get Current Datetime"); + assert!(skill.spec.description.contains("日期时间")); + assert_eq!(skill.spec.risk_level, crate::tools::skill::RiskLevel::Low); + assert!(!skill.execute_command.is_empty()); + assert!(skill.execute_command.contains("date")); + + fs::remove_dir_all(&dir).ok(); + } +} diff --git a/src/tools/registry.rs b/src/tools/registry.rs new file mode 100644 index 0000000..b6ddf19 --- /dev/null +++ b/src/tools/registry.rs @@ -0,0 +1,311 @@ +use super::approval::{ApprovalDecision, ApprovalManager}; +use crate::tools::parser::parse_skill; +use crate::tools::skill::*; +use std::future::Future; +use std::pin::Pin; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use tokio::process::Command; + +/// 技能注册表 +pub struct SkillRegistry { + skills: HashMap>, +} + +impl SkillRegistry { + pub fn new() -> Self { + Self { + skills: HashMap::new(), + } + } + + /// 从 system_dir 和 user_dir 加载所有技能 + /// system_dir: resources/skills/(随 iAs 分发) + /// user_dir: skills/(用户自行添加) + /// 同名时 user 覆盖 system + pub async fn load(system_dir: &str, user_dir: &str) -> Result> { + let mut registry = Self::new(); + let mut errors = Vec::new(); + + // 加载系统 skills + if Path::new(system_dir).exists() { + registry.load_from_dir(system_dir, SkillSource::System, &mut errors); + } + + // 加载用户 skills + if Path::new(user_dir).exists() { + registry.load_from_dir(user_dir, SkillSource::User, &mut errors); + } + + if errors.is_empty() { + Ok(registry) + } else { + Err(errors) + } + } + + fn load_from_dir(&mut self, dir: &str, source: SkillSource, errors: &mut Vec) { + let dir_path = Path::new(dir); + let entries = match std::fs::read_dir(dir_path) { + Ok(e) => e, + Err(e) => { + errors.push(format!("读取目录 {} 失败: {}", dir, e)); + return; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let skill_md = path.join("SKILL.md"); + if !skill_md.exists() { + continue; + } + + match parse_skill(&path) { + Ok(mut skill) => { + skill.source = source.clone(); + let name = skill.spec.name.clone(); + + // user 同名覆盖 system + if source == SkillSource::User && self.skills.contains_key(&name) { + tracing::info!("用户技能覆盖系统技能: {}", name); + } + self.skills.insert(name, Arc::new(skill)); + } + Err(e) => { + errors.push(format!("{}: {}", path.display(), e)); + } + } + } + } + + /// 列出所有技能 specs(给 LLM function calling) + pub fn list_specs(&self) -> Vec<&SkillSpec> { + let mut specs: Vec<&SkillSpec> = self.skills.values().map(|s| &s.spec).collect(); + specs.sort_by(|a, b| a.name.cmp(&b.name)); + specs + } + + /// 按风险等级过滤 + pub fn list_specs_by_risk(&self, level: RiskLevel) -> Vec<&SkillSpec> { + self.skills + .values() + .filter(|s| s.spec.risk_level == level) + .map(|s| &s.spec) + .collect() + } + + /// 按名称查找技能 + pub fn get(&self, name: &str) -> Option<&Skill> { + self.skills.get(name).map(|s| s.as_ref()) + } + + /// 获取技能数量 + pub fn count(&self) -> usize { + self.skills.len() + } + + // ─── 执行 ─── + + /// 执行技能(含审批流程) + pub async fn execute( + &self, + name: &str, + params: serde_json::Value, + ctx: &ExecutionContext, + ) -> SkillResult { + let skill = match self.skills.get(name) { + Some(s) => s.clone(), + None => { + return SkillResult::error(format!("技能不存在: {}", name)); + } + }; + + // High 风险 → 审批流程(透明模式) + if skill.spec.risk_level.is_high() { + match approval_flow(&skill, ctx).await { + ApprovalResult::Approved => { + // 继续执行 + } + ApprovalResult::Rejected => { + return SkillResult::rejected(name); + } + ApprovalResult::Expired => { + return SkillResult::expired(name); + } + ApprovalResult::Error(msg) => { + return SkillResult::error(msg); + } + } + } + + // 执行技能 + run_skill(&skill, params, ctx).await + } +} + +// ─── 审批流程 ─── + +enum ApprovalResult { + Approved, + Rejected, + Expired, + Error(String), +} + +async fn approval_flow(skill: &Skill, ctx: &ExecutionContext) -> ApprovalResult { + let manager = match ctx.approval_manager.as_ref() { + Some(m) => m.clone(), + None => return ApprovalResult::Approved, // 测试模式 + }; + + // 创建审批请求(生成确认码 + 注册通道) + let (code, rx) = match manager.create(&ctx.user_id, &skill.spec.name).await { + Ok(pair) => pair, + Err(e) => return ApprovalResult::Error(e), + }; + tracing::debug!("审批确认码已生成: {}...", &code[..2]); + + // 发送确认消息给用户 + let msg = format!( + "⚠️ 操作确认\n\n技能:{}\n\n确认码:{}\n\n回复确认码以继续\n回复 0 或「取消」以取消\n(5分钟内有效,最多3次尝试)", + skill.spec.name, code + ); + if let Some(ref send) = ctx.send_wechat { + if let Err(e) = send(&ctx.user_id, &msg).await { + return ApprovalResult::Error(format!("发送确认消息失败: {}", e)); + } + } + + // 等待用户确认(通过 oneshot channel,由消息循环驱动) + match tokio::time::timeout(Duration::from_secs(300), rx).await { + Ok(Ok(ApprovalDecision::Approved)) => ApprovalResult::Approved, + Ok(Ok(ApprovalDecision::Rejected)) => ApprovalResult::Rejected, + Ok(Ok(ApprovalDecision::Expired)) => ApprovalResult::Expired, + Ok(Err(_)) => ApprovalResult::Error("审批通道异常".to_string()), + Err(_) => ApprovalResult::Expired, + } +} + +// ─── 技能执行器 ─── + +async fn run_skill(skill: &Skill, params: serde_json::Value, _ctx: &ExecutionContext) -> SkillResult { + let timeout = Duration::from_secs(skill.spec.timeout_secs); + + let cmd_str = &skill.execute_command; + let parts: Vec<&str> = cmd_str.split_whitespace().collect(); + if parts.is_empty() { + return SkillResult::error("执行命令为空"); + } + + let mut cmd = Command::new(parts[0]); + for arg in &parts[1..] { + cmd.arg(arg); + } + cmd.current_dir(&skill.dir); + tracing::debug!( + "执行命令: {:?} (cwd={:?})", + &cmd_str, &skill.dir + ); + + // 通过 stdin 传入参数 + let params_json = serde_json::to_string(¶ms).unwrap_or_default(); + cmd.stdin(std::process::Stdio::piped()); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => return SkillResult::error(format!("启动进程失败: {}", e)), + }; + + // 写入参数到 stdin + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + let _ = stdin.write_all(params_json.as_bytes()).await; + // 关闭 stdin 让进程知道输入结束 + drop(stdin); + } + + // 等待完成(带超时) + let result = tokio::time::timeout(timeout, child.wait_with_output()).await; + + match result { + Ok(Ok(output)) => { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let exit_code = output.status.code().unwrap_or(-1); + + tracing::debug!( + "🖥️ {} exit={} out={:.200} err={:.100}", + skill.spec.name, exit_code, + if stdout.is_empty() { "(空)" } else { &stdout }, + if stderr.is_empty() { "(空)" } else { &stderr } + ); + + if output.status.success() { + // 尝试解析为 JSON + if let Ok(json) = serde_json::from_str::(&stdout) { + SkillResult::ok_with_data(stdout, json) + } else { + SkillResult::ok(stdout) + } + } else { + let msg = if !stderr.is_empty() { + format!("退出码 {}: {}", output.status.code().unwrap_or(-1), stderr) + } else { + format!("退出码 {}", output.status.code().unwrap_or(-1)) + }; + SkillResult::error(msg) + } + } + Ok(Err(e)) => SkillResult::error(format!("进程错误: {}", e)), + Err(_) => SkillResult::error(format!("执行超时 ({}s)", skill.spec.timeout_secs)), + } +} + +// ─── 执行上下文 ─── + +/// 发送微信消息的回调: (user_id, text) -> Result(异步) +pub type WechatSender = + Arc Pin> + Send>> + Send + Sync>; + +pub struct ExecutionContext { + pub user_id: String, + pub db: Option>, + /// 审批管理器 + pub approval_manager: Option>, + /// 发送微信消息的回调 + pub send_wechat: Option, +} + +impl ExecutionContext { + pub fn new(user_id: impl Into) -> Self { + Self { + user_id: user_id.into(), + db: None, + approval_manager: None, + send_wechat: None, + } + } + + pub fn with_db(mut self, db: Arc) -> Self { + self.db = Some(db); + self + } + + pub fn with_approval(mut self, mgr: Arc) -> Self { + self.approval_manager = Some(mgr); + self + } + + pub fn with_wechat(mut self, send: WechatSender) -> Self { + self.send_wechat = Some(send); + self + } +} diff --git a/src/tools/skill.rs b/src/tools/skill.rs new file mode 100644 index 0000000..63ac389 --- /dev/null +++ b/src/tools/skill.rs @@ -0,0 +1,156 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +// ─── 风险等级 ─── + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RiskLevel { + Low, // 只读/安全,自动执行 + High, // 写操作/有风险,需微信确认码审批 +} + +impl RiskLevel { + pub fn is_high(&self) -> bool { + matches!(self, RiskLevel::High) + } +} + +// ─── 技能来源 ─── + +#[derive(Debug, Clone, PartialEq)] +pub enum SkillSource { + System, // resources/skills/ + User, // skills/ +} + +impl SkillSource { + pub fn label(&self) -> &str { + match self { + SkillSource::System => "系统", + SkillSource::User => "用户", + } + } +} + +// ─── 技能元数据(从 SKILL.md 解析) ─── + +#[derive(Debug, Clone, Deserialize)] +pub struct SkillSpec { + pub name: String, + pub description: String, + #[serde(default = "default_risk_level")] + pub risk_level: RiskLevel, + #[serde(default)] + pub parameters: serde_json::Value, + #[serde(default = "default_timeout")] + pub timeout_secs: u64, +} + +fn default_risk_level() -> RiskLevel { + RiskLevel::Low +} + +fn default_timeout() -> u64 { + 30 +} + +// ─── 技能(加载到内存后) ─── + +#[derive(Debug, Clone)] +pub struct Skill { + pub spec: SkillSpec, + pub source: SkillSource, + pub dir: PathBuf, + pub execute_command: String, +} + +impl Skill { + pub fn new(spec: SkillSpec, source: SkillSource, dir: PathBuf, execute_command: String) -> Self { + Self { + spec, + source, + dir, + execute_command, + } + } +} + +// ─── 执行上下文 ─── + +pub type WechatSender = Arc Result<(), String> + Send + Sync>; +pub type ReplyWaiter = Arc Result, String> + Send + Sync>; + +/// LLM 能看到的结果 +#[derive(Debug, Clone, Serialize)] +pub struct SkillResult { + pub success: bool, + pub output: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl SkillResult { + pub fn ok(output: impl Into) -> Self { + Self { + success: true, + output: output.into(), + data: None, + } + } + + pub fn ok_with_data(output: impl Into, data: serde_json::Value) -> Self { + Self { + success: true, + output: output.into(), + data: Some(data), + } + } + + pub fn rejected(skill_name: &str) -> Self { + Self { + success: false, + output: format!("用户拒绝了你的调用:{}", skill_name), + data: None, + } + } + + pub fn expired(skill_name: &str) -> Self { + Self { + success: false, + output: format!("用户没有确认操作:{}", skill_name), + data: None, + } + } + + pub fn error(msg: impl Into) -> Self { + Self { + success: false, + output: msg.into(), + data: None, + } + } +} + +// ─── 错误 ─── + +#[derive(Debug)] +pub enum SkillError { + NotFound(String), + Execution(String), + Timeout(String), + InvalidParams(String), +} + +impl std::fmt::Display for SkillError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SkillError::NotFound(name) => write!(f, "技能不存在: {}", name), + SkillError::Execution(msg) => write!(f, "执行失败: {}", msg), + SkillError::Timeout(name) => write!(f, "执行超时: {}", name), + SkillError::InvalidParams(msg) => write!(f, "参数无效: {}", msg), + } + } +} diff --git a/src/wechat/client.rs b/src/wechat/client.rs new file mode 100644 index 0000000..a55e034 --- /dev/null +++ b/src/wechat/client.rs @@ -0,0 +1,436 @@ +use crate::wechat::types::*; +use base64::Engine; +use reqwest::Client as HttpClient; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use tracing::{debug, info, warn}; + +/// 微信 iLink Bot API 客户端 +#[derive(Clone)] +pub struct WeChatClient { + http: HttpClient, + base_url: String, + token: Arc>, + account_id: Arc>, + /// get_updates_buf,用于长轮询的上下文缓冲 + updates_buf: Arc>, +} + +impl WeChatClient { + /// iLink App ID(从 package.json 的 ilink_appid 字段,硬编码或环境变量) + const ILINK_APP_ID: &'static str = ""; + /// 默认 bot_type + const DEFAULT_BOT_TYPE: &'static str = "3"; + /// 默认 API 地址 + const DEFAULT_BASE_URL: &'static str = "https://ilinkai.weixin.qq.com"; + /// 二维码固定 API 地址 + const FIXED_BASE_URL: &'static str = "https://ilinkai.weixin.qq.com"; + /// 版本号编码:0x00MMNNPP + const CLIENT_VERSION: u32 = 0x000100_00; // 1.0.0 + + pub fn new(base_url: Option) -> Self { + let base = base_url.unwrap_or_else(|| Self::DEFAULT_BASE_URL.to_string()); + + Self { + http: HttpClient::builder() + .timeout(Duration::from_secs(60)) + .build() + .expect("创建 HTTP client 失败"), + base_url: base, + token: Arc::new(Mutex::new(String::new())), + account_id: Arc::new(Mutex::new(String::new())), + updates_buf: Arc::new(Mutex::new(String::new())), + } + } + + // ─── 公共方法 ─── + + /// 获取登录二维码链接 + pub async fn get_qrcode(&self) -> Result<(String, String), String> { + let url = format!( + "{}/ilink/bot/get_bot_qrcode?bot_type={}", + Self::FIXED_BASE_URL, + Self::DEFAULT_BOT_TYPE + ); + + let resp: QRCodeResponse = self + .http + .get(&url) + .headers(Self::common_headers()) + .send() + .await + .map_err(|e| format!("请求二维码失败: {}", e))? + .json() + .await + .map_err(|e| format!("解析二维码响应失败: {}", e))?; + + Ok((resp.qrcode, resp.qrcode_img_content)) + } + + /// 轮询扫码状态 + pub async fn poll_qr_status( + &self, + qrcode: &str, + base_url: &str, + ) -> Result { + let url = format!( + "{}/ilink/bot/get_qrcode_status?qrcode={}", + base_url, qrcode + ); + + let resp_result = self + .http + .get(&url) + .headers(Self::common_headers()) + .timeout(Duration::from_secs(35)) + .send() + .await; + + match resp_result { + Ok(response) => { + let text = response.text().await + .map_err(|e| format!("读取响应体失败: {}", e))?; + serde_json::from_str::(&text) + .map_err(|e| format!("解析状态响应失败: {}", e)) + } + Err(e) if e.is_timeout() => { + // 超时是正常的,返回 wait 让调用者继续轮询 + Ok(StatusResponse { + status: "wait".to_string(), + bot_token: None, + ilink_bot_id: None, + baseurl: None, + ilink_user_id: None, + redirect_host: None, + }) + } + Err(e) => { + warn!("轮询二维码状态网络错误: {},继续等待", e); + Ok(StatusResponse { + status: "wait".to_string(), + bot_token: None, + ilink_bot_id: None, + baseurl: None, + ilink_user_id: None, + redirect_host: None, + }) + } + } + } + + /// 执行完整扫码登录流程 + pub async fn login( + &self, + on_qrcode: impl Fn(&str), + timeout_secs: u64, + ) -> Result { + info!("开始微信扫码登录..."); + + let (qrcode, qrcode_img_content) = self.get_qrcode().await?; + + // 显示二维码链接 + on_qrcode(&qrcode_img_content); + + // 生成二维码终端显示 + if let Ok(code) = qrcode::QrCode::new(qrcode_img_content.as_bytes()) { + let svg = code.render::() + .dark_color(qrcode::render::unicode::Dense1x2::Dark) + .light_color(qrcode::render::unicode::Dense1x2::Light) + .build(); + println!("\n{}", svg); + } + + println!("请使用微信扫描上方二维码登录"); + println!("二维码链接(如二维码无法显示):{}", qrcode_img_content); + println!(); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); + let mut scanned = false; + let mut current_base = Self::FIXED_BASE_URL.to_string(); + + while tokio::time::Instant::now() < deadline { + let status = self.poll_qr_status(&qrcode, ¤t_base).await?; + + match status.status.as_str() { + "wait" => { + print!("."); + std::io::Write::flush(&mut std::io::stdout()).ok(); + } + "scaned" => { + if !scanned { + println!("\n👀 已扫码,请在微信上确认登录..."); + scanned = true; + } + } + "scaned_but_redirect" => { + if let Some(host) = &status.redirect_host { + current_base = format!("https://{}", host); + info!("IDC 重定向到: {}", current_base); + } + } + "confirmed" => { + let token = status + .bot_token + .ok_or_else(|| "登录成功但未返回 bot_token".to_string())?; + let account_id = status + .ilink_bot_id + .ok_or_else(|| "登录成功但未返回 ilink_bot_id".to_string())?; + let user_id = status.ilink_user_id.unwrap_or_default(); + let base_url = status.baseurl.unwrap_or_else(|| Self::DEFAULT_BASE_URL.to_string()); + + // 保存到内存状态 + *self.token.lock().await = token.clone(); + *self.account_id.lock().await = account_id.clone(); + + info!("✅ 登录成功! bot_id={}", account_id); + return Ok(LoginResult { + token, + account_id, + base_url, + user_id, + }); + } + "expired" => { + return Err("二维码已过期".to_string()); + } + _ => { + warn!("未知状态: {}", status.status); + } + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } + + Err("登录超时".to_string()) + } + + /// 注册监听器(通知微信服务端本客户端已启动) + pub async fn notify_start(&self) -> Result<(), String> { + let body = serde_json::json!({ "base_info": BaseInfo::new() }); + + let url = format!("{}/ilink/bot/msg/notifystart", self.base_url); + let token = self.token.lock().await.clone(); + + let _resp: NotifyResp = self + .post_json(&url, &body, Some(&token)) + .await? + .json() + .await + .map_err(|e| format!("解析 notifyStart 响应失败: {}", e))?; + + info!("监听器已注册"); + Ok(()) + } + + /// 注销监听器 + pub async fn notify_stop(&self) -> Result<(), String> { + let body = serde_json::json!({ "base_info": BaseInfo::new() }); + + let url = format!("{}/ilink/bot/msg/notifystop", self.base_url); + let token = self.token.lock().await.clone(); + + let _resp: NotifyResp = self + .post_json(&url, &body, Some(&token)) + .await? + .json() + .await + .map_err(|e| format!("解析 notifyStop 响应失败: {}", e))?; + + info!("监听器已注销"); + Ok(()) + } + + /// 长轮询接收消息 + pub async fn receive_messages(&self) -> Result { + let buf = self.updates_buf.lock().await.clone(); + + let body = GetUpdatesReq { + get_updates_buf: buf.clone(), + base_info: BaseInfo::new(), + }; + + let url = format!("{}/ilink/bot/getupdates", self.base_url); + let token = self.token.lock().await.clone(); + + let resp: GetUpdatesResp = match self + .post_json(&url, &body, Some(&token)) + .await + { + Ok(resp) => resp.json().await.map_err(|e| format!("解析 getUpdates 响应失败: {}", e))?, + Err(e) => { + // 如果超时,返回空响应 + if e.contains("超时") || e.contains("timeout") { + return Ok(GetUpdatesResp { + ret: 0, + errcode: None, + errmsg: None, + msgs: vec![], + get_updates_buf: buf.clone(), + longpolling_timeout_ms: None, + }); + } + return Err(e); + } + }; + + // 更新 buf + if !resp.get_updates_buf.is_empty() { + *self.updates_buf.lock().await = resp.get_updates_buf.clone(); + } + + Ok(resp) + } + + /// 发送文本消息 + pub async fn send_text( + &self, + to: &str, + text: &str, + context_token: Option<&str>, + ) -> Result { + let client_id = uuid::Uuid::new_v4().to_string(); + + let msg = WeixinMessage { + from_user_id: Some(String::new()), + to_user_id: Some(to.to_string()), + client_id: Some(client_id.clone()), + msg_type: Some(WeixinMessage::TYPE_BOT), + message_state: Some(2), // FINISH + item_list: Some(vec![MessageItem::text(text)]), + context_token: context_token.map(|s| s.to_string()), + ..Default::default() + }; + + let body = SendMessageReq { msg }; + + let url = format!("{}/ilink/bot/sendmessage", self.base_url); + let token = self.token.lock().await.clone(); + + let _resp_text = self + .post_json(&url, &body, Some(&token)) + .await? + .text() + .await + .map_err(|e| format!("发送消息网络错误: {}", e))?; + + Ok(client_id) + } + + /// 设置 auth 状态(从持久化恢复) + pub async fn set_auth(&self, token: &str, account_id: &str, base_url: &str) { + *self.token.lock().await = token.to_string(); + *self.account_id.lock().await = account_id.to_string(); + if !base_url.is_empty() { + // 只更新 base_url 如果传入了非空值 + } + } + + /// 获取当前 token(用于持久化) + pub async fn get_token(&self) -> String { + self.token.lock().await.clone() + } + + /// 获取当前 account_id(用于持久化) + pub async fn get_account_id(&self) -> String { + self.account_id.lock().await.clone() + } + + /// 获取当前 updates_buf(用于持久化) + pub async fn get_updates_buf(&self) -> String { + self.updates_buf.lock().await.clone() + } + + /// 设置 get_updates_buf(从持久化恢复) + pub async fn set_updates_buf(&self, buf: &str) { + *self.updates_buf.lock().await = buf.to_string(); + } + + // ─── 内部方法 ─── + + fn common_headers() -> reqwest::header::HeaderMap { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + "Content-Type", + reqwest::header::HeaderValue::from_static("application/json"), + ); + + if !Self::ILINK_APP_ID.is_empty() { + headers.insert( + "iLink-App-Id", + reqwest::header::HeaderValue::from_static(Self::ILINK_APP_ID), + ); + } + + headers.insert( + "iLink-App-ClientVersion", + reqwest::header::HeaderValue::from_str(&Self::CLIENT_VERSION.to_string()) + .unwrap(), + ); + + // X-WECHAT-UIN: random uint32 -> decimal -> base64 + let uin = rand::random::(); + let uin_b64 = base64::engine::general_purpose::STANDARD + .encode(uin.to_string()); + headers.insert( + "X-WECHAT-UIN", + reqwest::header::HeaderValue::from_str(&uin_b64).unwrap(), + ); + + headers + } + + fn auth_headers(token: &str) -> reqwest::header::HeaderMap { + let mut headers = Self::common_headers(); + if !token.is_empty() { + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token)) + .unwrap(), + ); + headers.insert( + "AuthorizationType", + reqwest::header::HeaderValue::from_static("ilink_bot_token"), + ); + } + headers + } + + async fn post_json( + &self, + url: &str, + body: &impl serde::Serialize, + token: Option<&str>, + ) -> Result { + let body_str = serde_json::to_string(body).map_err(|e| format!("序列化请求体失败: {}", e))?; + + debug!("POST {} body_len={}", url, body_str.len()); + + let mut headers = if let Some(t) = token { + Self::auth_headers(t) + } else { + Self::common_headers() + }; + + // 设置 Content-Length + headers.insert( + "Content-Length", + reqwest::header::HeaderValue::from_str(&body_str.len().to_string()) + .unwrap(), + ); + + self.http + .post(url) + .headers(headers) + .body(body_str) + .send() + .await + .map_err(|e| { + if e.is_timeout() { + format!("请求超时: {}", url) + } else { + format!("请求失败 ({}): {}", url, e) + } + }) + } +} diff --git a/src/wechat/mod.rs b/src/wechat/mod.rs new file mode 100644 index 0000000..9251ae9 --- /dev/null +++ b/src/wechat/mod.rs @@ -0,0 +1,2 @@ +pub mod client; +pub mod types; diff --git a/src/wechat/types.rs b/src/wechat/types.rs new file mode 100644 index 0000000..faf5df7 --- /dev/null +++ b/src/wechat/types.rs @@ -0,0 +1,333 @@ +use serde::{Deserialize, Serialize}; + +// ─── 基础类型 ─── + +/// 公共请求元数据 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct BaseInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_version: Option, +} + +impl BaseInfo { + pub fn new() -> Self { + Self { + channel_version: Some(env!("CARGO_PKG_VERSION").to_string()), + } + } +} + +// ─── 消息类型 ─── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TextItem { + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CDNMedia { + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypt_query_param: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aes_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypt_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub full_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageItem { + #[serde(skip_serializing_if = "Option::is_none")] + pub media: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumb_media: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aeskey: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mid_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumb_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumb_height: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumb_width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hd_size: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VoiceItem { + #[serde(skip_serializing_if = "Option::is_none")] + pub media: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub encode_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bits_per_sample: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sample_rate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub playtime: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileItem { + #[serde(skip_serializing_if = "Option::is_none")] + pub media: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub file_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub md5: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub len: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VideoItem { + #[serde(skip_serializing_if = "Option::is_none")] + pub media: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub video_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub play_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub video_md5: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumb_media: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumb_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumb_height: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumb_width: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefMessage { + #[serde(skip_serializing_if = "Option::is_none")] + pub message_item: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +/// 消息条目(支持 text/image/voice/file/video) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageItem { + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub item_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub create_time_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub update_time_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_completed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub msg_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ref_msg: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub text_item: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_item: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub voice_item: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub file_item: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub video_item: Option, +} + +impl MessageItem { + pub const TYPE_TEXT: i32 = 1; + pub const TYPE_IMAGE: i32 = 2; + pub const TYPE_VOICE: i32 = 3; + pub const TYPE_FILE: i32 = 4; + pub const TYPE_VIDEO: i32 = 5; + + pub fn text(text: &str) -> Self { + Self { + item_type: Some(Self::TYPE_TEXT), + text_item: Some(TextItem { + text: Some(text.to_string()), + }), + ..Default::default() + } + } +} + +impl Default for MessageItem { + fn default() -> Self { + Self { + item_type: None, + create_time_ms: None, + update_time_ms: None, + is_completed: None, + msg_id: None, + ref_msg: None, + text_item: None, + image_item: None, + voice_item: None, + file_item: None, + video_item: None, + } + } +} + +// ─── 微信消息 ─── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WeixinMessage { + #[serde(skip_serializing_if = "Option::is_none")] + pub seq: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_user_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to_user_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub create_time_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub update_time_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delete_time_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub group_id: Option, + #[serde(rename = "message_type", skip_serializing_if = "Option::is_none")] + pub msg_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message_state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub item_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_token: Option, +} + +impl Default for WeixinMessage { + fn default() -> Self { + Self { + seq: None, + message_id: None, + from_user_id: None, + to_user_id: None, + client_id: None, + create_time_ms: None, + update_time_ms: None, + delete_time_ms: None, + session_id: None, + group_id: None, + msg_type: None, + message_state: None, + item_list: None, + context_token: None, + } + } +} + +impl WeixinMessage { + pub const TYPE_NONE: i32 = 0; + pub const TYPE_USER: i32 = 1; + pub const TYPE_BOT: i32 = 2; + + /// 获取文本内容(如果有纯文本 item) + pub fn text_content(&self) -> Option<&str> { + self.item_list + .as_ref()? + .iter() + .find(|item| item.item_type == Some(MessageItem::TYPE_TEXT)) + .and_then(|item| item.text_item.as_ref()) + .and_then(|t| t.text.as_deref()) + } +} + +// ─── API 请求/响应 ─── + +/// 获取二维码响应 +#[derive(Debug, Deserialize)] +pub struct QRCodeResponse { + pub qrcode: String, + pub qrcode_img_content: String, +} + +/// 扫码状态响应 +#[derive(Debug, Deserialize)] +pub struct StatusResponse { + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub bot_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ilink_bot_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub baseurl: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ilink_user_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub redirect_host: Option, +} + +/// GetUpdates 请求 +#[derive(Debug, Serialize)] +pub struct GetUpdatesReq { + pub get_updates_buf: String, + pub base_info: BaseInfo, +} + +/// GetUpdates 响应 +#[derive(Debug, Deserialize)] +pub struct GetUpdatesResp { + #[serde(default)] + pub ret: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub errcode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub errmsg: Option, + #[serde(default)] + pub msgs: Vec, + #[serde(default)] + pub get_updates_buf: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub longpolling_timeout_ms: Option, +} + +/// SendMessage 请求 +#[derive(Debug, Serialize)] +pub struct SendMessageReq { + pub msg: WeixinMessage, +} + +/// NotifyStart/NotifyStop 响应 +#[derive(Debug, Deserialize)] +pub struct NotifyResp { + #[serde(default)] + pub ret: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub errmsg: Option, +} + +/// 登录结果 +#[derive(Debug, Clone)] +pub struct LoginResult { + pub token: String, + pub account_id: String, + pub base_url: String, + pub user_id: String, +} + +/// 收到的消息事件 +#[derive(Debug, Clone)] +pub struct MessageEvent { + pub from_user_id: String, + pub text: String, + pub context_token: Option, + pub message_id: i64, +}