Compare commits

...

22 Commits

Author SHA1 Message Date
wunianxiao 1aef0c2f69 docs: PROJECT_REFERENCE.md — 新会话参考文档
包含项目定位、源码结构、配置、工具架构、审批流程、上下文管理、
数据库表、已知限制
2026-06-02 15:46:19 +08:00
wunianxiao 6708fe33a0 fix: 3 项问题
1. 审批DB更新精确匹配 — code_hash 从 removed entry 提取
2. delete memo 提取 id — prompt 中数字 → id 参数
3. 清理未使用的 current_user_id 全局变量
2026-06-02 15:44:28 +08:00
wunianxiao fe83687495 fix: migration 0009 UNIQUE → 普通 INDEX(存量重复数据冲突)
ON CONFLICT DO NOTHING 已在 Rust 代码中处理去重
2026-06-02 15:40:44 +08:00
wunianxiao 3a7e40c174 fix: 回退 migration 0001 到原始内容
独立索引在 0009_chat_dedup.sql 中已有
2026-06-02 15:39:32 +08:00
wunianxiao 15e2a01dec fix: 4 项问题
1. (High) SQL 语法错误 — UPDATE ... ORDER BY ... LIMIT 1 → CTE 子查询
2. (High) 用户串扰 — 暂保持单用户序列,多用户场景后续重构
3. (Medium) 用户记忆 — 切换时 memory_store.load(from)
4. (Medium) call_capability prompt — 完整参数透传到目标工具
2026-06-02 15:36:32 +08:00
wunianxiao 065229b9da fix: 优先审批 + 首用户隔离 + 审批DB闭环 + memo错误
1. (High) 内置工具先审批再执行 — is_builtin 检查优先,
   High 风险通过 builtin_approve 后再调用 BuiltinRegistry
2. (High) 首用户 current_user_id — 移除 !is_empty() 守卫
3. (Medium) 审批 DB 状态闭环 — 取消/错误时按 user_id 更新最近 pending
4. (Medium) manage_memos 写入失败 — .ok() → 完整错误返回
2026-06-02 15:30:53 +08:00
wunianxiao 88de5597b3 fix: 用户隔离 — 切换用户时清空上下文 + 加载历史
listen_loop 新增用户切换逻辑:
  - 跟踪 last_user_id
  - 用户变化时清空 ChatSession (messages/checkpoint/summaries)
  - 加载新用户最近 20 条聊天记录
  - 隔离对话上下文、摘要、工作记忆

现在每个用户拥有独立的会话视图
2026-06-02 15:19:45 +08:00
wunianxiao e5c288ac6f fix: 5 项问题修复
1. (High) call_capability 路由内置工具 — 优先 BuiltinRegistry
2. (High) 内置 High 工具走审批 — builtin_approve 独立检查
3. (Medium) query_capabilities 去重 — 内置覆盖外部同名
4. (Medium) manage_memos 错误处理 — .ok() → 检查 Result
5. (Low) 重复 file_state — 删除第二个声明
2026-06-02 15:15:40 +08:00
wunianxiao 079150b1d0 refactor: 删除 config.json + 内置工具抽离 + 统一接口
删除:
  - config.rs / config.json(AppConfig/LmStudioConfig)
  - LM Studio 残留(lmstudio.rs 已在之前删除)
  - main.rs 中散落的内置工具 match arms

新增:
  - src/tools/builtin.rs — BuiltinRegistry 统一切入
    get_current_datetime、manage_memos 为核心内置工具
    统一参数校验、风险等级、错误返回

改动:
  - 模型名直接从 DEEPSEEK_MODEL 环境变量读取
  - builtin 工具与 skills 工具统一经过 SkillRegistry 审批
  - query_capabilities 合并内置 + 外部技能列表
2026-06-02 15:07:33 +08:00
wunianxiao a32593bc25 fix: 4 个安全/正确性问题
1. (High) 定时任务 FOR UPDATE 无事务保护
   → BEGIN/COMMIT 事务包裹 fetch + update
2. (Medium) 审批 DB 状态未更新
   → handle_reply 更新 status/consumed_at
3. (Medium) 过期审批未清理
   → 每 30s tokio::spawn 调用 clean_expired
4. (Low) 非零退出丢弃 stdout
   → 退时 stderr 空则用 stdout 作为错误信息
2026-06-02 14:32:18 +08:00
wunianxiao 3f447668b8 fix: 用户隔离完整性 + 聊天去重
1. 启动时不加载历史消息(等第一条消息确定身份后按需恢复)
2. MemoryStore 改为 HashMap<user_id, Vec<mem>>,按用户隔离缓存
3. session.current_user_id 与外部 mutex 同步
4. chat_records.message_id 加 UNIQUE 索引 + ON CONFLICT DO NOTHING
2026-06-02 14:27:35 +08:00
wunianxiao b710518a1f refactor: 工具简化为「查询 + 调用」二元接口
LLM 工具列表从 10+ 个缩减为 5 个核心:
  - query_capabilities: 按需查看所有工具说明
  - call_capability: 按名称桥接到任意工具
  - read_memories / write_memory / read_summaries: 上下文工具

datetime / memos: Rust 原生实现(不走子进程)
系统提示词精简为引导型(先查能力再调用)
2026-06-02 14:25:00 +08:00
wunianxiao a549bd3b2f fix: manage_scheduled_task + search_web 注入 + 删除 LM Studio
- manage_scheduled_task: INSERT 加 user_id='', 检查 psql 退出码
- search_web: 用 python json.dumps 构造请求体(防止注入)
- 删除 lmstudio.rs / LmStudioConfig,仅保留 DeepSeek
- config.json 移除 lmstudio 段
2026-06-02 13:51:46 +08:00
wunianxiao 7ab5052642 fix: 5 个安全/正确性问题(第三轮)
1. (High) 启动加载历史消息无用户过滤
   → 有 current_user_id 时按用户过滤
2. (High) 长期记忆跨用户共享
   → read_for/write_for 按 user_id 隔离
3. (Medium) 摘要读取无用户过滤 — 待后续
4. (Medium) WeChat API 未检查错误
   → notifyStart 检查 ret!=0
   → getUpdates 检查 ret/errcode
   → post_json 加 error_for_status()
5. (Low) set_auth 未更新 base_url
   → &mut self + 实际赋值
2026-06-02 11:54:34 +08:00
wunianxiao 6dd12425d6 fix: 5 个安全/正确性问题(第二轮)
1. (High) user_id NOT NULL 导致 add 任务失败
   → ALTER TABLE 加 DEFAULT '', DROP NOT NULL
2. (High) 调度器任务卡住阻塞后续
   → timeout(120s) + kill_on_drop
3. (Medium) 多实例重复执行
   → SELECT ... FOR UPDATE SKIP LOCKED
4. (Medium) SQL 注入(字符串拼接)
   → psql -v 参数化 (:name, :cmd, :interval)
5. (Medium) 非工具对话 usage 未记录
   → consume() 改为 &mut self,消费后再读 usage
2026-06-02 11:03:31 +08:00
wunianxiao 5602b08faf fix: 5 个安全/正确性问题
1. (High) 无 DB 时审批绕过 — approval_manager 始终创建,
   无 DB 时用纯内存模式,不再绕过审批
2. (High) 多用户串上下文 — user_id 改存 ChatSession,
   spawn 前写入,executor 从 session 读(而非共享 Arc)
3. (Medium) 技能超时未杀子进程 — cmd.kill_on_drop(true)
4. (Medium) provider 配置不生效 — 模型选择跟随 LLM_PROVIDER
   环境变量(deepseek/lmstudio 各自取对应模型名)
5. (Low) manage_memos 改为 High 风险(add/delete 有副作用)
2026-06-02 10:56:44 +08:00
wunianxiao 5daaba686b refactor: query_weather JSON 过滤空字段
输出前过滤 forecast 和 now 中的空值字段,避免 LLM 看到 null/空字符串
2026-06-02 10:34:55 +08:00
wunianxiao 81c736f873 refactor: skill 脚本输出改为结构化 JSON
- query_weather: {location, now: {temp,text}, forecast: [{date,day,night,tempMin,tempMax}]}
- search_web: {query, answer, results: [{title,url,content}]}
- get_current_datetime: {datetime}
- list_scheduled_tasks: {tasks: [{name,description,enabled,command,...}]}
- manage_scheduled_task: {ok, action, name} / {error}

LLM 直接看到结构化数据,不再依赖脚本拼接的中文自然语言
2026-06-02 10:32:27 +08:00
wunianxiao fac2016b7e refactor: 日志迁移到 ~/.ias/logs/
从 .data/logs/ → ~/.ias/logs/
与配置文件的 ~/.ias/ 目录统一管理
2026-06-02 10:06:01 +08:00
wunianxiao f7ba23db94 feat: 导入的 skills 默认存到 ~/.ias/skills/
- Import/Delete 目标改为 ~/.ias/skills/(全局共享)
- SkillRegistry::load() 返回 (Self, Vec<String>) 替代 Result
  允许部分加载:格式不兼容的 skill 记录警告但不阻止加载
- 新增 global_skills_dir() 辅助函数
2026-06-02 10:02:54 +08:00
wunianxiao e88f3240c4 feat: 配置回退到 ~/.ias/ 目录
配置加载顺序(当前目录 → 全局目录 → 默认):
  1. .env              → ~/.ias/.env        → 无
  2. config.json        → ~/.ias/config.json  → 默认内置
  3. resources/skills/  (始终从项目目录加载)

现在可以在任意目录运行 ias,如:
  cd /tmp && ias whoami    # 读取 ~/.ias/ 的配置
2026-06-02 09:51:22 +08:00
wunianxiao 1497b1bba0 fix: YAML frontmatter 解析 + call_capability iPet 兼容桥接
- SKILL.md 解析器支持跳过 YAML frontmatter (---...---)
  修复导入的 Claude Code / Agent Skills 的元数据头导致
  description 混入元数据的问题
- call_capability 桥接工具:兼容 iPet 遗留的
  {name, prompt} 调用格式
  按 name 查找 SkillRegistry,委托执行
- 清理 parser 中未使用的 in_desc 变量
2026-06-02 09:47:45 +08:00
39 changed files with 840 additions and 2556 deletions
+171
View File
@@ -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/runtimePostgreSQL 不可用时的降级方案)
├── scheduler.rs 定时任务调度器(轮询 PostgreSQL + 事务保护)
├── skills_importer.rs Skills 导入扫描器
├── context/ 上下文管理
│ ├── types.rs ChatSessioncheckpoint/messages/summaries/user_id
│ ├── builder.rs token budget 构建 + 摘要触发
│ ├── tools.rs MemoryStoreHashMap<user_id, Vec<mem>>+ read_summaries
│ └── DESIGN.md 上下文管理设计(未完全实现)
├── db/ 数据库
│ ├── mod.rs Database 连接池 + 迁移
│ └── models.rs CRUDauth/chat/usage/summary
├── llm/ 对话系统
│ ├── types.rs Message / Role / StreamChunk / Usage
│ ├── provider.rs create_provider / parse_chat_chunkSSE 解析)
│ ├── deepseek.rs DeepSeek 流式客户端(thinking + tool_calls
│ └── conversation.rs Conversationchat + chat_with_tools 工具循环)
├── tools/ 工具系统
│ ├── skill.rs SkillSpec / RiskLevel / SkillResult
│ ├── registry.rs SkillRegistry(加载/执行/审批流程)
│ ├── builtin.rs BuiltinRegistrydatetime + 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/OHigh 先审批)
└── SkillRegistry(外部 Skills:
├── query_weather → bash + openssl JWT
├── search_web → python Tavily
├── email → node.js IMAP
├── execute_shell → bashHigh
├── list_scheduled_tasks → psql
├── manage_scheduled_task → psqlHigh
└── manage_skill → bashHigh
```
---
## 审批流程
```
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 | ⚠️ 需运行时 |
| 核心工具未全用 Rustweather/search/email | ⚠️ 仍为 bash |
---
## 最近重要 commit
```
6708fe3 fix: 3 项问题 (审批DB精确匹配 + delete id提取 + 清理变量)
065229b fix: 优先审批 + 首用户隔离 + 审批DB闭环 + memo错误
079150b refactor: 删除 config.json + 内置工具抽离 + 统一接口
b710518 refactor: 工具简化为「查询 + 调用」二元接口
```
-19
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
-- 聊天记录去重索引(普通索引,不用 UNIQUE 避免存量重复数据冲突)
CREATE INDEX IF NOT EXISTS idx_chat_records_message_id
ON chat_records (message_id) WHERE message_id != '';
+2 -20
View File
@@ -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":[]}'
+3 -31
View File
@@ -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
+43 -147
View File
@@ -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":"解析天气数据失败"}'
+28 -46
View File
@@ -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":"搜索请求失败"}'
-21
View File
@@ -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.
-269
View File
@@ -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
-427
View File
@@ -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"
}
-406
View File
@@ -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);
}
})();
}
-20
View File
@@ -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();
-17
View File
@@ -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 35 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
View File
@@ -92,7 +92,7 @@ pub enum Commands {
/// 后台服务模式
///
/// 等同 listen --llm,同时日志写入 .data/logs/ 滚动文件。
/// 等同 listen --llm,同时日志写入 ~/.ias/logs/ 滚动文件。
/// 配合 systemd 实现开机自启和进程守护。
///
/// 示例:
-218
View File
@@ -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
View File
@@ -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
View File
@@ -30,6 +30,8 @@ pub struct SummaryEntry {
pub struct ChatSession {
pub user_id: String,
pub db_pool: Option<Arc<sqlx::PgPool>>,
/// 当前正在处理消息的用户 IDspawn 前设置,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
+1
View File
@@ -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
"#,
)
+5 -3
View File
@@ -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 确认工具有哪些。";
-157
View File
@@ -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
View File
@@ -1,6 +1,5 @@
pub mod conversation;
pub mod deepseek;
pub mod lmstudio;
pub mod provider;
pub mod types;
+1 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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());
}
/// 获取某个用户的待审批条数
+111
View File
@@ -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
View File
@@ -1,4 +1,5 @@
pub mod approval;
pub mod builtin;
pub mod parser;
pub mod registry;
pub mod skill;
+27 -27
View File
@@ -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]
+5 -9
View File
@@ -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
View File
@@ -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)