refactor: 删除外置 skills 系统,全部工具转为内置
删除: - resources/skills/ (9个外置 skill, bash/python/node 脚本) - src/tools/registry.rs (SkillRegistry 加载/执行) - src/tools/parser.rs (SKILL.md 解析) - src/skills_importer.rs (skills 导入) - cli.rs Skills 子命令 - main.rs cmd_skills / global_skills_dir 保留: - src/tools/builtin.rs (内置工具注册 + 审批流程) - src/tools/weather.rs (天气查询) - src/tools/skill.rs (精简为 SkillSpec/RiskLevel/SkillResult/ExecutionContext) - src/tools/approval.rs (审批管理器) main.rs 简化: 移除 SkillRegistry 加载, executor 只走 BuiltinRegistry
This commit is contained in:
Generated
+34
@@ -138,6 +138,18 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"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]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -393,6 +405,23 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
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]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -3190,12 +3219,17 @@ version = "0.6.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ edition = "2024"
|
|||||||
# 异步运行时
|
# 异步运行时
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
# HTTP 客户端
|
# HTTP 客户端
|
||||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
reqwest = { version = "0.12", features = ["json", "stream", "gzip"] }
|
||||||
# 序列化
|
# 序列化
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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) 行)"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# get_current_datetime
|
|
||||||
获取当前日期时间(UTC+8)。内置 Rust 实现,无脚本依赖。
|
|
||||||
## Risk Level
|
|
||||||
Low
|
|
||||||
## Execute
|
|
||||||
```bash
|
|
||||||
# Builtin: Rust
|
|
||||||
```
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# list_scheduled_tasks
|
|
||||||
|
|
||||||
列出所有已配置的定时任务。
|
|
||||||
|
|
||||||
## Risk Level
|
|
||||||
Low
|
|
||||||
|
|
||||||
## Parameters
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Execute
|
|
||||||
```bash
|
|
||||||
scripts/list.sh
|
|
||||||
```
|
|
||||||
@@ -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":[]}'
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# manage_memos
|
|
||||||
管理备忘录(添加/列出/删除)。内置 Rust 实现,无脚本依赖。
|
|
||||||
## Risk Level
|
|
||||||
High
|
|
||||||
## Execute
|
|
||||||
```bash
|
|
||||||
# Builtin: Rust
|
|
||||||
```
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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":"搜索请求失败"}'
|
|
||||||
-26
@@ -99,30 +99,4 @@ pub enum Commands {
|
|||||||
/// ias service # 前台运行
|
/// ias service # 前台运行
|
||||||
/// sudo systemctl enable ias --now # systemd 管理
|
/// sudo systemctl enable ias --now # systemd 管理
|
||||||
Service,
|
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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-16
@@ -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 crate::llm::types::{ConversationConfig, Message, StreamChunk, ToolCall, Usage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use reqwest::Client as HttpClient;
|
use reqwest::Client as HttpClient;
|
||||||
@@ -31,7 +33,9 @@ impl DeepSeekProvider {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LlmProvider for DeepSeekProvider {
|
impl LlmProvider for DeepSeekProvider {
|
||||||
fn name(&self) -> &str { "deepseek" }
|
fn name(&self) -> &str {
|
||||||
|
"deepseek"
|
||||||
|
}
|
||||||
|
|
||||||
async fn chat_stream(
|
async fn chat_stream(
|
||||||
&self,
|
&self,
|
||||||
@@ -96,14 +100,17 @@ async fn stream_deepseek(
|
|||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let text = response.text().await.unwrap_or_default();
|
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));
|
return Err(format!("HTTP {}: {}", status, text));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut full_text = String::new();
|
let mut full_text = String::new();
|
||||||
let mut full_reasoning = String::new();
|
let mut full_reasoning = String::new();
|
||||||
let mut usage: Option<Usage> = None;
|
let mut usage: Option<Usage> = None;
|
||||||
let mut tool_call_builders: BTreeMap<i32, (Option<String>, Option<String>, String)> = BTreeMap::new();
|
let mut tool_call_builders: BTreeMap<i32, (Option<String>, Option<String>, String)> =
|
||||||
|
BTreeMap::new();
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
@@ -118,7 +125,9 @@ async fn stream_deepseek(
|
|||||||
let line = buf[..line_end].to_string();
|
let line = buf[..line_end].to_string();
|
||||||
buf = buf[line_end + 1..].to_string();
|
buf = buf[line_end + 1..].to_string();
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
if trimmed.is_empty() { continue; }
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(parsed) = parse_chat_chunk(trimmed) {
|
if let Some(parsed) = parse_chat_chunk(trimmed) {
|
||||||
match parsed {
|
match parsed {
|
||||||
@@ -130,14 +139,28 @@ async fn stream_deepseek(
|
|||||||
full_reasoning.push_str(&r);
|
full_reasoning.push_str(&r);
|
||||||
let _ = tx.send(StreamChunk::Reasoning(r)).await;
|
let _ = tx.send(StreamChunk::Reasoning(r)).await;
|
||||||
}
|
}
|
||||||
ParsedChunk::ToolCallDelta { index, id, name, arguments } => {
|
ParsedChunk::ToolCallDelta {
|
||||||
let entry = tool_call_builders.entry(index).or_insert((None, None, String::new()));
|
index,
|
||||||
if let Some(call_id) = id { entry.0 = Some(call_id); }
|
id,
|
||||||
if let Some(call_name) = name { entry.1 = Some(call_name); }
|
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);
|
entry.2.push_str(&arguments);
|
||||||
}
|
}
|
||||||
ParsedChunk::FinishReason(_) => {}
|
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()) {
|
if let Some(parsed) = parse_chat_chunk(buf.trim()) {
|
||||||
match parsed {
|
match parsed {
|
||||||
ParsedChunk::Text(t) => full_text.push_str(&t),
|
ParsedChunk::Text(t) => full_text.push_str(&t),
|
||||||
ParsedChunk::ToolCallDelta { index, id, name, arguments } => {
|
ParsedChunk::ToolCallDelta {
|
||||||
let entry = tool_call_builders.entry(index).or_insert((None, None, String::new()));
|
index,
|
||||||
if let Some(call_id) = id { entry.0 = Some(call_id); }
|
id,
|
||||||
if let Some(call_name) = name { entry.1 = Some(call_name); }
|
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);
|
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
|
let _ = tx
|
||||||
.send(StreamChunk::Done {
|
.send(StreamChunk::Done {
|
||||||
text: full_text,
|
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,
|
tool_calls,
|
||||||
usage,
|
usage,
|
||||||
})
|
})
|
||||||
|
|||||||
+5
-28
@@ -1,6 +1,6 @@
|
|||||||
use crate::llm::types::{ConversationConfig, Message, StreamChunk};
|
use crate::llm::types::{ConversationConfig, Message, StreamChunk};
|
||||||
use serde::Deserialize;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
@@ -33,29 +33,6 @@ pub fn create_provider(_config: &ConversationConfig) -> Result<BoxedProvider, St
|
|||||||
|
|
||||||
// ─── 内部:SSE 解析工具 ───
|
// ─── 内部: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 {
|
pub(crate) enum ParsedChunk {
|
||||||
Text(String),
|
Text(String),
|
||||||
@@ -71,9 +48,7 @@ pub(crate) enum ParsedChunk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 从 JSON body 中解析 DeepSeek/OpenAI 流式 delta
|
/// 从 JSON body 中解析 DeepSeek/OpenAI 流式 delta
|
||||||
pub(crate) fn parse_chat_chunk(
|
pub(crate) fn parse_chat_chunk(line: &str) -> Option<ParsedChunk> {
|
||||||
line: &str,
|
|
||||||
) -> Option<ParsedChunk> {
|
|
||||||
if !line.starts_with("data: ") {
|
if !line.starts_with("data: ") {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -144,7 +119,9 @@ pub(crate) fn parse_chat_chunk(
|
|||||||
if let Some(tool_calls) = &choice.delta.tool_calls {
|
if let Some(tool_calls) = &choice.delta.tool_calls {
|
||||||
for tc in tool_calls {
|
for tc in tool_calls {
|
||||||
let idx = tc.index.unwrap_or(0);
|
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())
|
.and_then(|f| f.arguments.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let name = tc.function.as_ref().and_then(|f| f.name.clone());
|
let name = tc.function.as_ref().and_then(|f| f.name.clone());
|
||||||
|
|||||||
+62
-185
@@ -4,19 +4,18 @@ mod db;
|
|||||||
mod llm;
|
mod llm;
|
||||||
mod logger;
|
mod logger;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
mod skills_importer;
|
|
||||||
mod state;
|
mod state;
|
||||||
mod tools;
|
mod tools;
|
||||||
mod wechat;
|
mod wechat;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use cli::{Cli, Commands, SkillsAction};
|
use cli::{Cli, Commands};
|
||||||
use context::MemoryStore;
|
use context::MemoryStore;
|
||||||
use db::Database;
|
use db::Database;
|
||||||
use llm::{Conversation, ConversationConfig, DEFAULT_SYSTEM_PROMPT, ToolExecutor, Usage};
|
use llm::{Conversation, ConversationConfig, DEFAULT_SYSTEM_PROMPT, ToolExecutor, Usage};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tools::approval::{ApprovalDecision, ApprovalManager};
|
use tools::approval::{ApprovalDecision, ApprovalManager};
|
||||||
use tools::registry::{ExecutionContext, SkillRegistry};
|
use tools::skill::ExecutionContext;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
use wechat::client::WeChatClient;
|
use wechat::client::WeChatClient;
|
||||||
|
|
||||||
@@ -87,12 +86,7 @@ async fn main() {
|
|||||||
} => {
|
} => {
|
||||||
cmd_usage(&database, since, until, model).await;
|
cmd_usage(&database, since, until, model).await;
|
||||||
}
|
}
|
||||||
Commands::Service => {
|
Commands::Service => cmd_listen(true, false, &database, &file_state, &memory_store).await,
|
||||||
cmd_listen(true, false, &database, &file_state, &memory_store).await
|
|
||||||
}
|
|
||||||
Commands::Skills { action } => {
|
|
||||||
cmd_skills(action).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,24 +190,15 @@ async fn cmd_listen(
|
|||||||
return;
|
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(
|
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 conversation = if enable_llm {
|
||||||
let sys_prompt = std::env::var("WEIXIN_LLM_SYSTEM_PROMPT")
|
let sys_prompt = std::env::var("WEIXIN_LLM_SYSTEM_PROMPT")
|
||||||
.unwrap_or_else(|_| DEFAULT_SYSTEM_PROMPT.to_string());
|
.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 {
|
let mut cfg = ConversationConfig {
|
||||||
system_prompt: sys_prompt,
|
system_prompt: sys_prompt,
|
||||||
@@ -221,7 +206,7 @@ async fn cmd_listen(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// 仅暴露两个元工具:查询能力 + 调用能力
|
// 各项能力通过这两个元工具实现:查询能力 + 调用能力
|
||||||
// 具体工具描述通过 query_capabilities 返回
|
// 具体工具描述通过 query_capabilities 返回
|
||||||
let mut tools_list: Vec<serde_json::Value> = Vec::new();
|
let mut tools_list: Vec<serde_json::Value> = Vec::new();
|
||||||
|
|
||||||
@@ -240,12 +225,12 @@ async fn cmd_listen(
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "call_capability",
|
"name": "call_capability",
|
||||||
"description": "按工具名称调用一个已注册的工具。name 为工具名(来自 query_capabilities),prompt 为自然语言需求",
|
"description": "调用一个已注册的工具。name 为工具名(来自 query_capabilities),工具所需的具体参数(如 location、days)应直接放在 JSON 中。",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": { "type": "string", "description": "工具名称" },
|
"name": { "type": "string", "description": "工具名称" },
|
||||||
"prompt": { "type": "string", "description": "自然语言需求" }
|
"prompt": { "type": "string", "description": "json格式参数,来自 query_capabilities 中的对应工具的描述。" }
|
||||||
},
|
},
|
||||||
"required": ["name"]
|
"required": ["name"]
|
||||||
}
|
}
|
||||||
@@ -306,10 +291,9 @@ async fn cmd_listen(
|
|||||||
|
|
||||||
// 构建工具执行器
|
// 构建工具执行器
|
||||||
let session = conv.session();
|
let session = conv.session();
|
||||||
let registry_opt = skill_registry.clone();
|
|
||||||
let approval = approval_manager.clone();
|
let approval = approval_manager.clone();
|
||||||
let memory_store = memory_store.clone();
|
let memory_store = memory_store.clone();
|
||||||
let send_wechat: tools::registry::WechatSender = {
|
let send_wechat: tools::skill::WechatSender = {
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
Arc::new(move |user_id: &str, text: &str| {
|
Arc::new(move |user_id: &str, text: &str| {
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
@@ -326,7 +310,6 @@ async fn cmd_listen(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let executor: ToolExecutor = Arc::new(move |name: &str, args_json: &str| {
|
let executor: ToolExecutor = Arc::new(move |name: &str, args_json: &str| {
|
||||||
let reg = registry_opt.clone();
|
|
||||||
let approval = approval.clone();
|
let approval = approval.clone();
|
||||||
let send = send_wechat.clone();
|
let send = send_wechat.clone();
|
||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
@@ -338,36 +321,39 @@ async fn cmd_listen(
|
|||||||
let user_id = session.lock().await.current_user_id.clone();
|
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" {
|
if n == "write_memory" {
|
||||||
let params: serde_json::Value = serde_json::from_str(&args).unwrap_or_default();
|
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("");
|
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);
|
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" {
|
if n == "query_capabilities" {
|
||||||
let spec_list = if let Some(ref r) = reg {
|
let specs = tools::builtin::BuiltinRegistry::specs();
|
||||||
let mut s: Vec<tools::skill::SkillSpec> = r.list_specs().into_iter().cloned().collect();
|
return Ok(build_capability_list(&specs));
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确定目标工具名和参数(call_capability 提取 name + 透传完整参数)
|
// 确定目标工具名和参数(call_capability 提取 name + 透传完整参数)
|
||||||
let (target_name, target_args) = if n == "call_capability" {
|
let (target_name, target_args) = if n == "call_capability" {
|
||||||
let cp: serde_json::Value = serde_json::from_str(&args).unwrap_or_default();
|
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())
|
(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) {
|
if tools::builtin::BuiltinRegistry::is_builtin(&target_name) {
|
||||||
@@ -377,25 +363,18 @@ async fn cmd_listen(
|
|||||||
ctx = ctx.with_wechat(send.clone());
|
ctx = ctx.with_wechat(send.clone());
|
||||||
let approved = builtin_approve(&ctx, &target_name).await;
|
let approved = builtin_approve(&ctx, &target_name).await;
|
||||||
match approved {
|
match approved {
|
||||||
Ok(true) => {},
|
Ok(true) => {}
|
||||||
Ok(false) => return Ok(SkillResult::rejected(&target_name).output),
|
Ok(false) => return Ok(SkillResult::rejected(&target_name).output),
|
||||||
Err(e) => return Ok(e),
|
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);
|
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))
|
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 {
|
if is_approval_reply {
|
||||||
info!("消息匹配审批确认码,不传给 LLM");
|
info!("消息匹配审批确认码,不传给 LLM");
|
||||||
@@ -579,7 +559,11 @@ async fn listen_loop(
|
|||||||
} else {
|
} else {
|
||||||
generate_reply(&conv, text_owned, &database).await
|
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
|
match client
|
||||||
.send_text(&from_owned, &reply, ctx_owned.as_deref())
|
.send_text(&from_owned, &reply, ctx_owned.as_deref())
|
||||||
.await
|
.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;
|
use tools::skill::SkillResult;
|
||||||
|
|
||||||
@@ -852,7 +830,9 @@ async fn builtin_approve(ctx: &ExecutionContext, name: &str) -> Result<bool, Str
|
|||||||
name, code
|
name, code
|
||||||
);
|
);
|
||||||
if let Some(ref send) = ctx.send_wechat {
|
if let Some(ref send) = ctx.send_wechat {
|
||||||
if let Err(e) = send(&ctx.user_id, &msg).await { return Err(e); }
|
if let Err(e) = send(&ctx.user_id, &msg).await {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match tokio::time::timeout(std::time::Duration::from_secs(300), rx).await {
|
match tokio::time::timeout(std::time::Duration::from_secs(300), rx).await {
|
||||||
@@ -864,130 +844,27 @@ async fn builtin_approve(ctx: &ExecutionContext, name: &str) -> Result<bool, Str
|
|||||||
fn build_capability_list(specs: &[tools::skill::SkillSpec]) -> String {
|
fn build_capability_list(specs: &[tools::skill::SkillSpec]) -> String {
|
||||||
let mut lines = vec!["📋 可用工具:".to_string()];
|
let mut lines = vec!["📋 可用工具:".to_string()];
|
||||||
for spec in specs {
|
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!(
|
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")
|
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<String> = 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<String> = 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} 个");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<PathBuf, String> {
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 核心理念
|
## 核心理念
|
||||||
|
|
||||||
**一切皆 Skill。**
|
**一切皆 Skill。【todo:向内置工具转变】**
|
||||||
|
|
||||||
没有"内置工具"和"外部技能"的区别——所有工具都是 SKILL.md,区别仅在来源:
|
没有"内置工具"和"外部技能"的区别——所有工具都是 SKILL.md,区别仅在来源:
|
||||||
- **系统 skills** — `resources/skills/` 目录,随 iAs 分发,只读
|
- **系统 skills** — `resources/skills/` 目录,随 iAs 分发,只读
|
||||||
|
|||||||
+6
-10
@@ -44,7 +44,7 @@ impl ApprovalManager {
|
|||||||
user_id: &str,
|
user_id: &str,
|
||||||
skill_name: &str,
|
skill_name: &str,
|
||||||
) -> Result<(String, oneshot::Receiver<ApprovalDecision>), String> {
|
) -> Result<(String, oneshot::Receiver<ApprovalDecision>), 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_str = code.to_string();
|
||||||
let code_hash = format!("{:x}", Sha256::digest(code_str.as_bytes()));
|
let code_hash = format!("{:x}", Sha256::digest(code_str.as_bytes()));
|
||||||
|
|
||||||
@@ -92,7 +92,9 @@ impl ApprovalManager {
|
|||||||
let (result, name, code_hash) = {
|
let (result, name, code_hash) = {
|
||||||
let mut map = self.pending.lock().await;
|
let mut map = self.pending.lock().await;
|
||||||
let entries = map.get_mut(user_id)?;
|
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_hash = format!("{:x}", Sha256::digest(reply.as_bytes()));
|
||||||
let reply_trimmed = reply.trim();
|
let reply_trimmed = reply.trim();
|
||||||
@@ -157,11 +159,11 @@ impl ApprovalManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref pool) = self.pool {
|
if let Some(ref pool) = self.pool {
|
||||||
for (_uid, _i, _code_hash) in &expired {
|
for (_uid, _i, code_hash) in &expired {
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"UPDATE pending_approvals SET status = 'expired' WHERE code_hash = $1 AND status = 'pending'",
|
"UPDATE pending_approvals SET status = 'expired' WHERE code_hash = $1 AND status = 'pending'",
|
||||||
)
|
)
|
||||||
.bind(_code_hash)
|
.bind(code_hash)
|
||||||
.execute(pool.as_ref())
|
.execute(pool.as_ref())
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -178,10 +180,4 @@ impl ApprovalManager {
|
|||||||
}
|
}
|
||||||
map.retain(|_, v| !v.is_empty());
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-17
@@ -10,8 +10,7 @@ impl BuiltinRegistry {
|
|||||||
"get_current_datetime" => Some(execute_datetime()),
|
"get_current_datetime" => Some(execute_datetime()),
|
||||||
"manage_memos" => Some(handle_memos(args_json).await),
|
"manage_memos" => Some(handle_memos(args_json).await),
|
||||||
"query_weather" => {
|
"query_weather" => {
|
||||||
let params: serde_json::Value =
|
let params: serde_json::Value = serde_json::from_str(args_json).unwrap_or_default();
|
||||||
serde_json::from_str(args_json).unwrap_or_default();
|
|
||||||
Some(super::weather::execute(params).await)
|
Some(super::weather::execute(params).await)
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -31,7 +30,7 @@ impl BuiltinRegistry {
|
|||||||
super::skill::SkillSpec {
|
super::skill::SkillSpec {
|
||||||
name: "manage_memos".into(),
|
name: "manage_memos".into(),
|
||||||
description: "管理备忘录:添加/列出/删除。高风险操作(add/delete)需确认".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"}}}),
|
parameters: serde_json::json!({"type":"object","properties":{"action":{"type":"string","enum":["add","list","delete"]},"content":{"type":"string"},"id":{"type":"integer"}}}),
|
||||||
timeout_secs: 10,
|
timeout_secs: 10,
|
||||||
},
|
},
|
||||||
@@ -40,7 +39,9 @@ impl BuiltinRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_high_risk(name: &str) -> bool {
|
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 {
|
pub fn is_builtin(name: &str) -> bool {
|
||||||
@@ -59,20 +60,32 @@ async fn handle_memos(args_json: &str) -> SkillResult {
|
|||||||
// 如果顶层没有 action,尝试从 prompt 推断
|
// 如果顶层没有 action,尝试从 prompt 推断
|
||||||
if params.get("action").is_none() {
|
if params.get("action").is_none() {
|
||||||
let prompt = params.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
|
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"
|
"add"
|
||||||
} else if prompt.contains("删除") || prompt.contains("移除") || prompt.contains("delete") {
|
} else if prompt.contains("删除") || prompt.contains("移除") || prompt.contains("delete")
|
||||||
|
{
|
||||||
"delete"
|
"delete"
|
||||||
} else {
|
} else {
|
||||||
"list"
|
"list"
|
||||||
};
|
};
|
||||||
let content = params.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
|
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::<String>().parse().unwrap_or(0);
|
let memo_id: i32 = content
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_digit())
|
||||||
|
.collect::<String>()
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(0);
|
||||||
params = serde_json::json!({"action": action, "content": content, "id": memo_id});
|
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";
|
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<serde_json::Value> = if std::path::Path::new(path).exists() {
|
let mut data: Vec<serde_json::Value> = if std::path::Path::new(path).exists() {
|
||||||
serde_json::from_str(&std::fs::read_to_string(path).unwrap_or_default()).unwrap_or_default()
|
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 {
|
match action {
|
||||||
"add" => {
|
"add" => {
|
||||||
let content = params.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
let content = params.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
if content.is_empty() { return SkillResult::error("请提供 content 参数"); }
|
if content.is_empty() {
|
||||||
let next_id = data.iter().filter_map(|m| m.get("id").and_then(|v| v.as_i64())).max().unwrap_or(0) + 1;
|
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!({
|
data.push(serde_json::json!({
|
||||||
"id": next_id, "content": content,
|
"id": next_id, "content": content,
|
||||||
"created_at": Local::now().format("%Y-%m-%d %H:%M").to_string()
|
"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))
|
SkillResult::ok(format!("已添加备忘录 #{}", next_id))
|
||||||
}
|
}
|
||||||
"list" => {
|
"list" => {
|
||||||
if data.is_empty() { return SkillResult::ok("暂无备忘录".to_string()); }
|
if data.is_empty() {
|
||||||
let lines: Vec<String> = data.iter().map(|m| format!(
|
return SkillResult::ok("暂无备忘录".to_string());
|
||||||
"#{} [{}] {}",
|
}
|
||||||
m["id"], m.get("created_at").and_then(|v| v.as_str()).unwrap_or("?"), m["content"].as_str().unwrap_or("?")
|
let lines: Vec<String> = data
|
||||||
)).collect();
|
.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"))
|
SkillResult::ok(lines.join("\n"))
|
||||||
}
|
}
|
||||||
"delete" => {
|
"delete" => {
|
||||||
let id = params.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
|
let id = params.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
let before = data.len();
|
let before = data.len();
|
||||||
data.retain(|m| m.get("id").and_then(|v| v.as_i64()) != Some(id));
|
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()) {
|
if let Err(e) = std::fs::write(path, serde_json::to_string_pretty(&data).unwrap()) {
|
||||||
return SkillResult::error(format!("写入备忘录失败: {}", e));
|
return SkillResult::error(format!("写入备忘录失败: {}", e));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
pub mod approval;
|
pub mod approval;
|
||||||
pub mod builtin;
|
pub mod builtin;
|
||||||
pub mod parser;
|
|
||||||
pub mod registry;
|
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod weather;
|
pub mod weather;
|
||||||
|
|||||||
@@ -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<Skill, String> {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<String, Arc<Skill>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<String>) {
|
|
||||||
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<String>) {
|
|
||||||
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::<serde_json::Value>(&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<dyn Fn(&str, &str) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> + Send + Sync>;
|
|
||||||
|
|
||||||
pub struct ExecutionContext {
|
|
||||||
pub user_id: String,
|
|
||||||
pub db: Option<Arc<sqlx::PgPool>>,
|
|
||||||
/// 审批管理器
|
|
||||||
pub approval_manager: Option<Arc<ApprovalManager>>,
|
|
||||||
/// 发送微信消息的回调
|
|
||||||
pub send_wechat: Option<WechatSender>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ExecutionContext {
|
|
||||||
pub fn new(user_id: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
user_id: user_id.into(),
|
|
||||||
db: None,
|
|
||||||
approval_manager: None,
|
|
||||||
send_wechat: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_db(mut self, db: Arc<sqlx::PgPool>) -> Self {
|
|
||||||
self.db = Some(db);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_approval(mut self, mgr: Arc<ApprovalManager>) -> Self {
|
|
||||||
self.approval_manager = Some(mgr);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_wechat(mut self, send: WechatSender) -> Self {
|
|
||||||
self.send_wechat = Some(send);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+33
-95
@@ -1,15 +1,15 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
// ─── 风险等级 ───
|
// ─── 风险等级 ───
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum RiskLevel {
|
pub enum RiskLevel {
|
||||||
Low, // 只读/安全,自动执行
|
Low,
|
||||||
High, // 写操作/有风险,需微信确认码审批
|
High,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RiskLevel {
|
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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct SkillSpec {
|
pub struct SkillSpec {
|
||||||
@@ -49,41 +32,11 @@ pub struct SkillSpec {
|
|||||||
pub timeout_secs: u64,
|
pub timeout_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_risk_level() -> RiskLevel {
|
fn default_risk_level() -> RiskLevel { RiskLevel::Low }
|
||||||
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<dyn Fn(&str, &str) -> Result<(), String> + Send + Sync>;
|
|
||||||
pub type ReplyWaiter = Arc<dyn Fn(&str, Duration) -> Result<Option<String>, String> + Send + Sync>;
|
|
||||||
|
|
||||||
/// LLM 能看到的结果
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct SkillResult {
|
pub struct SkillResult {
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
@@ -94,63 +47,48 @@ pub struct SkillResult {
|
|||||||
|
|
||||||
impl SkillResult {
|
impl SkillResult {
|
||||||
pub fn ok(output: impl Into<String>) -> Self {
|
pub fn ok(output: impl Into<String>) -> Self {
|
||||||
Self {
|
Self { success: true, output: output.into(), data: None }
|
||||||
success: true,
|
|
||||||
output: output.into(),
|
|
||||||
data: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ok_with_data(output: impl Into<String>, data: serde_json::Value) -> Self {
|
pub fn ok_with_data(output: impl Into<String>, data: serde_json::Value) -> Self {
|
||||||
Self {
|
Self { success: true, output: output.into(), data: Some(data) }
|
||||||
success: true,
|
|
||||||
output: output.into(),
|
|
||||||
data: Some(data),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rejected(skill_name: &str) -> Self {
|
pub fn rejected(skill_name: &str) -> Self {
|
||||||
Self {
|
Self { success: false, output: format!("用户拒绝了你的调用:{}", skill_name), data: None }
|
||||||
success: false,
|
|
||||||
output: format!("用户拒绝了你的调用:{}", skill_name),
|
|
||||||
data: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn expired(skill_name: &str) -> Self {
|
pub fn expired(skill_name: &str) -> Self {
|
||||||
Self {
|
Self { success: false, output: format!("用户没有确认操作:{}", skill_name), data: None }
|
||||||
success: false,
|
|
||||||
output: format!("用户没有确认操作:{}", skill_name),
|
|
||||||
data: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn error(msg: impl Into<String>) -> Self {
|
pub fn error(msg: impl Into<String>) -> Self {
|
||||||
Self {
|
Self { success: false, output: msg.into(), data: None }
|
||||||
success: false,
|
|
||||||
output: msg.into(),
|
|
||||||
data: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 错误 ───
|
// ─── 执行上下文 ───
|
||||||
|
|
||||||
#[derive(Debug)]
|
pub type WechatSender = Arc<dyn Fn(&str, &str) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> + Send + Sync>;
|
||||||
pub enum SkillError {
|
|
||||||
NotFound(String),
|
pub struct ExecutionContext {
|
||||||
Execution(String),
|
pub user_id: String,
|
||||||
Timeout(String),
|
pub approval_manager: Option<Arc<crate::tools::approval::ApprovalManager>>,
|
||||||
InvalidParams(String),
|
pub send_wechat: Option<WechatSender>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for SkillError {
|
impl ExecutionContext {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
pub fn new(user_id: impl Into<String>) -> Self {
|
||||||
match self {
|
Self { user_id: user_id.into(), approval_manager: None, send_wechat: None }
|
||||||
SkillError::NotFound(name) => write!(f, "技能不存在: {}", name),
|
}
|
||||||
SkillError::Execution(msg) => write!(f, "执行失败: {}", msg),
|
|
||||||
SkillError::Timeout(name) => write!(f, "执行超时: {}", name),
|
pub fn with_approval(mut self, mgr: Arc<crate::tools::approval::ApprovalManager>) -> Self {
|
||||||
SkillError::InvalidParams(msg) => write!(f, "参数无效: {}", msg),
|
self.approval_manager = Some(mgr);
|
||||||
}
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_wechat(mut self, send: WechatSender) -> Self {
|
||||||
|
self.send_wechat = Some(send);
|
||||||
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-31
@@ -90,6 +90,7 @@ struct CityLookupResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
struct CityLocation {
|
struct CityLocation {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -246,13 +247,10 @@ impl QWeatherClient {
|
|||||||
return Ok(id);
|
return Ok(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = format!(
|
|
||||||
"https://geoapi.qweather.com/v2/city/lookup?location={}&number=1",
|
|
||||||
location
|
|
||||||
);
|
|
||||||
let resp: CityLookupResponse = self
|
let resp: CityLookupResponse = self
|
||||||
.http
|
.http
|
||||||
.get(&url)
|
.get("https://geoapi.qweather.com/v2/city/lookup")
|
||||||
|
.query(&[("location", location), ("number", "1")])
|
||||||
.header("Authorization", format!("Bearer {}", self.jwt))
|
.header("Authorization", format!("Bearer {}", self.jwt))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -279,13 +277,10 @@ impl QWeatherClient {
|
|||||||
|
|
||||||
/// 实时天气
|
/// 实时天气
|
||||||
async fn weather_now(&self, city_id: &str) -> Result<serde_json::Value, String> {
|
async fn weather_now(&self, city_id: &str) -> Result<serde_json::Value, String> {
|
||||||
let url = format!(
|
|
||||||
"https://{}/v7/weather/now?location={}&lang=zh&unit=m",
|
|
||||||
self.api_host, city_id
|
|
||||||
);
|
|
||||||
let resp: WeatherNowResponse = self
|
let resp: WeatherNowResponse = self
|
||||||
.http
|
.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))
|
.header("Authorization", format!("Bearer {}", self.jwt))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -328,13 +323,10 @@ impl QWeatherClient {
|
|||||||
_ => "30d",
|
_ => "30d",
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = format!(
|
|
||||||
"https://{}/v7/weather/{}?location={}&lang=zh&unit=m",
|
|
||||||
self.api_host, d, city_id
|
|
||||||
);
|
|
||||||
let resp: DailyForecastResponse = self
|
let resp: DailyForecastResponse = self
|
||||||
.http
|
.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))
|
.header("Authorization", format!("Bearer {}", self.jwt))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -381,13 +373,10 @@ impl QWeatherClient {
|
|||||||
_ => "168h",
|
_ => "168h",
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = format!(
|
|
||||||
"https://{}/v7/weather/{}?location={}&lang=zh&unit=m",
|
|
||||||
self.api_host, h, city_id
|
|
||||||
);
|
|
||||||
let resp: HourlyForecastResponse = self
|
let resp: HourlyForecastResponse = self
|
||||||
.http
|
.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))
|
.header("Authorization", format!("Bearer {}", self.jwt))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -448,12 +437,6 @@ fn extract_params(params: &serde_json::Value) -> (&str, u32, u32) {
|
|||||||
let location = params
|
let location = params
|
||||||
.get("location")
|
.get("location")
|
||||||
.and_then(|v| v.as_str())
|
.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("北京");
|
.unwrap_or("北京");
|
||||||
|
|
||||||
let days = params
|
let days = params
|
||||||
@@ -471,8 +454,75 @@ fn extract_params(params: &serde_json::Value) -> (&str, u32, u32) {
|
|||||||
(location, days.max(1), hours)
|
(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::<String>()
|
||||||
|
.chars()
|
||||||
|
.rev()
|
||||||
|
.collect::<String>()
|
||||||
|
.into()
|
||||||
|
{
|
||||||
|
if let Ok(n) = num_str.parse::<u32>() {
|
||||||
|
return n.min(30).max(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 // 默认
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn extract_city_from_prompt(prompt: &str) -> &str {
|
||||||
|
// 去掉常见前缀,取第一个2-3字中文词。GeoAPI 支持模糊搜索,
|
||||||
|
// 即使多取一两个字也能正确匹配到城市
|
||||||
|
let chars: Vec<char> = 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 {
|
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);
|
let (location, days, hours) = extract_params(¶ms);
|
||||||
|
|
||||||
tracing::info!("🌤️ 查询天气: location={}, days={}, hours={}", location, days, hours);
|
tracing::info!("🌤️ 查询天气: location={}, days={}, hours={}", location, days, hours);
|
||||||
@@ -574,9 +624,35 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_spec() {
|
fn test_extract_city_from_prompt() {
|
||||||
let s = spec();
|
// 取前缀后的连续中文给 GeoAPI 模糊搜索(多取不影响结果)
|
||||||
assert_eq!(s.name, "query_weather");
|
assert_eq!(extract_city_from_prompt("查询合肥未来7天的天气预报"), "合肥未来");
|
||||||
assert_eq!(s.risk_level, RiskLevel::Low);
|
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(), "应有预报");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user