Compare commits
22 Commits
2bd4485af6
...
1aef0c2f69
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aef0c2f69 | |||
| 6708fe33a0 | |||
| fe83687495 | |||
| 3a7e40c174 | |||
| 15e2a01dec | |||
| 065229b9da | |||
| 88de5597b3 | |||
| e5c288ac6f | |||
| 079150b1d0 | |||
| a32593bc25 | |||
| 3f447668b8 | |||
| b710518a1f | |||
| a549bd3b2f | |||
| 7ab5052642 | |||
| 6dd12425d6 | |||
| 5602b08faf | |||
| 5daaba686b | |||
| 81c736f873 | |||
| fac2016b7e | |||
| f7ba23db94 | |||
| e88f3240c4 | |||
| 1497b1bba0 |
@@ -0,0 +1,171 @@
|
||||
# iAs 项目参考文档
|
||||
|
||||
> 自动生成于 2026-06-02,供新会话快速了解当前状态。
|
||||
> 4950+ 行 Rust,20 次安全审查迭代后稳定运行。
|
||||
|
||||
---
|
||||
|
||||
## 项目定位
|
||||
|
||||
微信 AI 助手。扫码登录微信 iLink Bot,长轮询接收消息,DeepSeek 回复。
|
||||
支持工具调用(天气/搜索/邮件/备忘录/定时任务)、长期记忆、审批确认、Token 统计。
|
||||
|
||||
---
|
||||
|
||||
## 运行方式
|
||||
|
||||
```bash
|
||||
ias login # 扫码登录
|
||||
ias listen --llm # 监听消息 + AI 回复
|
||||
ias service # 后台模式(日志写入 ~/.ias/logs/)
|
||||
ias usage # Token 统计
|
||||
ias skills list # 列出可导入 Skill
|
||||
ias skills import # 交互式导入 Skill 到 ~/.ias/skills/
|
||||
ias skills delete # 交互式删除
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 源码结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs (900行) CLI 入口 + 命令分发 + 监听循环 + 工具执行器
|
||||
├── cli.rs clap 子命令定义
|
||||
├── logger.rs 日志初始化(终端 + 文件滚动)
|
||||
├── state.rs 文件状态管理(auth/runtime,PostgreSQL 不可用时的降级方案)
|
||||
├── scheduler.rs 定时任务调度器(轮询 PostgreSQL + 事务保护)
|
||||
├── skills_importer.rs Skills 导入扫描器
|
||||
├── context/ 上下文管理
|
||||
│ ├── types.rs ChatSession(checkpoint/messages/summaries/user_id)
|
||||
│ ├── builder.rs token budget 构建 + 摘要触发
|
||||
│ ├── tools.rs MemoryStore(HashMap<user_id, Vec<mem>>)+ read_summaries
|
||||
│ └── DESIGN.md 上下文管理设计(未完全实现)
|
||||
├── db/ 数据库
|
||||
│ ├── mod.rs Database 连接池 + 迁移
|
||||
│ └── models.rs CRUD(auth/chat/usage/summary)
|
||||
├── llm/ 对话系统
|
||||
│ ├── types.rs Message / Role / StreamChunk / Usage
|
||||
│ ├── provider.rs create_provider / parse_chat_chunk(SSE 解析)
|
||||
│ ├── deepseek.rs DeepSeek 流式客户端(thinking + tool_calls)
|
||||
│ └── conversation.rs Conversation(chat + chat_with_tools 工具循环)
|
||||
├── tools/ 工具系统
|
||||
│ ├── skill.rs SkillSpec / RiskLevel / SkillResult
|
||||
│ ├── registry.rs SkillRegistry(加载/执行/审批流程)
|
||||
│ ├── builtin.rs BuiltinRegistry(datetime + memos)
|
||||
│ ├── parser.rs SKILL.md 解析器(支持 YAML frontmatter)
|
||||
│ ├── approval.rs ApprovalManager(确认码 + oneshot 通道)
|
||||
│ └── DESIGN.md 工具系统设计文档
|
||||
└── wechat/ 微信通道
|
||||
├── types.rs iLink API 协议类型
|
||||
└── client.rs HTTP 客户端(login/poll/send/notify)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置(仅环境变量)
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `DEEPSEEK_API_KEY` | DeepSeek API 密钥 |
|
||||
| `DEEPSEEK_MODEL` | 模型名(默认 deepseek-v4-flash) |
|
||||
| `DATABASE_URL` | PostgreSQL 连接串(可选,无则用文件存储) |
|
||||
| `TAVILY_API_KEY` | Tavily 搜索 API |
|
||||
| `QWEATHER_API_HOST` | 和风天气 API |
|
||||
| `QWEATHER_JWT_KEY_ID/PROJECT_ID/PRIVATE_KEY_FILE` | 天气 JWT |
|
||||
| `WEIXIN_LLM_SYSTEM_PROMPT` | 自定义系统提示词 |
|
||||
|
||||
加载顺序:`./.env` → `~/.ias/.env` → 环境变量
|
||||
|
||||
---
|
||||
|
||||
## 工具架构
|
||||
|
||||
### LLM 看到的工具(Tool List)
|
||||
|
||||
```
|
||||
query_capabilities → 列出所有可用工具及说明
|
||||
call_capability → 按名称调用(name + prompt)
|
||||
read_memories → 读长期记忆
|
||||
write_memory → 写长期记忆
|
||||
read_summaries → 读历史摘要
|
||||
```
|
||||
|
||||
### 工具执行路径
|
||||
|
||||
```
|
||||
executor:
|
||||
├── 上下文工具: read_memories / write_memory / read_summaries
|
||||
├── query_capabilities: 合并内置 + 外部 SkillSpecs
|
||||
├── BuiltinRegistry(内置 Rust 工具):
|
||||
│ ├── get_current_datetime → chrono::Local::now()
|
||||
│ └── manage_memos → 文件 I/O(High 先审批)
|
||||
└── SkillRegistry(外部 Skills):
|
||||
├── query_weather → bash + openssl JWT
|
||||
├── search_web → python Tavily
|
||||
├── email → node.js IMAP
|
||||
├── execute_shell → bash(High)
|
||||
├── list_scheduled_tasks → psql
|
||||
├── manage_scheduled_task → psql(High)
|
||||
└── manage_skill → bash(High)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 审批流程
|
||||
|
||||
```
|
||||
High 风险工具 → builtin_approve() 或 approval_flow()
|
||||
├── 生成 6 位确认码 → SHA-256 入库
|
||||
├── 微信发送确认消息
|
||||
├── await oneshot::Receiver(最多 300s)
|
||||
├── 用户回复 → handle_reply() → oneshot::Sender
|
||||
└── 结果返回 LLM(审批过程 LLM 不可见)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 上下文管理(当前实现)
|
||||
|
||||
- **Token Budget**:28K tokens,按中/英文字符估算
|
||||
- **检查点**:chatSession.checkpoint,之前的消息已被压缩
|
||||
- **摘要触发**:预算溢出或 12h 空闲
|
||||
- **用户切换**:清空 session + 加载新用户历史 + 加载记忆
|
||||
- **摘要生成**:简单截断占位(未实现 LLM 生成)
|
||||
|
||||
---
|
||||
|
||||
## 数据库表
|
||||
|
||||
| 表 | 用途 |
|
||||
|----|------|
|
||||
| `app_state` | 认证 KV 存储 |
|
||||
| `chat_records` | 收发消息记录 |
|
||||
| `llm_usage` | Token 用量(缓存命中率) |
|
||||
| `user_memories` | 长期记忆(按 user_id) |
|
||||
| `pending_approvals` | 审批记录 |
|
||||
| `session_summaries` | 会话摘要 |
|
||||
| `scheduled_tasks` | 定时任务 |
|
||||
|
||||
---
|
||||
|
||||
## 已知限制
|
||||
|
||||
| 问题 | 状态 |
|
||||
|------|------|
|
||||
| 多用户并发(共享 Conversation) | ⚠️ 单用户假设 |
|
||||
| 上下文摘要未用 LLM 生成 | ⚠️ 占位截断 |
|
||||
| 子代理模式(call_capability prompt → 参数) | ⚠️ 简单关键词提取 |
|
||||
| 外部 Skills 依赖 python3/psql/node | ⚠️ 需运行时 |
|
||||
| 核心工具未全用 Rust(weather/search/email) | ⚠️ 仍为 bash |
|
||||
|
||||
---
|
||||
|
||||
## 最近重要 commit
|
||||
|
||||
```
|
||||
6708fe3 fix: 3 项问题 (审批DB精确匹配 + delete id提取 + 清理变量)
|
||||
065229b fix: 优先审批 + 首用户隔离 + 审批DB闭环 + memo错误
|
||||
079150b refactor: 删除 config.json + 内置工具抽离 + 统一接口
|
||||
b710518 refactor: 工具简化为「查询 + 调用」二元接口
|
||||
```
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"storage": {
|
||||
"state_dir": ".data/weixin-ilink",
|
||||
"database_url": ""
|
||||
},
|
||||
"llm": {
|
||||
"provider": "${WEIXIN_LLM_PROVIDER}",
|
||||
"deepseek": {
|
||||
"api_key": "${DEEPSEEK_API_KEY}",
|
||||
"model": "${DEEPSEEK_MODEL}",
|
||||
"base_url": "${DEEPSEEK_BASE_URL}"
|
||||
},
|
||||
"lmstudio": {
|
||||
"base_url": "${LM_STUDIO_BASE_URL}",
|
||||
"model": "${LM_STUDIO_MODEL}",
|
||||
"api_key": "${LM_STUDIO_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
-- scheduled_tasks.user_id 默认值修复
|
||||
ALTER TABLE scheduled_tasks ALTER COLUMN user_id SET DEFAULT '';
|
||||
ALTER TABLE scheduled_tasks ALTER COLUMN user_id DROP NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 聊天记录去重索引(普通索引,不用 UNIQUE 避免存量重复数据冲突)
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_records_message_id
|
||||
ON chat_records (message_id) WHERE message_id != '';
|
||||
@@ -1,26 +1,8 @@
|
||||
# get_current_datetime
|
||||
|
||||
获取当前日期时间(北京时间 UTC+8)。
|
||||
|
||||
获取当前日期时间(UTC+8)。内置 Rust 实现,无脚本依赖。
|
||||
## Risk Level
|
||||
Low
|
||||
|
||||
## Parameters
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"format": {
|
||||
"type": "string",
|
||||
"description": "时间格式: full | date | time",
|
||||
"enum": ["full", "date", "time"],
|
||||
"default": "full"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Execute
|
||||
```bash
|
||||
scripts/datetime.sh
|
||||
# Builtin: Rust
|
||||
```
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 读取 stdin JSON 参数
|
||||
read -r input
|
||||
format=$(echo "$input" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get('format', 'full'))
|
||||
except: print('full')
|
||||
" 2>/dev/null || echo "full")
|
||||
|
||||
case "$format" in
|
||||
date) TZ='Asia/Shanghai' date '+%Y-%m-%d' ;;
|
||||
time) TZ='Asia/Shanghai' date '+%H:%M:%S' ;;
|
||||
*) TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M:%S' ;;
|
||||
esac
|
||||
@@ -1,27 +1,26 @@
|
||||
#!/bin/bash
|
||||
# 列出定时任务(从 PostgreSQL)
|
||||
set -e
|
||||
|
||||
# 列出定时任务 → JSON 输出
|
||||
DB_URL="${DATABASE_URL}"
|
||||
if [ -z "$DB_URL" ]; then
|
||||
echo "数据库未配置(DATABASE_URL)"
|
||||
exit 0
|
||||
fi
|
||||
[ -z "$DB_URL" ] && echo '{"tasks":[],"error":"数据库未配置"}' && exit 0
|
||||
|
||||
# 使用 psql 查询
|
||||
psql "$DB_URL" -t -A -F '|' -c "
|
||||
SELECT name, description, enabled, next_run_at, interval_seconds
|
||||
FROM scheduled_tasks
|
||||
ORDER BY name
|
||||
" 2>/dev/null | while IFS='|' read -r name desc enabled next_run interval; do
|
||||
if [ -n "$name" ]; then
|
||||
echo "- $name${desc:+ ($desc)}"
|
||||
echo " 启用: $enabled | 间隔: ${interval}s | 下次: ${next_run:-无}"
|
||||
fi
|
||||
done
|
||||
|
||||
# 检查是否有任何任务
|
||||
COUNT=$(psql "$DB_URL" -t -A -c "SELECT count(*) FROM scheduled_tasks" 2>/dev/null || echo "0")
|
||||
if [ "$COUNT" = "0" ] || [ -z "$COUNT" ]; then
|
||||
echo "暂无定时任务"
|
||||
fi
|
||||
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,36 +1,8 @@
|
||||
# manage_memos
|
||||
|
||||
管理备忘录:添加、列出、删除。
|
||||
|
||||
备忘录存储在 `.data/memos.json` 文件中。
|
||||
|
||||
管理备忘录(添加/列出/删除)。内置 Rust 实现,无脚本依赖。
|
||||
## Risk Level
|
||||
Low
|
||||
|
||||
## Parameters
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "操作类型",
|
||||
"enum": ["add", "list", "delete"]
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "备忘内容(add 时需要)"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "备忘编号(delete 时需要)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
```
|
||||
|
||||
High
|
||||
## Execute
|
||||
```bash
|
||||
scripts/memo.sh
|
||||
# Builtin: Rust
|
||||
```
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 备忘录管理 — 存储 .data/memos.json
|
||||
MEMO_FILE=".data/memos.json"
|
||||
mkdir -p "$(dirname "$MEMO_FILE")"
|
||||
[ ! -f "$MEMO_FILE" ] && echo '[]' > "$MEMO_FILE"
|
||||
|
||||
read -r INPUT
|
||||
export MEMO_INPUT="$INPUT"
|
||||
|
||||
python3 - "$MEMO_FILE" << 'PYEOF'
|
||||
import sys, json, os, datetime
|
||||
|
||||
path = sys.argv[1]
|
||||
input_raw = os.environ.get('MEMO_INPUT', '{}')
|
||||
|
||||
try: req = json.loads(input_raw)
|
||||
except:
|
||||
print('无效的 JSON 输入')
|
||||
sys.exit(1)
|
||||
|
||||
action = req.get('action', 'list')
|
||||
content = req.get('content', '')
|
||||
memo_id = req.get('id', '')
|
||||
|
||||
data = json.load(open(path)) if os.path.exists(path) and os.path.getsize(path) > 0 else []
|
||||
|
||||
if action == 'add':
|
||||
if not content:
|
||||
print('请提供 content 参数')
|
||||
sys.exit(1)
|
||||
next_id = max([m.get('id', 0) for m in data], default=0) + 1
|
||||
data.append({'id': next_id, 'content': content, 'created_at': datetime.datetime.now().isoformat()})
|
||||
json.dump(data, open(path, 'w'), ensure_ascii=False, indent=2)
|
||||
print(f'已添加备忘录 #{next_id}')
|
||||
|
||||
elif action == 'list':
|
||||
if not data:
|
||||
print('暂无备忘录')
|
||||
else:
|
||||
for m in data:
|
||||
ts = m.get('created_at', '?')[:16]
|
||||
print(f"#{m['id']} [{ts}] {m['content']}")
|
||||
|
||||
elif action == 'delete':
|
||||
if not memo_id:
|
||||
print('请提供 id 参数')
|
||||
sys.exit(1)
|
||||
new = [m for m in data if str(m.get('id', '')) != str(memo_id)]
|
||||
if len(new) == len(data):
|
||||
print(f'未找到备忘 #{memo_id}')
|
||||
else:
|
||||
json.dump(new, open(path, 'w'), ensure_ascii=False, indent=2)
|
||||
print(f'已删除备忘 #{memo_id}')
|
||||
|
||||
else:
|
||||
print(f'未知操作: {action}(支持: add, list, delete)')
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
@@ -1,7 +1,5 @@
|
||||
#!/bin/bash
|
||||
# 管理定时任务
|
||||
set -e
|
||||
|
||||
# 管理定时任务 → 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)
|
||||
@@ -10,39 +8,40 @@ INTERVAL=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdi
|
||||
DESC=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('description',''))" 2>/dev/null)
|
||||
|
||||
DB_URL="${DATABASE_URL}"
|
||||
|
||||
if [ -z "$DB_URL" ]; then
|
||||
echo "数据库未配置(DATABASE_URL)"
|
||||
exit 1
|
||||
fi
|
||||
[ -z "$DB_URL" ] && echo '{"error":"数据库未配置"}' && exit 0
|
||||
[ -z "$NAME" ] && echo '{"error":"请提供 name 参数"}' && exit 0
|
||||
|
||||
case "$ACTION" in
|
||||
add)
|
||||
if [ -z "$CMD" ]; then
|
||||
echo "add 操作需要 command 参数"
|
||||
exit 1
|
||||
fi
|
||||
psql "$DB_URL" -c "
|
||||
INSERT INTO scheduled_tasks (id, name, description, command, interval_seconds, enabled, next_run_at)
|
||||
VALUES (gen_random_uuid(), '$NAME', '$DESC', '$CMD', $INTERVAL, true, NOW() + interval '$INTERVAL seconds')
|
||||
ON CONFLICT (name) DO UPDATE
|
||||
SET command = EXCLUDED.command, interval_seconds = EXCLUDED.interval_seconds, enabled = true, description = EXCLUDED.description
|
||||
" 2>/dev/null && echo "✅ 已添加/更新定时任务: $NAME" || echo "❌ 添加失败(表 scheduled_tasks 可能未创建)"
|
||||
;;
|
||||
delete)
|
||||
psql "$DB_URL" -c "DELETE FROM scheduled_tasks WHERE name = '$NAME'" 2>/dev/null
|
||||
echo "✅ 已删除定时任务: $NAME"
|
||||
;;
|
||||
enable)
|
||||
psql "$DB_URL" -c "UPDATE scheduled_tasks SET enabled = true WHERE name = '$NAME'" 2>/dev/null
|
||||
echo "✅ 已启用定时任务: $NAME"
|
||||
;;
|
||||
disable)
|
||||
psql "$DB_URL" -c "UPDATE scheduled_tasks SET enabled = false WHERE name = '$NAME'" 2>/dev/null
|
||||
echo "✅ 已禁用定时任务: $NAME"
|
||||
;;
|
||||
*)
|
||||
echo "未知操作: $ACTION(支持: add, delete, enable, disable)"
|
||||
exit 1
|
||||
;;
|
||||
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,160 +1,56 @@
|
||||
#!/bin/bash
|
||||
# 和风天气查询脚本
|
||||
# 读取 stdin JSON: {"location":"北京","days":3}
|
||||
|
||||
# 和风天气查询 → 输出 JSON
|
||||
read -r input
|
||||
LOCATION=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('location',''))" 2>/dev/null)
|
||||
DAYS=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('days',3))" 2>/dev/null)
|
||||
|
||||
LOCATION=$(echo "$input" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get('location', ''))
|
||||
" 2>/dev/null)
|
||||
[ -z "$LOCATION" ] && echo '{"error":"请提供 location 参数"}' && exit 0
|
||||
|
||||
DAYS=$(echo "$input" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get('days', 3))
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$LOCATION" ]; then
|
||||
echo "请提供 location 参数"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 从环境变量读取配置
|
||||
API_HOST="${QWEATHER_API_HOST}"
|
||||
KEY_FILE="${QWEATHER_JWT_PRIVATE_KEY_FILE:-qweather/ed25519-private.pem}"
|
||||
KEY_ID="${QWEATHER_JWT_KEY_ID}"
|
||||
PROJECT_ID="${QWEATHER_JWT_PROJECT_ID}"
|
||||
KEY_FILE="${QWEATHER_JWT_PRIVATE_KEY_FILE:-qweather/ed25519-private.pem}"
|
||||
TTL="${QWEATHER_JWT_TTL_SECONDS:-3600}"
|
||||
|
||||
if [ -z "$API_HOST" ]; then
|
||||
echo "请设置 QWEATHER_API_HOST 环境变量"
|
||||
exit 1
|
||||
fi
|
||||
NOW=$(date +%s)
|
||||
HEADER=$(echo -n '{"alg":"EdDSA","kid":"'"$KEY_ID"'"}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
|
||||
PAYLOAD=$(echo -n '{"sub":"'"$PROJECT_ID"'","iat":'$((NOW-30))',"exp":'$((NOW+TTL))'}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
|
||||
INPUT="${HEADER}.${PAYLOAD}"
|
||||
TMP=$(mktemp) && echo -n "$INPUT" > "$TMP"
|
||||
SIG=$(openssl pkeyutl -sign -inkey "$KEY_FILE" -rawin -in "$TMP" 2>/dev/null | base64 -w0 | tr '+/' '-_' | tr -d '=')
|
||||
rm -f "$TMP"
|
||||
JWT="${INPUT}.${SIG}"
|
||||
[ -z "$JWT" ] && JWT="${QWEATHER_JWT}"
|
||||
[ -z "$JWT" ] && echo '{"error":"JWT 生成失败"}' && exit 0
|
||||
|
||||
# 生成 JWT(使用 python3)
|
||||
JWT=$(python3 -c "
|
||||
import json, time, base64, struct, sys
|
||||
from pathlib import Path
|
||||
ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$LOCATION'))")
|
||||
CITY=$(curl -s --compressed "https://geoapi.qweather.com/v2/city/lookup?location=${ENCODED}" -H "Authorization: Bearer $JWT")
|
||||
CITY_ID=$(echo "$CITY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['location'][0]['id'] if d.get('location') else '')" 2>/dev/null)
|
||||
[ -z "$CITY_ID" ] && echo '{"error":"未找到城市: '"$LOCATION"'"}' && exit 0
|
||||
|
||||
key_id = '$KEY_ID'
|
||||
project_id = '$PROJECT_ID'
|
||||
key_file = '$KEY_FILE'
|
||||
ttl = int('$TTL')
|
||||
|
||||
if not key_id or not project_id:
|
||||
sys.exit(0)
|
||||
|
||||
# Read Ed25519 private key
|
||||
key_path = Path(key_file)
|
||||
if not key_path.exists():
|
||||
sys.exit(0)
|
||||
|
||||
key_data = key_path.read_text()
|
||||
|
||||
# Simple base64url
|
||||
def b64url(data):
|
||||
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
|
||||
|
||||
# Extract raw key bytes from PEM
|
||||
import re
|
||||
pem_match = re.search(r'-----BEGIN PRIVATE KEY-----\\s*(.*?)\\s*-----END PRIVATE KEY-----', key_data, re.DOTALL)
|
||||
if not pem_match:
|
||||
sys.exit(0)
|
||||
|
||||
der = base64.b64decode(pem_match.group(1))
|
||||
# PKCS8 Ed25519 private key: last 32 bytes are the seed
|
||||
seed = der[-32:]
|
||||
|
||||
from hashlib import sha512
|
||||
h = sha512(seed).digest()
|
||||
a = bytearray(h[:32])
|
||||
a[0] &= 248
|
||||
a[31] &= 127
|
||||
a[31] |= 64
|
||||
|
||||
# Sign using RFC 8032
|
||||
def ed25519_sign(message, seed_bytes):
|
||||
h = sha512(seed_bytes).digest()
|
||||
a = bytearray(h[:32])
|
||||
a[0] &= 248
|
||||
a[31] &= 127
|
||||
a[31] |= 64
|
||||
r = sha512(bytes(a[32:]) + message).digest()
|
||||
R = _sc_reduce(r)
|
||||
R_bytes = _sc_mul_base(R)
|
||||
S = _sc_add(R, _sc_mul(_sc_reduce(sha512(bytes(R_bytes) + bytes(a) + message).digest()), _sc_reduce(bytes(a))))
|
||||
return bytes(R_bytes) + _sc_encode(S)
|
||||
|
||||
# Skipping full impl - use openssl instead
|
||||
print('')
|
||||
" 2>/dev/null)
|
||||
|
||||
# If python3 JWT failed, try openssl
|
||||
if [ -z "$JWT" ]; then
|
||||
NOW=$(date +%s)
|
||||
IAT=$((NOW - 30))
|
||||
EXP=$((NOW + TTL))
|
||||
|
||||
HEADER=$(echo -n '{"alg":"EdDSA","kid":"'"$KEY_ID"'"}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
|
||||
PAYLOAD=$(echo -n '{"sub":"'"$PROJECT_ID"'","iat":'"$IAT"',"exp":'"$EXP"'}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
|
||||
SIGNING_INPUT="${HEADER}.${PAYLOAD}"
|
||||
|
||||
# Try openssl pkeyutl (OpenSSL >= 3.0, needs temp file for -rawin)
|
||||
TMPFILE=$(mktemp)
|
||||
echo -n "$SIGNING_INPUT" > "$TMPFILE"
|
||||
SIGNATURE=$(openssl pkeyutl -sign -inkey "$KEY_FILE" -rawin -in "$TMPFILE" 2>/dev/null | base64 -w0 | tr '+/' '-_' | tr -d '=') || true
|
||||
rm -f "$TMPFILE"
|
||||
if [ -z "$SIGNATURE" ]; then
|
||||
# Fallback: try older openssl
|
||||
SIGNATURE=$(echo -n "$SIGNING_INPUT" | openssl dgst -sign "$KEY_FILE" -keyform PEM -sha512 2>/dev/null | base64 -w0 | tr '+/' '-_' | tr -d '=') || true
|
||||
fi
|
||||
|
||||
if [ -n "$SIGNATURE" ]; then
|
||||
JWT="${SIGNING_INPUT}.${SIGNATURE}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try env var JWT as fallback
|
||||
if [ -z "$JWT" ]; then
|
||||
JWT="${QWEATHER_JWT}"
|
||||
fi
|
||||
|
||||
if [ -z "$JWT" ]; then
|
||||
echo "无法生成 QWeather JWT,请检查 QWEATHER_JWT_KEY_ID/QWEATHER_JWT_PROJECT_ID/QWEATHER_JWT_PRIVATE_KEY_FILE 配置"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 查询城市 ID
|
||||
CITY_RESP=$(curl -s --compressed "https://geoapi.qweather.com/v2/city/lookup?location=$(echo -n "$LOCATION" | python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()))")" -H "Authorization: Bearer $JWT")
|
||||
CITY_ID=$(echo "$CITY_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['location'][0]['id'] if d.get('location') else '')" 2>/dev/null)
|
||||
|
||||
if [ -z "$CITY_ID" ]; then
|
||||
echo "未找到城市: $LOCATION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 查询实况天气
|
||||
EP_DAYS=$([ "$DAYS" -le 3 ] && echo 3 || echo 7)
|
||||
NOW_RESP=$(curl -s --compressed "https://${API_HOST}/v7/weather/now?location=${CITY_ID}&lang=zh&unit=m" -H "Authorization: Bearer $JWT")
|
||||
NOW_TEMP=$(echo "$NOW_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f\"{d['now']['temp']}°C, {d['now']['text']}\")" 2>/dev/null)
|
||||
DAILY_RESP=$(curl -s --compressed "https://${API_HOST}/v7/weather/${EP_DAYS}d?location=${CITY_ID}&lang=zh&unit=m" -H "Authorization: Bearer $JWT")
|
||||
|
||||
# 查询预报(最多7天)
|
||||
ENDPOINT_DAYS=$DAYS
|
||||
[ "$DAYS" -gt 3 ] && ENDPOINT_DAYS=7
|
||||
[ "$DAYS" -le 3 ] && ENDPOINT_DAYS=3
|
||||
|
||||
DAILY_RESP=$(curl -s --compressed "https://${API_HOST}/v7/weather/${ENDPOINT_DAYS}d?location=${CITY_ID}&lang=zh&unit=m" -H "Authorization: Bearer $JWT")
|
||||
FORECAST=$(echo "$DAILY_RESP" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
days = d.get('daily', [])[:int('$DAYS')]
|
||||
lines = ['未来天气:']
|
||||
for day in days:
|
||||
lines.append(f\" {day['fxDate']}: {day['textDay']} {day['tempMin']}~{day['tempMax']}°C\")
|
||||
print('\\n'.join(lines))
|
||||
" 2>/dev/null)
|
||||
|
||||
echo "${LOCATION}天气: 当前 ${NOW_TEMP}"
|
||||
echo ""
|
||||
echo "$FORECAST"
|
||||
export NOW_RESP DAILY_RESP LOCATION DAYS
|
||||
python3 -c "
|
||||
import os, json
|
||||
now = json.loads(os.environ['NOW_RESP'])
|
||||
daily = json.loads(os.environ['DAILY_RESP'])
|
||||
days = daily.get('daily', [])[:int(os.environ['DAYS'])]
|
||||
result = {
|
||||
'location': os.environ['LOCATION'],
|
||||
'now': {
|
||||
'temp': now['now']['temp'],
|
||||
'text': now['now']['text'],
|
||||
},
|
||||
'forecast': [
|
||||
{'date': d['fxDate'], 'day': d['textDay'], 'night': d.get('textNight',''), 'tempMin': d['tempMin'], 'tempMax': d['tempMax']}
|
||||
for d in days
|
||||
]
|
||||
}
|
||||
# 过滤空字段
|
||||
result['now'] = {k: v for k, v in result['now'].items() if v}
|
||||
result['forecast'] = [{k: v for k, v in f.items() if v} for f in result['forecast']]
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
" 2>/dev/null || echo '{"error":"解析天气数据失败"}'
|
||||
|
||||
@@ -1,54 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Tavily 搜索
|
||||
|
||||
# Tavily 搜索 → JSON 输出
|
||||
read -r input
|
||||
QUERY=$(echo "$input" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get('query', ''))
|
||||
" 2>/dev/null)
|
||||
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)
|
||||
|
||||
COUNT=$(echo "$input" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get('count', 5))
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$QUERY" ]; then
|
||||
echo "请提供 query 参数"
|
||||
exit 1
|
||||
fi
|
||||
[ -z "$QUERY" ] && echo '{"error":"请提供 query 参数"}' && exit 0
|
||||
|
||||
API_KEY="${TAVILY_API_KEY}"
|
||||
if [ -z "$API_KEY" ]; then
|
||||
echo "请设置 TAVILY_API_KEY 环境变量"
|
||||
exit 1
|
||||
fi
|
||||
[ -z "$API_KEY" ] && echo '{"error":"请设置 TAVILY_API_KEY"}' && exit 0
|
||||
|
||||
RESP=$(curl -s -X POST "https://api.tavily.com/search" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"api_key\":\"$API_KEY\",\"query\":\"$QUERY\",\"max_results\":$COUNT,\"include_answer\":true}")
|
||||
|
||||
# 解析并输出
|
||||
ANSWER=$(echo "$RESP" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
answer = d.get('answer', '')
|
||||
results = d.get('results', [])
|
||||
lines = []
|
||||
if answer:
|
||||
lines.append(f'总结: {answer}')
|
||||
lines.append('')
|
||||
for i, r in enumerate(results[:$COUNT], 1):
|
||||
title = r.get('title', '无标题')
|
||||
url = r.get('url', '')
|
||||
content = r.get('content', '')[:200]
|
||||
lines.append(f'{i}. {title}')
|
||||
lines.append(f' {url}')
|
||||
if content:
|
||||
lines.append(f' {content}')
|
||||
lines.append('')
|
||||
print('\\n'.join(lines))
|
||||
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)
|
||||
|
||||
echo "$ANSWER"
|
||||
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":"搜索请求失败"}'
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 高德地图开放平台
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,269 +0,0 @@
|
||||
# 高德地图综合服务 Skill
|
||||
|
||||
高德地图综合服务向开发者提供完整的地图数据服务,包括地点搜索、路径规划、旅游规划和数据可视化等功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 自动管理高德 Web Service Key
|
||||
- ✅ POI 搜索功能
|
||||
- ✅ 路径规划(步行、驾车、骑行、公交)
|
||||
- ✅ 智能旅游规划助手
|
||||
- ✅ 地图可视化链接生成
|
||||
- ✅ 热力图数据可视化
|
||||
- ✅ 支持命令行脚本执行
|
||||
- ✅ 配置本地持久化
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 配置 API Key
|
||||
|
||||
首次使用需要配置高德 Web Service Key:
|
||||
|
||||
```bash
|
||||
# 方式1: 运行时通过环境变量
|
||||
export AMAP_WEBSERVICE_KEY=your_key
|
||||
node scripts/poi-search.js --keywords=肯德基 --city=北京
|
||||
|
||||
# 方式2: 运行时自动提示输入(会保存到 config.json)
|
||||
node scripts/poi-search.js --keywords=肯德基 --city=北京
|
||||
|
||||
# 方式3: 手动创建配置文件
|
||||
cp config.example.json config.json
|
||||
# 然后编辑 config.json 填入你的 Key
|
||||
```
|
||||
|
||||
获取 API Key:访问 [高德开放平台](https://lbs.amap.com/api/webservice/create-project-and-key) 创建应用并获取 Key
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. POI 搜索
|
||||
|
||||
```bash
|
||||
# 基础搜索
|
||||
node scripts/poi-search.js --keywords=肯德基 --city=北京
|
||||
|
||||
# 带更多参数的搜索
|
||||
node scripts/poi-search.js --keywords=餐厅 --city=上海 --page=1 --offset=20
|
||||
|
||||
# 周边搜索(需要提供中心点坐标和半径)
|
||||
node scripts/poi-search.js --keywords=酒店 --location=116.397428,39.90923 --radius=1000
|
||||
```
|
||||
|
||||
### 2. 路径规划
|
||||
|
||||
```bash
|
||||
# 步行路线
|
||||
node scripts/route-planning.js --type=walking --origin=116.397428,39.90923 --destination=116.427281,39.903719
|
||||
|
||||
# 驾车路线(带途经点)
|
||||
node scripts/route-planning.js --type=driving --origin=116.397428,39.90923 --destination=116.427281,39.903719 --waypoints=116.410000,39.910000
|
||||
|
||||
# 骑行路线
|
||||
node scripts/route-planning.js --type=riding --origin=116.397428,39.90923 --destination=116.427281,39.903719
|
||||
|
||||
# 公交路线
|
||||
node scripts/route-planning.js --type=transfer --origin=116.397428,39.90923 --destination=116.427281,39.903719 --city=北京
|
||||
```
|
||||
|
||||
### 3. 智能旅游规划
|
||||
|
||||
```bash
|
||||
# 基础旅游规划
|
||||
node scripts/travel-planner.js --city=北京 --interests=景点,美食,酒店
|
||||
|
||||
# 指定路线类型
|
||||
node scripts/travel-planner.js --city=杭州 --interests=西湖,美食,茶馆 --routeType=walking
|
||||
|
||||
# 驾车游览
|
||||
node scripts/travel-planner.js --city=上海 --interests=外滩,南京路,城隍庙 --routeType=driving
|
||||
```
|
||||
|
||||
### 4. 在代码中使用
|
||||
|
||||
```javascript
|
||||
const {
|
||||
searchPOI,
|
||||
walkingRoute,
|
||||
drivingRoute,
|
||||
travelPlanner,
|
||||
generateMapLink
|
||||
} = require('./index');
|
||||
|
||||
// POI 搜索
|
||||
async function searchExample() {
|
||||
const result = await searchPOI({
|
||||
keywords: '肯德基',
|
||||
city: '北京',
|
||||
page: 1,
|
||||
offset: 10
|
||||
});
|
||||
console.log(result);
|
||||
}
|
||||
|
||||
// 步行路线规划
|
||||
async function routeExample() {
|
||||
const result = await walkingRoute({
|
||||
origin: '116.397428,39.90923',
|
||||
destination: '116.427281,39.903719'
|
||||
});
|
||||
console.log(result);
|
||||
}
|
||||
|
||||
// 旅游规划
|
||||
async function travelExample() {
|
||||
const result = await travelPlanner({
|
||||
city: '北京',
|
||||
interests: ['景点', '美食', '酒店'],
|
||||
routeType: 'walking'
|
||||
});
|
||||
console.log(result.mapLink); // 地图可视化链接
|
||||
}
|
||||
|
||||
// 生成地图链接
|
||||
function mapLinkExample() {
|
||||
const mapData = [
|
||||
{
|
||||
type: 'poi',
|
||||
lnglat: [116.397428, 39.90923],
|
||||
sort: '风景名胜',
|
||||
text: '故宫博物院',
|
||||
remark: '明清两代的皇家宫殿'
|
||||
},
|
||||
{
|
||||
type: 'route',
|
||||
routeType: 'walking',
|
||||
start: [116.397428, 39.90923],
|
||||
end: [116.427281, 39.903719],
|
||||
remark: '步行路线'
|
||||
}
|
||||
];
|
||||
|
||||
const link = generateMapLink(mapData);
|
||||
console.log(link);
|
||||
}
|
||||
```
|
||||
|
||||
## API 参数说明
|
||||
|
||||
### POI 搜索参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| keywords | string | 是 | 查询关键字 |
|
||||
| city | string | 否 | 城市名称或城市编码 |
|
||||
| types | string | 否 | POI类型编码,多个用\|分隔 |
|
||||
| location | string | 否 | 中心点坐标(经度,纬度) |
|
||||
| radius | number | 否 | 搜索半径,单位:米 |
|
||||
| page | number | 否 | 当前页数,默认1 |
|
||||
| offset | number | 否 | 每页记录数,默认10,最大25 |
|
||||
|
||||
### 路径规划参数
|
||||
|
||||
#### 步行路线 (walkingRoute)
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| origin | string | 是 | 起点坐标 "经度,纬度" |
|
||||
| destination | string | 是 | 终点坐标 "经度,纬度" |
|
||||
|
||||
#### 驾车路线 (drivingRoute)
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| origin | string | 是 | 起点坐标 "经度,纬度" |
|
||||
| destination | string | 是 | 终点坐标 "经度,纬度" |
|
||||
| waypoints | string | 否 | 途经点,多个用;分隔,最多16个 |
|
||||
| strategy | number | 否 | 驾车策略,默认10(躲避拥堵) |
|
||||
|
||||
#### 骑行路线 (ridingRoute)
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| origin | string | 是 | 起点坐标 "经度,纬度" |
|
||||
| destination | string | 是 | 终点坐标 "经度,纬度" |
|
||||
|
||||
#### 公交路线 (transitRoute)
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| origin | string | 是 | 起点坐标 "经度,纬度" |
|
||||
| destination | string | 是 | 终点坐标 "经度,纬度" |
|
||||
| city | string | 是 | 城市名称或城市编码 |
|
||||
| strategy | number | 否 | 公交策略,0-5,默认0(最快捷) |
|
||||
| nightflag | boolean | 否 | 是否计算夜班车,默认false |
|
||||
|
||||
### 旅游规划参数 (travelPlanner)
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| city | string | 是 | 城市名称 |
|
||||
| interests | array | 否 | 兴趣点关键词数组,默认['景点','美食'] |
|
||||
| routeType | string | 否 | 路线类型:walking/driving/riding/transfer,默认walking |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
jsapi-skills/
|
||||
├── index.js # 主入口文件,包含核心功能
|
||||
├── scripts/
|
||||
│ ├── poi-search.js # POI 搜索脚本
|
||||
│ ├── route-planning.js # 路径规划脚本
|
||||
│ └── travel-planner.js # 智能旅游规划脚本
|
||||
├── config.json # 配置文件(自动生成,不要提交)
|
||||
├── config.example.json # 配置示例
|
||||
├── package.json # 依赖配置
|
||||
├── .gitignore # Git 忽略配置
|
||||
├── SKILL.md # OpenClaw Skill 描述文件
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 地图可视化
|
||||
|
||||
所有规划结果都会生成地图可视化链接,格式如下:
|
||||
|
||||
```
|
||||
https://a.amap.com/jsapi_demo_show/static/openclaw/travel_plan.html?data=<encoded_json_data>
|
||||
```
|
||||
|
||||
数据格式符合 MapTaskData 接口规范,支持:
|
||||
- **POI 任务**:展示兴趣点位置和信息
|
||||
- **路线任务**:展示路径规划结果
|
||||
|
||||
示例数据结构:
|
||||
```javascript
|
||||
[
|
||||
// POI 兴趣点
|
||||
{
|
||||
type: 'poi',
|
||||
lnglat: [116.397428, 39.90923],
|
||||
sort: '风景名胜',
|
||||
text: '故宫博物院',
|
||||
remark: '明清两代的皇家宫殿,旧称紫禁城。'
|
||||
},
|
||||
// 路线规划
|
||||
{
|
||||
type: 'route',
|
||||
routeType: 'walking',
|
||||
start: [116.397428, 39.90923],
|
||||
end: [116.427281, 39.903719],
|
||||
remark: '步行路线'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 请妥善保管你的 Web Service Key,不要提交到公开仓库
|
||||
2. `config.json` 已在 `.gitignore` 中,不会被提交
|
||||
3. 高德 Web 服务 API 有调用频率限制,请合理使用
|
||||
4. 免费用户每日调用量有限制,具体请查看高德开放平台说明
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [高德开放平台](https://lbs.amap.com/)
|
||||
- [创建应用和获取 Key](https://lbs.amap.com/api/webservice/create-project-and-key)
|
||||
- [POI 搜索 API 文档](https://lbs.amap.com/api/webservice/guide/api-advanced/newpoisearch)
|
||||
- [Web 服务 API 总览](https://lbs.amap.com/api/webservice/summary)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -1,427 +0,0 @@
|
||||
---
|
||||
name: amap-lbs-skill
|
||||
description: 高德地图综合服务,支持POI搜索、路径规划、旅游规划、周边搜索和热力图数据可视化
|
||||
version: 2.0.0
|
||||
metadata:
|
||||
openclaw:
|
||||
requires:
|
||||
env:
|
||||
- AMAP_WEBSERVICE_KEY
|
||||
bins:
|
||||
- node
|
||||
primaryEnv: AMAP_WEBSERVICE_KEY
|
||||
homepage: https://lbs.amap.com/api/webservice/summary
|
||||
install:
|
||||
- kind: node
|
||||
package: axios
|
||||
bins: []
|
||||
---
|
||||
|
||||
# 高德地图综合服务 Skill
|
||||
|
||||
高德地图综合服务向开发者提供完整的地图数据服务,包括地点搜索、路径规划、旅游规划和数据可视化等功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🔍 POI(地点)搜索功能
|
||||
- 🏙️ 支持关键词搜索、城市限定、类型筛选
|
||||
- 📍 支持周边搜索(基于坐标和半径)
|
||||
- 🛣️ 路径规划(步行、驾车、骑行、公交)
|
||||
- 🗺️ 智能旅游规划助手
|
||||
- 🔥 热力图数据可视化
|
||||
- 🔗 地图可视化链接生成
|
||||
- 💾 配置本地持久化存储
|
||||
- 🎯 自动管理高德 Web Service Key
|
||||
|
||||
## 首次配置
|
||||
|
||||
首次使用时需要配置高德 Web Service Key:
|
||||
|
||||
1. 访问 [高德开放平台](https://lbs.amap.com/api/webservice/create-project-and-key) 创建应用并获取 Key
|
||||
2. 设置环境变量:`export AMAP_WEBSERVICE_KEY=your_key`
|
||||
3. 或运行时自动提示输入并保存到本地配置文件
|
||||
|
||||
当用户想要搜索地址、地点、周边信息(如美食、酒店、景点等)、规划路线或可视化数据时,使用此 skill。
|
||||
|
||||
## 触发条件
|
||||
|
||||
用户表达了以下意图之一:
|
||||
- 搜索某类地点或某个确定地点(如"搜美食"、"找酒店"、"天安门在哪")
|
||||
- 基于某个位置搜索周边(如"西直门周边美食"、"北京南站附近酒店")
|
||||
- 规划路线(如"从天安门到故宫怎么走"、"规划驾车路线")
|
||||
- 旅游规划(如"帮我规划北京一日游"、"杭州西湖游览路线")
|
||||
- 包含"搜"、"找"、"查"、"附近"、"周边"、"路线"、"规划"等关键词
|
||||
- 希望将地理数据可视化为热力图(如"生成热力图"、"用这份数据做热力图展示")
|
||||
|
||||
## 场景判断
|
||||
|
||||
收到用户请求后,先判断属于哪个场景:
|
||||
|
||||
- **场景一**:用户搜索一个**明确的类别**(美食、酒店)或**确定的地点**(天安门、西湖),没有指定"在哪个位置附近"
|
||||
- **场景二**:用户搜索**某个位置周边**的某类地点,输入中同时包含「位置」和「搜索类别」两个要素(如"西直门周边美食"、"北京南站附近酒店")
|
||||
- **场景三**:热力图数据可视化
|
||||
- **场景四**:POI 详细搜索(使用 Web 服务 API)
|
||||
- **场景五**:路径规划
|
||||
- **场景六**:智能旅游规划
|
||||
|
||||
---
|
||||
|
||||
## 场景一:明确关键词搜索
|
||||
|
||||
直接搜索一个类别或地点,不涉及特定位置的周边搜索。
|
||||
|
||||
**URL 格式:**
|
||||
|
||||
```
|
||||
https://www.amap.com/search?query={关键词}
|
||||
```
|
||||
|
||||
- **域名**:`www.amap.com`
|
||||
- **路由**:`/search`
|
||||
- **参数**:`query` = 搜索关键词
|
||||
|
||||
### 执行步骤
|
||||
|
||||
1. **提取关键词**:从用户输入中识别出核心搜索词,去掉"搜"、"找"等修饰词
|
||||
2. **生成 URL**:拼接 `https://www.amap.com/search?query={关键词}`
|
||||
3. **返回链接给用户**
|
||||
|
||||
### 示例
|
||||
|
||||
| 用户输入 | 提取关键词 | 生成 URL |
|
||||
|---------|-----------|---------|
|
||||
| 搜美食 | 美食 | `https://www.amap.com/search?query=美食` |
|
||||
| 找酒店 | 酒店 | `https://www.amap.com/search?query=酒店` |
|
||||
| 天安门在哪 | 天安门 | `https://www.amap.com/search?query=天安门` |
|
||||
| 找个加油站 | 加油站 | `https://www.amap.com/search?query=加油站` |
|
||||
|
||||
### 回复模板
|
||||
|
||||
```
|
||||
🔍 已为你生成高德地图搜索链接:
|
||||
|
||||
https://www.amap.com/search?query={关键词}
|
||||
|
||||
点击链接即可查看搜索结果。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 场景二:基于位置的周边搜索
|
||||
|
||||
用户想搜索**某个位置周边**的某类地点。需要先通过地理编码 API 获取该位置的经纬度,再拼接带坐标的搜索链接。
|
||||
|
||||
**前置条件:** 需要用户提供高德开放平台的 API Key。
|
||||
|
||||
### 执行步骤
|
||||
|
||||
#### 第一步:解析用户输入
|
||||
|
||||
从用户输入中拆分出两个要素:
|
||||
- **位置**:用户指定的中心位置(如"西直门"、"北京南站")
|
||||
- **搜索类别**:要搜索的内容(如"美食"、"酒店")
|
||||
|
||||
| 用户输入 | 位置 | 搜索类别 |
|
||||
|---------|------|---------|
|
||||
| 西直门周边美食 | 西直门 | 美食 |
|
||||
| 北京南站附近酒店 | 北京南站 | 酒店 |
|
||||
| 天坛周边有什么好吃的 | 天坛 | 美食 |
|
||||
|
||||
#### 第二步:检查 API Key
|
||||
|
||||
- 如果用户之前未提供过 Key,**先提示用户提供高德 API Key**,等待用户回复后再继续
|
||||
- 如果用户已提供 Key,直接使用
|
||||
|
||||
**请求 Key 的回复模板:**
|
||||
|
||||
```
|
||||
🔑 搜索「{位置}」周边的{搜索类别}需要使用高德 API,请提供你的高德开放平台 API Key。
|
||||
|
||||
(如果还没有 Key,可以在 https://lbs.amap.com 注册并创建应用获取)
|
||||
```
|
||||
|
||||
#### 第三步:调用地理编码 API 获取经纬度
|
||||
|
||||
**API 格式:**
|
||||
|
||||
```
|
||||
https://restapi.amap.com/v3/geocode/geo?address={位置}&output=JSON&key={用户的key}
|
||||
```
|
||||
|
||||
**执行 curl 请求:**
|
||||
|
||||
```bash
|
||||
curl -s "https://restapi.amap.com/v3/geocode/geo?address={位置}&output=JSON&key={用户的key}"
|
||||
```
|
||||
|
||||
**API 返回示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "1",
|
||||
"info": "OK",
|
||||
"geocodes": [
|
||||
{
|
||||
"formatted_address": "北京市西城区西直门",
|
||||
"location": "116.353138,39.939385"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
从返回结果中提取 `geocodes[0].location`,格式为 `经度,纬度`(如 `116.353138,39.939385`),拆分为:
|
||||
- **经度(longitude)**:`116.353138`
|
||||
- **纬度(latitude)**:`39.939385`
|
||||
|
||||
#### 第四步:拼接带坐标的搜索链接
|
||||
|
||||
**URL 格式:**
|
||||
|
||||
```
|
||||
https://ditu.amap.com/search?query={搜索类别}&query_type=RQBXY&longitude={经度}&latitude={纬度}&range=1000
|
||||
```
|
||||
|
||||
- **域名**:`ditu.amap.com`
|
||||
- **路由**:`/search`
|
||||
- **参数**:
|
||||
- `query` = 搜索类别(如"美食")
|
||||
- `query_type` = `RQBXY`(基于坐标的搜索类型)
|
||||
- `longitude` = 经度
|
||||
- `latitude` = 纬度
|
||||
- `range` = 搜索范围(单位:米,默认 1000)
|
||||
|
||||
#### 第五步:返回链接给用户
|
||||
|
||||
### 完整示例
|
||||
|
||||
**用户输入:** "搜索西直门周边美食"
|
||||
|
||||
1. 解析:位置 = `西直门`,搜索类别 = `美食`
|
||||
2. 调用地理编码 API:`curl -s "https://restapi.amap.com/v3/geocode/geo?address=西直门&output=JSON&key=xxx"`
|
||||
3. 获取坐标:`116.353138,39.939385` → 经度 `116.353138`,纬度 `39.939385`
|
||||
4. 拼接链接:`https://ditu.amap.com/search?query=美食&query_type=RQBXY&longitude=116.353138&latitude=39.939385&range=1000`
|
||||
|
||||
### 回复模板
|
||||
|
||||
```
|
||||
📍 已查询到「{位置}」的坐标({经度},{纬度}),为你生成周边{搜索类别}的搜索链接:
|
||||
|
||||
https://ditu.amap.com/search?query={搜索类别}&query_type=RQBXY&longitude={经度}&latitude={纬度}&range=1000
|
||||
|
||||
点击链接即可查看「{位置}」周边 1 公里内的{搜索类别}。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 场景三:热力图展示
|
||||
|
||||
用户有一份包含地理坐标的数据,希望在地图上以热力图的形式可视化展示。
|
||||
|
||||
### 触发条件
|
||||
|
||||
用户提到"热力图"、"数据可视化"、"地图上展示数据"等意图,并提供了数据地址。
|
||||
|
||||
### URL 格式
|
||||
|
||||
```
|
||||
http://a.amap.com/jsapi_demo_show/static/openclaw/heatmap.html?mapStyle={地图风格}&dataUrl={数据地址(URL编码)}
|
||||
```
|
||||
|
||||
- **域名**:`a.amap.com`
|
||||
- **路由**:`/jsapi_demo_show/static/openclaw/heatmap.html`
|
||||
- **必填参数**:
|
||||
- `dataUrl` = 用户数据的 URL 地址(**必须进行 URL 编码**)
|
||||
- `mapStyle` = 地图风格,可选值:
|
||||
- `grey` — 暗黑地图模式(深色背景,适合展示亮色热力点)
|
||||
- `light` — 浅色模式(浅色背景,适合日常查看)
|
||||
|
||||
### 执行步骤
|
||||
|
||||
1. **获取数据地址**:从用户输入中提取数据 URL,如果用户未提供,提示用户给出数据地址
|
||||
2. **确认地图风格**:询问用户偏好的地图风格(`grey` 或 `light`),如果用户未指定,默认使用 `grey`
|
||||
3. **URL 编码**:将数据地址进行 URL 编码(将 `://` → `%3A%2F%2F`,`/` → `%2F` 等)
|
||||
4. **拼接链接**:生成完整的热力图 URL
|
||||
5. **返回链接给用户**
|
||||
|
||||
### 示例
|
||||
|
||||
**用户输入:** "帮我用这份数据生成热力图:`https://a.amap.com/Loca/static/loca-v2/demos/mock_data/hz_house_order.json`,用暗黑模式"
|
||||
|
||||
1. 数据地址:`https://a.amap.com/Loca/static/loca-v2/demos/mock_data/hz_house_order.json`
|
||||
2. 地图风格:`grey`
|
||||
3. URL 编码后的数据地址:`https%3A%2F%2Fa.amap.com%2FLoca%2Fstatic%2Floca-v2%2Fdemos%2Fmock_data%2Fhz_house_order.json`
|
||||
4. 最终链接:
|
||||
|
||||
```
|
||||
http://a.amap.com/jsapi_demo_show/static/openclaw/heatmap.html?mapStyle=grey&dataUrl=https%3A%2F%2Fa.amap.com%2FLoca%2Fstatic%2Floca-v2%2Fdemos%2Fmock_data%2Fhz_house_order.json
|
||||
```
|
||||
|
||||
### 回复模板
|
||||
|
||||
```
|
||||
🔥 已为你生成热力图链接:
|
||||
|
||||
http://a.amap.com/jsapi_demo_show/static/openclaw/heatmap.html?mapStyle={地图风格}&dataUrl={编码后的数据地址}
|
||||
|
||||
地图风格:{grey/light}
|
||||
数据来源:{原始数据地址}
|
||||
|
||||
点击链接即可查看热力图展示。
|
||||
```
|
||||
|
||||
**请求数据地址的回复模板(用户未提供时):**
|
||||
|
||||
```
|
||||
🔥 生成热力图需要你提供数据地址(JSON 格式的 URL),请给出数据链接。
|
||||
|
||||
另外,你希望使用哪种地图风格?
|
||||
- grey(暗黑模式)
|
||||
- light(浅色模式)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 场景四:POI 详细搜索
|
||||
|
||||
使用高德 Web 服务 API 进行更详细的 POI 搜索,支持更多参数和筛选条件。
|
||||
|
||||
### 使用方法
|
||||
|
||||
```bash
|
||||
# 基础搜索
|
||||
node scripts/poi-search.js --keywords=肯德基 --city=北京
|
||||
|
||||
# 搜索更多结果
|
||||
node scripts/poi-search.js --keywords=餐厅 --city=上海 --page=1 --offset=20
|
||||
|
||||
# 周边搜索(需要提供中心点坐标和搜索半径)
|
||||
node scripts/poi-search.js --keywords=酒店 --location=116.397428,39.90923 --radius=1000
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 说明 | 必填 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `--keywords` | 搜索关键词 | 是 | `--keywords=肯德基` |
|
||||
| `--city` | 城市名称或编码 | 否 | `--city=北京` |
|
||||
| `--types` | POI 类型编码 | 否 | `--types=050000` |
|
||||
| `--location` | 中心点坐标(经度,纬度) | 否 | `--location=116.397428,39.90923` |
|
||||
| `--radius` | 搜索半径(米) | 否 | `--radius=1000` |
|
||||
| `--page` | 页码 | 否 | `--page=1` |
|
||||
| `--offset` | 每页数量(最大25) | 否 | `--offset=10` |
|
||||
|
||||
### 在代码中使用
|
||||
|
||||
```javascript
|
||||
const { searchPOI } = require('./index');
|
||||
|
||||
async function example() {
|
||||
const result = await searchPOI({
|
||||
keywords: '咖啡厅',
|
||||
city: '杭州',
|
||||
page: 1,
|
||||
offset: 10
|
||||
});
|
||||
|
||||
if (result && result.pois) {
|
||||
result.pois.forEach(poi => {
|
||||
console.log(`${poi.name} - ${poi.address}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
example();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 场景五:路径规划
|
||||
|
||||
规划不同出行方式的路线。
|
||||
|
||||
### 使用方法
|
||||
|
||||
```bash
|
||||
# 步行路线
|
||||
node scripts/route-planning.js --type=walking --origin=116.397428,39.90923 --destination=116.427281,39.903719
|
||||
|
||||
# 驾车路线
|
||||
node scripts/route-planning.js --type=driving --origin=116.397428,39.90923 --destination=116.427281,39.903719
|
||||
|
||||
# 公交路线
|
||||
node scripts/route-planning.js --type=transfer --origin=116.397428,39.90923 --destination=116.427281,39.903719 --city=北京
|
||||
```
|
||||
|
||||
### 路线类型
|
||||
|
||||
- `walking` - 步行路线
|
||||
- `driving` - 驾车路线
|
||||
- `riding` - 骑行路线
|
||||
- `transfer` - 公交路线(需要指定城市)
|
||||
|
||||
---
|
||||
|
||||
## 场景六:智能旅游规划
|
||||
|
||||
自动搜索兴趣点并规划游览路线,生成地图可视化链接。
|
||||
|
||||
### 使用方法
|
||||
|
||||
```bash
|
||||
# 基础旅游规划
|
||||
node scripts/travel-planner.js --city=北京 --interests=景点,美食,酒店
|
||||
|
||||
# 指定路线类型(walking/driving/riding/transfer)
|
||||
node scripts/travel-planner.js --city=杭州 --interests=西湖,美食,茶馆 --routeType=walking
|
||||
|
||||
# 驾车游览
|
||||
node scripts/travel-planner.js --city=上海 --interests=外滩,南京路,城隍庙 --routeType=driving
|
||||
```
|
||||
|
||||
### 功能说明
|
||||
|
||||
- 自动搜索指定城市的兴趣点(每类最多5个)
|
||||
- 按顺序规划各兴趣点之间的路线
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 配置管理
|
||||
|
||||
配置文件位于 `config.json`,包含以下内容:
|
||||
|
||||
```json
|
||||
{
|
||||
"webServiceKey": "your_amap_webservice_key_here"
|
||||
}
|
||||
```
|
||||
|
||||
设置 Key 的方式:
|
||||
|
||||
1. **环境变量**:`export AMAP_WEBSERVICE_KEY=your_key`
|
||||
2. **命令行参数**:`node index.js your_key`
|
||||
3. **自动提示**:首次运行时自动提示输入
|
||||
4. **手动编辑**:直接编辑 `config.json` 文件
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **场景判断是关键**:区分用户是"直接搜某个东西"、"在某个位置附近搜某个东西"、"规划路线"还是"旅游规划"
|
||||
- 关键词应尽量精简准确,提取用户真正想搜的内容
|
||||
- URL 中的中文关键词浏览器会自动处理编码,无需手动 encode
|
||||
- 场景二、四、五、六需要用户提供高德 API Key,**必须先获取 Key 后再发起请求**,不能跳过
|
||||
- 如果地理编码 API 返回 `status` 不为 `"1"`,说明请求失败,需提示用户检查 Key 是否正确或地址是否有效
|
||||
- API 返回的 `location` 格式为 `经度,纬度`(注意:经度在前,纬度在后)
|
||||
- 场景二的搜索范围默认 1000 米,用户如有需要可调整 `range` 参数
|
||||
- 请妥善保管你的 Web Service Key,不要分享给他人
|
||||
- 高德 Web 服务 API 有调用频率限制,请合理使用
|
||||
- 免费用户每日调用量有限制,具体请查看高德开放平台说明
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [高德开放平台](https://lbs.amap.com/)
|
||||
- [创建应用和获取 Key](https://lbs.amap.com/api/webservice/create-project-and-key)
|
||||
- [POI 搜索 API 文档](https://lbs.amap.com/api/webservice/guide/api-advanced/newpoisearch)
|
||||
- [Web 服务 API 总览](https://lbs.amap.com/api/webservice/summary)
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"webServiceKey": "your_amap_webservice_key_here"
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const axios = require('axios');
|
||||
|
||||
// 配置文件路径
|
||||
const CONFIG_FILE = path.join(__dirname, 'config.json');
|
||||
|
||||
/**
|
||||
* 读取配置文件
|
||||
*/
|
||||
function readConfig() {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const data = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('读取配置文件失败:', error.message);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置文件
|
||||
*/
|
||||
function saveConfig(config) {
|
||||
try {
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
||||
console.log('配置已保存到:', CONFIG_FILE);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('保存配置文件失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取高德 Web Service Key
|
||||
*/
|
||||
function getWebServiceKey() {
|
||||
const config = readConfig();
|
||||
return config.webServiceKey || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置高德 Web Service Key
|
||||
*/
|
||||
function setWebServiceKey(key) {
|
||||
const config = readConfig();
|
||||
config.webServiceKey = key;
|
||||
return saveConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并提示用户输入 Key
|
||||
*/
|
||||
async function ensureWebServiceKey() {
|
||||
// 优先从环境变量读取
|
||||
let key = process.env.AMAP_KEY || process.env.AMAP_WEBSERVICE_KEY;
|
||||
|
||||
if (!key) {
|
||||
// 尝试从配置文件读取
|
||||
key = getWebServiceKey();
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
console.log('\n⚠️ 未找到高德 Web Service Key');
|
||||
console.log('请访问以下地址创建应用并获取 Key:');
|
||||
console.log('https://lbs.amap.com/api/webservice/create-project-and-key\n');
|
||||
throw new Error('请设置环境变量 AMAP_KEY 或提供高德 Web Service Key');
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* POI 搜索
|
||||
* @param {Object} params - 搜索参数
|
||||
* @param {string} params.keywords - 查询关键字
|
||||
* @param {string} params.city - 城市名称或城市编码
|
||||
* @param {string} params.types - POI类型编码
|
||||
* @param {string} params.location - 中心点坐标
|
||||
* @param {number} params.radius - 搜索半径(米)
|
||||
* @param {number} params.page - 当前页数
|
||||
* @param {number} params.offset - 每页记录数
|
||||
*/
|
||||
async function searchPOI(params) {
|
||||
const key = await ensureWebServiceKey();
|
||||
|
||||
const url = 'https://restapi.amap.com/v5/place/text';
|
||||
|
||||
const requestParams = {
|
||||
key: key,
|
||||
keywords: params.keywords || '',
|
||||
region: params.city || '',
|
||||
city_limit: params.cityLimit !== false,
|
||||
...params
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('🔍 正在搜索 POI...');
|
||||
const response = await axios.get(url, { params: requestParams });
|
||||
|
||||
if (response.data.status === '1') {
|
||||
console.log(`✅ 搜索成功,共找到 ${response.data.count} 条结果\n`);
|
||||
return response.data;
|
||||
} else {
|
||||
console.error('❌ 搜索失败:', response.data.info);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 请求失败:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 步行路径规划
|
||||
* @param {Object} params - 规划参数
|
||||
* @param {string} params.origin - 起点坐标 "经度,纬度"
|
||||
* @param {string} params.destination - 终点坐标 "经度,纬度"
|
||||
*/
|
||||
async function walkingRoute(params) {
|
||||
const key = await ensureWebServiceKey();
|
||||
|
||||
const url = 'https://restapi.amap.com/v3/direction/walking';
|
||||
|
||||
const requestParams = {
|
||||
key: key,
|
||||
origin: params.origin,
|
||||
destination: params.destination
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('🚶 正在规划步行路线...');
|
||||
const response = await axios.get(url, { params: requestParams });
|
||||
|
||||
if (response.data.status === '1') {
|
||||
console.log('✅ 步行路线规划成功\n');
|
||||
return response.data;
|
||||
} else {
|
||||
console.error('❌ 步行路线规划失败:', response.data.info);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 请求失败:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 驾车路径规划
|
||||
* @param {Object} params - 规划参数
|
||||
* @param {string} params.origin - 起点坐标 "经度,纬度"
|
||||
* @param {string} params.destination - 终点坐标 "经度,纬度"
|
||||
* @param {string} params.waypoints - 途经点坐标,多个用;分隔
|
||||
* @param {number} params.strategy - 驾车策略,默认10
|
||||
*/
|
||||
async function drivingRoute(params) {
|
||||
const key = await ensureWebServiceKey();
|
||||
|
||||
const url = 'https://restapi.amap.com/v3/direction/driving';
|
||||
|
||||
const requestParams = {
|
||||
key: key,
|
||||
origin: params.origin,
|
||||
destination: params.destination,
|
||||
strategy: params.strategy || 10,
|
||||
extensions: 'base'
|
||||
};
|
||||
|
||||
if (params.waypoints) {
|
||||
requestParams.waypoints = params.waypoints;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🚗 正在规划驾车路线...');
|
||||
const response = await axios.get(url, { params: requestParams });
|
||||
|
||||
if (response.data.status === '1') {
|
||||
console.log('✅ 驾车路线规划成功\n');
|
||||
return response.data;
|
||||
} else {
|
||||
console.error('❌ 驾车路线规划失败:', response.data.info);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 请求失败:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 骑行路径规划
|
||||
* @param {Object} params - 规划参数
|
||||
* @param {string} params.origin - 起点坐标 "经度,纬度"
|
||||
* @param {string} params.destination - 终点坐标 "经度,纬度"
|
||||
*/
|
||||
async function ridingRoute(params) {
|
||||
const key = await ensureWebServiceKey();
|
||||
|
||||
const url = 'https://restapi.amap.com/v4/direction/bicycling';
|
||||
|
||||
const requestParams = {
|
||||
key: key,
|
||||
origin: params.origin,
|
||||
destination: params.destination
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('🚴 正在规划骑行路线...');
|
||||
const response = await axios.get(url, { params: requestParams });
|
||||
|
||||
if (response.data.errcode === 0) {
|
||||
console.log('✅ 骑行路线规划成功\n');
|
||||
return response.data;
|
||||
} else {
|
||||
console.error('❌ 骑行路线规划失败:', response.data.errmsg);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 请求失败:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 公交路径规划
|
||||
* @param {Object} params - 规划参数
|
||||
* @param {string} params.origin - 起点坐标 "经度,纬度"
|
||||
* @param {string} params.destination - 终点坐标 "经度,纬度"
|
||||
* @param {string} params.city - 城市名称或城市编码
|
||||
* @param {number} params.strategy - 公交策略,默认0(最快捷)
|
||||
* @param {boolean} params.nightflag - 是否计算夜班车,默认false
|
||||
*/
|
||||
async function transitRoute(params) {
|
||||
const key = await ensureWebServiceKey();
|
||||
|
||||
const url = 'https://restapi.amap.com/v3/direction/transit/integrated';
|
||||
|
||||
const requestParams = {
|
||||
key: key,
|
||||
origin: params.origin,
|
||||
destination: params.destination,
|
||||
city: params.city,
|
||||
strategy: params.strategy || 0,
|
||||
nightflag: params.nightflag ? 1 : 0
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('🚌 正在规划公交路线...');
|
||||
const response = await axios.get(url, { params: requestParams });
|
||||
|
||||
if (response.data.status === '1') {
|
||||
console.log('✅ 公交路线规划成功\n');
|
||||
return response.data;
|
||||
} else {
|
||||
console.error('❌ 公交路线规划失败:', response.data.info);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 请求失败:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成地图可视化链接
|
||||
* @param {Array} mapTaskData - 地图任务数据数组
|
||||
* @returns {string} 可视化链接
|
||||
*/
|
||||
function generateMapLink(mapTaskData) {
|
||||
const baseUrl = 'https://a.amap.com/jsapi_demo_show/static/openclaw/travel_plan.html';
|
||||
const dataStr = encodeURIComponent(JSON.stringify(mapTaskData));
|
||||
return `${baseUrl}?data=${dataStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 旅游规划助手
|
||||
* @param {Object} params - 规划参数
|
||||
* @param {string} params.city - 城市名称
|
||||
* @param {Array<string>} params.interests - 兴趣点关键词数组,如 ['景点', '美食', '酒店']
|
||||
* @param {string} params.routeType - 路线类型:driving/walking/riding/transfer
|
||||
* @returns {Object} 包含 pois、mapTaskData、mapLink 和 htmlLink
|
||||
*/
|
||||
async function travelPlanner(params) {
|
||||
const { city, interests = [], routeType = 'walking' } = params;
|
||||
|
||||
console.log(`\n🗺️ 开始为您规划 ${city} 的旅游行程...\n`);
|
||||
|
||||
const mapTaskData = [];
|
||||
const poiResults = [];
|
||||
|
||||
// 搜索各类兴趣点
|
||||
for (const interest of interests) {
|
||||
console.log(`📍 搜索 ${interest}...`);
|
||||
const result = await searchPOI({
|
||||
keywords: interest,
|
||||
city: city,
|
||||
page: 1,
|
||||
offset: 5
|
||||
});
|
||||
|
||||
if (result && result.pois && result.pois.length > 0) {
|
||||
poiResults.push(...result.pois);
|
||||
|
||||
// 添加到地图数据 - 严格按照 PoiTask 接口格式
|
||||
result.pois.forEach(poi => {
|
||||
const [lng, lat] = poi.location.split(',').map(Number);
|
||||
mapTaskData.push({
|
||||
type: 'poi',
|
||||
lnglat: [lng, lat],
|
||||
sort: poi.type || interest,
|
||||
text: poi.name,
|
||||
remark: poi.address || `${interest}推荐`
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有多个POI,规划路线
|
||||
if (poiResults.length >= 2) {
|
||||
console.log(`\n🛣️ 规划游览路线(${routeType})...\n`);
|
||||
|
||||
for (let i = 0; i < poiResults.length - 1; i++) {
|
||||
const start = poiResults[i];
|
||||
const end = poiResults[i + 1];
|
||||
|
||||
const [startLng, startLat] = start.location.split(',').map(Number);
|
||||
const [endLng, endLat] = end.location.split(',').map(Number);
|
||||
|
||||
// 添加路线到地图数据 - 严格按照 RouteTask 接口格式
|
||||
const routeTask = {
|
||||
type: 'route',
|
||||
routeType: routeType,
|
||||
start: [startLng, startLat],
|
||||
end: [endLng, endLat],
|
||||
remark: `从 ${start.name} 到 ${end.name}`
|
||||
};
|
||||
|
||||
// 如果是公交路线,添加 city 参数
|
||||
if (routeType === 'transfer') {
|
||||
routeTask.city = city;
|
||||
}
|
||||
|
||||
mapTaskData.push(routeTask);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log('\n✅ 旅游规划完成!\n');
|
||||
console.log('📍 推荐地点:');
|
||||
poiResults.forEach((poi, index) => {
|
||||
console.log(`${index + 1}. ${poi.name}`);
|
||||
console.log(` 地址: ${poi.address}`);
|
||||
console.log(` 类型: ${poi.type}\n`);
|
||||
});
|
||||
|
||||
return {
|
||||
pois: poiResults,
|
||||
};
|
||||
}
|
||||
|
||||
// 导出函数供其他脚本使用
|
||||
module.exports = {
|
||||
readConfig,
|
||||
saveConfig,
|
||||
getWebServiceKey,
|
||||
setWebServiceKey,
|
||||
ensureWebServiceKey,
|
||||
searchPOI,
|
||||
walkingRoute,
|
||||
drivingRoute,
|
||||
ridingRoute,
|
||||
transitRoute,
|
||||
generateMapLink,
|
||||
travelPlanner
|
||||
};
|
||||
|
||||
// 如果直接运行此文件,执行示例搜索
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
try {
|
||||
// 示例:搜索北京的肯德基
|
||||
const result = await searchPOI({
|
||||
keywords: '肯德基',
|
||||
city: '北京',
|
||||
page: 1,
|
||||
offset: 10
|
||||
});
|
||||
|
||||
if (result && result.pois) {
|
||||
console.log('搜索结果:');
|
||||
result.pois.forEach((poi, index) => {
|
||||
console.log(`${index + 1}. ${poi.name}`);
|
||||
console.log(` 地址: ${poi.address}`);
|
||||
console.log(` 类型: ${poi.type}`);
|
||||
console.log(` 坐标: ${poi.location}\n`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('执行失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "amap-webservice",
|
||||
"version": "1.0.0",
|
||||
"description": "高德Web服务API向开发者提供HTTP接口,开发者可通过这些接口使用各类型的地理数据服务,返回结果支持JSON和XML格式。",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [
|
||||
"amap",
|
||||
"webservice",
|
||||
"poi",
|
||||
"geocoding"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6"
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* POI 搜索脚本
|
||||
* 使用方法:
|
||||
* node scripts/poi-search.js --keywords=肯德基 --city=北京
|
||||
* 或设置环境变量: AMAP_KEY=your_key node scripts/poi-search.js --keywords=肯德基
|
||||
*/
|
||||
|
||||
const { searchPOI } = require('../index');
|
||||
|
||||
// 解析命令行参数
|
||||
function parseArgs() {
|
||||
const args = {};
|
||||
process.argv.slice(2).forEach(arg => {
|
||||
if (arg.startsWith('--')) {
|
||||
const [key, value] = arg.slice(2).split('=');
|
||||
args[key] = value;
|
||||
}
|
||||
});
|
||||
return args;
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
|
||||
// 检查必需参数
|
||||
if (!args.keywords) {
|
||||
console.error('❌ 缺少必需参数: --keywords');
|
||||
console.log('\n使用方法:');
|
||||
console.log('node scripts/poi-search.js --keywords=关键词 [--city=城市] [--types=类型] [--page=页码] [--offset=每页数量]');
|
||||
console.log('\n示例:');
|
||||
console.log('node scripts/poi-search.js --keywords=肯德基 --city=北京 --page=1 --offset=20');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 检查是否设置了 AMAP_KEY
|
||||
if (!process.env.AMAP_KEY) {
|
||||
console.error('❌ 请设置环境变量 AMAP_KEY');
|
||||
console.log('\n示例:');
|
||||
console.log('export AMAP_KEY=your_amap_key');
|
||||
console.log('node scripts/poi-search.js --keywords=美食 --location=116.397428,39.90923 --radius=1000');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 构建搜索参数
|
||||
const params = {
|
||||
keywords: args.keywords,
|
||||
city: args.city || '',
|
||||
types: args.types || '',
|
||||
page: parseInt(args.page) || 1,
|
||||
offset: parseInt(args.offset) || 10
|
||||
};
|
||||
|
||||
// 可选参数
|
||||
if (args.location) params.location = args.location;
|
||||
if (args.radius) params.radius = parseInt(args.radius);
|
||||
if (args.cityLimit !== undefined) params.cityLimit = args.cityLimit === 'true';
|
||||
|
||||
try {
|
||||
const result = await searchPOI(params);
|
||||
|
||||
if (result && result.pois && result.pois.length > 0) {
|
||||
console.log(`\n📍 共找到 ${result.count} 条结果,当前显示第 ${params.page} 页:\n`);
|
||||
console.log('='.repeat(80));
|
||||
|
||||
result.pois.forEach((poi, index) => {
|
||||
const num = (params.page - 1) * params.offset + index + 1;
|
||||
console.log(`\n${num}. ${poi.name}`);
|
||||
console.log(` 📍 地址: ${poi.address || '无'}`);
|
||||
console.log(` 🏷️ 类型: ${poi.type}`);
|
||||
console.log(` 📞 电话: ${poi.tel || '无'}`);
|
||||
console.log(` 🗺️ 坐标: ${poi.location}`);
|
||||
if (poi.distance) {
|
||||
console.log(` 📏 距离: ${poi.distance}米`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n' + '='.repeat(80));
|
||||
|
||||
// 输出分页信息
|
||||
const totalPages = Math.ceil(result.count / params.offset);
|
||||
console.log(`\n第 ${params.page}/${totalPages} 页`);
|
||||
|
||||
if (params.page < totalPages) {
|
||||
console.log(`\n💡 查看下一页: node scripts/poi-search.js --keywords=${params.keywords} --city=${params.city} --page=${params.page + 1}`);
|
||||
}
|
||||
} else {
|
||||
console.log('\n❌ 未找到相关结果');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('\n❌ 执行失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行主函数
|
||||
main();
|
||||
@@ -1,179 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 路径规划脚本
|
||||
* 使用方法:
|
||||
* node scripts/route-planning.js --type=walking --origin=116.397428,39.90923 --destination=116.427281,39.903719
|
||||
*/
|
||||
|
||||
const { walkingRoute, drivingRoute, ridingRoute, transitRoute, generateMapLink } = require('../index');
|
||||
|
||||
// 解析命令行参数
|
||||
function parseArgs() {
|
||||
const args = {};
|
||||
process.argv.slice(2).forEach(arg => {
|
||||
if (arg.startsWith('--')) {
|
||||
const [key, value] = arg.slice(2).split('=');
|
||||
args[key] = value;
|
||||
}
|
||||
});
|
||||
return args;
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
|
||||
// 检查必需参数
|
||||
if (!args.type || !args.origin || !args.destination) {
|
||||
console.error('❌ 缺少必需参数');
|
||||
console.log('\n使用方法:');
|
||||
console.log('node scripts/route-planning.js --type=路线类型 --origin=起点坐标 --destination=终点坐标 [其他参数]');
|
||||
console.log('\n路线类型:');
|
||||
console.log(' walking - 步行');
|
||||
console.log(' driving - 驾车');
|
||||
console.log(' riding - 骑行');
|
||||
console.log(' transfer - 公交(需要额外提供 --city 参数)');
|
||||
console.log('\n示例:');
|
||||
console.log('# 步行路线');
|
||||
console.log('node scripts/route-planning.js --type=walking --origin=116.397428,39.90923 --destination=116.427281,39.903719');
|
||||
console.log('\n# 驾车路线(带途经点)');
|
||||
console.log('node scripts/route-planning.js --type=driving --origin=116.397428,39.90923 --destination=116.427281,39.903719 --waypoints=116.410000,39.910000');
|
||||
console.log('\n# 公交路线');
|
||||
console.log('node scripts/route-planning.js --type=transfer --origin=116.397428,39.90923 --destination=116.427281,39.903719 --city=北京');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { type, origin, destination } = args;
|
||||
|
||||
try {
|
||||
let result = null;
|
||||
let mapTaskData = [];
|
||||
|
||||
// 解析起点和终点坐标
|
||||
const [originLng, originLat] = origin.split(',').map(Number);
|
||||
const [destLng, destLat] = destination.split(',').map(Number);
|
||||
|
||||
// 根据类型调用不同的路径规划API
|
||||
switch (type) {
|
||||
case 'walking':
|
||||
result = await walkingRoute({ origin, destination });
|
||||
if (result && result.route) {
|
||||
mapTaskData.push({
|
||||
type: 'route',
|
||||
routeType: 'walking',
|
||||
start: [originLng, originLat],
|
||||
end: [destLng, destLat],
|
||||
remark: `步行路线,距离约 ${(result.route.paths[0].distance / 1000).toFixed(2)} 公里`
|
||||
});
|
||||
|
||||
console.log('📊 路线信息:');
|
||||
console.log(` 距离: ${(result.route.paths[0].distance / 1000).toFixed(2)} 公里`);
|
||||
console.log(` 预计时间: ${Math.round(result.route.paths[0].duration / 60)} 分钟\n`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'driving':
|
||||
const drivingParams = { origin, destination };
|
||||
if (args.waypoints) {
|
||||
drivingParams.waypoints = args.waypoints;
|
||||
}
|
||||
if (args.strategy) {
|
||||
drivingParams.strategy = parseInt(args.strategy);
|
||||
}
|
||||
|
||||
result = await drivingRoute(drivingParams);
|
||||
if (result && result.route) {
|
||||
const path = result.route.paths[0];
|
||||
mapTaskData.push({
|
||||
type: 'route',
|
||||
routeType: 'driving',
|
||||
start: [originLng, originLat],
|
||||
end: [destLng, destLat],
|
||||
remark: `驾车路线,距离约 ${(path.distance / 1000).toFixed(2)} 公里`
|
||||
});
|
||||
|
||||
console.log('📊 路线信息:');
|
||||
console.log(` 距离: ${(path.distance / 1000).toFixed(2)} 公里`);
|
||||
console.log(` 预计时间: ${Math.round(path.duration / 60)} 分钟`);
|
||||
console.log(` 过路费: ${path.tolls || 0} 元`);
|
||||
console.log(` 红绿灯: ${path.traffic_lights || 0} 个\n`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'riding':
|
||||
result = await ridingRoute({ origin, destination });
|
||||
if (result && result.data) {
|
||||
const path = result.data.paths[0];
|
||||
mapTaskData.push({
|
||||
type: 'route',
|
||||
routeType: 'riding',
|
||||
start: [originLng, originLat],
|
||||
end: [destLng, destLat],
|
||||
remark: `骑行路线,距离约 ${(path.distance / 1000).toFixed(2)} 公里`
|
||||
});
|
||||
|
||||
console.log('📊 路线信息:');
|
||||
console.log(` 距离: ${(path.distance / 1000).toFixed(2)} 公里`);
|
||||
console.log(` 预计时间: ${Math.round(path.duration / 60)} 分钟\n`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'transfer':
|
||||
if (!args.city) {
|
||||
console.error('❌ 公交路线规划需要提供 --city 参数');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const transitParams = {
|
||||
origin,
|
||||
destination,
|
||||
city: args.city,
|
||||
strategy: args.strategy ? parseInt(args.strategy) : 0,
|
||||
nightflag: args.nightflag === 'true'
|
||||
};
|
||||
|
||||
result = await transitRoute(transitParams);
|
||||
if (result && result.route) {
|
||||
mapTaskData.push({
|
||||
type: 'route',
|
||||
routeType: 'transfer',
|
||||
start: [originLng, originLat],
|
||||
end: [destLng, destLat],
|
||||
city: args.city,
|
||||
remark: `公交路线,共 ${result.route.transits.length} 个方案`
|
||||
});
|
||||
|
||||
console.log('📊 路线信息:');
|
||||
console.log(` 方案数量: ${result.route.transits.length} 个`);
|
||||
if (result.route.transits.length > 0) {
|
||||
const transit = result.route.transits[0];
|
||||
console.log(` 预计时间: ${Math.round(transit.duration / 60)} 分钟`);
|
||||
console.log(` 费用: ${transit.cost} 元`);
|
||||
console.log(` 步行距离: ${transit.walking_distance} 米\n`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`❌ 不支持的路线类型: ${type}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result && mapTaskData.length > 0) {
|
||||
// 生成地图链接
|
||||
const mapLink = generateMapLink(mapTaskData);
|
||||
console.log('🗺️ 地图可视化链接:');
|
||||
console.log(mapLink);
|
||||
console.log('\n💡 提示: 复制链接到浏览器打开即可查看路线详情\n');
|
||||
} else {
|
||||
console.log('\n❌ 路线规划失败,请检查参数是否正确');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('\n❌ 执行失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行主函数
|
||||
main();
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 旅游规划脚本
|
||||
* 使用方法:
|
||||
* node scripts/travel-planner.js --city=北京 --interests=景点,美食,酒店 --routeType=walking
|
||||
*/
|
||||
|
||||
const { travelPlanner } = require('../index');
|
||||
|
||||
// 解析命令行参数
|
||||
function parseArgs() {
|
||||
const args = {};
|
||||
process.argv.slice(2).forEach(arg => {
|
||||
if (arg.startsWith('--')) {
|
||||
const [key, value] = arg.slice(2).split('=');
|
||||
args[key] = value;
|
||||
}
|
||||
});
|
||||
return args;
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
|
||||
// 检查必需参数
|
||||
if (!args.city) {
|
||||
console.error('❌ 缺少必需参数: --city');
|
||||
console.log('\n使用方法:');
|
||||
console.log('node scripts/travel-planner.js --city=城市名 [--interests=兴趣点1,兴趣点2] [--routeType=路线类型]');
|
||||
console.log('\n示例:');
|
||||
console.log('node scripts/travel-planner.js --city=北京 --interests=景点,美食,酒店 --routeType=walking');
|
||||
console.log('\n路线类型选项:');
|
||||
console.log(' walking - 步行(默认)');
|
||||
console.log(' driving - 驾车');
|
||||
console.log(' riding - 骑行');
|
||||
console.log(' transfer - 公交');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 解析兴趣点
|
||||
const interests = args.interests ? args.interests.split(',') : ['景点', '美食'];
|
||||
const routeType = args.routeType || 'walking';
|
||||
|
||||
// 验证路线类型
|
||||
const validRouteTypes = ['walking', 'driving', 'riding', 'transfer'];
|
||||
if (!validRouteTypes.includes(routeType)) {
|
||||
console.error(`❌ 无效的路线类型: ${routeType}`);
|
||||
console.log('有效的路线类型:', validRouteTypes.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await travelPlanner({
|
||||
city: args.city,
|
||||
interests: interests,
|
||||
routeType: routeType
|
||||
});
|
||||
|
||||
if (result && result.pois.length > 0) {
|
||||
console.log('═'.repeat(80));
|
||||
console.log('\n📊 规划数据统计:');
|
||||
console.log(` 兴趣点数量: ${result.pois.length} 个`);
|
||||
console.log(` 路线数量: ${result.mapTaskData.filter(item => item.type === 'route').length} 条`);
|
||||
console.log(` 出行方式: ${routeType === 'walking' ? '步行' : routeType === 'driving' ? '驾车' : routeType === 'riding' ? '骑行' : '公交'}`);
|
||||
console.log('\n' + '═'.repeat(80));
|
||||
console.log('\n🎉 规划完成!\n');
|
||||
console.log('📋 数据格式符合 MapTaskData 接口规范');
|
||||
console.log(' - PoiTask: 兴趣点任务');
|
||||
console.log(' - RouteTask: 路线规划任务\n');
|
||||
} else {
|
||||
console.log('\n❌ 未找到相关地点,请尝试更换关键词或城市。');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('\n❌ 执行失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行主函数
|
||||
main();
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: fetch-page
|
||||
description: Fetch and summarize a web page
|
||||
argument-hint: <url>
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Fetch Page
|
||||
|
||||
Fetch the content of a URL and produce a concise summary.
|
||||
|
||||
## Process
|
||||
|
||||
1. Fetch the URL passed as `$1` using the `web_fetch` tool
|
||||
2. Summarize the main content in 3–5 sentences, preserving key facts and figures
|
||||
3. List any notable links or resources mentioned in the page
|
||||
4. If the URL is inaccessible, report the error and suggest checking the URL format
|
||||
+1
-1
@@ -92,7 +92,7 @@ pub enum Commands {
|
||||
|
||||
/// 后台服务模式
|
||||
///
|
||||
/// 等同 listen --llm,同时日志写入 .data/logs/ 滚动文件。
|
||||
/// 等同 listen --llm,同时日志写入 ~/.ias/logs/ 滚动文件。
|
||||
/// 配合 systemd 实现开机自启和进程守护。
|
||||
///
|
||||
/// 示例:
|
||||
|
||||
-218
@@ -1,218 +0,0 @@
|
||||
// 允许未来阶段使用的字段
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
/// iAs 全局配置
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub llm: LlmConfig,
|
||||
#[serde(default)]
|
||||
pub storage: StorageConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LlmConfig {
|
||||
/// LLM 提供商:deepseek | lmstudio
|
||||
#[serde(default = "default_llm_provider")]
|
||||
pub provider: String,
|
||||
|
||||
/// DeepSeek 配置
|
||||
#[serde(default)]
|
||||
pub deepseek: DeepSeekConfig,
|
||||
|
||||
/// LM Studio 配置
|
||||
#[serde(default)]
|
||||
pub lmstudio: LmStudioConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DeepSeekConfig {
|
||||
#[serde(default = "default_deepseek_api_key")]
|
||||
pub api_key: String,
|
||||
#[serde(default = "default_deepseek_model")]
|
||||
pub model: String,
|
||||
#[serde(default = "default_deepseek_base_url")]
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LmStudioConfig {
|
||||
#[serde(default = "default_lmstudio_base_url")]
|
||||
pub base_url: String,
|
||||
#[serde(default = "default_lmstudio_model")]
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct StorageConfig {
|
||||
/// 状态文件目录
|
||||
#[serde(default = "default_state_dir")]
|
||||
pub state_dir: String,
|
||||
/// PostgreSQL 连接串(可选,默认为文件存储)
|
||||
#[serde(default)]
|
||||
pub database_url: String,
|
||||
}
|
||||
|
||||
// ─── 默认值 ───
|
||||
|
||||
fn default_llm_provider() -> String {
|
||||
"deepseek".to_string()
|
||||
}
|
||||
|
||||
fn default_deepseek_api_key() -> String {
|
||||
env_or_default("DEEPSEEK_API_KEY", "")
|
||||
}
|
||||
|
||||
fn default_deepseek_model() -> String {
|
||||
env_or_default("DEEPSEEK_MODEL", "deepseek-v4-flash")
|
||||
}
|
||||
|
||||
fn default_deepseek_base_url() -> String {
|
||||
env_or_default("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")
|
||||
}
|
||||
|
||||
fn default_lmstudio_base_url() -> String {
|
||||
env_or_default("LM_STUDIO_BASE_URL", "http://localhost:1234/v1")
|
||||
}
|
||||
|
||||
fn default_lmstudio_model() -> String {
|
||||
env_or_default("LM_STUDIO_MODEL", "local-model")
|
||||
}
|
||||
|
||||
fn default_state_dir() -> String {
|
||||
".data/weixin-ilink".to_string()
|
||||
}
|
||||
|
||||
fn env_or_default(key: &str, default: &str) -> String {
|
||||
std::env::var(key).unwrap_or_else(|_| default.to_string())
|
||||
}
|
||||
|
||||
impl Default for LlmConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: default_llm_provider(),
|
||||
deepseek: DeepSeekConfig::default(),
|
||||
lmstudio: LmStudioConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DeepSeekConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
api_key: default_deepseek_api_key(),
|
||||
model: default_deepseek_model(),
|
||||
base_url: default_deepseek_base_url(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LmStudioConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: default_lmstudio_base_url(),
|
||||
model: default_lmstudio_model(),
|
||||
api_key: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StorageConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state_dir: default_state_dir(),
|
||||
database_url: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
llm: LlmConfig::default(),
|
||||
storage: StorageConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// 从文件加载配置,缺失字段回退到环境变量
|
||||
pub fn from_file(path: impl AsRef<Path>) -> Self {
|
||||
let path = path.as_ref();
|
||||
if path.exists() {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(content) => {
|
||||
// 先做环境变量替换
|
||||
let resolved = resolve_env_vars(&content);
|
||||
match serde_json::from_str(&resolved) {
|
||||
Ok(cfg) => return cfg,
|
||||
Err(e) => {
|
||||
tracing::warn!("解析配置文件失败 ({}): {},使用默认配置", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("读取配置文件失败 ({}): {},使用默认配置", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 替换字符串中的 `${VAR_NAME}` 为环境变量值
|
||||
fn resolve_env_vars(input: &str) -> String {
|
||||
let mut result = String::with_capacity(input.len());
|
||||
let mut chars = input.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '$' && chars.peek() == Some(&'{') {
|
||||
chars.next(); // 跳过 '{'
|
||||
let mut var_name = String::new();
|
||||
while let Some(&ch) = chars.peek() {
|
||||
if ch == '}' {
|
||||
chars.next(); // 跳过 '}'
|
||||
break;
|
||||
}
|
||||
var_name.push(ch);
|
||||
chars.next();
|
||||
}
|
||||
let value = std::env::var(&var_name).unwrap_or_default();
|
||||
result.push_str(&value);
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_env_vars() {
|
||||
// set_var is unsafe in edition 2024; skip env test
|
||||
let result = resolve_env_vars("prefix_${TEST_VAR}_suffix");
|
||||
// TEST_VAR might not be set, so result could be "prefix__suffix"
|
||||
assert_eq!(result, "prefix__suffix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_missing_env_var() {
|
||||
let result = resolve_env_vars("prefix_${MISSING_VAR}_suffix");
|
||||
assert_eq!(result, "prefix__suffix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_no_vars() {
|
||||
let result = resolve_env_vars("plain text");
|
||||
assert_eq!(result, "plain text");
|
||||
}
|
||||
}
|
||||
+27
-36
@@ -1,64 +1,61 @@
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// 长期记忆管理器(内存 + PostgreSQL 双写)
|
||||
/// 长期记忆管理器(内存 + PostgreSQL 双写,按用户隔离)
|
||||
pub struct MemoryStore {
|
||||
pool: Option<Arc<PgPool>>,
|
||||
cache: Arc<Mutex<Vec<String>>>,
|
||||
cache: Arc<Mutex<HashMap<String, Vec<String>>>>,
|
||||
}
|
||||
|
||||
impl MemoryStore {
|
||||
pub fn new(pool: Option<Arc<PgPool>>) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
cache: Arc::new(Mutex::new(Vec::new())),
|
||||
cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化:从数据库加载记忆到缓存
|
||||
pub async fn load(&self) {
|
||||
pub async fn load(&self, user_id: &str) {
|
||||
let pool = match self.pool.as_ref() {
|
||||
Some(p) => p.clone(),
|
||||
None => return,
|
||||
};
|
||||
let uid = if user_id.is_empty() { "default" } else { user_id };
|
||||
if let Ok(rows) = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT content FROM user_memories WHERE user_id = 'default' ORDER BY id",
|
||||
"SELECT content FROM user_memories WHERE user_id = $1 ORDER BY id",
|
||||
)
|
||||
.bind(uid)
|
||||
.fetch_all(pool.as_ref())
|
||||
.await
|
||||
{
|
||||
let mut cache = self.cache.lock().await;
|
||||
*cache = rows.into_iter().map(|(c,)| c).collect();
|
||||
let mems: Vec<String> = rows.into_iter().map(|(c,)| c).collect();
|
||||
self.cache.lock().await.insert(uid.to_string(), mems);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取所有记忆
|
||||
pub async fn read(&self) -> String {
|
||||
let mems = self.cache.lock().await;
|
||||
if mems.is_empty() {
|
||||
"暂无长期记忆".to_string()
|
||||
} else {
|
||||
mems.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| format!("{}. {}", i + 1, m))
|
||||
pub async fn read_for(&self, user_id: &str) -> String {
|
||||
let uid = if user_id.is_empty() { "default" } else { user_id };
|
||||
let cache = self.cache.lock().await;
|
||||
match cache.get(uid) {
|
||||
Some(m) if !m.is_empty() => m.iter().enumerate()
|
||||
.map(|(i, v)| format!("{}. {}", i + 1, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.join("\n"),
|
||||
_ => "暂无长期记忆".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 写入一条记忆
|
||||
pub async fn write(&self, content: &str) -> String {
|
||||
// 写入缓存
|
||||
self.cache.lock().await.push(content.to_string());
|
||||
// 写入数据库
|
||||
pub async fn write_for(&self, user_id: &str, content: &str) -> String {
|
||||
let uid = if user_id.is_empty() { "default" } else { user_id };
|
||||
self.cache.lock().await.entry(uid.to_string()).or_default().push(content.to_string());
|
||||
if let Some(ref pool) = self.pool {
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO user_memories (user_id, content) VALUES ('default', $1)",
|
||||
)
|
||||
.bind(content)
|
||||
.execute(pool.as_ref())
|
||||
.await;
|
||||
let _ = sqlx::query("INSERT INTO user_memories (user_id, content) VALUES ($1, $2)")
|
||||
.bind(uid)
|
||||
.bind(content)
|
||||
.execute(pool.as_ref())
|
||||
.await;
|
||||
}
|
||||
"已添加长期记忆".to_string()
|
||||
}
|
||||
@@ -71,7 +68,6 @@ pub async fn read_summaries(
|
||||
let s = session.lock().await;
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
|
||||
// 内存中的摘要
|
||||
for sum in &s.summaries {
|
||||
let reason = match sum.reason {
|
||||
super::types::SummaryReason::Overflow => "上下文压缩",
|
||||
@@ -85,7 +81,6 @@ pub async fn read_summaries(
|
||||
));
|
||||
}
|
||||
|
||||
// 数据库中的历史摘要
|
||||
if let Some(pool) = &s.db_pool {
|
||||
if let Ok(db_summaries) = crate::db::models::load_summaries(pool, 5).await {
|
||||
for text in db_summaries {
|
||||
@@ -96,9 +91,5 @@ pub async fn read_summaries(
|
||||
}
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
"暂无历史摘要".to_string()
|
||||
} else {
|
||||
lines.join("\n---\n")
|
||||
}
|
||||
if lines.is_empty() { "暂无历史摘要".to_string() } else { lines.join("\n---\n") }
|
||||
}
|
||||
|
||||
+20
-6
@@ -30,6 +30,8 @@ pub struct SummaryEntry {
|
||||
pub struct ChatSession {
|
||||
pub user_id: String,
|
||||
pub db_pool: Option<Arc<sqlx::PgPool>>,
|
||||
/// 当前正在处理消息的用户 ID(spawn 前设置,executor 中读取)
|
||||
pub current_user_id: String,
|
||||
/// 摘要总结点——此位置之前的消息已被压缩
|
||||
pub checkpoint: usize,
|
||||
/// 全部消息(checkpoint 之后的保持完整)
|
||||
@@ -49,6 +51,7 @@ impl Default for ChatSession {
|
||||
Self {
|
||||
user_id: "default".to_string(),
|
||||
db_pool: None,
|
||||
current_user_id: String::new(),
|
||||
checkpoint: 0,
|
||||
messages: Vec::new(),
|
||||
summaries: Vec::new(),
|
||||
@@ -121,12 +124,23 @@ impl ChatSession {
|
||||
None => return,
|
||||
};
|
||||
|
||||
let rows = sqlx::query_as::<_, (String, String, String, String)>(
|
||||
"SELECT direction, user_id, text, message_id FROM chat_records ORDER BY created_at DESC LIMIT $1",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool.as_ref())
|
||||
.await;
|
||||
let uid = self.current_user_id.clone();
|
||||
let rows = if uid.is_empty() {
|
||||
sqlx::query_as::<_, (String, String, String, String)>(
|
||||
"SELECT direction, user_id, text, message_id FROM chat_records ORDER BY created_at DESC LIMIT $1",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool.as_ref())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as::<_, (String, String, String, String)>(
|
||||
"SELECT direction, user_id, text, message_id FROM chat_records WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(limit)
|
||||
.fetch_all(pool.as_ref())
|
||||
.await
|
||||
};
|
||||
|
||||
if let Ok(rows) = rows {
|
||||
let msgs: Vec<Message> = rows
|
||||
|
||||
@@ -70,6 +70,7 @@ pub async fn insert_chat_record(
|
||||
r#"
|
||||
INSERT INTO chat_records (direction, user_id, account_id, text, source, context_token, message_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (message_id) DO NOTHING
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -222,7 +222,8 @@ impl ChatHandle {
|
||||
chunk
|
||||
}
|
||||
|
||||
pub async fn consume(mut self) -> Result<String, String> {
|
||||
/// 消费所有流式块,返回完整文本
|
||||
pub async fn consume(&mut self) -> Result<String, String> {
|
||||
while let Some(chunk) = self.next_chunk().await {
|
||||
if let StreamChunk::Error(e) = chunk { return Err(e); }
|
||||
}
|
||||
@@ -238,5 +239,6 @@ pub const DEFAULT_SYSTEM_PROMPT: &str = "You are a concise and helpful assistant
|
||||
Keep replies practical and natural. \
|
||||
Unless the user asks for detail, keep most replies under 120 Chinese characters. \
|
||||
\
|
||||
如果需要了解用户的长期记忆或历史对话摘要,使用 read_memories / read_summaries 工具。\
|
||||
如果用户提到重要的个人信息或约定,使用 write_memory 工具记录下来。";
|
||||
可用的核心工具:query_capabilities(查看所有可用工具)、call_capability(按名调用工具)。\
|
||||
可使用 read_memories / write_memory 管理用户长期记忆,read_summaries 查看历史摘要。\
|
||||
调用工具前先用 query_capabilities 确认工具有哪些。";
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
use crate::llm::provider::{parse_chat_chunk, LlmProvider, ParsedChunk, StreamReceiver, StreamSender};
|
||||
use crate::llm::types::{ConversationConfig, Message, StreamChunk};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client as HttpClient;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// LM Studio 提供商(OpenAI 兼容接口)
|
||||
pub struct LmStudioProvider {
|
||||
http: HttpClient,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
impl LmStudioProvider {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let base_url = std::env::var("LM_STUDIO_BASE_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:1234/v1".to_string());
|
||||
// 标准化 base_url: 确保有协议和 /v1 路径
|
||||
let base_url = normalize_base_url(&base_url);
|
||||
let api_key = std::env::var("LM_STUDIO_API_KEY").unwrap_or_default();
|
||||
|
||||
Ok(Self {
|
||||
http: HttpClient::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| format!("创建 HTTP client 失败: {}", e))?,
|
||||
base_url,
|
||||
api_key,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for LmStudioProvider {
|
||||
fn name(&self) -> &str {
|
||||
"lmstudio"
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
config: &ConversationConfig,
|
||||
messages: &[Message],
|
||||
) -> Result<StreamReceiver, String> {
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": config.model,
|
||||
"messages": messages,
|
||||
"temperature": config.temperature,
|
||||
"max_tokens": config.max_tokens,
|
||||
"stream": true,
|
||||
});
|
||||
|
||||
let (tx, rx) = mpsc::channel::<StreamChunk>(64);
|
||||
let http = self.http.clone();
|
||||
let api_key = self.api_key.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = stream_openai(http, &url, &api_key, body, tx.clone()).await {
|
||||
let _ = tx.send(StreamChunk::Error(e)).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(rx)
|
||||
}
|
||||
}
|
||||
|
||||
/// 与 DeepSeek 公用相同的 SSE 流式处理,但去除 thinking 相关字段
|
||||
async fn stream_openai(
|
||||
http: HttpClient,
|
||||
url: &str,
|
||||
api_key: &str,
|
||||
body: serde_json::Value,
|
||||
tx: StreamSender,
|
||||
) -> Result<(), String> {
|
||||
let mut req = http.post(url).header("Content-Type", "application/json");
|
||||
|
||||
if !api_key.is_empty() {
|
||||
req = req.header("Authorization", format!("Bearer {}", api_key));
|
||||
}
|
||||
|
||||
let response = req
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求失败: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
let _ = tx.send(StreamChunk::Error(format!("HTTP {}: {}", status, text))).await;
|
||||
return Err(format!("HTTP {}: {}", status, text));
|
||||
}
|
||||
|
||||
let mut full_text = String::new();
|
||||
let mut buf = String::new();
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
use futures_util::StreamExt;
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result.map_err(|e| format!("读取流失败: {}", e))?;
|
||||
let text = String::from_utf8_lossy(&chunk);
|
||||
buf.push_str(&text);
|
||||
|
||||
while let Some(line_end) = buf.find('\n') {
|
||||
let line = buf[..line_end].to_string();
|
||||
buf = buf[line_end + 1..].to_string();
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// LM Studio 使用标准 OpenAI SSE 格式
|
||||
if let Some(parsed) = parse_chat_chunk(trimmed) {
|
||||
match parsed {
|
||||
ParsedChunk::Text(t) => {
|
||||
full_text.push_str(&t);
|
||||
let _ = tx.send(StreamChunk::Text(t)).await;
|
||||
}
|
||||
ParsedChunk::Reasoning(r) => {
|
||||
let _ = tx.send(StreamChunk::Reasoning(r)).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx
|
||||
.send(StreamChunk::Done {
|
||||
text: full_text,
|
||||
reasoning: None,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
})
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 确保 base_url 有协议前缀和 /v1 路径
|
||||
fn normalize_base_url(raw: &str) -> String {
|
||||
let raw = raw.trim();
|
||||
let with_protocol = if raw.starts_with("http://") || raw.starts_with("https://") {
|
||||
raw.to_string()
|
||||
} else {
|
||||
format!("http://{}", raw)
|
||||
};
|
||||
|
||||
// 去掉末尾 /
|
||||
let trimmed = with_protocol.trim_end_matches('/');
|
||||
// 确保路径以 /v1 结尾
|
||||
if trimmed.ends_with("/v1") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{}/v1", trimmed)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
pub mod conversation;
|
||||
pub mod deepseek;
|
||||
pub mod lmstudio;
|
||||
pub mod provider;
|
||||
pub mod types;
|
||||
|
||||
|
||||
+1
-10
@@ -28,16 +28,7 @@ pub type BoxedProvider = Arc<dyn LlmProvider>;
|
||||
|
||||
/// 从配置创建恰当的提供商
|
||||
pub fn create_provider(_config: &ConversationConfig) -> Result<BoxedProvider, String> {
|
||||
// 根据 model 前缀或环境变量选择
|
||||
let provider = std::env::var("LLM_PROVIDER")
|
||||
.unwrap_or_else(|_| "deepseek".to_string())
|
||||
.to_lowercase();
|
||||
|
||||
match provider.as_str() {
|
||||
"deepseek" => Ok(Arc::new(super::deepseek::DeepSeekProvider::new()?)),
|
||||
"lmstudio" => Ok(Arc::new(super::lmstudio::LmStudioProvider::new()?)),
|
||||
_ => Err(format!("不支持的 LLM 提供商: {}", provider)),
|
||||
}
|
||||
Ok(Arc::new(super::deepseek::DeepSeekProvider::new()?))
|
||||
}
|
||||
|
||||
// ─── 内部:SSE 解析工具 ───
|
||||
|
||||
+212
-111
@@ -1,5 +1,4 @@
|
||||
mod cli;
|
||||
mod config;
|
||||
mod context;
|
||||
mod db;
|
||||
mod llm;
|
||||
@@ -12,12 +11,11 @@ mod wechat;
|
||||
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Commands, SkillsAction};
|
||||
use config::AppConfig;
|
||||
use context::MemoryStore;
|
||||
use db::Database;
|
||||
use llm::{Conversation, ConversationConfig, DEFAULT_SYSTEM_PROMPT, ToolExecutor, Usage};
|
||||
use std::sync::Arc;
|
||||
use tools::approval::ApprovalManager;
|
||||
use tools::approval::{ApprovalDecision, ApprovalManager};
|
||||
use tools::registry::{ExecutionContext, SkillRegistry};
|
||||
use tracing::{error, info};
|
||||
use wechat::client::WeChatClient;
|
||||
@@ -26,13 +24,25 @@ use wechat::client::WeChatClient;
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
let is_service = matches!(cli.command, Commands::Service);
|
||||
logger::init_logger(Some(".data/logs"), is_service);
|
||||
let log_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".ias")
|
||||
.join("logs");
|
||||
let log_dir_str = log_dir.to_string_lossy().to_string();
|
||||
logger::init_logger(Some(&log_dir_str), is_service);
|
||||
|
||||
dotenvy::dotenv().ok();
|
||||
// .env 加载:先当前目录,再 ~/.ias/.env
|
||||
if std::path::Path::new(".env").exists() {
|
||||
dotenvy::dotenv().ok();
|
||||
} else if let Ok(home) = std::env::var("HOME") {
|
||||
let fallback = std::path::PathBuf::from(&home).join(".ias").join(".env");
|
||||
if fallback.exists() {
|
||||
dotenvy::from_path(&fallback).ok();
|
||||
tracing::info!("使用全局环境配置: {}", fallback.display());
|
||||
}
|
||||
}
|
||||
|
||||
let config = AppConfig::from_file("config.json");
|
||||
|
||||
// 初始化数据库(如果配置了 DATABASE_URL)
|
||||
let file_state = state::StateManager::new(".data/weixin-ilink");
|
||||
let database = match Database::connect().await {
|
||||
Ok(db) => {
|
||||
info!("✅ 数据库连接成功");
|
||||
@@ -48,10 +58,7 @@ async fn main() {
|
||||
let memory_store = Arc::new(MemoryStore::new(
|
||||
database.as_ref().map(|db| Arc::new(db.pool().clone())),
|
||||
));
|
||||
memory_store.load().await;
|
||||
|
||||
// 文件状态管理器(降级方案)
|
||||
let file_state = state::StateManager::new(&config.storage.state_dir);
|
||||
memory_store.load("").await;
|
||||
|
||||
match cli.command {
|
||||
Commands::Login { timeout } => {
|
||||
@@ -61,15 +68,7 @@ async fn main() {
|
||||
llm: enable_llm,
|
||||
echo,
|
||||
} => {
|
||||
cmd_listen(
|
||||
enable_llm,
|
||||
echo,
|
||||
&database,
|
||||
&file_state,
|
||||
&config,
|
||||
&memory_store,
|
||||
)
|
||||
.await;
|
||||
cmd_listen(enable_llm, echo, &database, &file_state, &memory_store).await;
|
||||
}
|
||||
Commands::Send {
|
||||
to,
|
||||
@@ -89,7 +88,7 @@ async fn main() {
|
||||
cmd_usage(&database, since, until, model).await;
|
||||
}
|
||||
Commands::Service => {
|
||||
cmd_listen(true, false, &database, &file_state, &config, &memory_store).await
|
||||
cmd_listen(true, false, &database, &file_state, &memory_store).await
|
||||
}
|
||||
Commands::Skills { action } => {
|
||||
cmd_skills(action).await;
|
||||
@@ -148,7 +147,6 @@ async fn cmd_listen(
|
||||
echo: bool,
|
||||
database: &Option<Arc<Database>>,
|
||||
file_state: &state::StateManager,
|
||||
config: &AppConfig,
|
||||
memory_store: &Arc<MemoryStore>,
|
||||
) {
|
||||
// 从数据库或文件加载认证
|
||||
@@ -181,7 +179,7 @@ async fn cmd_listen(
|
||||
}
|
||||
};
|
||||
|
||||
let client = WeChatClient::new(Some(auth.base_url.clone()));
|
||||
let mut client = WeChatClient::new(Some(auth.base_url.clone()));
|
||||
client
|
||||
.set_auth(&auth.token, &auth.account_id, &auth.base_url)
|
||||
.await;
|
||||
@@ -197,29 +195,23 @@ async fn cmd_listen(
|
||||
}
|
||||
|
||||
// 加载系统 Skills
|
||||
let skill_registry = match SkillRegistry::load("resources/skills", "skills").await {
|
||||
Ok(r) => {
|
||||
info!("已加载 {} 个技能", r.count());
|
||||
Some(Arc::new(r))
|
||||
}
|
||||
Err(e) => {
|
||||
info!("技能加载结果: {:?}", e);
|
||||
None
|
||||
let (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 = database
|
||||
.as_ref()
|
||||
.map(|db| Arc::new(ApprovalManager::new(Some(Arc::new(db.pool().clone())))));
|
||||
|
||||
// 共享的当前用户 ID(用于审批消息)
|
||||
let current_user_id = Arc::new(tokio::sync::Mutex::new(String::new()));
|
||||
let approval_manager = Arc::new(ApprovalManager::new(
|
||||
database.as_ref().map(|db| Arc::new(db.pool().clone()))
|
||||
));
|
||||
|
||||
let conversation = if enable_llm {
|
||||
let sys_prompt = std::env::var("WEIXIN_LLM_SYSTEM_PROMPT")
|
||||
.unwrap_or_else(|_| DEFAULT_SYSTEM_PROMPT.to_string());
|
||||
let model = config.llm.deepseek.model.clone();
|
||||
let model = std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| "deepseek-v4-flash".to_string());
|
||||
|
||||
let mut cfg = ConversationConfig {
|
||||
system_prompt: sys_prompt,
|
||||
@@ -227,28 +219,43 @@ async fn cmd_listen(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// 构建 LLM tools 列表(skills + 上下文工具)
|
||||
// 仅暴露两个元工具:查询能力 + 调用能力
|
||||
// 具体工具描述通过 query_capabilities 返回
|
||||
let mut tools_list: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
if let Some(ref registry) = skill_registry {
|
||||
for spec in registry.list_specs() {
|
||||
tools_list.push(serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": spec.name,
|
||||
"description": spec.description,
|
||||
"parameters": spec.parameters,
|
||||
}
|
||||
}));
|
||||
// query_capabilities:列出所有可用工具及其说明
|
||||
tools_list.push(serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "query_capabilities",
|
||||
"description": "列出所有可用工具的详细说明、参数格式和调用示例。在需要了解有哪些工具可用时首先调用此工具。",
|
||||
"parameters": { "type": "object", "properties": {} }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// 上下文工具(长期记忆 + 摘要)
|
||||
// call_capability:按名称调用任意工具
|
||||
tools_list.push(serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "call_capability",
|
||||
"description": "按工具名称调用一个已注册的工具。name 为工具名(来自 query_capabilities),prompt 为自然语言需求",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "工具名称" },
|
||||
"prompt": { "type": "string", "description": "自然语言需求" }
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// 上下文工具(仍然直接暴露给 LLM)
|
||||
tools_list.push(serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_memories",
|
||||
"description": "读取用户的长期记忆(跨会话持久保存的个人信息、偏好、约定等)",
|
||||
"description": "读取用户的长期记忆(偏好、个人信息、约定)",
|
||||
"parameters": { "type": "object", "properties": {} }
|
||||
}
|
||||
}));
|
||||
@@ -256,7 +263,7 @@ async fn cmd_listen(
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "write_memory",
|
||||
"description": "写入一条用户的长期记忆(当用户提到重要偏好、个人信息、约定时调用)",
|
||||
"description": "记录用户的重要信息或偏好",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -270,7 +277,7 @@ async fn cmd_listen(
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_summaries",
|
||||
"description": "读取历史会话摘要(包括因上下文压缩或空闲超时产生的摘要)",
|
||||
"description": "读取历史会话摘要",
|
||||
"parameters": { "type": "object", "properties": {} }
|
||||
}
|
||||
}));
|
||||
@@ -280,14 +287,11 @@ async fn cmd_listen(
|
||||
// 创建 Conversation
|
||||
let mut conv = match Conversation::new(cfg) {
|
||||
Ok(c) => {
|
||||
// 设置 db_pool 并加载历史消息
|
||||
// 设置 db_pool(启动时不加载消息,等第一条消息到达后再按需恢复)
|
||||
if let Some(db) = database {
|
||||
let session = c.session();
|
||||
{
|
||||
let mut s = session.lock().await;
|
||||
s.db_pool = Some(Arc::new(db.pool().clone()));
|
||||
s.load_recent_messages(20).await;
|
||||
}
|
||||
let mut s = session.lock().await;
|
||||
s.db_pool = Some(Arc::new(db.pool().clone()));
|
||||
}
|
||||
info!("LLM 已初始化: {} / {}", c.provider_name(), c.model());
|
||||
c
|
||||
@@ -319,46 +323,78 @@ async fn cmd_listen(
|
||||
})
|
||||
};
|
||||
|
||||
let executor_user_id = current_user_id.clone();
|
||||
let executor: ToolExecutor = Arc::new(move |name: &str, args_json: &str| {
|
||||
let reg = registry_opt.clone();
|
||||
let approval = approval.clone();
|
||||
let send = send_wechat.clone();
|
||||
let session = session.clone();
|
||||
let memory_store = memory_store.clone();
|
||||
let current_user = executor_user_id.clone();
|
||||
let n = name.to_string();
|
||||
let args = args_json.to_string();
|
||||
|
||||
Box::pin(async move {
|
||||
match n.as_str() {
|
||||
"read_memories" => return Ok(memory_store.read().await),
|
||||
"write_memory" => {
|
||||
let params: serde_json::Value =
|
||||
serde_json::from_str(&args).unwrap_or_default();
|
||||
let content = params.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if content.is_empty() {
|
||||
return Ok("请提供 content 参数".to_string());
|
||||
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 == "write_memory" {
|
||||
let params: serde_json::Value = serde_json::from_str(&args).unwrap_or_default();
|
||||
let content = params.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if content.is_empty() { return Ok("请提供 content 参数".to_string()); }
|
||||
return Ok(memory_store.write_for(&user_id, content).await);
|
||||
}
|
||||
if n == "read_summaries" { return Ok(context::tools::read_summaries(&session).await); }
|
||||
|
||||
// query_capabilities: 合并内置 + 外部(去重)
|
||||
if n == "query_capabilities" {
|
||||
let spec_list = if let Some(ref r) = reg {
|
||||
let mut s: Vec<tools::skill::SkillSpec> = r.list_specs().into_iter().cloned().collect();
|
||||
for builtin in tools::builtin::BuiltinRegistry::specs() {
|
||||
s.retain(|x| x.name != builtin.name);
|
||||
s.push(builtin);
|
||||
}
|
||||
return Ok(memory_store.write(content).await);
|
||||
}
|
||||
"read_summaries" => return Ok(context::tools::read_summaries(&session).await),
|
||||
_ => {}
|
||||
s
|
||||
} else {
|
||||
tools::builtin::BuiltinRegistry::specs()
|
||||
};
|
||||
return Ok(build_capability_list(&spec_list));
|
||||
}
|
||||
let user_id = current_user.lock().await.clone();
|
||||
if let Some(ref reg) = reg {
|
||||
let params: serde_json::Value =
|
||||
serde_json::from_str(&args).unwrap_or(serde_json::json!({}));
|
||||
|
||||
// 确定目标工具名和参数(call_capability 提取 name + 透传完整参数)
|
||||
let (target_name, target_args) = if n == "call_capability" {
|
||||
let cp: serde_json::Value = serde_json::from_str(&args).unwrap_or_default();
|
||||
let name = cp.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_default();
|
||||
(name, serde_json::to_string(&cp).unwrap_or_default())
|
||||
} else { (n.clone(), args.clone()) };
|
||||
|
||||
// 内置工具
|
||||
if tools::builtin::BuiltinRegistry::is_builtin(&target_name) {
|
||||
if tools::builtin::BuiltinRegistry::is_high_risk(&target_name) {
|
||||
let mut ctx = ExecutionContext::new(&user_id);
|
||||
ctx = ctx.with_approval(approval.clone());
|
||||
ctx = ctx.with_wechat(send.clone());
|
||||
let approved = builtin_approve(&ctx, &target_name).await;
|
||||
match approved {
|
||||
Ok(true) => {},
|
||||
Ok(false) => return Ok(SkillResult::rejected(&target_name).output),
|
||||
Err(e) => return Ok(e),
|
||||
}
|
||||
}
|
||||
if let Some(result) = tools::builtin::BuiltinRegistry::execute(&target_name, &target_args).await {
|
||||
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);
|
||||
if let Some(a) = approval {
|
||||
ctx = ctx.with_approval(a);
|
||||
}
|
||||
ctx = ctx.with_approval(approval.clone());
|
||||
ctx = ctx.with_wechat(send);
|
||||
let result = reg.execute(&n, params, &ctx).await;
|
||||
Ok(result.output)
|
||||
} else {
|
||||
Ok(format!("未知工具: {}", n))
|
||||
let result = r.execute(&target_name, params, &ctx).await;
|
||||
return Ok(result.output);
|
||||
}
|
||||
Ok(format!("未知工具: {}", target_name))
|
||||
})
|
||||
});
|
||||
|
||||
@@ -391,6 +427,14 @@ async fn cmd_listen(
|
||||
.await;
|
||||
});
|
||||
info!("定时任务调度器已启动");
|
||||
// 审批定期清理
|
||||
let approval_clean = approval_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
approval_clean.clean_expired().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
info!("开始监听消息 (llm={}, echo={})...", enable_llm, echo);
|
||||
@@ -398,8 +442,6 @@ async fn cmd_listen(
|
||||
|
||||
let shutdown = async { tokio::signal::ctrl_c().await.expect("Ctrl+C 失败") };
|
||||
|
||||
let listen_user_id = current_user_id.clone();
|
||||
|
||||
tokio::select! {
|
||||
_ = shutdown => {
|
||||
info!("收到退出信号,注销监听器...");
|
||||
@@ -407,7 +449,7 @@ async fn cmd_listen(
|
||||
file_state.save_runtime(&client.get_updates_buf().await);
|
||||
info!("已退出");
|
||||
}
|
||||
_ = listen_loop(&client, enable_llm, echo, &account_id, database, file_state, conversation, approval_manager, listen_user_id) => {}
|
||||
_ = listen_loop(&client, enable_llm, echo, &account_id, database, file_state, conversation, &approval_manager, memory_store) => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,9 +461,10 @@ async fn listen_loop(
|
||||
database: &Option<Arc<Database>>,
|
||||
file_state: &state::StateManager,
|
||||
conversation: Option<Arc<Conversation>>,
|
||||
approval_manager: Option<Arc<ApprovalManager>>,
|
||||
current_user_id: Arc<tokio::sync::Mutex<String>>,
|
||||
approval_manager: &ApprovalManager,
|
||||
memory_store: &Arc<MemoryStore>,
|
||||
) {
|
||||
let last_user_id = Arc::new(tokio::sync::Mutex::new(String::new()));
|
||||
loop {
|
||||
match client.receive_messages().await {
|
||||
Ok(resp) => {
|
||||
@@ -482,19 +525,34 @@ async fn listen_loop(
|
||||
}
|
||||
|
||||
// 审批回复检查
|
||||
let is_approval_reply = if let Some(mgr) = &approval_manager {
|
||||
mgr.handle_reply(from, text).await.is_some()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let is_approval_reply = approval_manager.handle_reply(from, text).await.is_some();
|
||||
|
||||
if is_approval_reply {
|
||||
info!("消息匹配审批确认码,不传给 LLM");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新当前用户(审批流程需要)
|
||||
*current_user_id.lock().await = from.to_string();
|
||||
// 用户切换:清空上下文,加载新用户历史
|
||||
{
|
||||
let mut last_user = last_user_id.lock().await;
|
||||
if *last_user != *from {
|
||||
info!("用户切换: {} → {}", *last_user, from);
|
||||
if let Some(conv) = &conversation {
|
||||
let session = conv.session();
|
||||
let mut s = session.lock().await;
|
||||
s.messages.clear();
|
||||
s.checkpoint = 0;
|
||||
s.summaries.clear();
|
||||
s.last_user_at = None;
|
||||
s.current_user_id = from.to_string();
|
||||
// 加载该用户最近 20 条历史
|
||||
s.load_recent_messages(20).await;
|
||||
drop(s);
|
||||
memory_store.load(from).await;
|
||||
}
|
||||
}
|
||||
*last_user = from.to_string();
|
||||
}
|
||||
|
||||
// LLM 回复(异步执行,避免审批阻塞主循环)
|
||||
if enable_llm {
|
||||
@@ -556,7 +614,9 @@ async fn generate_reply(
|
||||
user_text: String,
|
||||
db: &Option<Arc<Database>>,
|
||||
) -> Result<String, String> {
|
||||
let handle = conv.chat(user_text).await?;
|
||||
let mut handle = conv.chat(user_text).await?;
|
||||
// 先消费流(usage 在 Done chunk 中才可用)
|
||||
let reply = handle.consume().await?;
|
||||
if let Some(u) = handle.get_usage() {
|
||||
info!(
|
||||
"📊 Token: {} 总 / {} 提示 / {} 生成 | 缓存命中 {} | 缓存未命中 {}",
|
||||
@@ -568,7 +628,7 @@ async fn generate_reply(
|
||||
);
|
||||
store_usage(db, "", conv.model(), conv.provider_name(), u).await;
|
||||
}
|
||||
handle.consume().await
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
async fn generate_reply_with_tools(
|
||||
@@ -726,7 +786,7 @@ async fn cmd_send(
|
||||
|
||||
let base_url = auth.base_url.clone();
|
||||
let account_id = auth.account_id.clone();
|
||||
let client = WeChatClient::new(Some(auth.base_url));
|
||||
let mut client = WeChatClient::new(Some(auth.base_url));
|
||||
client
|
||||
.set_auth(&auth.token, &auth.account_id, &base_url)
|
||||
.await;
|
||||
@@ -754,10 +814,53 @@ 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;
|
||||
|
||||
async fn builtin_approve(ctx: &ExecutionContext, name: &str) -> Result<bool, String> {
|
||||
let manager = match ctx.approval_manager.as_ref() {
|
||||
Some(m) => m.clone(),
|
||||
None => return Ok(true),
|
||||
};
|
||||
let (code, rx) = manager.create(&ctx.user_id, name).await?;
|
||||
|
||||
let msg = format!(
|
||||
"⚠️ 操作确认\n\n技能:{}\n\n确认码:{}\n\n回复确认码以继续\n回复 0 或「取消」以取消\n(5分钟内有效,最多3次尝试)",
|
||||
name, code
|
||||
);
|
||||
if let Some(ref send) = ctx.send_wechat {
|
||||
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 {
|
||||
Ok(Ok(ApprovalDecision::Approved)) => Ok(true),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_capability_list(specs: &[tools::skill::SkillSpec]) -> String {
|
||||
let mut lines = vec!["📋 可用工具:".to_string()];
|
||||
for spec in specs {
|
||||
let risk = if spec.risk_level == tools::skill::RiskLevel::High { " ⚠️需确认" } else { "" };
|
||||
lines.push(format!(
|
||||
"\n🔹 {} {} — {}", spec.name, risk, spec.description
|
||||
));
|
||||
}
|
||||
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();
|
||||
@@ -792,8 +895,8 @@ async fn cmd_skills(action: SkillsAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dest = std::path::PathBuf::from("skills");
|
||||
std::fs::create_dir_all(&dest).ok();
|
||||
let dest = &global_dir;
|
||||
std::fs::create_dir_all(dest).ok();
|
||||
|
||||
let mut imported = 0;
|
||||
let mut skipped = 0;
|
||||
@@ -811,18 +914,16 @@ async fn cmd_skills(action: SkillsAction) {
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\n完成:导入 {imported} 个,跳过 {skipped} 个");
|
||||
println!("重启 iAs 后生效");
|
||||
println!("\n完成:导入 {imported} 个,跳过 {skipped} 个 (→ {})", global_dir.display());
|
||||
}
|
||||
SkillsAction::Delete => {
|
||||
let skills_dir = std::path::PathBuf::from("skills");
|
||||
if !skills_dir.exists() {
|
||||
println!("skills 目录不存在,没有可删除的 Skill");
|
||||
if !global_dir.exists() {
|
||||
println!("全局 skills 目录不存在,没有可删除的 Skill");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut entries: Vec<(String, String)> = Vec::new();
|
||||
if let Ok(dir) = std::fs::read_dir(&skills_dir) {
|
||||
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() {
|
||||
@@ -860,7 +961,7 @@ async fn cmd_skills(action: SkillsAction) {
|
||||
let mut deleted = 0;
|
||||
for idx in selection {
|
||||
let (name, _) = &entries[idx];
|
||||
let path = skills_dir.join(name);
|
||||
let path = global_dir.join(name);
|
||||
match std::fs::remove_dir_all(&path) {
|
||||
Ok(_) => {
|
||||
println!(" ✅ 已删除 {name}");
|
||||
|
||||
+28
-12
@@ -70,19 +70,31 @@ struct DueTask {
|
||||
}
|
||||
|
||||
async fn fetch_due_tasks(pool: &PgPool) -> Result<Vec<DueTask>, String> {
|
||||
let mut tx = pool.begin().await.map_err(|e| format!("开始事务失败: {}", e))?;
|
||||
let rows = sqlx::query_as::<_, (uuid::Uuid, String, String, String, String, String)>(
|
||||
r#"
|
||||
SELECT id, name, user_id, command, shell, cwd
|
||||
FROM scheduled_tasks
|
||||
WHERE enabled = true AND next_run_at <= NOW()
|
||||
ORDER BY next_run_at ASC
|
||||
LIMIT 10
|
||||
LIMIT 5
|
||||
FOR UPDATE SKIP LOCKED
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| format!("查询到期任务失败: {}", e))?;
|
||||
|
||||
// 立即标记为已调度(防止其他实例重复获取)
|
||||
for (id, _, _, _, _, _) in &rows {
|
||||
sqlx::query("UPDATE scheduled_tasks SET next_run_at = NOW() + (interval_seconds * INTERVAL '1 second') WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
tx.commit().await.map_err(|e| format!("提交事务失败: {}", e))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, name, user_id, command, shell, cwd)| DueTask {
|
||||
@@ -110,17 +122,20 @@ async fn execute_task(task: &DueTask) -> String {
|
||||
task.cwd.clone()
|
||||
};
|
||||
|
||||
let result = tokio::process::Command::new(shell)
|
||||
.arg("-c")
|
||||
.arg(&task.command)
|
||||
.current_dir(&cwd)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.output()
|
||||
.await;
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(120),
|
||||
tokio::process::Command::new(shell)
|
||||
.arg("-c")
|
||||
.arg(&task.command)
|
||||
.current_dir(&cwd)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.output()
|
||||
).await;
|
||||
|
||||
match result {
|
||||
Ok(output) => {
|
||||
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();
|
||||
|
||||
@@ -138,7 +153,8 @@ async fn execute_task(task: &DueTask) -> String {
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(e) => format!("执行失败: {}", e),
|
||||
Ok(Err(e)) => format!("进程错误: {}", e),
|
||||
Err(_timeout) => "执行超时 (120s)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+76
-43
@@ -89,61 +89,94 @@ impl ApprovalManager {
|
||||
/// 处理用户回复(由消息循环调用)
|
||||
/// 匹配成功时返回 Some(技能名),匹配失败返回 None
|
||||
pub async fn handle_reply(&self, user_id: &str, reply: &str) -> Option<String> {
|
||||
let mut map = self.pending.lock().await;
|
||||
let entries = map.get_mut(user_id)?;
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (result, name, code_hash) = {
|
||||
let mut map = self.pending.lock().await;
|
||||
let entries = map.get_mut(user_id)?;
|
||||
if entries.is_empty() { return None; }
|
||||
|
||||
let reply_hash = format!("{:x}", Sha256::digest(reply.as_bytes()));
|
||||
let reply_trimmed = reply.trim();
|
||||
let reply_hash = format!("{:x}", Sha256::digest(reply.as_bytes()));
|
||||
let reply_trimmed = reply.trim();
|
||||
|
||||
// 检查取消
|
||||
if reply_trimmed == "0" || reply_trimmed == "取消" {
|
||||
let entry = entries.remove(0);
|
||||
let name = entry.skill_name.clone();
|
||||
let _ = entry.sender.send(ApprovalDecision::Rejected);
|
||||
return Some(name);
|
||||
}
|
||||
|
||||
// 检查确认码
|
||||
let pos = entries.iter().position(|e| e.code_hash == reply_hash);
|
||||
if let Some(idx) = pos {
|
||||
let entry = entries.remove(idx);
|
||||
let name = entry.skill_name.clone();
|
||||
let _ = entry.sender.send(ApprovalDecision::Approved);
|
||||
return Some(name);
|
||||
}
|
||||
|
||||
// 确认码错误
|
||||
if let Some(entry) = entries.first_mut() {
|
||||
entry.attempts_left -= 1;
|
||||
if entry.attempts_left <= 0 {
|
||||
if reply_trimmed == "0" || reply_trimmed == "取消" {
|
||||
let entry = entries.remove(0);
|
||||
let name = entry.skill_name.clone();
|
||||
let hash = entry.code_hash.clone();
|
||||
let _ = entry.sender.send(ApprovalDecision::Rejected);
|
||||
return Some(name);
|
||||
(ApprovalDecision::Rejected, entry.skill_name.clone(), hash)
|
||||
} else if let Some(idx) = entries.iter().position(|e| e.code_hash == reply_hash) {
|
||||
let entry = entries.remove(idx);
|
||||
let hash = entry.code_hash.clone();
|
||||
let _ = entry.sender.send(ApprovalDecision::Approved);
|
||||
(ApprovalDecision::Approved, entry.skill_name.clone(), hash)
|
||||
} else if let Some(entry) = entries.first_mut() {
|
||||
entry.attempts_left -= 1;
|
||||
if entry.attempts_left <= 0 {
|
||||
let entry = entries.remove(0);
|
||||
let n = entry.skill_name.clone();
|
||||
let hash = entry.code_hash.clone();
|
||||
let _ = entry.sender.send(ApprovalDecision::Rejected);
|
||||
(ApprovalDecision::Rejected, n, hash)
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 清理过期(由定时任务调用)
|
||||
pub async fn clean_expired(&self) {
|
||||
let mut map = self.pending.lock().await;
|
||||
for entries in map.values_mut() {
|
||||
entries.retain(|e| !e.sender.is_closed());
|
||||
}
|
||||
map.retain(|_, v| !v.is_empty());
|
||||
};
|
||||
|
||||
// 更新数据库状态
|
||||
if let Some(ref pool) = self.pool {
|
||||
let status = match result {
|
||||
ApprovalDecision::Approved => "approved",
|
||||
_ => "rejected",
|
||||
};
|
||||
// 按 user_id + 最近 pending 记录更新
|
||||
let _ = sqlx::query(
|
||||
"UPDATE pending_approvals SET status = 'expired' WHERE status = 'pending' AND expires_at < NOW()",
|
||||
"UPDATE pending_approvals SET status = $1, consumed_at = NOW() WHERE code_hash = $2 AND status = 'pending'",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(&code_hash)
|
||||
.execute(pool.as_ref())
|
||||
.await;
|
||||
}
|
||||
|
||||
Some(name)
|
||||
}
|
||||
|
||||
/// 清理过期审批(通知等待方 + 更新 DB)
|
||||
pub async fn clean_expired(&self) {
|
||||
let mut expired = Vec::new();
|
||||
{
|
||||
let map = self.pending.lock().await;
|
||||
for (uid, entries) in map.iter() {
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
if e.sender.is_closed() {
|
||||
expired.push((uid.clone(), i, e.code_hash.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref pool) = self.pool {
|
||||
for (_uid, _i, _code_hash) in &expired {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE pending_approvals SET status = 'expired' WHERE code_hash = $1 AND status = 'pending'",
|
||||
)
|
||||
.bind(_code_hash)
|
||||
.execute(pool.as_ref())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let mut map = self.pending.lock().await;
|
||||
for (uid, i, _) in expired.iter().rev() {
|
||||
if let Some(entries) = map.get_mut(uid) {
|
||||
if *i < entries.len() {
|
||||
let entry = entries.remove(*i);
|
||||
let _ = entry.sender.send(ApprovalDecision::Expired);
|
||||
}
|
||||
}
|
||||
}
|
||||
map.retain(|_, v| !v.is_empty());
|
||||
}
|
||||
|
||||
/// 获取某个用户的待审批条数
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use crate::tools::skill::{RiskLevel, SkillResult};
|
||||
use chrono::Local;
|
||||
|
||||
/// 内置工具执行器:统一处理参数校验、执行、错误返回
|
||||
pub struct BuiltinRegistry;
|
||||
|
||||
impl BuiltinRegistry {
|
||||
pub async fn execute(name: &str, args_json: &str) -> Option<SkillResult> {
|
||||
match name {
|
||||
"get_current_datetime" => Some(execute_datetime()),
|
||||
"manage_memos" => Some(handle_memos(args_json).await),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回所有内置工具的 spec 列表
|
||||
pub fn specs() -> Vec<super::skill::SkillSpec> {
|
||||
vec![
|
||||
super::skill::SkillSpec {
|
||||
name: "get_current_datetime".into(),
|
||||
description: "获取当前日期时间(北京时间 UTC+8)".into(),
|
||||
risk_level: RiskLevel::Low,
|
||||
parameters: serde_json::json!({"type":"object","properties":{"format":{"type":"string"}}}),
|
||||
timeout_secs: 5,
|
||||
},
|
||||
super::skill::SkillSpec {
|
||||
name: "manage_memos".into(),
|
||||
description: "管理备忘录:添加/列出/删除。高风险操作(add/delete)需确认".into(),
|
||||
risk_level: RiskLevel::High,
|
||||
parameters: serde_json::json!({"type":"object","properties":{"action":{"type":"string","enum":["add","list","delete"]},"content":{"type":"string"},"id":{"type":"integer"}}}),
|
||||
timeout_secs: 10,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn is_high_risk(name: &str) -> bool {
|
||||
Self::specs().iter().any(|s| s.name == name && s.risk_level == RiskLevel::High)
|
||||
}
|
||||
|
||||
pub fn is_builtin(name: &str) -> bool {
|
||||
Self::specs().iter().any(|s| s.name == name)
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_datetime() -> SkillResult {
|
||||
let now = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
SkillResult::ok(serde_json::json!({"datetime": now}).to_string())
|
||||
}
|
||||
|
||||
async fn handle_memos(args_json: &str) -> SkillResult {
|
||||
let mut params: serde_json::Value = serde_json::from_str(args_json).unwrap_or_default();
|
||||
// call_capability 透传完整参数,action/content 在顶层
|
||||
// 如果顶层没有 action,尝试从 prompt 推断
|
||||
if params.get("action").is_none() {
|
||||
let prompt = params.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let action = if prompt.contains("添加") || prompt.contains("新增") || prompt.contains("add") {
|
||||
"add"
|
||||
} else if prompt.contains("删除") || prompt.contains("移除") || prompt.contains("delete") {
|
||||
"delete"
|
||||
} else {
|
||||
"list"
|
||||
};
|
||||
let content = params.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let memo_id: i32 = content.chars().filter(|c| c.is_ascii_digit()).collect::<String>().parse().unwrap_or(0);
|
||||
params = serde_json::json!({"action": action, "content": content, "id": memo_id});
|
||||
}
|
||||
let action = params.get("action").and_then(|v| v.as_str()).unwrap_or("list");
|
||||
let path = ".data/memos.json";
|
||||
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() {
|
||||
serde_json::from_str(&std::fs::read_to_string(path).unwrap_or_default()).unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
match action {
|
||||
"add" => {
|
||||
let content = params.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if content.is_empty() { return SkillResult::error("请提供 content 参数"); }
|
||||
let next_id = data.iter().filter_map(|m| m.get("id").and_then(|v| v.as_i64())).max().unwrap_or(0) + 1;
|
||||
data.push(serde_json::json!({
|
||||
"id": next_id, "content": content,
|
||||
"created_at": Local::now().format("%Y-%m-%d %H:%M").to_string()
|
||||
}));
|
||||
if let Err(e) = std::fs::write(path, serde_json::to_string_pretty(&data).unwrap()) {
|
||||
return SkillResult::error(format!("写入备忘录失败: {}", e));
|
||||
}
|
||||
SkillResult::ok(format!("已添加备忘录 #{}", next_id))
|
||||
}
|
||||
"list" => {
|
||||
if data.is_empty() { return SkillResult::ok("暂无备忘录".to_string()); }
|
||||
let lines: Vec<String> = data.iter().map(|m| format!(
|
||||
"#{} [{}] {}",
|
||||
m["id"], m.get("created_at").and_then(|v| v.as_str()).unwrap_or("?"), m["content"].as_str().unwrap_or("?")
|
||||
)).collect();
|
||||
SkillResult::ok(lines.join("\n"))
|
||||
}
|
||||
"delete" => {
|
||||
let id = params.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let before = data.len();
|
||||
data.retain(|m| m.get("id").and_then(|v| v.as_i64()) != Some(id));
|
||||
if data.len() == before { return SkillResult::error(format!("未找到备忘录 #{}", id)); }
|
||||
if let Err(e) = std::fs::write(path, serde_json::to_string_pretty(&data).unwrap()) {
|
||||
return SkillResult::error(format!("写入备忘录失败: {}", e));
|
||||
}
|
||||
SkillResult::ok(format!("已删除备忘录 #{}", id))
|
||||
}
|
||||
_ => SkillResult::error("未知操作,支持: add, list, delete"),
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod approval;
|
||||
pub mod builtin;
|
||||
pub mod parser;
|
||||
pub mod registry;
|
||||
pub mod skill;
|
||||
|
||||
+27
-27
@@ -38,11 +38,20 @@ pub fn parse_skill(skill_dir: &Path) -> Result<Skill, String> {
|
||||
let mut params_fence = 0;
|
||||
let mut execute_fence = 0;
|
||||
let mut desc_lines: Vec<&str> = Vec::new();
|
||||
let mut in_desc = false;
|
||||
let mut frontmatter_fences = 0;
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
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();
|
||||
@@ -54,24 +63,20 @@ pub fn parse_skill(skill_dir: &Path) -> Result<Skill, String> {
|
||||
let section = trimmed.trim_start_matches("## ").trim().to_lowercase();
|
||||
match section.as_str() {
|
||||
"risk level" | "risk_level" | "risk" => {
|
||||
in_desc = false;
|
||||
in_params = false;
|
||||
in_execute = false;
|
||||
}
|
||||
"parameters" | "param" => {
|
||||
in_desc = false;
|
||||
in_params = true;
|
||||
in_execute = false;
|
||||
}
|
||||
"execute" | "execution" | "command" | "run" => {
|
||||
in_desc = false;
|
||||
in_params = false;
|
||||
in_execute = true;
|
||||
params_fence = 0;
|
||||
execute_fence = 0;
|
||||
}
|
||||
_ => {
|
||||
in_desc = false;
|
||||
in_params = false;
|
||||
in_execute = false;
|
||||
}
|
||||
@@ -205,32 +210,27 @@ mod tests {
|
||||
#[test]
|
||||
fn test_load_all_system_skills() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let result = rt.block_on(crate::tools::registry::SkillRegistry::load(
|
||||
let (registry, errors) = rt.block_on(crate::tools::registry::SkillRegistry::load(
|
||||
"resources/skills",
|
||||
"/nonexistent",
|
||||
));
|
||||
match result {
|
||||
Ok(registry) => {
|
||||
assert_eq!(registry.count(), 9, "应该有 9 个系统技能");
|
||||
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(&"query_weather"));
|
||||
assert!(names.contains(&"search_web"));
|
||||
assert!(names.contains(&"email"));
|
||||
assert!(names.contains(&"execute_shell"));
|
||||
assert!(names.contains(&"list_scheduled_tasks"));
|
||||
assert!(names.contains(&"manage_scheduled_task"));
|
||||
}
|
||||
Err(errors) => {
|
||||
for e in &errors {
|
||||
eprintln!("加载失败: {}", e);
|
||||
}
|
||||
panic!("技能加载失败");
|
||||
if !errors.is_empty() {
|
||||
for e in &errors {
|
||||
eprintln!("加载警告: {}", e);
|
||||
}
|
||||
}
|
||||
assert_eq!(registry.count(), 9, "应该有 9 个系统技能");
|
||||
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(&"query_weather"));
|
||||
assert!(names.contains(&"search_web"));
|
||||
assert!(names.contains(&"email"));
|
||||
assert!(names.contains(&"execute_shell"));
|
||||
assert!(names.contains(&"list_scheduled_tasks"));
|
||||
assert!(names.contains(&"manage_scheduled_task"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,10 +22,7 @@ impl SkillRegistry {
|
||||
}
|
||||
|
||||
/// 从 system_dir 和 user_dir 加载所有技能
|
||||
/// system_dir: resources/skills/(随 iAs 分发)
|
||||
/// user_dir: skills/(用户自行添加)
|
||||
/// 同名时 user 覆盖 system
|
||||
pub async fn load(system_dir: &str, user_dir: &str) -> Result<Self, Vec<String>> {
|
||||
pub async fn load(system_dir: &str, user_dir: &str) -> (Self, Vec<String>) {
|
||||
let mut registry = Self::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
@@ -47,11 +44,7 @@ impl SkillRegistry {
|
||||
registry.load_from_dir(user_dir, SkillSource::User, &mut errors);
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(registry)
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
(registry, errors)
|
||||
}
|
||||
|
||||
fn load_from_dir(&mut self, dir: &str, source: SkillSource, errors: &mut Vec<String>) {
|
||||
@@ -233,6 +226,7 @@ async fn run_skill(skill: &Skill, params: serde_json::Value, _ctx: &ExecutionCon
|
||||
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,
|
||||
@@ -273,6 +267,8 @@ async fn run_skill(skill: &Skill, params: serde_json::Value, _ctx: &ExecutionCon
|
||||
} 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))
|
||||
};
|
||||
|
||||
+15
-3
@@ -219,6 +219,10 @@ impl WeChatClient {
|
||||
.await
|
||||
.map_err(|e| format!("解析 notifyStart 响应失败: {}", e))?;
|
||||
|
||||
if _resp.ret != 0 {
|
||||
return Err(format!("notifyStart 失败: ret={} errmsg={:?}", _resp.ret, _resp.errmsg));
|
||||
}
|
||||
|
||||
info!("监听器已注册");
|
||||
Ok(())
|
||||
}
|
||||
@@ -257,7 +261,14 @@ impl WeChatClient {
|
||||
.post_json(&url, &body, Some(&token))
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.map_err(|e| format!("解析 getUpdates 响应失败: {}", e))?,
|
||||
Ok(resp) => {
|
||||
let resp: GetUpdatesResp = resp.json().await.map_err(|e| format!("解析 getUpdates 响应失败: {}", e))?;
|
||||
if resp.ret != 0 || resp.errcode.unwrap_or(0) != 0 {
|
||||
tracing::warn!("getUpdates 返回错误: ret={} errcode={:?} errmsg={:?}",
|
||||
resp.ret, resp.errcode, resp.errmsg);
|
||||
}
|
||||
resp
|
||||
}
|
||||
Err(e) => {
|
||||
// 如果超时,返回空响应
|
||||
if e.contains("超时") || e.contains("timeout") {
|
||||
@@ -318,11 +329,11 @@ impl WeChatClient {
|
||||
}
|
||||
|
||||
/// 设置 auth 状态(从持久化恢复)
|
||||
pub async fn set_auth(&self, token: &str, account_id: &str, base_url: &str) {
|
||||
pub async fn set_auth(&mut self, token: &str, account_id: &str, base_url: &str) {
|
||||
*self.token.lock().await = token.to_string();
|
||||
*self.account_id.lock().await = account_id.to_string();
|
||||
if !base_url.is_empty() {
|
||||
// 只更新 base_url 如果传入了非空值
|
||||
self.base_url = base_url.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +436,7 @@ impl WeChatClient {
|
||||
.body(body_str)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
format!("请求超时: {}", url)
|
||||
|
||||
Reference in New Issue
Block a user