diff --git a/Cargo.lock b/Cargo.lock index 5c566ad..a58c3a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,6 +138,18 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -393,6 +405,23 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -3190,12 +3219,17 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ + "async-compression", "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", diff --git a/Cargo.toml b/Cargo.toml index d99d1fa..b3bf394 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" # 异步运行时 tokio = { version = "1.0", features = ["full"] } # HTTP 客户端 -reqwest = { version = "0.12", features = ["json", "stream"] } +reqwest = { version = "0.12", features = ["json", "stream", "gzip"] } # 序列化 serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/resources/skills/email/SKILL.md b/resources/skills/email/SKILL.md deleted file mode 100644 index 5b366b3..0000000 --- a/resources/skills/email/SKILL.md +++ /dev/null @@ -1,31 +0,0 @@ -# email - -收发邮件(IMAP)。 - -## Risk Level -Low - -## 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 deleted file mode 100755 index d57bca9..0000000 --- a/resources/skills/email/scripts/email.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/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 deleted file mode 100644 index 8da32e0..0000000 --- a/resources/skills/execute_shell/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ -# 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 deleted file mode 100755 index 0214770..0000000 --- a/resources/skills/execute_shell/scripts/shell.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/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 deleted file mode 100644 index acfdc7e..0000000 --- a/resources/skills/get_current_datetime/SKILL.md +++ /dev/null @@ -1,8 +0,0 @@ -# get_current_datetime -获取当前日期时间(UTC+8)。内置 Rust 实现,无脚本依赖。 -## Risk Level -Low -## Execute -```bash -# Builtin: Rust -``` diff --git a/resources/skills/list_scheduled_tasks/SKILL.md b/resources/skills/list_scheduled_tasks/SKILL.md deleted file mode 100644 index 0c1566b..0000000 --- a/resources/skills/list_scheduled_tasks/SKILL.md +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100755 index 6075f42..0000000 --- a/resources/skills/list_scheduled_tasks/scripts/list.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash -# 列出定时任务 → JSON 输出 -DB_URL="${DATABASE_URL}" -[ -z "$DB_URL" ] && echo '{"tasks":[],"error":"数据库未配置"}' && exit 0 - -psql "$DB_URL" -t -A -F '|' -c " - SELECT jsonb_build_object( - 'name', name, - 'description', description, - 'enabled', enabled, - 'command', command, - 'interval_seconds', interval_seconds, - 'next_run_at', next_run_at - ) - FROM scheduled_tasks ORDER BY name -" 2>/dev/null | python3 -c " -import sys, json -tasks = [] -for line in sys.stdin: - line = line.strip() - if line: - try: tasks.append(json.loads(line)) - except: pass -print(json.dumps({'tasks': tasks}, ensure_ascii=False)) -" -[ -z "$(psql "$DB_URL" -t -A -c "SELECT count(*) FROM scheduled_tasks" 2>/dev/null)" ] && echo '{"tasks":[]}' diff --git a/resources/skills/manage_memos/SKILL.md b/resources/skills/manage_memos/SKILL.md deleted file mode 100644 index d7ae6ce..0000000 --- a/resources/skills/manage_memos/SKILL.md +++ /dev/null @@ -1,8 +0,0 @@ -# manage_memos -管理备忘录(添加/列出/删除)。内置 Rust 实现,无脚本依赖。 -## Risk Level -High -## Execute -```bash -# Builtin: Rust -``` diff --git a/resources/skills/manage_scheduled_task/SKILL.md b/resources/skills/manage_scheduled_task/SKILL.md deleted file mode 100644 index 0e4efb9..0000000 --- a/resources/skills/manage_scheduled_task/SKILL.md +++ /dev/null @@ -1,42 +0,0 @@ -# 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 deleted file mode 100755 index 12f1aba..0000000 --- a/resources/skills/manage_scheduled_task/scripts/manage.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash -# 管理定时任务 → JSON 输出(参数化查询) -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}" -[ -z "$DB_URL" ] && echo '{"error":"数据库未配置"}' && exit 0 -[ -z "$NAME" ] && echo '{"error":"请提供 name 参数"}' && exit 0 - -case "$ACTION" in -add) - [ -z "$CMD" ] && echo '{"error":"add 需要 command 参数"}' && exit 0 - psql "$DB_URL" -v name="$NAME" -v desc="$DESC" -v cmd="$CMD" -v interval="$INTERVAL" << 'SQL' - INSERT INTO scheduled_tasks (id, name, description, command, interval_seconds, enabled, user_id, 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 -SQL - if [ $? -eq 0 ]; then - echo '{"ok":true,"action":"add","name":"'"$NAME"'"}' - else - echo '{"error":"添加失败"}' - fi - ;; -delete) - psql "$DB_URL" -v name="$NAME" -c "DELETE FROM scheduled_tasks WHERE name = :'name'" 2>/dev/null && \ - echo '{"ok":true,"action":"delete","name":"'"$NAME"'"}' || \ - echo '{"error":"删除失败"}' - ;; -enable) - psql "$DB_URL" -v name="$NAME" -c "UPDATE scheduled_tasks SET enabled = true WHERE name = :'name'" 2>/dev/null && \ - echo '{"ok":true,"action":"enable","name":"'"$NAME"'"}' || \ - echo '{"error":"启用失败"}' - ;; -disable) - psql "$DB_URL" -v name="$NAME" -c "UPDATE scheduled_tasks SET enabled = false WHERE name = :'name'" 2>/dev/null && \ - echo '{"ok":true,"action":"disable","name":"'"$NAME"'"}' || \ - echo '{"error":"禁用失败"}' - ;; -*) - echo '{"error":"未知操作: '"$ACTION"'(支持: add, delete, enable, disable)"}' - exit 1 - ;; -esac diff --git a/resources/skills/manage_skill/SKILL.md b/resources/skills/manage_skill/SKILL.md deleted file mode 100644 index d6b9347..0000000 --- a/resources/skills/manage_skill/SKILL.md +++ /dev/null @@ -1,49 +0,0 @@ -# manage_skill - -管理 Skill:创建新的 Skill,列出所有已加载的 Skill,删除 Skill。 - -创建的 Skill 会持久化到 `skills/` 目录,重启后自动加载。 - -## Risk Level -High - -## Parameters -```json -{ - "type": "object", - "properties": { - "action": { - "type": "string", - "description": "操作类型", - "enum": ["create", "delete", "list"] - }, - "name": { - "type": "string", - "description": "Skill 名称(create/delete 时需要)" - }, - "description": { - "type": "string", - "description": "Skill 描述(create 时需要)" - }, - "command": { - "type": "string", - "description": "Skill 执行的命令(create 时需要)" - }, - "parameters": { - "type": "string", - "description": "参数 JSON Schema(create 时需要,JSON 字符串)" - }, - "risk_level": { - "type": "string", - "description": "风险等级 Low|High,默认 Low", - "enum": ["Low", "High"] - } - }, - "required": ["action"] -} -``` - -## Execute -```bash -scripts/manage.sh -``` diff --git a/resources/skills/manage_skill/scripts/manage.sh b/resources/skills/manage_skill/scripts/manage.sh deleted file mode 100755 index 7839559..0000000 --- a/resources/skills/manage_skill/scripts/manage.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/bin/bash -read -r INPUT -export MANAGE_INPUT="$INPUT" - -python3 - "skills" << 'PYEOF' -import sys, json, os, stat - -skills_dir = sys.argv[1] -input_raw = os.environ.get('MANAGE_INPUT', '{}') - -try: - req = json.loads(input_raw) if input_raw else json.loads(sys.stdin.read()) -except: - print("无效的 JSON 输入") - sys.exit(1) - -action = req.get('action', 'list') -name = req.get('name', '').strip() -desc = req.get('description', '') -command = req.get('command', '') -params = req.get('parameters', '{"type":"object","properties":{}}') -risk = req.get('risk_level', 'Low') - -os.makedirs(skills_dir, exist_ok=True) - -if action == 'list': - if not os.path.isdir(skills_dir): - print("skills 目录不存在") - sys.exit(0) - entries = [d for d in os.listdir(skills_dir) if os.path.isdir(os.path.join(skills_dir, d))] - if not entries: - print("暂无自定义 Skill") - else: - print("自定义 Skills:") - for d in sorted(entries): - md = os.path.join(skills_dir, d, "SKILL.md") - if os.path.exists(md): - first = open(md).readline().strip('# \n') - print(f" • {d} — {first}") - -elif action == 'create': - if not name: - print("请提供 name 参数") - sys.exit(1) - if not command: - print("请提供 command 参数") - sys.exit(1) - - skill_dir = os.path.join(skills_dir, name) - scripts_dir = os.path.join(skill_dir, "scripts") - os.makedirs(scripts_dir, exist_ok=True) - - # 解析参数 JSON(美化) - try: - params_obj = json.loads(params) if isinstance(params, str) else params - params_pretty = json.dumps(params_obj, indent=2, ensure_ascii=False) - except: - params_pretty = params - - # 写 SKILL.md - md_content = f"""# {name} - -{desc} - -## Risk Level -{risk} - -## Parameters -```json -{params_pretty} -``` - -## Execute -```bash -scripts/main.sh -``` -""" - with open(os.path.join(skill_dir, "SKILL.md"), "w") as f: - f.write(md_content.strip() + "\n") - - # 写执行脚本 - script = os.path.join(scripts_dir, "main.sh") - with open(script, "w") as f: - f.write(f"#!/bin/bash\n{command}\n") - os.chmod(script, 0o755) - - print(f"✅ 已创建 Skill: {name}") - print(f" 位置: {skill_dir}") - print(f" 风险: {risk}") - -elif action == 'delete': - if not name: - print("请提供 name 参数") - sys.exit(1) - skill_dir = os.path.join(skills_dir, name) - if not os.path.isdir(skill_dir): - print(f"Skill 不存在: {name}") - sys.exit(1) - import shutil - shutil.rmtree(skill_dir) - print(f"✅ 已删除 Skill: {name}") - -else: - print(f"未知操作: {action}(支持: create, delete, list)") - sys.exit(1) -PYEOF diff --git a/resources/skills/search_web/SKILL.md b/resources/skills/search_web/SKILL.md deleted file mode 100644 index 6383423..0000000 --- a/resources/skills/search_web/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ -# 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 deleted file mode 100755 index 1a72909..0000000 --- a/resources/skills/search_web/scripts/search.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash -# Tavily 搜索 → JSON 输出 -read -r input -QUERY=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('query',''))" 2>/dev/null) -COUNT=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('count',5))" 2>/dev/null) - -[ -z "$QUERY" ] && echo '{"error":"请提供 query 参数"}' && exit 0 - -API_KEY="${TAVILY_API_KEY}" -[ -z "$API_KEY" ] && echo '{"error":"请设置 TAVILY_API_KEY"}' && exit 0 - -export SEARCH_QUERY="$QUERY" SEARCH_COUNT="$COUNT" -RESP=$(python3 -c " -import urllib.request, json, os -key = os.environ['TAVILY_API_KEY'] -query = os.environ['SEARCH_QUERY'] -count = int(os.environ.get('SEARCH_COUNT', 5)) -body = json.dumps({'api_key': key, 'query': query, 'max_results': count, 'include_answer': True}).encode() -req = urllib.request.Request('https://api.tavily.com/search', data=body, headers={'Content-Type': 'application/json'}) -print(urllib.request.urlopen(req).read().decode()) -" 2>/dev/null) - -export RESP QUERY COUNT -python3 -c " -import os, json -d = json.loads(os.environ['RESP']) -result = { - 'query': os.environ['QUERY'], - 'answer': d.get('answer', ''), - 'results': [ - {'title': r.get('title', ''), 'url': r.get('url', ''), 'content': r.get('content', '')[:300]} - for r in d.get('results', [])[:int(os.environ['COUNT'])] - ] -} -print(json.dumps(result, ensure_ascii=False)) -" 2>/dev/null || echo '{"error":"搜索请求失败"}' diff --git a/src/cli.rs b/src/cli.rs index 61f58a6..9280560 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -99,30 +99,4 @@ pub enum Commands { /// ias service # 前台运行 /// sudo systemctl enable ias --now # systemd 管理 Service, - - /// Skills 管理 - /// - /// 从其他工具(Claude Code、OpenCode、pi 等)导入 Skills, - /// 或管理已导入的 Skills。 - /// - /// 示例: - /// ias skills list # 列出可导入的 Skill - /// ias skills import # 交互式多选导入 - /// ias skills delete # 交互式删除已导入的 Skill - Skills { - #[command(subcommand)] - action: SkillsAction, - }, -} - -#[derive(Subcommand, Debug)] -pub enum SkillsAction { - /// 扫描 ~/.claude/、~/.agents/、~/.opencode/、~/.pi/ 等路径, - /// 交互式多选导入 Skill 到当前项目的 skills/ 目录。 - Import, - /// 列出所有已知路径中可导入的 Skill(不导入)。 - List, - /// 扫描 skills/ 目录中的用户 Skill,交互式多选删除。 - /// 系统 Skill(resources/skills/)不会被删除。 - Delete, } diff --git a/src/llm/deepseek.rs b/src/llm/deepseek.rs index 7b8369c..e503ad0 100644 --- a/src/llm/deepseek.rs +++ b/src/llm/deepseek.rs @@ -1,4 +1,6 @@ -use crate::llm::provider::{parse_chat_chunk, LlmProvider, ParsedChunk, StreamReceiver, StreamSender}; +use crate::llm::provider::{ + LlmProvider, ParsedChunk, StreamReceiver, StreamSender, parse_chat_chunk, +}; use crate::llm::types::{ConversationConfig, Message, StreamChunk, ToolCall, Usage}; use async_trait::async_trait; use reqwest::Client as HttpClient; @@ -31,7 +33,9 @@ impl DeepSeekProvider { #[async_trait] impl LlmProvider for DeepSeekProvider { - fn name(&self) -> &str { "deepseek" } + fn name(&self) -> &str { + "deepseek" + } async fn chat_stream( &self, @@ -96,14 +100,17 @@ async fn stream_deepseek( 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; + 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(); + let mut tool_call_builders: BTreeMap, Option, String)> = + BTreeMap::new(); use futures_util::StreamExt; let mut buf = String::new(); @@ -118,7 +125,9 @@ async fn stream_deepseek( let line = buf[..line_end].to_string(); buf = buf[line_end + 1..].to_string(); let trimmed = line.trim(); - if trimmed.is_empty() { continue; } + if trimmed.is_empty() { + continue; + } if let Some(parsed) = parse_chat_chunk(trimmed) { match parsed { @@ -130,14 +139,28 @@ async fn stream_deepseek( 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); } + 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); } + ParsedChunk::Usage(u) => { + usage = Some(u); + } } } } @@ -148,13 +171,27 @@ async fn stream_deepseek( 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); } + 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); } + ParsedChunk::Usage(u) => { + usage = Some(u); + } _ => {} } } @@ -186,7 +223,11 @@ async fn stream_deepseek( let _ = tx .send(StreamChunk::Done { text: full_text, - reasoning: if full_reasoning.is_empty() { None } else { Some(full_reasoning) }, + reasoning: if full_reasoning.is_empty() { + None + } else { + Some(full_reasoning) + }, tool_calls, usage, }) diff --git a/src/llm/provider.rs b/src/llm/provider.rs index b088b01..5c1cd87 100644 --- a/src/llm/provider.rs +++ b/src/llm/provider.rs @@ -1,6 +1,6 @@ use crate::llm::types::{ConversationConfig, Message, StreamChunk}; -use serde::Deserialize; use async_trait::async_trait; +use serde::Deserialize; use std::sync::Arc; use tokio::sync::mpsc; @@ -33,29 +33,6 @@ pub fn create_provider(_config: &ConversationConfig) -> Result 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), @@ -71,9 +48,7 @@ pub(crate) enum ParsedChunk { } /// 从 JSON body 中解析 DeepSeek/OpenAI 流式 delta -pub(crate) fn parse_chat_chunk( - line: &str, -) -> Option { +pub(crate) fn parse_chat_chunk(line: &str) -> Option { if !line.starts_with("data: ") { return None; } @@ -144,7 +119,9 @@ pub(crate) fn parse_chat_chunk( 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() + 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()); diff --git a/src/main.rs b/src/main.rs index bb7fe16..ca6b3bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,19 +4,18 @@ mod db; mod llm; mod logger; mod scheduler; -mod skills_importer; mod state; mod tools; mod wechat; use clap::Parser; -use cli::{Cli, Commands, SkillsAction}; +use cli::{Cli, Commands}; use context::MemoryStore; use db::Database; use llm::{Conversation, ConversationConfig, DEFAULT_SYSTEM_PROMPT, ToolExecutor, Usage}; use std::sync::Arc; use tools::approval::{ApprovalDecision, ApprovalManager}; -use tools::registry::{ExecutionContext, SkillRegistry}; +use tools::skill::ExecutionContext; use tracing::{error, info}; use wechat::client::WeChatClient; @@ -87,12 +86,7 @@ async fn main() { } => { cmd_usage(&database, since, until, model).await; } - Commands::Service => { - cmd_listen(true, false, &database, &file_state, &memory_store).await - } - Commands::Skills { action } => { - cmd_skills(action).await; - } + Commands::Service => cmd_listen(true, false, &database, &file_state, &memory_store).await, } } @@ -196,24 +190,15 @@ async fn cmd_listen( return; } - // 加载系统 Skills - let (skill_registry, _warnings) = SkillRegistry::load("resources/skills", "skills").await; - let skill_registry = { - if !_warnings.is_empty() { - info!("技能加载警告: {:?}", _warnings); - } - info!("已加载 {} 个技能", skill_registry.count()); - Some(Arc::new(skill_registry)) - }; - let approval_manager = Arc::new(ApprovalManager::new( - database.as_ref().map(|db| Arc::new(db.pool().clone())) + database.as_ref().map(|db| 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 = std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| "deepseek-v4-flash".to_string()); + let model = + std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| "deepseek-v4-flash".to_string()); let mut cfg = ConversationConfig { system_prompt: sys_prompt, @@ -221,7 +206,7 @@ async fn cmd_listen( ..Default::default() }; - // 仅暴露两个元工具:查询能力 + 调用能力 + // 各项能力通过这两个元工具实现:查询能力 + 调用能力 // 具体工具描述通过 query_capabilities 返回 let mut tools_list: Vec = Vec::new(); @@ -240,12 +225,12 @@ async fn cmd_listen( "type": "function", "function": { "name": "call_capability", - "description": "按工具名称调用一个已注册的工具。name 为工具名(来自 query_capabilities),prompt 为自然语言需求", + "description": "调用一个已注册的工具。name 为工具名(来自 query_capabilities),工具所需的具体参数(如 location、days)应直接放在 JSON 中。", "parameters": { "type": "object", "properties": { "name": { "type": "string", "description": "工具名称" }, - "prompt": { "type": "string", "description": "自然语言需求" } + "prompt": { "type": "string", "description": "json格式参数,来自 query_capabilities 中的对应工具的描述。" } }, "required": ["name"] } @@ -306,10 +291,9 @@ async fn cmd_listen( // 构建工具执行器 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 send_wechat: tools::skill::WechatSender = { let client = client.clone(); Arc::new(move |user_id: &str, text: &str| { let client = client.clone(); @@ -326,7 +310,6 @@ async fn cmd_listen( }; 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(); @@ -338,36 +321,39 @@ async fn cmd_listen( let user_id = session.lock().await.current_user_id.clone(); // 上下文工具直接处理 - if n == "read_memories" { return Ok(memory_store.read_for(&user_id).await); } + if n == "read_memories" { + return Ok(memory_store.read_for(&user_id).await); + } if n == "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()); } + if content.is_empty() { + return Ok("请提供 content 参数".to_string()); + } return Ok(memory_store.write_for(&user_id, content).await); } - if n == "read_summaries" { return Ok(context::tools::read_summaries(&session).await); } + if n == "read_summaries" { + return Ok(context::tools::read_summaries(&session).await); + } - // query_capabilities: 合并内置 + 外部(去重) + // query_capabilities: 列出所有内置工具 if n == "query_capabilities" { - let spec_list = if let Some(ref r) = reg { - let mut s: Vec = r.list_specs().into_iter().cloned().collect(); - for builtin in tools::builtin::BuiltinRegistry::specs() { - s.retain(|x| x.name != builtin.name); - s.push(builtin); - } - s - } else { - tools::builtin::BuiltinRegistry::specs() - }; - return Ok(build_capability_list(&spec_list)); + let specs = tools::builtin::BuiltinRegistry::specs(); + return Ok(build_capability_list(&specs)); } // 确定目标工具名和参数(call_capability 提取 name + 透传完整参数) let (target_name, target_args) = if n == "call_capability" { let cp: serde_json::Value = serde_json::from_str(&args).unwrap_or_default(); - let name = cp.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_default(); + let name = cp + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_default(); (name, serde_json::to_string(&cp).unwrap_or_default()) - } else { (n.clone(), args.clone()) }; + } else { + (n.clone(), args.clone()) + }; // 内置工具 if tools::builtin::BuiltinRegistry::is_builtin(&target_name) { @@ -377,25 +363,18 @@ async fn cmd_listen( ctx = ctx.with_wechat(send.clone()); let approved = builtin_approve(&ctx, &target_name).await; match approved { - Ok(true) => {}, + Ok(true) => {} Ok(false) => return Ok(SkillResult::rejected(&target_name).output), Err(e) => return Ok(e), } } - if let Some(result) = tools::builtin::BuiltinRegistry::execute(&target_name, &target_args).await { + if let Some(result) = + tools::builtin::BuiltinRegistry::execute(&target_name, &target_args).await + { return Ok(result.output); } } - // 回退到外部 SkillRegistry - if let Some(ref r) = reg { - let params: serde_json::Value = serde_json::from_str(&target_args).unwrap_or(serde_json::json!({})); - let mut ctx = ExecutionContext::new(&user_id); - ctx = ctx.with_approval(approval.clone()); - ctx = ctx.with_wechat(send); - let result = r.execute(&target_name, params, &ctx).await; - return Ok(result.output); - } Ok(format!("未知工具: {}", target_name)) }) }); @@ -533,7 +512,8 @@ async fn listen_loop( } // 审批回复检查 - let is_approval_reply = approval_manager.handle_reply(from, text).await.is_some(); + let is_approval_reply = + approval_manager.handle_reply(from, text).await.is_some(); if is_approval_reply { info!("消息匹配审批确认码,不传给 LLM"); @@ -579,7 +559,11 @@ async fn listen_loop( } else { generate_reply(&conv, text_owned, &database).await }; - let reply = reply_result.ok().unwrap_or_default(); + let reply = match reply_result { + Ok(r) if !r.trim().is_empty() => r, + Ok(_) => "抱歉,生成回复为空,请重试。".to_string(), + Err(e) => format!("处理消息时出错:{:.200}", e), + }; match client .send_text(&from_owned, &reply, ctx_owned.as_deref()) .await @@ -831,12 +815,6 @@ async fn cmd_send( } } -fn global_skills_dir() -> std::path::PathBuf { - dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".ias") - .join("skills") -} use tools::skill::SkillResult; @@ -852,7 +830,9 @@ async fn builtin_approve(ctx: &ExecutionContext, name: &str) -> Result Result String { let mut lines = vec!["📋 可用工具:".to_string()]; for spec in specs { - let risk = if spec.risk_level == tools::skill::RiskLevel::High { " ⚠️需确认" } else { "" }; + let risk = if spec.risk_level == tools::skill::RiskLevel::High { + " ⚠️需确认" + } else { + "" + }; lines.push(format!( - "\n🔹 {} {} — {}", spec.name, risk, spec.description + "\n🔹 {} {} — {}", + spec.name, risk, spec.description )); + if !spec + .parameters + .get("properties") + .and_then(|v| v.as_object()) + .map_or(true, |o| o.is_empty()) + { + lines.push(format!( + " 参数: {}", + serde_json::to_string(&spec.parameters).unwrap_or_default() + )); + } } lines.join("\n") } -async fn cmd_skills(action: SkillsAction) { - use dialoguer::{MultiSelect, theme::ColorfulTheme}; - use console::Term; - - let global_dir = global_skills_dir(); - - match action { - SkillsAction::List => { - let discovered = skills_importer::discover_skills(); - if discovered.is_empty() { - println!("未发现可导入的 Skill"); - return; - } - println!("发现 {} 个可导入的 Skill:", discovered.len()); - for (source, name, _path) in &discovered { - println!(" [{source}] {name}"); - } - } - SkillsAction::Import => { - let discovered = skills_importer::discover_skills(); - if discovered.is_empty() { - println!("未发现可导入的 Skill"); - return; - } - - let items: Vec = discovered.iter() - .map(|(src, name, _)| format!("[{}] {}", src, name)) - .collect(); - - println!("发现 {} 个可导入的 Skill(空格选择,回车确认,q 退出):", discovered.len()); - let selection = MultiSelect::with_theme(&ColorfulTheme::default()) - .items(&items) - .interact_on(&Term::stderr()) - .unwrap_or_default(); - - if selection.is_empty() { - println!("未选择任何 Skill"); - return; - } - - let dest = &global_dir; - std::fs::create_dir_all(dest).ok(); - - let mut imported = 0; - let mut skipped = 0; - for idx in selection { - let (src, name, path) = &discovered[idx]; - println!(" 导入 [{src}] {name}..."); - match skills_importer::copy_skill(path, name, &dest) { - Ok(_) => { - println!(" ✅ 已导入"); - imported += 1; - } - Err(e) => { - println!(" ⚠️ {e}"); - skipped += 1; - } - } - } - println!("\n完成:导入 {imported} 个,跳过 {skipped} 个 (→ {})", global_dir.display()); - } - SkillsAction::Delete => { - if !global_dir.exists() { - println!("全局 skills 目录不存在,没有可删除的 Skill"); - return; - } - - let mut entries: Vec<(String, String)> = Vec::new(); - if let Ok(dir) = std::fs::read_dir(&global_dir) { - for entry in dir.flatten() { - let path = entry.path(); - if path.is_dir() && path.join("SKILL.md").exists() { - let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); - let desc = std::fs::read_to_string(path.join("SKILL.md")) - .ok() - .and_then(|s| s.lines().nth(0).map(|l| l.trim_start_matches("# ").to_string())) - .unwrap_or_default(); - entries.push((name, desc)); - } - } - } - entries.sort_by(|a, b| a.0.cmp(&b.0)); - - if entries.is_empty() { - println!("skills 目录下没有可删除的 Skill"); - return; - } - - let items: Vec = entries.iter() - .map(|(name, desc)| format!("{} — {}", name, desc)) - .collect(); - - println!("选择要删除的 Skill(空格选择,回车确认,q 退出):"); - let selection = MultiSelect::with_theme(&ColorfulTheme::default()) - .items(&items) - .interact_on(&Term::stderr()) - .unwrap_or_default(); - - if selection.is_empty() { - println!("未选择任何 Skill"); - return; - } - - let mut deleted = 0; - for idx in selection { - let (name, _) = &entries[idx]; - let path = global_dir.join(name); - match std::fs::remove_dir_all(&path) { - Ok(_) => { - println!(" ✅ 已删除 {name}"); - deleted += 1; - } - Err(e) => println!(" ❌ 删除 {name} 失败: {e}"), - } - } - println!("\n完成:删除 {deleted} 个"); - } - } -} diff --git a/src/skills_importer.rs b/src/skills_importer.rs deleted file mode 100644 index d8231c2..0000000 --- a/src/skills_importer.rs +++ /dev/null @@ -1,80 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::fs; - -/// 已知的 Skills 来源路径 -fn known_skill_paths() -> Vec<(String, PathBuf)> { - let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); - - let mut paths = Vec::new(); - - // Claude Code - paths.push(("Claude Code".into(), home.join(".claude").join("skills"))); - // Claude (project) - paths.push(("Claude (项目)".into(), PathBuf::from(".claude/skills"))); - - // Agent Skills 标准 - paths.push(("Agent Skills (全局)".into(), home.join(".agents").join("skills"))); - paths.push(("Agent Skills (项目)".into(), PathBuf::from(".agents/skills"))); - - // opencode - paths.push(("OpenCode".into(), home.join(".opencode").join("skills"))); - - // pi coding agent - paths.push(("pi".into(), home.join(".pi").join("agent").join("skills"))); - - paths -} - -/// 扫描所有已知路径,返回 (来源, 技能名称, 技能目录) 列表 -pub fn discover_skills() -> Vec<(String, String, PathBuf)> { - let mut results = Vec::new(); - - for (source, dir) in known_skill_paths() { - if let Ok(entries) = fs::read_dir(&dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - let skill_md = path.join("SKILL.md"); - if skill_md.exists() { - let name = path.file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - results.push((source.clone(), name, path.clone())); - } - } - } - } - } - - results.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0))); // sort by name, then source - results -} - -/// 复制一个技能到目标目录 -pub fn copy_skill(src: &Path, name: &str, dest_dir: &Path) -> Result { - let target = dest_dir.join(name); - if target.exists() { - return Err(format!("技能 {} 已存在", name)); - } - - copy_dir_recursive(src, &target)?; - Ok(target) -} - -fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { - fs::create_dir_all(dst).map_err(|e| format!("创建目录失败: {}", e))?; - - for entry in fs::read_dir(src).map_err(|e| format!("读取目录失败: {}", e))? { - let entry = entry.map_err(|e| format!("读取条目失败: {}", e))?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - - if src_path.is_dir() { - copy_dir_recursive(&src_path, &dst_path)?; - } else { - fs::copy(&src_path, &dst_path) - .map_err(|e| format!("复制文件失败 {}: {}", src_path.display(), e))?; - } - } - Ok(()) -} diff --git a/src/tools/DESIGN.md b/src/tools/DESIGN.md index 3c1e8e2..473b79e 100644 --- a/src/tools/DESIGN.md +++ b/src/tools/DESIGN.md @@ -2,7 +2,7 @@ ## 核心理念 -**一切皆 Skill。** +**一切皆 Skill。【todo:向内置工具转变】** 没有"内置工具"和"外部技能"的区别——所有工具都是 SKILL.md,区别仅在来源: - **系统 skills** — `resources/skills/` 目录,随 iAs 分发,只读 diff --git a/src/tools/approval.rs b/src/tools/approval.rs index 1eaba85..bdb5d85 100644 --- a/src/tools/approval.rs +++ b/src/tools/approval.rs @@ -44,7 +44,7 @@ impl ApprovalManager { user_id: &str, skill_name: &str, ) -> Result<(String, oneshot::Receiver), String> { - let code: u32 = rand::thread_rng().gen_range(100000..999999); + let code: u32 = rand::rng().random_range(100000..999999); let code_str = code.to_string(); let code_hash = format!("{:x}", Sha256::digest(code_str.as_bytes())); @@ -92,7 +92,9 @@ impl ApprovalManager { let (result, name, code_hash) = { let mut map = self.pending.lock().await; let entries = map.get_mut(user_id)?; - if entries.is_empty() { return None; } + if entries.is_empty() { + return None; + } let reply_hash = format!("{:x}", Sha256::digest(reply.as_bytes())); let reply_trimmed = reply.trim(); @@ -157,11 +159,11 @@ impl ApprovalManager { } if let Some(ref pool) = self.pool { - for (_uid, _i, _code_hash) in &expired { + for (_uid, _i, code_hash) in &expired { let _ = sqlx::query( "UPDATE pending_approvals SET status = 'expired' WHERE code_hash = $1 AND status = 'pending'", ) - .bind(_code_hash) + .bind(code_hash) .execute(pool.as_ref()) .await; } @@ -178,10 +180,4 @@ impl ApprovalManager { } map.retain(|_, v| !v.is_empty()); } - - /// 获取某个用户的待审批条数 - 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/builtin.rs b/src/tools/builtin.rs index b6982a9..3d89a7d 100644 --- a/src/tools/builtin.rs +++ b/src/tools/builtin.rs @@ -10,8 +10,7 @@ impl BuiltinRegistry { "get_current_datetime" => Some(execute_datetime()), "manage_memos" => Some(handle_memos(args_json).await), "query_weather" => { - let params: serde_json::Value = - serde_json::from_str(args_json).unwrap_or_default(); + let params: serde_json::Value = serde_json::from_str(args_json).unwrap_or_default(); Some(super::weather::execute(params).await) } _ => None, @@ -31,7 +30,7 @@ impl BuiltinRegistry { super::skill::SkillSpec { name: "manage_memos".into(), description: "管理备忘录:添加/列出/删除。高风险操作(add/delete)需确认".into(), - risk_level: RiskLevel::High, + risk_level: RiskLevel::Low, parameters: serde_json::json!({"type":"object","properties":{"action":{"type":"string","enum":["add","list","delete"]},"content":{"type":"string"},"id":{"type":"integer"}}}), timeout_secs: 10, }, @@ -40,7 +39,9 @@ impl BuiltinRegistry { } pub fn is_high_risk(name: &str) -> bool { - Self::specs().iter().any(|s| s.name == name && s.risk_level == RiskLevel::High) + Self::specs() + .iter() + .any(|s| s.name == name && s.risk_level == RiskLevel::High) } pub fn is_builtin(name: &str) -> bool { @@ -59,20 +60,32 @@ async fn handle_memos(args_json: &str) -> SkillResult { // 如果顶层没有 action,尝试从 prompt 推断 if params.get("action").is_none() { let prompt = params.get("prompt").and_then(|v| v.as_str()).unwrap_or(""); - let action = if prompt.contains("添加") || prompt.contains("新增") || prompt.contains("add") { + let action = if prompt.contains("添加") || prompt.contains("新增") || prompt.contains("add") + { "add" - } else if prompt.contains("删除") || prompt.contains("移除") || prompt.contains("delete") { + } else if prompt.contains("删除") || prompt.contains("移除") || prompt.contains("delete") + { "delete" } else { "list" }; let content = params.get("prompt").and_then(|v| v.as_str()).unwrap_or(""); - let memo_id: i32 = content.chars().filter(|c| c.is_ascii_digit()).collect::().parse().unwrap_or(0); + let memo_id: i32 = content + .chars() + .filter(|c| c.is_ascii_digit()) + .collect::() + .parse() + .unwrap_or(0); params = serde_json::json!({"action": action, "content": content, "id": memo_id}); } - let action = params.get("action").and_then(|v| v.as_str()).unwrap_or("list"); + let action = params + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or("list"); let path = ".data/memos.json"; - if let Err(e) = std::fs::create_dir_all(".data") { return SkillResult::error(format!("创建目录失败: {}", e)); } + if let Err(e) = std::fs::create_dir_all(".data") { + return SkillResult::error(format!("创建目录失败: {}", e)); + } let mut data: Vec = if std::path::Path::new(path).exists() { serde_json::from_str(&std::fs::read_to_string(path).unwrap_or_default()).unwrap_or_default() @@ -83,8 +96,15 @@ async fn handle_memos(args_json: &str) -> SkillResult { match action { "add" => { let content = params.get("content").and_then(|v| v.as_str()).unwrap_or(""); - if content.is_empty() { return SkillResult::error("请提供 content 参数"); } - let next_id = data.iter().filter_map(|m| m.get("id").and_then(|v| v.as_i64())).max().unwrap_or(0) + 1; + if content.is_empty() { + return SkillResult::error("请提供 content 参数"); + } + let next_id = data + .iter() + .filter_map(|m| m.get("id").and_then(|v| v.as_i64())) + .max() + .unwrap_or(0) + + 1; data.push(serde_json::json!({ "id": next_id, "content": content, "created_at": Local::now().format("%Y-%m-%d %H:%M").to_string() @@ -95,18 +115,29 @@ async fn handle_memos(args_json: &str) -> SkillResult { SkillResult::ok(format!("已添加备忘录 #{}", next_id)) } "list" => { - if data.is_empty() { return SkillResult::ok("暂无备忘录".to_string()); } - let lines: Vec = data.iter().map(|m| format!( - "#{} [{}] {}", - m["id"], m.get("created_at").and_then(|v| v.as_str()).unwrap_or("?"), m["content"].as_str().unwrap_or("?") - )).collect(); + if data.is_empty() { + return SkillResult::ok("暂无备忘录".to_string()); + } + let lines: Vec = data + .iter() + .map(|m| { + format!( + "#{} [{}] {}", + m["id"], + m.get("created_at").and_then(|v| v.as_str()).unwrap_or("?"), + m["content"].as_str().unwrap_or("?") + ) + }) + .collect(); SkillResult::ok(lines.join("\n")) } "delete" => { let id = params.get("id").and_then(|v| v.as_i64()).unwrap_or(0); let before = data.len(); data.retain(|m| m.get("id").and_then(|v| v.as_i64()) != Some(id)); - if data.len() == before { return SkillResult::error(format!("未找到备忘录 #{}", id)); } + if data.len() == before { + return SkillResult::error(format!("未找到备忘录 #{}", id)); + } if let Err(e) = std::fs::write(path, serde_json::to_string_pretty(&data).unwrap()) { return SkillResult::error(format!("写入备忘录失败: {}", e)); } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 54ba6c5..0c2f5a9 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1,6 +1,4 @@ pub mod approval; pub mod builtin; -pub mod parser; -pub mod registry; pub mod skill; pub mod weather; diff --git a/src/tools/parser.rs b/src/tools/parser.rs deleted file mode 100644 index 2fa26e4..0000000 --- a/src/tools/parser.rs +++ /dev/null @@ -1,279 +0,0 @@ -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 frontmatter_fences = 0; - - for (_i, line) in lines.iter().enumerate() { - let trimmed = line.trim(); - - // 跳过 YAML frontmatter (---...---) - if trimmed == "---" && frontmatter_fences < 2 { - frontmatter_fences += 1; - continue; - } - if frontmatter_fences == 1 { - continue; - } - - // 标题行 → 技能名 - 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_params = false; - in_execute = false; - } - "parameters" | "param" => { - in_params = true; - in_execute = false; - } - "execute" | "execution" | "command" | "run" => { - in_params = false; - in_execute = true; - params_fence = 0; - execute_fence = 0; - } - _ => { - 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 (registry, errors) = rt.block_on(crate::tools::registry::SkillRegistry::load( - "resources/skills", - "/nonexistent", - )); - if !errors.is_empty() { - for e in &errors { - eprintln!("加载警告: {}", e); - } - } - assert_eq!(registry.count(), 8, "应该有 8 个系统技能(query_weather 已重构为 builtin)"); - 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(&"manage_skill")); - assert!(names.contains(&"get_current_datetime")); - 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")); - } - - #[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 deleted file mode 100644 index b934eeb..0000000 --- a/src/tools/registry.rs +++ /dev/null @@ -1,322 +0,0 @@ -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 加载所有技能 - pub async fn load(system_dir: &str, user_dir: &str) -> (Self, Vec) { - 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 (~/.ias/skills/) - if let Ok(home) = std::env::var("HOME") { - let global_dir = format!("{}/.ias/skills", home); - if Path::new(&global_dir).exists() { - registry.load_from_dir(&global_dir, SkillSource::User, &mut errors); - } - } - - // 加载用户 skills(项目目录) - if Path::new(user_dir).exists() { - registry.load_from_dir(user_dir, SkillSource::User, &mut errors); - } - - (registry, 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 fn register_skill(&mut self, spec: SkillSpec, dir: std::path::PathBuf, execute_command: String) { - let skill = Arc::new(Skill::new(spec, SkillSource::User, dir, execute_command)); - let name = skill.spec.name.clone(); - self.skills.insert(name, skill); - } - - // ─── 执行 ─── - - /// 执行技能(含审批流程) - 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()); - cmd.kill_on_drop(true); - - 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 if !stdout.is_empty() { - format!("退出码 {}: {}", output.status.code().unwrap_or(-1), stdout) - } 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 index 63ac389..2564dcd 100644 --- a/src/tools/skill.rs +++ b/src/tools/skill.rs @@ -1,15 +1,15 @@ use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; -use std::time::Duration; // ─── 风险等级 ─── #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum RiskLevel { - Low, // 只读/安全,自动执行 - High, // 写操作/有风险,需微信确认码审批 + Low, + High, } impl RiskLevel { @@ -18,24 +18,7 @@ impl RiskLevel { } } -// ─── 技能来源 ─── - -#[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 { @@ -49,41 +32,11 @@ pub struct SkillSpec { pub timeout_secs: u64, } -fn default_risk_level() -> RiskLevel { - RiskLevel::Low -} +fn default_risk_level() -> RiskLevel { RiskLevel::Low } +fn default_timeout() -> u64 { 30 } -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, @@ -94,63 +47,48 @@ pub struct SkillResult { impl SkillResult { pub fn ok(output: impl Into) -> Self { - Self { - success: true, - output: output.into(), - data: None, - } + 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), - } + 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, - } + 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, - } + Self { success: false, output: format!("用户没有确认操作:{}", skill_name), data: None } } pub fn error(msg: impl Into) -> Self { - Self { - success: false, - output: msg.into(), - data: None, - } + Self { success: false, output: msg.into(), data: None } } } -// ─── 错误 ─── +// ─── 执行上下文 ─── -#[derive(Debug)] -pub enum SkillError { - NotFound(String), - Execution(String), - Timeout(String), - InvalidParams(String), +pub type WechatSender = Arc Pin> + Send>> + Send + Sync>; + +pub struct ExecutionContext { + pub user_id: String, + pub approval_manager: Option>, + pub send_wechat: Option, } -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), - } +impl ExecutionContext { + pub fn new(user_id: impl Into) -> Self { + Self { user_id: user_id.into(), approval_manager: None, send_wechat: None } + } + + 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/weather.rs b/src/tools/weather.rs index 4c2a1a3..73a5e37 100644 --- a/src/tools/weather.rs +++ b/src/tools/weather.rs @@ -90,6 +90,7 @@ struct CityLookupResponse { } #[derive(Deserialize)] +#[allow(dead_code)] struct CityLocation { id: String, name: String, @@ -246,13 +247,10 @@ impl QWeatherClient { return Ok(id); } - let url = format!( - "https://geoapi.qweather.com/v2/city/lookup?location={}&number=1", - location - ); let resp: CityLookupResponse = self .http - .get(&url) + .get("https://geoapi.qweather.com/v2/city/lookup") + .query(&[("location", location), ("number", "1")]) .header("Authorization", format!("Bearer {}", self.jwt)) .send() .await @@ -279,13 +277,10 @@ impl QWeatherClient { /// 实时天气 async fn weather_now(&self, city_id: &str) -> Result { - let url = format!( - "https://{}/v7/weather/now?location={}&lang=zh&unit=m", - self.api_host, city_id - ); let resp: WeatherNowResponse = self .http - .get(&url) + .get(format!("https://{}/v7/weather/now", self.api_host)) + .query(&[("location", city_id), ("lang", "zh"), ("unit", "m")]) .header("Authorization", format!("Bearer {}", self.jwt)) .send() .await @@ -328,13 +323,10 @@ impl QWeatherClient { _ => "30d", }; - let url = format!( - "https://{}/v7/weather/{}?location={}&lang=zh&unit=m", - self.api_host, d, city_id - ); let resp: DailyForecastResponse = self .http - .get(&url) + .get(format!("https://{}/v7/weather/{}", self.api_host, d)) + .query(&[("location", city_id), ("lang", "zh"), ("unit", "m")]) .header("Authorization", format!("Bearer {}", self.jwt)) .send() .await @@ -381,13 +373,10 @@ impl QWeatherClient { _ => "168h", }; - let url = format!( - "https://{}/v7/weather/{}?location={}&lang=zh&unit=m", - self.api_host, h, city_id - ); let resp: HourlyForecastResponse = self .http - .get(&url) + .get(format!("https://{}/v7/weather/{}", self.api_host, h)) + .query(&[("location", city_id), ("lang", "zh"), ("unit", "m")]) .header("Authorization", format!("Bearer {}", self.jwt)) .send() .await @@ -448,12 +437,6 @@ fn extract_params(params: &serde_json::Value) -> (&str, u32, u32) { let location = params .get("location") .and_then(|v| v.as_str()) - .or_else(|| params.get("prompt").and_then(|v| v.as_str()).and_then(|s| { - // call_capability 可能用 prompt 透传,尝试从中提取城市名 - // 简单匹配:"北京"、"上海" 等中文名或拼音 - s.split(|c: char| c == '"' || c == '\'' || c == ',' || c == ',') - .find(|w| w.chars().any(|c| c as u32 > 127)) - })) .unwrap_or("北京"); let days = params @@ -471,8 +454,75 @@ fn extract_params(params: &serde_json::Value) -> (&str, u32, u32) { (location, days.max(1), hours) } -/// 执行天气查询 +#[allow(dead_code)] +fn extract_days_from_prompt(prompt: &str) -> u32 { + // 匹配模式: "7天" "3日" "未来7" "7d" "7D" + for pat in &["天", "日", "d", "D"] { + if let Some(pos) = prompt.find(pat) { + // 向前找数字 + let before = &prompt[..pos]; + if let Some(num_str) = before + .chars() + .rev() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .chars() + .rev() + .collect::() + .into() + { + if let Ok(n) = num_str.parse::() { + return n.min(30).max(1); + } + } + } + } + 3 // 默认 +} + +#[allow(dead_code)] +fn extract_city_from_prompt(prompt: &str) -> &str { + // 去掉常见前缀,取第一个2-3字中文词。GeoAPI 支持模糊搜索, + // 即使多取一两个字也能正确匹配到城市 + let chars: Vec = prompt.chars().collect(); + let byte_pos = |idx: usize| -> usize { chars[..idx].iter().map(|c| c.len_utf8()).sum() }; + + // 跳过前缀 + let mut i = 0; + for prefix in &["帮我查一下", "帮我看看", "帮我查", "帮我", "查一下", "查询", "看看"] { + if prompt.starts_with(prefix) { + i = prefix.chars().count(); + while i < chars.len() && chars[i] as u32 <= 0x4e00 { i += 1; } + break; + } + } + // 跳过非中文 + while i < chars.len() && chars[i] as u32 <= 0x4e00 { i += 1; } + // 取至多4个连续中文 + let start = i; + while i < chars.len() && chars[i] as u32 > 0x4e00 && i - start < 4 { i += 1; } + if i - start >= 2 { + &prompt[byte_pos(start)..byte_pos(i)] + } else { + "北京" + } +} + pub async fn execute(params: serde_json::Value) -> SkillResult { + // call_capability 透传但没有 structured 参数 → 告知正确格式 + if params.get("location").is_none() + && params.get("name").and_then(|v| v.as_str()) == Some("query_weather") + { + let spec = spec(); + return SkillResult::error(format!( + "参数格式错误。请将 query_weather 所需的参数直接放在 call_capability 的 prompt 字段中,格式:\ + prompt: {{\"location\": \"城市名\", \"days\": 3, \"hours\": 0}}。\ + 工具说明:{}。参数 schema:{}", + spec.description, + serde_json::to_string(&spec.parameters).unwrap_or_default() + )); + } + let (location, days, hours) = extract_params(¶ms); tracing::info!("🌤️ 查询天气: location={}, days={}, hours={}", location, days, hours); @@ -574,9 +624,35 @@ mod tests { } #[test] - fn test_spec() { - let s = spec(); - assert_eq!(s.name, "query_weather"); - assert_eq!(s.risk_level, RiskLevel::Low); + fn test_extract_city_from_prompt() { + // 取前缀后的连续中文给 GeoAPI 模糊搜索(多取不影响结果) + assert_eq!(extract_city_from_prompt("查询合肥未来7天的天气预报"), "合肥未来"); + assert_eq!(extract_city_from_prompt("北京今天天气"), "北京今天"); + assert_eq!(extract_city_from_prompt("上海"), "上海"); + assert_eq!(extract_city_from_prompt("帮我查一下广州"), "广州"); + } + + #[test] + fn test_extract_days() { + assert_eq!(extract_days_from_prompt("查询合肥未来7天的天气预报"), 7); + assert_eq!(extract_days_from_prompt("3天预报"), 3); + assert_eq!(extract_days_from_prompt("北京天气"), 3); + } + + /// 端到端测试:需要有效的 QWeather 环境变量 + #[tokio::test] + async fn test_execute_weather_integration() { + // 跳过无环境变量的情况 + if std::env::var("QWEATHER_JWT_KEY_ID").is_err() { + eprintln!("跳过集成测试: 未设置 QWeather 环境变量"); + return; + } + + let result = super::execute(serde_json::json!({"location": "合肥", "days": 3})).await; + assert!(result.success, "查询失败: {}", result.output); + let data = result.data.expect("应有 data 字段"); + assert_eq!(data["location"], "合肥"); + assert!(data["now"].is_object(), "应有实时天气"); + assert!(data["forecast"].is_array(), "应有预报"); } }