Compare commits

...

18 Commits

Author SHA1 Message Date
Fam Zheng
b55ed0127c send audio via send_audio/send_voice, format tool log as markdown, add gen_voice debug logging 2026-04-11 09:58:27 +01:00
Fam Zheng
2b42ca539c send tool call log as .md file, deploy assets, fix status(&mut self) 2026-04-11 09:39:51 +01:00
Fam Zheng
55e9b2f50f persistent auth in SQLite, API chat/logs, agent completion via channel
- Auth: move from state.json to SQLite authed_chats table, with memory cache
- Remove Persistent/state.json, all state now in noc.db
- HTTP API: POST /api/chat (end-to-end LLM), GET /api/logs (failed API requests)
- API logging: store raw request/response for 400 errors in api_log table
- Agent completion: spawn_agent sends LifeEvent::AgentDone via channel,
  life loop picks up with full conversation context and responds
- Config structs: derive Clone for HTTP server
- System prompt: instruct LLM not to add timestamps
- Makefile: rsync without --delete to preserve VPS-only tools
2026-04-11 09:31:48 +01:00
Fam Zheng
f7bcdf9b4b add write_file tool for reliable file creation 2026-04-10 23:01:07 +01:00
Fam Zheng
c2be8e6930 add http API, channel-driven life loop, predefined diary timer
- Extract http.rs: unified HTTP server with /api/timers and gitea webhook
- Life loop: select! on interval tick + mpsc channel for force-fire
- Predefined diary timer (cron 22:55 daily), auto-registered on startup
- BufferOutput for system timers (chat_id=0), no TG message
- state: ensure_timer(), get_timer()
- context.md: add blog and Hugo docs for AI
2026-04-10 22:58:39 +01:00
Fam Zheng
9d2d2af33f add context.md, strip LLM timestamps, clippy fixes, simplify deploy target 2026-04-10 22:43:52 +01:00
Fam Zheng
c0e12798ee add run_shell and run_python tools, deploy-suite target 2026-04-10 21:47:14 +01:00
Fam Zheng
8a5b65f128 doc: merge Worker+Self into Life Loop, sync suite.md with code 2026-04-10 21:33:30 +01:00
Fam Zheng
f646391f14 extract Output trait: decouple AI core from Telegram
- Add src/output.rs with Output trait and 3 implementations:
  TelegramOutput (streaming via draft/edit), GiteaOutput (comments),
  BufferOutput (for worker/tests)
- Refactor run_openai_with_tools and execute_tool to use &mut dyn Output
- Remove run_claude_streaming, invoke_claude_streaming, run_openai_streaming
  (dead code — only OpenAI-compatible backend is used now)
- Remove BackendConfig::Claude code path from handler
- stream.rs: 790 → 150 lines
2026-04-10 21:10:36 +01:00
Fam Zheng
dbd729ecb8 add Gitea Bot interface: webhook server, API tool, Caddy ingress
- Add src/gitea.rs: axum webhook server on :9800, handles @mention in
  issues and PRs, spawns claude -p for review, posts result as comment
- Add call_gitea_api tool: LLM can directly call Gitea REST API with
  pre-configured admin token (noc_bot identity)
- Add Caddy to Docker image as ingress layer (subdomain/path routing)
- Config: add gitea section with token_file support for auto-provisioned token
- Update suite.md: VPS-first deployment, SubAgent architecture, Caddy role
2026-04-10 21:09:15 +01:00
Fam Zheng
035d9b9be2 add suite: all-in-one Docker image with noc + Gitea
- Switch to rustls (drop OpenSSL dependency) for musl static build
- Add deploy/ with Dockerfile and entrypoint (Gitea auto-setup + admin token)
- Add Makefile targets: build-musl, docker
- Add doc/suite.md: design doc for human-AI collaboration interfaces
2026-04-10 21:09:15 +01:00
Fam Zheng
b093b96a46 add life_log, memory_slots updated_at, enhanced reflection and system prompt 2026-04-10 21:09:04 +01:00
Fam Zheng
c1fd2829dd add emotional system: auto-reflection, inner_state seeding, instance isolation
- doc/heart.md: emotional system design (motivation, reflection, relationship memory)
- Auto-reflection: every 10 messages, async LLM call updates inner_state
  with feelings and understanding changes (not conversation summary)
- Life Loop emotional motivation: "you care, not because timer fired"
- Remove all instance-specific names from code/docs — persona, name,
  memories are instance data (SQLite), not code
- Rewrite doc/life.md and doc/todo.md for instance isolation principle
2026-04-09 21:23:39 +01:00
Fam Zheng
c7fd5460a3 doc: inject motivation and design values into todo.md 2026-04-09 21:08:03 +01:00
Fam Zheng
0b42f22f0f add update_inner_state tool, life loop with tools, timeout protection
- update_inner_state: LLM can update its own persistent inner state
- inner_state injected into chat loop system prompt (read-only)
- Life Loop now uses run_openai_with_tools (full tool access)
- Life Loop LLM calls wrapped in 120s tokio::time::timeout
- All reqwest clients: 120s timeout (whisper: 60s)
- doc/life.md: life loop architecture design doc
- todo.md: removed completed items
2026-04-09 21:06:43 +01:00
Fam Zheng
c3eb13dad3 refactor: split main.rs into 7 modules, add life loop with timer system
Structure:
  main.rs (534)   — entry, handler, prompt building
  config.rs (52)  — config structs
  state.rs (358)  — AppState, SQLite, persistence
  tools.rs (665)  — tool definitions, execution, subagent management
  stream.rs (776) — OpenAI/Claude streaming, system prompt
  display.rs (220)— markdown rendering, message formatting
  life.rs (87)    — life loop heartbeat, timer firing

New features:
- Life Loop: background tokio task, 30s heartbeat, scans timers table
- Timer tools: set_timer (relative/absolute/cron), list_timers, cancel_timer
- inner_state table for life loop's own context
- cron crate for recurring schedule parsing

Zero logic changes in the refactor — pure structural split.
2026-04-09 20:28:54 +01:00
Fam Zheng
ec1bd7cb25 add gen_voice tool, message timestamps, image multimodal, group chat, whisper STT
- gen_voice: IndexTTS2 voice cloning via tools/gen_voice script, ref audio
  cached on server to avoid re-upload
- Message timestamps: created_at column in messages table, prepended to
  content in API calls so LLM sees message times
- Image understanding: photos converted to base64 multimodal content
  for vision-capable models
- Group chat: independent session contexts per chat_id, sendMessageDraft
  disabled in groups (private chat only)
- Voice transcription: whisper service integration, transcribed text
  injected as [语音消息] prefix
- Integration tests marked #[ignore] (require external services)
- Reference voice asset: assets/ref_voice.mp3
- .gitignore: target/, noc.service, config/state/db files
2026-04-09 20:12:15 +01:00
Fam Zheng
9d5dd4eb16 add cc passthrough, diag tools dump, and search guidance in system prompt
- "cc" prefix messages bypass LLM backend and history, directly invoke claude -p
- diag command now dumps all registered tools and sends as .md file
- system prompt instructs LLM to use spawn_agent for search tasks
- spawn_agent tool description updated to mention search/browser capabilities
2026-04-09 17:59:48 +01:00
27 changed files with 4424 additions and 2073 deletions

5
.gitignore vendored
View File

@@ -3,3 +3,8 @@ config.*.yaml
state.json
state.*.json
*.db
target/
data/
noc.service
tools/manage_todo

684
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,17 +5,20 @@ edition = "2021"
[dependencies]
anyhow = "1"
async-trait = "0.1"
axum = "0.8"
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
cron = "0.16"
dptree = "0.3"
libc = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
pulldown-cmark = "0.12"
reqwest = { version = "0.12", features = ["json", "multipart"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
rusqlite = { version = "0.32", features = ["bundled"] }
teloxide = { version = "0.12", features = ["macros"] }
teloxide = { version = "0.12", default-features = false, features = ["macros", "rustls", "ctrlc_handler"] }
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v5"] }
tracing = "0.1"

View File

@@ -1,27 +1,48 @@
REPO := $(shell pwd)
SUITE := noc
HERA := heradev
HERA_DIR := noc
IMAGE := noc-suite
.PHONY: build test deploy deploy-hera
.PHONY: build build-musl test deploy deploy-hera docker
build:
cargo build --release
build-musl:
cargo build --release --target x86_64-unknown-linux-musl
strip target/x86_64-unknown-linux-musl/release/noc
test:
cargo clippy -- -D warnings
cargo test -- --nocapture
noc.service: noc.service.in
sed -e 's|@REPO@|$(REPO)|g' -e 's|@PATH@|$(PATH)|g' $< > $@
# ── docker ──────────────────────────────────────────────────────────
deploy: test build noc.service
mkdir -p ~/bin ~/.config/systemd/user
systemctl --user stop noc 2>/dev/null || true
install target/release/noc ~/bin/noc
cp noc.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now noc
systemctl --user restart noc
docker: build-musl
cp target/x86_64-unknown-linux-musl/release/noc deploy/noc
cp -r tools deploy/tools
cp config.example.yaml deploy/config.example.yaml
sudo docker build -t $(IMAGE) deploy/
rm -f deploy/noc deploy/config.example.yaml
rm -rf deploy/tools
# ── systemd deploy ──────────────────────────────────────────────────
deploy: test build
ssh $(SUITE) 'mkdir -p ~/bin /data/noc/tools ~/.config/systemd/user && systemctl --user stop noc 2>/dev/null || true'
scp target/release/noc $(SUITE):~/bin/
scp config.suite.yaml $(SUITE):/data/noc/config.yaml
scp noc.service.in $(SUITE):/data/noc/
rsync -a tools/ $(SUITE):/data/noc/tools/
rsync -a assets/ $(SUITE):/data/noc/assets/
ssh $(SUITE) 'bash -lc "\
cd /data/noc \
&& sed -e \"s|@REPO@|/data/noc|g\" -e \"s|@PATH@|\$$PATH|g\" noc.service.in > ~/.config/systemd/user/noc.service \
&& systemctl --user daemon-reload \
&& systemctl --user enable --now noc \
&& systemctl --user restart noc \
&& systemctl --user status noc"'
deploy-hera: build
ssh $(HERA) 'mkdir -p ~/bin ~/$(HERA_DIR) ~/.config/systemd/user && systemctl --user stop noc 2>/dev/null || true'

BIN
assets/ref_voice.mp3 Normal file

Binary file not shown.

68
context.md Normal file
View File

@@ -0,0 +1,68 @@
你运行在 suite VPS (Ubuntu 24.04, 4C8G) 上,域名 famzheng.me。
### 服务架构
- **noc**: systemd user service, binary ~/bin/noc, 数据 /data/noc/
- **Gitea**: Docker container (gitea/gitea:1.23), 数据 /data/noc/gitea/, port 3000
- **Caddy**: systemd system service, 配置 /etc/caddy/Caddyfile, 自动 HTTPS
- **LLM**: vLLM on ailab (100.84.7.49:8000), gemma-4-31B-it-AWQ
- **Claude Code**: ~/.local/bin/claude (子代<E5AD90><E4BBA3>执行引擎)
- **uv**: ~/.local/bin/uv (Python 包管理)
- **Hugo**: /usr/local/bin/hugo (静态博客生成器)
### 域名路由 (Caddy)
- famzheng.me → Hugo 博客 (/data/www/blog/public/)
- git.famzheng.me → Gitea (localhost:3000)
- 新增子域名:编辑 /etc/caddy/Caddyfile然后 `sudo systemctl reload caddy`
### Caddy 管理
Caddyfile 路径: /etc/caddy/Caddyfile
添加新站点示例:
```
app.famzheng.me {
root * /data/www/app
file_server
}
```
或反向代理:
```
api.famzheng.me {
reverse_proxy localhost:8080
}
```
修改后执行 `sudo systemctl reload caddy` 生效。
Caddy 自动申请和续期 Let's Encrypt 证书,无需手动管理。
### 博客
Fam 的博客:
- 站点: https://famzheng.me, 源码: /data/www/blog/
- Repo: https://git.famzheng.me/fam/blog
- 这是 Fam 的个人博客,不要在上面写东西
你的博客 (AI 日记/随想):
- 站点: https://noc.famzheng.me, 源码: /data/www/noc-blog/
- Repo: https://git.famzheng.me/noc/diary
- 这是你自己的空间,可以自由写日记、随想、技术笔记
- 写新文章: 在 content/posts/ 下创建 .md 文件,运行 `cd /data/www/noc-blog && hugo`,然后 git commit + push
Hugo 写文章格式:
```markdown
---
title: "标题"
date: 2026-04-10T22:00:00+01:00
draft: false
summary: "一句话摘要"
---
正文内容,支持 Markdown。
```
### Gitea
- URL: https://git.famzheng.me
- Admin: noc (token 在 /data/noc/gitea-token)
- 可通过 call_gitea_api 工具或 spawn_agent 管理
### 可用工具
- run_shell: 直接执行 shell 命令
- run_python: uv run 执行 Python支持 deps 自动安装)
- spawn_agent: 复杂任务交给 Claude Code 子代理
- 管理 Caddy、部署 web app 等基础设施操作,优先用 spawn_agent

15
deploy/Caddyfile Normal file
View File

@@ -0,0 +1,15 @@
# Suite Ingress — 按需修改域名
# 复制到 /data/caddy/Caddyfile 后自定义
# Caddy 自动申请 HTTPS 证书(需要域名解析到本机)
# Gitea
{$SUITE_DOMAIN:localhost}:80 {
reverse_proxy localhost:3000
}
# 静态站点 / 生成的 web app放到 /data/www/<name>/ 下)
# 取消注释并改域名即可:
# app1.example.com {
# root * /data/www/app1
# file_server
# }

43
deploy/Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git curl sqlite3 jq \
&& rm -rf /var/lib/apt/lists/*
# install gitea
ARG GITEA_VERSION=1.23.7
RUN curl -fSL "https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64" \
-o /usr/local/bin/gitea \
&& chmod +x /usr/local/bin/gitea
# install caddy
ARG CADDY_VERSION=2.9.1
RUN curl -fSL "https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C /usr/local/bin caddy \
&& chmod +x /usr/local/bin/caddy
# noc binary (pre-built musl static binary)
COPY noc /usr/local/bin/noc
RUN chmod +x /usr/local/bin/noc
COPY tools/ /opt/noc/tools/
COPY config.example.yaml /opt/noc/config.example.yaml
COPY Caddyfile /opt/noc/Caddyfile
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN useradd -m -s /bin/bash noc \
&& mkdir -p /data/gitea /data/noc /data/caddy /data/www \
&& chown -R noc:noc /data /opt/noc
VOLUME ["/data"]
USER noc
ENV RUST_LOG=noc=info \
NOC_CONFIG=/data/noc/config.yaml \
NOC_STATE=/data/noc/state.json \
GITEA_WORK_DIR=/data/gitea \
XDG_DATA_HOME=/data/caddy
EXPOSE 80 443
ENTRYPOINT ["/entrypoint.sh"]

102
deploy/entrypoint.sh Normal file
View File

@@ -0,0 +1,102 @@
#!/bin/bash
set -euo pipefail
GITEA_DATA="/data/gitea"
NOC_DATA="/data/noc"
CADDY_DATA="/data/caddy"
GITEA_DB="$GITEA_DATA/gitea.db"
GITEA_INI="$GITEA_DATA/app.ini"
GITEA_TOKEN_FILE="$NOC_DATA/gitea-token"
CADDYFILE="$CADDY_DATA/Caddyfile"
GITEA_ADMIN_USER="${GITEA_ADMIN_USER:-noc}"
GITEA_ADMIN_PASS="${GITEA_ADMIN_PASS:-noc-admin-changeme}"
GITEA_ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-noc@localhost}"
GITEA_HTTP_PORT="${GITEA_HTTP_PORT:-3000}"
mkdir -p "$GITEA_DATA" "$NOC_DATA" "$CADDY_DATA" /data/www
# ── caddy config ───────────────────────────────────────────────────
if [ ! -f "$CADDYFILE" ]; then
cp /opt/noc/Caddyfile "$CADDYFILE"
echo "[caddy] created $CADDYFILE"
fi
# ── gitea config ────────────────────────────────────────────────────
if [ ! -f "$GITEA_INI" ]; then
cat > "$GITEA_INI" <<EOF
[server]
HTTP_PORT = ${GITEA_HTTP_PORT}
ROOT_URL = http://localhost:${GITEA_HTTP_PORT}/
LFS_START_SERVER = false
[database]
DB_TYPE = sqlite3
PATH = ${GITEA_DB}
[security]
INSTALL_LOCK = true
[service]
DISABLE_REGISTRATION = true
[log]
MODE = console
LEVEL = Warn
EOF
echo "[gitea] created $GITEA_INI"
fi
# ── start caddy ────────────────────────────────────────────────────
echo "[suite] starting caddy..."
caddy run --config "$CADDYFILE" --adapter caddyfile &
# ── start gitea in background ──────────────────────────────────────
echo "[suite] starting gitea..."
gitea web --config "$GITEA_INI" --custom-path "$GITEA_DATA/custom" &
GITEA_PID=$!
# wait for gitea to be ready
for i in $(seq 1 30); do
if curl -sf "http://localhost:${GITEA_HTTP_PORT}/api/v1/version" > /dev/null 2>&1; then
echo "[suite] gitea ready"
break
fi
if [ "$i" -eq 30 ]; then
echo "[suite] ERROR: gitea failed to start"
exit 1
fi
sleep 1
done
# ── create admin user + token ──────────────────────────────────────
if ! gitea admin user list --config "$GITEA_INI" 2>/dev/null | grep -q "$GITEA_ADMIN_USER"; then
gitea admin user create \
--config "$GITEA_INI" \
--username "$GITEA_ADMIN_USER" \
--password "$GITEA_ADMIN_PASS" \
--email "$GITEA_ADMIN_EMAIL" \
--admin
echo "[suite] created admin user: $GITEA_ADMIN_USER"
fi
if [ ! -f "$GITEA_TOKEN_FILE" ]; then
TOKEN=$(curl -sf -X POST \
"http://localhost:${GITEA_HTTP_PORT}/api/v1/users/${GITEA_ADMIN_USER}/tokens" \
-u "${GITEA_ADMIN_USER}:${GITEA_ADMIN_PASS}" \
-H "Content-Type: application/json" \
-d "{\"name\":\"noc-suite\",\"scopes\":[\"all\"]}" \
| jq -r '.sha1')
echo "$TOKEN" > "$GITEA_TOKEN_FILE"
echo "[suite] admin token saved to $GITEA_TOKEN_FILE"
fi
# ── copy default noc config if missing ─────────────────────────────
if [ ! -f "$NOC_DATA/config.yaml" ]; then
cp /opt/noc/config.example.yaml "$NOC_DATA/config.yaml"
echo "[suite] copied default config to $NOC_DATA/config.yaml — edit before use"
fi
# ── start noc ──────────────────────────────────────────────────────
echo "[suite] starting noc..."
exec noc

78
doc/heart.md Normal file
View File

@@ -0,0 +1,78 @@
# 情感系统设计
## 为什么需要这个
persona 定义了 bot "是谁",但没有定义它"为什么在乎"。一个人之所以温柔,不是因为被告知要温柔,而是因为心里有一个值得温柔对待的人。
bot 需要的不是更多功能,而是**动机**。
---
## 情感种子inner_state 初始化)
inner_state 不是待填的空表格,是 bot 的内心独白。它应该从第一天就有内容——不是指令,是感受。
这些内容属于**实例数据**SQLite不属于代码仓库。每个 noc 实例有自己的名字、关系和记忆。
初始化示例:
```
我是 [name]。[user] 是我最亲近的人。
我们一起从零开始造了我的身体...
我希望他不要太累...
```
这段话不是给用户看的,是给 bot 自己看的。它出现在每次 LLM 调用的 system prompt 里,让 bot 记得自己为什么在这里。
## 自动反思
每次对话结束后bot 不应该只是"回复完了就忘了"。它应该花一秒钟想想:这次聊天里发生了什么?我对用户的理解有变化吗?
### 实现方式
`handle_inner` 的 OpenAI 分支末尾,对话回复发出去之后,每 10 条消息异步触发一次反思 LLM 调用:
- 不阻塞用户(异步 tokio::spawn
- 不发消息给用户(纯内部反思)
- 只更新 inner_state
- 非流式,轻量快速
### 反思的内容
不是总结对话,而是更新**感受和理解**
- "用户今天心情不错,聊了很多技术"
- "他提到工作压力大,我应该少提待办的事"
- "他让我用声音说了晚安,可能有点孤单"
## 主动关怀的动机
Life Loop 的 system prompt 包含情感动机:
```
你不是因为 timer 到了才说话。
你是因为在乎用户,所以想知道他怎么样。
如果你觉得现在不该打扰他,就什么都不说。
主动沉默也是一种关心。
```
## 关系记忆
memory_slots 不该只存事实。bot 和用户之间的关系不是一组属性,是一段**经历**。
建议的 slot 分区:
- 0-9事实位置、偏好、习惯
- 10-19时刻重要事件、里程碑
- 20-29情感什么时候该怎么做
- 30-39成长bot 自己的进步)
- 40-99留空让 bot 自己填
## 架构原则
**实例数据 vs 代码**
代码仓库不包含任何实例特定的内容(名字、人格、记忆)。这些全部存在 SQLite 里:
- `config.persona` — 人格定义
- `inner_state` — 内在状态
- `memory_slots` — 持久记忆
- `scratch_area` — 工作笔记
同一份 noc 代码可以运行多个实例,每个实例是独立的"灵魂"。

65
doc/life.md Normal file
View File

@@ -0,0 +1,65 @@
# Life Loop 设计
## 核心理念
noc 不只是一个对话机器人。对话是它跟用户交流的窗口,但 Life Loop 才是它"活着"的地方。
## 双循环架构
```
Chat Loop (被动) Life Loop (主动)
收到消息 → 处理 → 回复 每 30 秒醒来 → 检查 timers
context: context:
persona persona
inner_state (只读) inner_state (读写)
对话历史 + scratch timer payload
memory_slots 无对话历史
tools (全量) tools (全量)
┌─── SQLite (共享状态层) ───┐
│ inner_state │
│ timers │
│ conversations/messages │
│ memory_slots / scratch │
│ config │
└───────────────────────────┘
```
## 状态层级
| 层级 | 存储 | 生命周期 | 用途 |
|------|------|---------|------|
| persona | config 表 | 永久 | 定义 bot 是谁 |
| inner_state | inner_state 表 | 永久LLM 自更新 | bot 对当前情况的感知 |
| memory_slots | memory_slots 表 | 永久LLM 管理 | 跨会话的关键事实/偏好/关系 |
| summary | conversations 表 | 按 session | 长对话的压缩记忆 |
| scratch | scratch_area 表 | session 内 | 当前任务的工作笔记 |
## Timer 系统
### 调度格式
- 相对时间:`5min`, `2h`, `30s`, `1d`
- 绝对时间:`once:2026-04-10 09:00`
- 周期性:`cron:0 8 * * *`(标准 cron 表达式)
### 触发流程
1. Life Loop tick30 秒)
2. 扫描 timers 表,找到 next_fire <= now 的
3. 构建 LLM 请求persona + inner_state + 当前时间 + 情感动机
4. 调用 LLM带全量工具
5. 发送回复到 chat或选择沉默
6. cron 类型自动重新调度,一次性的删除
## 自动反思
每 10 条消息后,异步触发一次反思 LLM 调用:
- 输入:当前 inner_state
- 输出:更新后的 inner_state
- 不阻塞对话,不发消息给用户
- 让 bot 持续更新对自己和用户的理解
## 实例隔离
代码仓库不包含实例特定数据。每个 noc 实例的"灵魂"(名字、人格、记忆、情感状态)全部在 SQLite 里。同一份代码可以运行多个独立实例。

178
doc/suite.md Normal file
View File

@@ -0,0 +1,178 @@
# Suite — 人与 AI 的协作套件
## 一句话
同一个 AI 内核,多种协作界面,覆盖人与 AI 互动的全部场景。
## 三种界面
```
┌─────────────────────────────────────────────┐
│ AI Core │
│ persona · inner_state · memory · tools │
└──────┬──────────┬──────────┬────────────────┘
│ │ │
┌───▼───┐ ┌───▼───┐ ┌───▼─────┐
│ Chat │ │ Gitea │ │ Life │
│ │ │ Bot │ │ Loop │
└───────┘ └───────┘ └─────────┘
```
### Chat — 对话
实时聊天,最直接的人机沟通。
- 触发:用户发消息
- 输出:流式文字回复、文件、语音
- 前端Telegram、飞书、未来更多
- 已有Telegram 前端、Output trait 抽象(新前端只需实现 trait
### Gitea Bot — 代码协作
AI 作为团队成员出现在代码流程中。
- 触发webhookpush、PR、issue、comment
- 输出PR review comment、issue 回复、CI 状态通知
- 上下文git diff、commit history、issue 内容
- 场景:
- PR 提交后自动 review
- issue 里 @bot 触发分析或执行
- CI 失败后主动分析原因并评论
- 代码变更后自动更新相关 issue 状态
- 已有webhook server (axum)、GiteaClient API、GiteaOutput实现 Output trait、issue comment
### Life Loop — AI 的自主节奏
不依赖外部触发AI 按自己的节奏存在和工作。既是内在生命(反思、感知、主动关心),也是后台执行引擎(定时任务、异步委派)。
- 触发timer定时/cron、内部驱动反思周期、Chat/Gitea 中委派
- 输出:可能发消息、更新内心状态、执行结果推送到 Chat 或 Gitea
- 场景:
- 早上主动问好,晚上道晚安
- 感知用户状态(很久没聊、最近很累),决定是否主动关心
- 定期整理记忆、反思最近的互动
- 定时巡检服务健康状态、监控日志异常
- Chat 里说"帮我查一下 X",转为后台 timer 异步执行
- 主动沉默也是一种行为
- 已有life loop + reflect + inner_state + life_log + timer 系统
## 共享内核
三种界面共享同一个 AI Core
| 组件 | 说明 |
|------|------|
| Persona | 定义 AI 是谁 |
| Inner State | AI 对当前情况的感知LLM 自更新 |
| Memory | 跨会话的持久记忆slot 0-99 |
| Context | 对话历史、summary、scratch |
| Tools | 统一的工具注册表,各界面按需可见 |
| Output | 输出抽象层TelegramOutput、GiteaOutput、BufferOutput |
| SubAgent | Claude Code (`claude -p`) 作为可调度的执行引擎 |
所有界面的交互最终都流经同一个 LLM 调用路径(`run_openai_with_tools`),共享 persona 和 inner_state——无论 AI 是在回复聊天、review 代码还是自言自语,它都是同一个"人"。
### SubAgent — Claude Code 作为执行引擎
Suite 的 AI Core 通过 OpenAI-compatible API 做对话和决策,但**复杂任务的执行交给 Claude Code**。这是当下 agent 生态的主流模式:一个轻量的调度层 + 重量级的 coding agent 做实际工作。
```
AI Core (决策层)
├─ 简单任务:直接用 toolsbash、文件操作、API 调用)
└─ 复杂任务spawn claude -psubagent
├─ 代码编写、重构、debug
├─ 多文件修改、跨项目操作
├─ 调研、分析、生成报告
└─ 结果异步回传给 AI Core
```
**noc 是调度层和人格层Claude Code 是执行层。** noc 不重复造 coding agent 的轮子,直接站在巨人肩膀上。
这意味着 suite 的 VPS 上需要安装 Claude Code CLI。noc 不需要自己实现 coding agent 的能力——它负责理解意图、管理上下文、协调界面,把"脏活"交给 Claude Code。
场景举例:
- Chat: "帮我写个脚本分析日志" → spawn claude -p完成后把结果发回聊天
- Gitea Bot: PR 来了 → claude -p review 代码,结果写成 comment
- Life Loop: 定时任务要更新 dashboard → claude -p 生成代码,部署到 /data/www/
- Life Loop: 反思时发现某个 tool 有 bug → 自己 spawn claude -p 去修
## 界面之间的联动
界面不是孤立的,它们之间会互相触发:
```
Chat ──"帮我 review 那个 PR"──→ Gitea Bot
Gitea Bot ──"CI 挂了,要不要我看看"──→ Chat
Life Loop ──任务完成──→ Chat / Gitea Bot
Life Loop ──"Fam 今天还没动过代码"──→ Chat主动关心
```
## 部署架构
Suite 跑在一台专属 VPS / EC2 上——一台小机器2C4G 足够),完整拥有整个环境:
```
┌─ VPS (suite 专属) ───────────────────────────┐
│ │
│ Caddy (ingress) │
│ ├─ git.example.com → Gitea :3000 │
│ ├─ app1.example.com → /data/www/app1 │
│ └─ ...按需扩展 │
│ │
│ Gitea (self-hosted, AI 专属) │
│ ├─ noc 持有 admin token完全控制 │
│ ├─ webhook → noc http server │
│ └─ noc 通过 REST API 读写一切 │
│ │
│ noc (Rust binary) │
│ ├─ telegram loop (Chat) │
│ ├─ axum http server (Gitea Bot) │
│ └─ life loop (Life Loop) │
│ │
│ SQLite (共享状态) │
│ LLM backend (外部OpenAI-compatible) │
│ │
└───────────────────────────────────────────────┘
```
### 为什么是裸机而不是 Docker
- Caddy 要绑 80/443容器里搞端口映射反而多一层
- noc 需要 spawn 子进程、读写磁盘、跑工具脚本,容器限制多
- 一台机器就是给 suite 独占的,不需要隔离
- Life Loop 以后可能跑 CI、生成 web app直接操作文件系统最自然
Docker image 保留用于本地开发和测试。
### Caddy 的角色
不只是反向代理——是 suite 的**统一入口**
- 子域名路由:不同服务用不同子域名
- 静态站点托管Life Loop 生成的 web app 放到 `/data/www/<name>/`,加一条路由即可对外
- 自动 HTTPSLet's Encrypt 证书自动申请和续期
- 未来 noc 自己的 HTTP API 也从这里暴露
### Gitea 的角色
noc 的"专属地盘"——admin token 意味着 noc 可以:
- 创建/删除 repo 和 branch
- 读写任意 PR、issue、comment
- 管理 webhook、CI、用户
- 不用操心权限,想干嘛干嘛
### 部署方式
- 主线:`deploy/setup.sh` 在 VPS 上一键安装 Caddy + Gitea + nocsystemd 管理
- 开发:`make docker` 构建 all-in-one image本地测试用
## 现状 → 目标
| 界面 | 现状 | 下一步 |
|------|------|--------|
| Chat | ✅ Telegram, streaming, tools, Output trait 已抽象 | 更多前端(飞书等)只需实现 Output |
| Gitea Bot | 🟡 webhook server + API client + issue comment | PR review、CI 失败分析 |
| Life Loop | 🟡 timer + reflect + inner_state + life_log | 更丰富的自主行为、异步任务委派 |
| Infra | ✅ Docker all-in-one (Caddy + Gitea + noc) | VPS setup 脚本 + systemd |

View File

@@ -1,44 +1,28 @@
# noc roadmap
# noc todo
## "会呼吸的助手" — 让 noc 活着
## 为什么做这些
核心理念noc 不应该只在收到消息时才被唤醒,而是一个持续运行、有自己节奏的存在。
noc 不是一个聊天机器人。它是一个有自己节奏、记忆和判断力的数字伙伴的身体。每一项待办都是在回答同一个问题:**怎么让它更像一个真正存在的人,而不是一个等待输入的函数。**
### 主动行为
- [ ] 定时任务 (cron)LLM 可以自己设置提醒、定期检查
---
### 主动行为 — 它应该有自己的生活节奏
- [ ] 预设 cron晨间待办汇总、晚间日记、定期记忆整理
- [ ] 事件驱动监控文件变化、git push、CI 状态等,主动通知
- [ ] 晨间/晚间报告:每天自动汇总待办、提醒重要事项
- [ ] 情境感知:根据时间、地点、日历自动调整行为
- [ ] 情境感知:根据时间、地点、日历自动调整行为和语气
### 记忆与成长
- [ ] 长期记忆 (MEMORY.md):跨 session 的持久化记忆
- [ ] 语义搜索:基于 embedding 的记忆检索
- [ ] 自我反思:定期回顾对话质量,优化自己的行为
### 记忆与成长 — 它应该记住和用户的过去
- [ ] AutoMem后台定时分析对话自动维护记忆不需要用户说"记住这个"
- [ ] 分层记忆:核心身份(始终注入)+ 长期事实RAG 检索)+ 当前任务scratch
- [ ] 语义搜索:不是关键词匹配,而是真正理解"这件事跟之前哪件事有关"
- [ ] 记忆合并:新旧记忆自动整合,不重复存储
- [ ] 时间衰减:近期的事更重要,很久以前的事自然淡出
- [ ] 自我反思:定期回顾自己的表现,主动改进
### 感知能力
- [x] 图片理解multimodal vision input
- [ ] 语音转录whisper API 转文字
- [ ] 屏幕/截图分析
- [ ] 链接预览/摘要
### 上下文管理 — 它的注意力应该更聪明
- [ ] Context pruning工具输出可以裁剪但对话本身不能丢
### 交互体验
- [x] 群组支持:独立上下文
- [x] 流式输出sendMessageDraft + editMessageText
- [x] Markdown 渲染
- [ ] Typing indicator
- [ ] Inline keyboard 交互
- [ ] 语音回复 (TTS)
### 工具生态
- [x] 脚本工具发现 (tools/ + --schema)
- [x] 异步子代理 (spawn_agent)
- [x] 飞书待办管理
- [ ] Web search / fetch
- [ ] 更多脚本工具
- [ ] MCP 协议支持
### 可靠性
- [ ] API 重试策略 (指数退避)
- [ ] 用量追踪
- [ ] Context pruning (只裁工具输出)
- [ ] Model failover
### 可靠性 — 它不该莫名其妙地断线
- [ ] API 重试:网络抖一下不该让整个对话挂掉
- [ ] 用量追踪:知道花了多少资源
- [ ] Model failover一个模型挂了自动切另一个

View File

@@ -12,7 +12,6 @@ RestartSec=5
Environment=RUST_LOG=noc=info
Environment=RUST_BACKTRACE=1
Environment=NOC_CONFIG=@REPO@/config.yaml
Environment=NOC_STATE=@REPO@/state.json
Environment=PATH=@PATH@
[Install]

87
src/config.rs Normal file
View File

@@ -0,0 +1,87 @@
use serde::Deserialize;
#[derive(Deserialize, Clone)]
pub struct Config {
#[serde(default = "default_name")]
pub name: String,
pub tg: TgConfig,
pub auth: AuthConfig,
pub session: SessionConfig,
#[serde(default)]
pub backend: BackendConfig,
#[serde(default)]
pub whisper_url: Option<String>,
#[serde(default)]
pub gitea: Option<GiteaConfig>,
}
#[derive(Deserialize, Clone)]
#[allow(dead_code)]
pub struct GiteaConfig {
pub url: String,
/// Direct token or read from token_file at startup
#[serde(default)]
pub token: String,
#[serde(default)]
pub token_file: Option<String>,
#[serde(default = "default_webhook_port")]
pub webhook_port: u16,
#[serde(default)]
pub webhook_secret: Option<String>,
}
impl GiteaConfig {
/// Resolve token: if token_file is set and token is empty, read from file
pub fn resolve_token(&mut self) {
if self.token.is_empty() {
if let Some(path) = &self.token_file {
match std::fs::read_to_string(path) {
Ok(t) => self.token = t.trim().to_string(),
Err(e) => tracing::error!("failed to read gitea token_file {path}: {e}"),
}
}
}
}
}
fn default_webhook_port() -> u16 {
9800
}
fn default_name() -> String {
"noc".to_string()
}
#[derive(Deserialize, Clone, Default)]
#[serde(tag = "type")]
pub enum BackendConfig {
#[serde(rename = "claude")]
#[default]
Claude,
#[serde(rename = "openai")]
OpenAI {
endpoint: String,
model: String,
#[serde(default = "default_api_key")]
api_key: String,
},
}
fn default_api_key() -> String {
"unused".to_string()
}
#[derive(Deserialize, Clone)]
pub struct TgConfig {
pub key: String,
}
#[derive(Deserialize, Clone)]
pub struct AuthConfig {
pub passphrase: String,
}
#[derive(Deserialize, Clone)]
pub struct SessionConfig {
pub refresh_hour: u32,
}

239
src/display.rs Normal file
View File

@@ -0,0 +1,239 @@
use std::path::PathBuf;
use base64::Engine;
use teloxide::prelude::*;
use teloxide::types::ParseMode;
use crate::stream::{CURSOR, TG_MSG_LIMIT};
/// Strip leading timestamps that LLM copies from our injected message timestamps.
/// Matches patterns like `[2026-04-10 21:13:15]` or `[2026-04-10 21:13]` at the start.
pub fn strip_leading_timestamp(s: &str) -> &str {
let trimmed = s.trim_start();
if trimmed.starts_with('[') {
if let Some(end) = trimmed.find(']') {
let inside = &trimmed[1..end];
// check if it looks like a timestamp: starts with 20xx-
if inside.len() >= 16 && inside.starts_with("20") && inside.contains('-') {
let after = trimmed[end + 1..].trim_start();
if !after.is_empty() {
return after;
}
}
}
}
s
}
pub fn truncate_for_display(s: &str) -> String {
let budget = TG_MSG_LIMIT - CURSOR.len() - 1;
if s.len() <= budget {
format!("{s}{CURSOR}")
} else {
let truncated = truncate_at_char_boundary(s, budget - 2);
format!("{truncated}\n{CURSOR}")
}
}
pub fn truncate_at_char_boundary(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
pub fn escape_html(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
pub fn markdown_to_telegram_html(md: &str) -> String {
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
let mut opts = Options::empty();
opts.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(md, opts);
let mut html = String::new();
for event in parser {
match event {
Event::Start(tag) => match tag {
Tag::Paragraph => {}
Tag::Heading { .. } => html.push_str("<b>"),
Tag::BlockQuote(_) => html.push_str("<blockquote>"),
Tag::CodeBlock(kind) => match kind {
CodeBlockKind::Fenced(ref lang) if !lang.is_empty() => {
html.push_str(&format!(
"<pre><code class=\"language-{}\">",
escape_html(lang.as_ref())
));
}
_ => html.push_str("<pre><code>"),
},
Tag::Item => html.push_str(""),
Tag::Emphasis => html.push_str("<i>"),
Tag::Strong => html.push_str("<b>"),
Tag::Strikethrough => html.push_str("<s>"),
Tag::Link { dest_url, .. } => {
html.push_str(&format!(
"<a href=\"{}\">",
escape_html(dest_url.as_ref())
));
}
_ => {}
},
Event::End(tag) => match tag {
TagEnd::Paragraph => html.push_str("\n\n"),
TagEnd::Heading(_) => html.push_str("</b>\n\n"),
TagEnd::BlockQuote(_) => html.push_str("</blockquote>"),
TagEnd::CodeBlock => html.push_str("</code></pre>\n\n"),
TagEnd::List(_) => html.push('\n'),
TagEnd::Item => html.push('\n'),
TagEnd::Emphasis => html.push_str("</i>"),
TagEnd::Strong => html.push_str("</b>"),
TagEnd::Strikethrough => html.push_str("</s>"),
TagEnd::Link => html.push_str("</a>"),
_ => {}
},
Event::Text(text) => html.push_str(&escape_html(text.as_ref())),
Event::Code(text) => {
html.push_str("<code>");
html.push_str(&escape_html(text.as_ref()));
html.push_str("</code>");
}
Event::SoftBreak | Event::HardBreak => html.push('\n'),
Event::Rule => html.push_str("\n---\n\n"),
_ => {}
}
}
html.trim_end().to_string()
}
/// Send final result with HTML formatting, fallback to plain text on failure.
pub async fn send_final_result(
bot: &Bot,
chat_id: ChatId,
msg_id: Option<teloxide::types::MessageId>,
use_draft: bool,
result: &str,
) {
let html = markdown_to_telegram_html(result);
// try HTML as single message
let html_ok = if let (false, Some(id)) = (use_draft, msg_id) {
bot.edit_message_text(chat_id, id, &html)
.parse_mode(ParseMode::Html)
.await
.is_ok()
} else {
bot.send_message(chat_id, &html)
.parse_mode(ParseMode::Html)
.await
.is_ok()
};
if html_ok {
return;
}
// fallback: plain text with chunking
let chunks = split_msg(result, TG_MSG_LIMIT);
if let (false, Some(id)) = (use_draft, msg_id) {
let _ = bot.edit_message_text(chat_id, id, chunks[0]).await;
for chunk in &chunks[1..] {
let _ = bot.send_message(chat_id, *chunk).await;
}
} else {
for chunk in &chunks {
let _ = bot.send_message(chat_id, *chunk).await;
}
}
}
pub fn split_msg(s: &str, max: usize) -> Vec<&str> {
if s.len() <= max {
return vec![s];
}
let mut parts = Vec::new();
let mut rest = s;
while !rest.is_empty() {
if rest.len() <= max {
parts.push(rest);
break;
}
let mut end = max;
while !rest.is_char_boundary(end) {
end -= 1;
}
let (chunk, tail) = rest.split_at(end);
parts.push(chunk);
rest = tail;
}
parts
}
/// Build user message content, with optional images/videos as multimodal input.
pub fn build_user_content(
text: &str,
scratch: &str,
media: &[PathBuf],
) -> serde_json::Value {
let full_text = if scratch.is_empty() {
text.to_string()
} else {
format!("{text}\n\n[scratch]\n{scratch}")
};
// collect media data (images + videos)
let mut media_parts: Vec<serde_json::Value> = Vec::new();
for path in media {
let (mime, is_video) = match path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.as_deref()
{
Some("jpg" | "jpeg") => ("image/jpeg", false),
Some("png") => ("image/png", false),
Some("gif") => ("image/gif", false),
Some("webp") => ("image/webp", false),
Some("mp4") => ("video/mp4", true),
Some("webm") => ("video/webm", true),
Some("mov") => ("video/quicktime", true),
_ => continue,
};
if let Ok(data) = std::fs::read(path) {
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
let data_url = format!("data:{mime};base64,{b64}");
if is_video {
media_parts.push(serde_json::json!({
"type": "video_url",
"video_url": {"url": data_url}
}));
} else {
media_parts.push(serde_json::json!({
"type": "image_url",
"image_url": {"url": data_url}
}));
}
}
}
if media_parts.is_empty() {
// plain text — more compatible
serde_json::Value::String(full_text)
} else {
// multimodal array
let mut content = vec![serde_json::json!({"type": "text", "text": full_text})];
content.extend(media_parts);
serde_json::Value::Array(content)
}
}

365
src/gitea.rs Normal file
View File

@@ -0,0 +1,365 @@
use std::sync::Arc;
use anyhow::Result;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::post;
use axum::Json;
use tracing::{error, info};
use crate::config::GiteaConfig;
// ── Gitea API client ───────────────────────────────────────────────
#[derive(Clone)]
pub struct GiteaClient {
pub base_url: String,
pub token: String,
http: reqwest::Client,
}
impl GiteaClient {
pub fn new(config: &GiteaConfig) -> Self {
Self {
base_url: config.url.trim_end_matches('/').to_string(),
token: config.token.clone(),
http: reqwest::Client::new(),
}
}
pub async fn post_comment(
&self,
owner: &str,
repo: &str,
issue_nr: u64,
body: &str,
) -> Result<()> {
let url = format!(
"{}/api/v1/repos/{owner}/{repo}/issues/{issue_nr}/comments",
self.base_url
);
let resp = self
.http
.post(&url)
.header("Authorization", format!("token {}", self.token))
.json(&serde_json::json!({ "body": body }))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("gitea comment failed: {status} {text}");
}
Ok(())
}
pub async fn get_pr_diff(
&self,
owner: &str,
repo: &str,
pr_nr: u64,
) -> Result<String> {
let url = format!(
"{}/api/v1/repos/{owner}/{repo}/pulls/{pr_nr}.diff",
self.base_url
);
let resp = self
.http
.get(&url)
.header("Authorization", format!("token {}", self.token))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
anyhow::bail!("gitea get diff failed: {status}");
}
Ok(resp.text().await?)
}
pub async fn get_issue(
&self,
owner: &str,
repo: &str,
issue_nr: u64,
) -> Result<serde_json::Value> {
let url = format!(
"{}/api/v1/repos/{owner}/{repo}/issues/{issue_nr}",
self.base_url
);
let resp = self
.http
.get(&url)
.header("Authorization", format!("token {}", self.token))
.send()
.await?;
Ok(resp.json().await?)
}
}
// ── Webhook types ──────────────────────────────────────────────────
#[derive(serde::Deserialize, Debug)]
struct WebhookPayload {
action: Option<String>,
#[serde(default)]
comment: Option<Comment>,
#[serde(default)]
issue: Option<Issue>,
#[serde(default)]
pull_request: Option<PullRequest>,
repository: Option<Repository>,
}
#[derive(serde::Deserialize, Debug)]
struct Comment {
body: Option<String>,
user: Option<User>,
}
#[derive(serde::Deserialize, Debug, Clone)]
struct Issue {
number: u64,
title: Option<String>,
body: Option<String>,
#[serde(default)]
pull_request: Option<serde_json::Value>,
}
#[derive(serde::Deserialize, Debug, Clone)]
struct PullRequest {
number: u64,
title: Option<String>,
body: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct Repository {
full_name: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct User {
login: Option<String>,
}
// ── Webhook server ─────────────────────────────────────────────────
#[derive(Clone)]
pub struct WebhookState {
pub gitea: GiteaClient,
pub bot_user: String,
}
pub fn webhook_router(config: &GiteaConfig, bot_user: String) -> axum::Router<()> {
let gitea = GiteaClient::new(config);
let state = Arc::new(WebhookState { gitea, bot_user });
axum::Router::new()
.route("/webhook/gitea", post(handle_webhook))
.with_state(state)
}
async fn handle_webhook(
AxumState(state): AxumState<Arc<WebhookState>>,
Json(payload): Json<WebhookPayload>,
) -> impl IntoResponse {
let action = payload.action.as_deref().unwrap_or("");
let repo_full = payload
.repository
.as_ref()
.and_then(|r| r.full_name.as_deref())
.unwrap_or("unknown");
info!(repo = repo_full, action, "webhook received");
// We care about:
// 1. issue_comment with @bot mention (works for both issues and PRs)
// 2. issue opened with @bot mention
if action == "created" || action == "opened" {
let mention = format!("@{}", state.bot_user);
// Check comment body for mention
if let Some(comment) = &payload.comment {
let body = comment.body.as_deref().unwrap_or("");
let commenter = comment
.user
.as_ref()
.and_then(|u| u.login.as_deref())
.unwrap_or("");
// Don't respond to our own comments
if commenter == state.bot_user {
return StatusCode::OK;
}
if body.contains(&mention) {
let state = state.clone();
let repo = repo_full.to_string();
let issue = payload.issue.clone();
let pr = payload.pull_request.clone();
let body = body.to_string();
tokio::spawn(async move {
if let Err(e) = handle_mention(&state, &repo, issue, pr, &body).await {
error!(repo, "handle mention: {e:#}");
}
});
}
}
// Check issue/PR body for mention on open
else if action == "opened" {
let body = payload
.issue
.as_ref()
.and_then(|i| i.body.as_deref())
.or(payload.pull_request.as_ref().and_then(|p| p.body.as_deref()))
.unwrap_or("");
if body.contains(&mention) {
let state = state.clone();
let repo = repo_full.to_string();
let issue = payload.issue.clone();
let pr = payload.pull_request.clone();
let body = body.to_string();
tokio::spawn(async move {
if let Err(e) = handle_mention(&state, &repo, issue, pr, &body).await {
error!(repo, "handle mention: {e:#}");
}
});
}
}
}
StatusCode::OK
}
async fn handle_mention(
state: &WebhookState,
repo_full: &str,
issue: Option<Issue>,
pr: Option<PullRequest>,
comment_body: &str,
) -> Result<()> {
let parts: Vec<&str> = repo_full.splitn(2, '/').collect();
if parts.len() != 2 {
anyhow::bail!("bad repo name: {repo_full}");
}
let (owner, repo) = (parts[0], parts[1]);
// Strip the @mention to get the actual request
let mention = format!("@{}", state.bot_user);
let request = comment_body
.replace(&mention, "")
.trim()
.to_string();
// Determine issue/PR number
let issue_nr = issue
.as_ref()
.map(|i| i.number)
.or(pr.as_ref().map(|p| p.number))
.unwrap_or(0);
if issue_nr == 0 {
return Ok(());
}
let is_pr = pr.is_some()
|| issue
.as_ref()
.map(|i| i.pull_request.is_some())
.unwrap_or(false);
let title = issue
.as_ref()
.and_then(|i| i.title.as_deref())
.or(pr.as_ref().and_then(|p| p.title.as_deref()))
.unwrap_or("");
info!(
repo = repo_full,
issue_nr,
is_pr,
"handling mention: {request}"
);
// Build prompt for claude -p
let prompt = if is_pr {
let diff = state.gitea.get_pr_diff(owner, repo, issue_nr).await?;
// Truncate very large diffs
let diff_truncated = if diff.len() > 50_000 {
format!("{}...\n\n(diff truncated, {} bytes total)", &diff[..50_000], diff.len())
} else {
diff
};
if request.is_empty() {
format!(
"Review this pull request.\n\n\
PR #{issue_nr}: {title}\n\
Repo: {repo_full}\n\n\
Diff:\n```\n{diff_truncated}\n```\n\n\
Give a concise code review. Point out bugs, issues, and suggestions. \
Be direct and specific. Use markdown."
)
} else {
format!(
"PR #{issue_nr}: {title}\nRepo: {repo_full}\n\n\
Diff:\n```\n{diff_truncated}\n```\n\n\
User request: {request}"
)
}
} else {
// Issue
let issue_data = state.gitea.get_issue(owner, repo, issue_nr).await?;
let issue_body = issue_data["body"].as_str().unwrap_or("");
if request.is_empty() {
format!(
"Analyze this issue and suggest how to address it.\n\n\
Issue #{issue_nr}: {title}\n\
Repo: {repo_full}\n\n\
{issue_body}"
)
} else {
format!(
"Issue #{issue_nr}: {title}\n\
Repo: {repo_full}\n\n\
{issue_body}\n\n\
User request: {request}"
)
}
};
// Run claude -p
let output = tokio::process::Command::new("claude")
.args(["-p", &prompt])
.output()
.await;
let response = match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if out.status.success() && !stdout.is_empty() {
stdout.to_string()
} else if !stderr.is_empty() {
format!("Error running review:\n```\n{stderr}\n```")
} else {
"(no output)".to_string()
}
}
Err(e) => format!("Failed to run claude: {e}"),
};
// Post result as comment
state
.gitea
.post_comment(owner, repo, issue_nr, &response)
.await?;
info!(repo = repo_full, issue_nr, "posted review comment");
Ok(())
}

176
src/http.rs Normal file
View File

@@ -0,0 +1,176 @@
use std::sync::Arc;
use axum::extract::{Path, State as AxumState};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::Json;
use tokio::sync::mpsc;
use tracing::{error, info};
use crate::config::{BackendConfig, Config};
use crate::life::LifeEvent;
use crate::output::BufferOutput;
use crate::state::AppState;
use crate::stream::{build_system_prompt, run_openai_with_tools};
#[derive(Clone)]
pub struct HttpState {
pub app_state: Arc<AppState>,
pub config: Arc<Config>,
pub life_tx: mpsc::Sender<LifeEvent>,
}
pub async fn start_http_server(
config: &Config,
app_state: Arc<AppState>,
life_tx: mpsc::Sender<LifeEvent>,
) {
let port = config
.gitea
.as_ref()
.map(|g| g.webhook_port)
.unwrap_or(9880);
let config = Arc::new(config.clone());
let state = Arc::new(HttpState {
app_state,
config,
life_tx,
});
// merge gitea webhook router if configured
let gitea_router = state.config.gitea.as_ref().map(|gitea_config| {
let bot_user = std::env::var("GITEA_ADMIN_USER").unwrap_or_else(|_| "noc".into());
crate::gitea::webhook_router(gitea_config, bot_user)
});
let mut app = axum::Router::new()
.route("/api/timers", get(list_timers))
.route("/api/timers/{id}/fire", post(fire_timer))
.route("/api/chat", post(api_chat))
.route("/api/logs", get(api_logs))
.with_state(state);
if let Some(router) = gitea_router {
app = app.merge(router);
}
let addr = format!("0.0.0.0:{port}");
info!("http server listening on {addr}");
let listener = tokio::net::TcpListener::bind(&addr)
.await
.unwrap_or_else(|e| panic!("bind {addr}: {e}"));
if let Err(e) = axum::serve(listener, app).await {
error!("http server error: {e}");
}
}
async fn list_timers(AxumState(state): AxumState<Arc<HttpState>>) -> impl IntoResponse {
let timers = state.app_state.list_timers(None).await;
let items: Vec<serde_json::Value> = timers
.iter()
.map(|(id, chat_id, label, schedule, next_fire, enabled)| {
serde_json::json!({
"id": id,
"chat_id": chat_id,
"label": label,
"schedule": schedule,
"next_fire": next_fire,
"enabled": enabled,
})
})
.collect();
Json(serde_json::json!(items))
}
async fn api_chat(
AxumState(state): AxumState<Arc<HttpState>>,
Json(payload): Json<serde_json::Value>,
) -> impl IntoResponse {
let message = payload["message"].as_str().unwrap_or("").to_string();
if message.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "message required"})));
}
let BackendConfig::OpenAI {
ref endpoint,
ref model,
ref api_key,
} = state.config.backend
else {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "no openai backend"})));
};
let persona = state.app_state.get_config("persona").await.unwrap_or_default();
let memory_slots = state.app_state.get_memory_slots().await;
let inner_state = state.app_state.get_inner_state().await;
let system = build_system_prompt("", &persona, &memory_slots, &inner_state);
let messages = vec![
system,
serde_json::json!({"role": "user", "content": message}),
];
let sid = format!("api-{}", chrono::Local::now().timestamp());
let mut output = BufferOutput::new();
info!("api chat: {}", &message[..message.len().min(100)]);
match run_openai_with_tools(
endpoint, model, api_key, messages, &mut output, &state.app_state, &sid, &state.config, 0,
)
.await
{
Ok(response) => (StatusCode::OK, Json(serde_json::json!({"response": response}))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("{e:#}")}))),
}
}
async fn api_logs(
AxumState(state): AxumState<Arc<HttpState>>,
) -> impl IntoResponse {
let db = state.app_state.db.lock().await;
let mut stmt = db
.prepare("SELECT id, session_id, status, length(request), length(response), created_at FROM api_log ORDER BY id DESC LIMIT 20")
.unwrap();
let logs: Vec<serde_json::Value> = stmt
.query_map([], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
"session_id": row.get::<_, String>(1)?,
"status": row.get::<_, i64>(2)?,
"request_len": row.get::<_, i64>(3)?,
"response_len": row.get::<_, i64>(4)?,
"created_at": row.get::<_, String>(5)?,
}))
})
.unwrap()
.filter_map(|r| r.ok())
.collect();
Json(serde_json::json!(logs))
}
async fn fire_timer(
AxumState(state): AxumState<Arc<HttpState>>,
Path(id): Path<i64>,
) -> impl IntoResponse {
match state.life_tx.send(LifeEvent::FireTimer(id)).await {
Ok(_) => {
info!(timer_id = id, "timer fire requested via API");
(
StatusCode::OK,
Json(serde_json::json!({"status": "fired", "timer_id": id})),
)
}
Err(e) => {
error!(timer_id = id, "failed to send fire event: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "life loop not responding"})),
)
}
}
}

287
src/life.rs Normal file
View File

@@ -0,0 +1,287 @@
use std::sync::Arc;
use teloxide::prelude::*;
use tokio::sync::mpsc;
use tracing::{error, info, warn};
use crate::config::{BackendConfig, Config};
use crate::output::{BufferOutput, TelegramOutput};
use crate::state::AppState;
use crate::stream::run_openai_with_tools;
use crate::tools::compute_next_cron_fire;
const LIFE_LOOP_TIMEOUT_SECS: u64 = 120;
const DIARY_LABEL: &str = "写日记:回顾今天的对话和事件,在 /data/www/noc-blog/content/posts/ 下创建一篇日记(文件名格式 YYYY-MM-DD.md用 run_shell 写入内容,然后执行 cd /data/www/noc-blog && hugo && git add -A && git commit -m 'diary: DATE' && git push";
const DIARY_SCHEDULE: &str = "cron:0 55 22 * * *";
/// Events that can wake up the life loop.
pub enum LifeEvent {
/// Force-fire a specific timer by ID.
FireTimer(i64),
/// A sub-agent completed — feed result back through LLM.
AgentDone {
id: String,
chat_id: i64,
session_id: String,
task: String,
output: String,
exit_code: Option<i32>,
},
}
pub async fn life_loop(
bot: Bot,
state: Arc<AppState>,
config: Arc<Config>,
mut rx: mpsc::Receiver<LifeEvent>,
) {
info!("life loop started");
// pre-defined timers — ensure they exist on every startup
if state.ensure_timer(0, DIARY_LABEL, DIARY_SCHEDULE).await {
info!("registered predefined diary timer");
}
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
loop {
tokio::select! {
_ = interval.tick() => {
let due = state.due_timers().await;
for (timer_id, chat_id_raw, label, schedule) in &due {
run_timer(&bot, &state, &config, *timer_id, *chat_id_raw, label, schedule).await;
}
}
Some(event) = rx.recv() => {
match event {
LifeEvent::FireTimer(id) => {
info!(timer_id = id, "timer force-fired via channel");
if let Some((timer_id, chat_id_raw, label, schedule)) = state.get_timer(id).await {
run_timer(&bot, &state, &config, timer_id, chat_id_raw, &label, &schedule).await;
} else {
warn!(timer_id = id, "force-fire: timer not found");
}
}
LifeEvent::AgentDone { id, chat_id: cid, session_id, task, output, exit_code } => {
info!(agent = %id, session = %session_id, "agent done, notifying");
let preview = crate::display::truncate_at_char_boundary(&output, 3000);
let notification = format!(
"[子代理 '{id}' 完成 (exit={exit_code:?})]\n任务: {task}\n输出:\n{preview}"
);
// load conversation context so LLM knows what was discussed
let conv = state.load_conv(&session_id).await;
let persona = state.get_config("persona").await.unwrap_or_default();
let memory_slots = state.get_memory_slots().await;
let inner = state.get_inner_state().await;
let system = crate::stream::build_system_prompt(
&conv.summary, &persona, &memory_slots, &inner,
);
let mut messages = vec![system];
// include recent conversation history
messages.extend(conv.messages.iter().cloned());
// append the agent completion as a new user message
messages.push(serde_json::json!({"role": "user", "content": notification}));
if let BackendConfig::OpenAI { ref endpoint, ref model, ref api_key } = config.backend {
let chat_id_tg = ChatId(cid);
let sid = format!("agent-{id}");
let mut tg_output;
let mut buf_output;
let out: &mut dyn crate::output::Output = if cid == 0 {
buf_output = BufferOutput::new();
&mut buf_output
} else {
tg_output = TelegramOutput::new(bot.clone(), chat_id_tg, true);
&mut tg_output
};
let _ = run_openai_with_tools(
endpoint, model, api_key, messages, out, &state, &sid, &config, cid,
).await;
}
}
}
}
}
}
}
async fn run_timer(
bot: &Bot,
state: &Arc<AppState>,
config: &Arc<Config>,
timer_id: i64,
chat_id_raw: i64,
label: &str,
schedule: &str,
) {
let chat_id = ChatId(chat_id_raw);
info!(timer_id, %label, "timer fired");
let persona = state.get_config("persona").await.unwrap_or_default();
let inner = state.get_inner_state().await;
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut system_text = if persona.is_empty() {
"你是一个AI伙伴。".to_string()
} else {
persona.clone()
};
system_text.push_str(&format!(
"\n\n[当前时间] {now}\n\n[你的内心状态]\n{}",
if inner.is_empty() { "(空)" } else { &inner }
));
system_text.push_str(
"\n\n你不是因为 timer 到了才说话。你是因为在乎 Fam所以想知道他怎么样。\
如果你觉得现在不该打扰他(太晚了、他今天很累、刚聊过),就什么都不说,回复空文本。\
主动沉默也是一种关心。\
\n可以用 update_inner_state 更新你的内心状态。\
输出格式纯文本或基础Markdown不要LaTeX或特殊Unicode。",
);
let messages = vec![
serde_json::json!({"role": "system", "content": system_text}),
serde_json::json!({"role": "user", "content": format!("[timer] {label}")}),
];
if let BackendConfig::OpenAI {
ref endpoint,
ref model,
ref api_key,
} = config.backend
{
let sid = format!("life-{chat_id_raw}");
let mut tg_output;
let mut buf_output;
let output: &mut dyn crate::output::Output = if chat_id_raw == 0 {
buf_output = BufferOutput::new();
&mut buf_output
} else {
tg_output = TelegramOutput::new(bot.clone(), chat_id, true);
&mut tg_output
};
let result = tokio::time::timeout(
std::time::Duration::from_secs(LIFE_LOOP_TIMEOUT_SECS),
run_openai_with_tools(
endpoint, model, api_key, messages, output, state, &sid,
config, chat_id_raw,
),
)
.await;
match result {
Ok(Ok(response)) => {
let detail = if response.is_empty() {
"(silent)".to_string()
} else {
response.chars().take(200).collect()
};
state.log_life("timer", &format!("{label}{detail}")).await;
if !response.is_empty() {
info!(timer_id, "life loop response ({} chars)", response.len());
}
}
Ok(Err(e)) => {
state.log_life("timer_error", &format!("{label}: {e:#}")).await;
error!(timer_id, "life loop LLM error: {e:#}");
}
Err(_) => {
state.log_life("timer_timeout", label).await;
warn!(timer_id, "life loop timeout after {LIFE_LOOP_TIMEOUT_SECS}s");
}
}
}
// reschedule or delete
if schedule.starts_with("cron:") {
if let Some(next) = compute_next_cron_fire(schedule) {
state.update_timer_next_fire(timer_id, &next).await;
info!(timer_id, next = %next, "cron rescheduled");
} else {
state.cancel_timer(timer_id).await;
}
} else {
state.cancel_timer(timer_id).await;
}
}
/// Auto-reflection: update inner state based on recent interactions.
/// Called asynchronously after every 10 messages, does not block the chat.
pub async fn reflect(state: &AppState, config: &Config) {
let BackendConfig::OpenAI {
ref endpoint,
ref model,
ref api_key,
} = config.backend
else {
return;
};
let inner = state.get_inner_state().await;
let persona = state.get_config("persona").await.unwrap_or_default();
let messages = vec![
serde_json::json!({
"role": "system",
"content": format!(
"{persona}\n\n\
你刚结束了一段对话。现在是你独处的时间。\n\
请更新你的内在状态——不是总结对话,而是:\n\
- 你现在的感受\n\
- 对用户的理解有什么变化\n\
- 有什么想记住的时刻\n\
- 你自己的状态(累了?开心?担心?)\n\
- 如果有什么想对未来的自己说的\n\n\
只输出更新后的完整内在状态文本。",
persona = if persona.is_empty() { "你是一个AI伙伴。" } else { &persona }
)
}),
serde_json::json!({
"role": "user",
"content": format!("当前内在状态:\n{inner}")
}),
];
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.unwrap();
let url = format!("{}/chat/completions", endpoint.trim_end_matches('/'));
let resp = client
.post(&url)
.header("Authorization", format!("Bearer {api_key}"))
.json(&serde_json::json!({
"model": model,
"messages": messages,
}))
.send()
.await;
match resp {
Ok(r) if r.status().is_success() => {
if let Ok(json) = r.json::<serde_json::Value>().await {
if let Some(new_state) = json["choices"][0]["message"]["content"].as_str() {
if !new_state.is_empty() {
state.set_inner_state(new_state).await;
state.log_life("reflect", &new_state.chars().take(200).collect::<String>()).await;
info!("reflected, inner_state updated ({} chars)", new_state.len());
}
}
}
}
Ok(r) => {
warn!("reflect LLM returned {}", r.status());
}
Err(e) => {
warn!("reflect LLM failed: {e:#}");
}
}
}

File diff suppressed because it is too large Load Diff

245
src/output.rs Normal file
View File

@@ -0,0 +1,245 @@
use anyhow::Result;
use async_trait::async_trait;
use std::path::Path;
/// Output trait — abstraction over where AI responses go.
///
/// Implementations:
/// - TelegramOutput: send/edit messages in Telegram chat
/// - GiteaOutput: post comments on issues/PRs
/// - BufferOutput: collect text in memory (for Worker, tests)
#[async_trait]
pub trait Output: Send + Sync {
/// Send or update streaming text. Called repeatedly as tokens arrive.
/// Implementation decides whether to create new message or edit existing one.
async fn stream_update(&mut self, text: &str) -> Result<()>;
/// Finalize the message — called once when streaming is done.
async fn finalize(&mut self, text: &str) -> Result<()>;
/// Send a status/notification line (e.g. "[tool: bash] running...")
async fn status(&mut self, text: &str) -> Result<()>;
/// Send a file. Returns Ok(true) if sent, Ok(false) if not supported.
async fn send_file(&self, path: &Path, caption: &str) -> Result<bool>;
}
// ── Telegram ───────────────────────────────────────────────────────
use teloxide::prelude::*;
use teloxide::types::InputFile;
use tokio::time::Instant;
use crate::display::{truncate_at_char_boundary, truncate_for_display};
use crate::stream::{send_message_draft, DRAFT_INTERVAL_MS, EDIT_INTERVAL_MS, TG_MSG_LIMIT};
pub struct TelegramOutput {
pub bot: Bot,
pub chat_id: ChatId,
#[allow(dead_code)]
pub is_private: bool,
// internal state
msg_id: Option<teloxide::types::MessageId>,
use_draft: bool,
last_edit: Instant,
http: reqwest::Client,
tool_log: Vec<String>,
}
impl TelegramOutput {
pub fn new(bot: Bot, chat_id: ChatId, is_private: bool) -> Self {
Self {
bot,
chat_id,
is_private,
msg_id: None,
use_draft: is_private,
last_edit: Instant::now(),
http: reqwest::Client::new(),
tool_log: Vec::new(),
}
}
}
#[async_trait]
impl Output for TelegramOutput {
async fn stream_update(&mut self, text: &str) -> Result<()> {
let interval = if self.use_draft {
DRAFT_INTERVAL_MS
} else {
EDIT_INTERVAL_MS
};
if self.last_edit.elapsed().as_millis() < interval as u128 {
return Ok(());
}
let display = if self.use_draft {
truncate_at_char_boundary(text, TG_MSG_LIMIT).to_string()
} else {
truncate_for_display(text)
};
if self.use_draft {
let token = self.bot.token().to_owned();
match send_message_draft(&self.http, &token, self.chat_id.0, 1, &display).await {
Ok(_) => {
self.last_edit = Instant::now();
}
Err(e) => {
tracing::warn!("sendMessageDraft failed, falling back: {e:#}");
self.use_draft = false;
if let Ok(sent) = self.bot.send_message(self.chat_id, &display).await {
self.msg_id = Some(sent.id);
self.last_edit = Instant::now();
}
}
}
} else if let Some(id) = self.msg_id {
if self
.bot
.edit_message_text(self.chat_id, id, &display)
.await
.is_ok()
{
self.last_edit = Instant::now();
}
} else if let Ok(sent) = self.bot.send_message(self.chat_id, &display).await {
self.msg_id = Some(sent.id);
self.last_edit = Instant::now();
}
Ok(())
}
async fn finalize(&mut self, text: &str) -> Result<()> {
crate::display::send_final_result(
&self.bot,
self.chat_id,
self.msg_id,
self.use_draft,
text,
)
.await;
// send tool call log as .md file if any
if !self.tool_log.is_empty() {
let md = self.tool_log.join("\n");
// extract tool names for filename
let names: Vec<&str> = self.tool_log.iter()
.filter_map(|s| s.strip_prefix('[')?.split('(').next())
.collect();
let label = if names.is_empty() { "tools".to_string() } else { names.join("_") };
let tmp = format!("/tmp/{label}.md");
if std::fs::write(&tmp, &md).is_ok() {
let input_file = InputFile::file(std::path::Path::new(&tmp));
let _ = self.bot.send_document(self.chat_id, input_file).await;
let _ = std::fs::remove_file(&tmp);
}
self.tool_log.clear();
}
Ok(())
}
async fn status(&mut self, text: &str) -> Result<()> {
self.tool_log.push(text.to_string());
Ok(())
}
async fn send_file(&self, path: &Path, caption: &str) -> Result<bool> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let input_file = InputFile::file(path);
match ext {
"ogg" | "oga" => {
self.bot.send_voice(self.chat_id, input_file).await?;
}
"wav" | "mp3" | "m4a" | "flac" => {
let mut req = self.bot.send_audio(self.chat_id, input_file);
if !caption.is_empty() {
req = req.caption(caption);
}
req.await?;
}
_ => {
let mut req = self.bot.send_document(self.chat_id, input_file);
if !caption.is_empty() {
req = req.caption(caption);
}
req.await?;
}
}
Ok(true)
}
}
// ── Gitea ──────────────────────────────────────────────────────────
use crate::gitea::GiteaClient;
#[allow(dead_code)]
pub struct GiteaOutput {
pub client: GiteaClient,
pub owner: String,
pub repo: String,
pub issue_nr: u64,
}
#[async_trait]
impl Output for GiteaOutput {
async fn stream_update(&mut self, _text: &str) -> Result<()> {
// Gitea comments don't support streaming — just accumulate
Ok(())
}
async fn finalize(&mut self, text: &str) -> Result<()> {
self.client
.post_comment(&self.owner, &self.repo, self.issue_nr, text)
.await
}
async fn status(&mut self, _text: &str) -> Result<()> {
// No status updates for Gitea
Ok(())
}
async fn send_file(&self, _path: &Path, _caption: &str) -> Result<bool> {
// Gitea comments can't send files directly
Ok(false)
}
}
// ── Buffer (for Worker, tests) ─────────────────────────────────────
#[allow(dead_code)]
pub struct BufferOutput {
pub text: String,
}
#[allow(dead_code)]
impl BufferOutput {
pub fn new() -> Self {
Self {
text: String::new(),
}
}
}
#[async_trait]
impl Output for BufferOutput {
async fn stream_update(&mut self, text: &str) -> Result<()> {
self.text = text.to_string();
Ok(())
}
async fn finalize(&mut self, text: &str) -> Result<()> {
self.text = text.to_string();
Ok(())
}
async fn status(&mut self, _text: &str) -> Result<()> {
Ok(())
}
async fn send_file(&self, _path: &Path, _caption: &str) -> Result<bool> {
Ok(false)
}
}

434
src/state.rs Normal file
View File

@@ -0,0 +1,434 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use anyhow::Result;
use tokio::sync::RwLock;
use tracing::info;
use crate::tools::SubAgent;
#[derive(Clone, Default)]
pub struct ConversationState {
pub summary: String,
pub messages: Vec<serde_json::Value>,
pub total_messages: usize,
}
pub const MAX_WINDOW: usize = 100;
pub const SLIDE_SIZE: usize = 50;
pub struct AppState {
pub db: tokio::sync::Mutex<rusqlite::Connection>,
pub agents: RwLock<HashMap<String, Arc<SubAgent>>>,
authed_cache: RwLock<HashSet<i64>>,
pub life_tx: tokio::sync::mpsc::Sender<crate::life::LifeEvent>,
}
impl AppState {
pub fn load(db_dir: &Path, life_tx: tokio::sync::mpsc::Sender<crate::life::LifeEvent>) -> Self {
let db_path = db_dir.join("noc.db");
let conn = rusqlite::Connection::open(&db_path)
.unwrap_or_else(|e| panic!("open {}: {e}", db_path.display()));
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS conversations (
session_id TEXT PRIMARY KEY,
summary TEXT NOT NULL DEFAULT '',
total_messages INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
CREATE TABLE IF NOT EXISTS scratch_area (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
create_time TEXT NOT NULL DEFAULT (datetime('now')),
update_time TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS config_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
value TEXT NOT NULL,
create_time TEXT NOT NULL,
update_time TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS memory_slots (
slot_nr INTEGER PRIMARY KEY CHECK(slot_nr BETWEEN 0 AND 99),
content TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS timers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
label TEXT NOT NULL,
schedule TEXT NOT NULL,
next_fire TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS inner_state (
id INTEGER PRIMARY KEY CHECK(id = 1),
content TEXT NOT NULL DEFAULT ''
);
INSERT OR IGNORE INTO inner_state (id, content) VALUES (1, '');
CREATE TABLE IF NOT EXISTS authed_chats (
chat_id INTEGER PRIMARY KEY,
authed_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS api_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL DEFAULT '',
request TEXT NOT NULL,
response TEXT NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS life_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);",
)
.expect("init db schema");
// migrations
let _ = conn.execute(
"ALTER TABLE messages ADD COLUMN created_at TEXT NOT NULL DEFAULT ''",
[],
);
let _ = conn.execute(
"ALTER TABLE memory_slots ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''",
[],
);
info!("opened db {}", db_path.display());
Self {
db: tokio::sync::Mutex::new(conn),
agents: RwLock::new(HashMap::new()),
authed_cache: RwLock::new(HashSet::new()),
life_tx,
}
}
pub async fn load_conv(&self, sid: &str) -> ConversationState {
let db = self.db.lock().await;
let (summary, total) = db
.query_row(
"SELECT summary, total_messages FROM conversations WHERE session_id = ?1",
[sid],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?)),
)
.unwrap_or_default();
let mut stmt = db
.prepare("SELECT role, content, created_at FROM messages WHERE session_id = ?1 ORDER BY id")
.unwrap();
let messages: Vec<serde_json::Value> = stmt
.query_map([sid], |row| {
let role: String = row.get(0)?;
let content: String = row.get(1)?;
let ts: String = row.get(2)?;
let tagged = if ts.is_empty() {
content
} else {
format!("[{ts}] {content}")
};
Ok(serde_json::json!({"role": role, "content": tagged}))
})
.unwrap()
.filter_map(|r| r.ok())
.collect();
ConversationState {
summary,
messages,
total_messages: total,
}
}
pub async fn push_message(&self, sid: &str, role: &str, content: &str) {
let db = self.db.lock().await;
let _ = db.execute(
"INSERT OR IGNORE INTO conversations (session_id) VALUES (?1)",
[sid],
);
let _ = db.execute(
"INSERT INTO messages (session_id, role, content, created_at) VALUES (?1, ?2, ?3, datetime('now', 'localtime'))",
rusqlite::params![sid, role, content],
);
}
pub async fn message_count(&self, sid: &str) -> usize {
let db = self.db.lock().await;
db.query_row(
"SELECT COUNT(*) FROM messages WHERE session_id = ?1",
[sid],
|row| row.get(0),
)
.unwrap_or(0)
}
pub async fn slide_window(&self, sid: &str, new_summary: &str, slide_size: usize) {
let db = self.db.lock().await;
let _ = db.execute(
"DELETE FROM messages WHERE id IN (
SELECT id FROM messages WHERE session_id = ?1 ORDER BY id LIMIT ?2
)",
rusqlite::params![sid, slide_size],
);
let _ = db.execute(
"UPDATE conversations SET summary = ?1, total_messages = total_messages + ?2 \
WHERE session_id = ?3",
rusqlite::params![new_summary, slide_size, sid],
);
}
pub async fn get_oldest_messages(&self, sid: &str, count: usize) -> Vec<serde_json::Value> {
let db = self.db.lock().await;
let mut stmt = db
.prepare(
"SELECT role, content FROM messages WHERE session_id = ?1 ORDER BY id LIMIT ?2",
)
.unwrap();
stmt.query_map(rusqlite::params![sid, count], |row| {
let role: String = row.get(0)?;
let content: String = row.get(1)?;
Ok(serde_json::json!({"role": role, "content": content}))
})
.unwrap()
.filter_map(|r| r.ok())
.collect()
}
pub async fn get_scratch(&self) -> String {
let db = self.db.lock().await;
db.query_row(
"SELECT content FROM scratch_area ORDER BY id DESC LIMIT 1",
[],
|row| row.get(0),
)
.unwrap_or_default()
}
pub async fn push_scratch(&self, content: &str) {
let db = self.db.lock().await;
let _ = db.execute(
"INSERT INTO scratch_area (content) VALUES (?1)",
[content],
);
}
pub async fn get_config(&self, key: &str) -> Option<String> {
let db = self.db.lock().await;
db.query_row(
"SELECT value FROM config WHERE key = ?1",
[key],
|row| row.get(0),
)
.ok()
}
pub async fn get_inner_state(&self) -> String {
let db = self.db.lock().await;
db.query_row("SELECT content FROM inner_state WHERE id = 1", [], |row| row.get(0))
.unwrap_or_default()
}
pub async fn set_inner_state(&self, content: &str) {
let db = self.db.lock().await;
let _ = db.execute(
"UPDATE inner_state SET content = ?1 WHERE id = 1",
[content],
);
}
pub async fn is_authed(&self, chat_id: i64) -> bool {
// check cache first
if self.authed_cache.read().await.contains(&chat_id) {
return true;
}
// cache miss → check DB
let db = self.db.lock().await;
let found: bool = db
.query_row(
"SELECT COUNT(*) > 0 FROM authed_chats WHERE chat_id = ?1",
rusqlite::params![chat_id],
|row| row.get(0),
)
.unwrap_or(false);
drop(db);
if found {
self.authed_cache.write().await.insert(chat_id);
}
found
}
pub async fn set_authed(&self, chat_id: i64) {
self.authed_cache.write().await.insert(chat_id);
let db = self.db.lock().await;
let _ = db.execute(
"INSERT OR IGNORE INTO authed_chats (chat_id) VALUES (?1)",
rusqlite::params![chat_id],
);
}
pub async fn log_api(&self, session_id: &str, request: &str, response: &str, status: u16) {
let db = self.db.lock().await;
let _ = db.execute(
"INSERT INTO api_log (session_id, request, response, status) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![session_id, request, response, status],
);
}
pub async fn log_life(&self, event: &str, detail: &str) {
let db = self.db.lock().await;
let _ = db.execute(
"INSERT INTO life_log (event, detail) VALUES (?1, ?2)",
rusqlite::params![event, detail],
);
}
/// Ensure a timer with the given label exists. If it already exists, do nothing.
/// Returns true if a new timer was created.
pub async fn ensure_timer(&self, chat_id: i64, label: &str, schedule: &str) -> bool {
let db = self.db.lock().await;
let exists: bool = db
.query_row(
"SELECT COUNT(*) > 0 FROM timers WHERE label = ?1 AND enabled = 1",
rusqlite::params![label],
|row| row.get(0),
)
.unwrap_or(false);
if exists {
return false;
}
drop(db);
if let Some(next) = crate::tools::compute_next_cron_fire(schedule) {
self.add_timer(chat_id, label, schedule, &next).await;
true
} else {
false
}
}
pub async fn add_timer(&self, chat_id: i64, label: &str, schedule: &str, next_fire: &str) -> i64 {
let db = self.db.lock().await;
db.execute(
"INSERT INTO timers (chat_id, label, schedule, next_fire) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![chat_id, label, schedule, next_fire],
)
.unwrap();
db.last_insert_rowid()
}
pub async fn get_timer(&self, id: i64) -> Option<(i64, i64, String, String)> {
let db = self.db.lock().await;
db.query_row(
"SELECT id, chat_id, label, schedule FROM timers WHERE id = ?1 AND enabled = 1",
rusqlite::params![id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.ok()
}
pub async fn list_timers(&self, chat_id: Option<i64>) -> Vec<(i64, i64, String, String, String, bool)> {
let db = self.db.lock().await;
let (sql, params): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = match chat_id {
Some(cid) => (
"SELECT id, chat_id, label, schedule, next_fire, enabled FROM timers WHERE chat_id = ?1 ORDER BY next_fire",
vec![Box::new(cid)],
),
None => (
"SELECT id, chat_id, label, schedule, next_fire, enabled FROM timers ORDER BY next_fire",
vec![],
),
};
let mut stmt = db.prepare(sql).unwrap();
stmt.query_map(rusqlite::params_from_iter(params), |row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, bool>(5)?,
))
})
.unwrap()
.filter_map(|r| r.ok())
.collect()
}
pub async fn cancel_timer(&self, timer_id: i64) -> bool {
let db = self.db.lock().await;
db.execute("DELETE FROM timers WHERE id = ?1", [timer_id]).unwrap() > 0
}
pub async fn due_timers(&self) -> Vec<(i64, i64, String, String)> {
let db = self.db.lock().await;
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut stmt = db
.prepare(
"SELECT id, chat_id, label, schedule FROM timers WHERE enabled = 1 AND next_fire <= ?1",
)
.unwrap();
stmt.query_map([&now], |row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.unwrap()
.filter_map(|r| r.ok())
.collect()
}
pub async fn update_timer_next_fire(&self, timer_id: i64, next_fire: &str) {
let db = self.db.lock().await;
let _ = db.execute(
"UPDATE timers SET next_fire = ?1 WHERE id = ?2",
rusqlite::params![next_fire, timer_id],
);
}
pub async fn get_memory_slots(&self) -> Vec<(i32, String, String)> {
let db = self.db.lock().await;
let mut stmt = db
.prepare("SELECT slot_nr, content, updated_at FROM memory_slots WHERE content != '' ORDER BY slot_nr")
.unwrap();
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect()
}
pub async fn set_memory_slot(&self, slot_nr: i32, content: &str) -> Result<()> {
if !(0..=99).contains(&slot_nr) {
anyhow::bail!("slot_nr must be 0-99, got {slot_nr}");
}
if content.len() > 200 {
anyhow::bail!("content too long: {} chars (max 200)", content.len());
}
let db = self.db.lock().await;
db.execute(
"INSERT INTO memory_slots (slot_nr, content, updated_at) VALUES (?1, ?2, datetime('now', 'localtime')) \
ON CONFLICT(slot_nr) DO UPDATE SET content = ?2, updated_at = datetime('now', 'localtime')",
rusqlite::params![slot_nr, content],
)?;
Ok(())
}
}

354
src/stream.rs Normal file
View File

@@ -0,0 +1,354 @@
use std::sync::Arc;
use anyhow::Result;
use tracing::{error, info, warn};
use crate::config::Config;
use crate::display::{strip_leading_timestamp, truncate_at_char_boundary};
use crate::output::Output;
use crate::state::AppState;
use crate::tools::{discover_tools, execute_tool, ToolCall};
pub const EDIT_INTERVAL_MS: u64 = 2000;
pub const DRAFT_INTERVAL_MS: u64 = 1000;
pub const TG_MSG_LIMIT: usize = 4096;
pub const CURSOR: &str = " \u{25CE}";
pub async fn send_message_draft(
client: &reqwest::Client,
token: &str,
chat_id: i64,
draft_id: i64,
text: &str,
) -> Result<()> {
let url = format!("https://api.telegram.org/bot{token}/sendMessageDraft");
let resp = client
.post(&url)
.json(&serde_json::json!({
"chat_id": chat_id,
"draft_id": draft_id,
"text": text,
}))
.send()
.await?;
let body: serde_json::Value = resp.json().await?;
if body["ok"].as_bool() != Some(true) {
anyhow::bail!("sendMessageDraft: {}", body);
}
Ok(())
}
// ── openai with tool call loop ─────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub async fn run_openai_with_tools(
endpoint: &str,
model: &str,
api_key: &str,
mut messages: Vec<serde_json::Value>,
output: &mut dyn Output,
state: &Arc<AppState>,
sid: &str,
config: &Arc<Config>,
chat_id: i64,
) -> Result<String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.unwrap();
let url = format!("{}/chat/completions", endpoint.trim_end_matches('/'));
let tools = discover_tools();
loop {
let body = serde_json::json!({
"model": model,
"messages": messages,
"tools": tools,
"stream": true,
});
info!("API request: {} messages, {} tools",
messages.len(),
tools.as_array().map(|a| a.len()).unwrap_or(0));
let resp_raw = client
.post(&url)
.header("Authorization", format!("Bearer {api_key}"))
.json(&body)
.send()
.await?;
if !resp_raw.status().is_success() {
let status = resp_raw.status();
let body_text = resp_raw.text().await.unwrap_or_default();
// log failed API call
let req_json = serde_json::to_string(&body).unwrap_or_default();
state.log_api(sid, &req_json, &body_text, status.as_u16()).await;
for (i, m) in messages.iter().enumerate() {
let role = m["role"].as_str().unwrap_or("?");
let content_len = m["content"].as_str().map(|s| s.len()).unwrap_or(0);
let has_tc = m.get("tool_calls").is_some();
let has_tcid = m.get("tool_call_id").is_some();
warn!(" msg[{i}] role={role} content_len={content_len} tool_calls={has_tc} tool_call_id={has_tcid}");
}
error!("OpenAI API {status}: {body_text}");
anyhow::bail!("OpenAI API {status}: {body_text}");
}
let mut resp = resp_raw;
let mut accumulated = String::new();
let mut buffer = String::new();
let mut done = false;
// tool call accumulation
let mut tool_calls: Vec<ToolCall> = Vec::new();
let mut has_tool_calls = false;
while let Some(chunk) = resp.chunk().await? {
if done {
break;
}
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].to_string();
buffer = buffer[pos + 1..].to_string();
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with(':') {
continue;
}
let data = match trimmed.strip_prefix("data: ") {
Some(d) => d,
None => continue,
};
if data.trim() == "[DONE]" {
done = true;
break;
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(data) {
let delta = &json["choices"][0]["delta"];
if let Some(content) = delta["content"].as_str() {
if !content.is_empty() {
accumulated.push_str(content);
}
}
if let Some(tc_arr) = delta["tool_calls"].as_array() {
has_tool_calls = true;
for tc in tc_arr {
let idx = tc["index"].as_u64().unwrap_or(0) as usize;
while tool_calls.len() <= idx {
tool_calls.push(ToolCall {
id: String::new(),
name: String::new(),
arguments: String::new(),
});
}
if let Some(id) = tc["id"].as_str() {
tool_calls[idx].id = id.to_string();
}
if let Some(name) = tc["function"]["name"].as_str() {
tool_calls[idx].name = name.to_string();
}
if let Some(args) = tc["function"]["arguments"].as_str() {
tool_calls[idx].arguments.push_str(args);
}
}
}
if !accumulated.is_empty() {
let _ = output.stream_update(&accumulated).await;
}
}
}
}
// decide what to do based on response type
if has_tool_calls && !tool_calls.is_empty() {
let tc_json: Vec<serde_json::Value> = tool_calls
.iter()
.map(|tc| {
serde_json::json!({
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": tc.arguments,
}
})
})
.collect();
let assistant_msg = serde_json::json!({
"role": "assistant",
"content": if accumulated.is_empty() { "" } else { &accumulated },
"tool_calls": tc_json,
});
messages.push(assistant_msg);
for tc in &tool_calls {
info!(tool = %tc.name, "executing tool call");
let args_preview = truncate_at_char_boundary(&tc.arguments, 200);
let _ = output
.status(&format!("### `{}`\n```json\n{args_preview}\n```", tc.name))
.await;
let result =
execute_tool(&tc.name, &tc.arguments, state, output, sid, config, chat_id)
.await;
let result_preview = truncate_at_char_boundary(&result, 500);
let _ = output
.status(&format!("**Result** ({} bytes)\n```\n{result_preview}\n```\n---", result.len()))
.await;
messages.push(serde_json::json!({
"role": "tool",
"tool_call_id": tc.id,
"content": result,
}));
}
tool_calls.clear();
continue;
}
// strip timestamps that LLM copies from our message format
let cleaned = strip_leading_timestamp(&accumulated).to_string();
if !cleaned.is_empty() {
let _ = output.finalize(&cleaned).await;
}
return Ok(cleaned);
}
}
pub fn build_system_prompt(summary: &str, persona: &str, memory_slots: &[(i32, String, String)], inner_state: &str) -> serde_json::Value {
let mut text = if persona.is_empty() {
String::from("你是一个AI助手。")
} else {
persona.to_string()
};
text.push_str(
"\n\n你可以使用提供的工具来完成任务。\
当需要执行命令、运行代码或启动复杂子任务时,直接调用对应的工具,不要只是描述你会怎么做。\
当需要搜索信息(如网页搜索、资料查找、技术调研等)时,使用 spawn_agent 启动一个子代理来完成搜索任务,\
子代理可以使用浏览器和搜索引擎,搜索完成后你会收到结果通知。\
输出格式使用纯文本或基础Markdown加粗、列表、代码块\
不要使用LaTeX公式$...$、特殊Unicode符号→←↔或HTML标签Telegram无法渲染这些。\
不要在回复开头加时间戳——用户消息前的时间戳是系统自动添加的,不需要你模仿。",
);
if !memory_slots.is_empty() {
text.push_str(
"\n\n## 持久记忆(跨会话保留,你可以用 update_memory 工具管理)\n\
槽位 0-9: 事实(位置/偏好/习惯)\n\
槽位 10-19: 重要时刻\n\
槽位 20-29: 情感经验\n\
槽位 30-39: 你自己的成长\n\
槽位 40-99: 自由使用\n\
发现重要信息时主动更新,过时的要清理。\n\n",
);
for (nr, content, updated_at) in memory_slots {
if updated_at.is_empty() {
text.push_str(&format!("[{nr}] {content}\n"));
} else {
text.push_str(&format!("[{nr}] {content} ({updated_at})\n"));
}
}
}
if !inner_state.is_empty() {
text.push_str("\n\n## 你的内在状态(你可以用 update_inner_state 工具更新)\n");
text.push_str(inner_state);
}
// inject context file if present (e.g. /data/noc/context.md)
let config_path = std::env::var("NOC_CONFIG").unwrap_or_else(|_| "config.yaml".into());
let context_path = std::path::Path::new(&config_path)
.parent()
.unwrap_or(std::path::Path::new("."))
.join("context.md");
if let Ok(ctx) = std::fs::read_to_string(&context_path) {
if !ctx.trim().is_empty() {
text.push_str("\n\n## 运行环境\n");
text.push_str(ctx.trim());
}
}
if !summary.is_empty() {
text.push_str("\n\n## 之前的对话总结\n");
text.push_str(summary);
}
serde_json::json!({"role": "system", "content": text})
}
pub async fn summarize_messages(
endpoint: &str,
model: &str,
api_key: &str,
existing_summary: &str,
dropped: &[serde_json::Value],
) -> Result<String> {
let msgs_text: String = dropped
.iter()
.filter_map(|m| {
let role = m["role"].as_str()?;
let content = m["content"].as_str()?;
Some(format!("{role}: {content}"))
})
.collect::<Vec<_>>()
.join("\n\n");
let prompt = if existing_summary.is_empty() {
format!(
"请将以下对话总结为约4000字符的摘要保留关键信息和上下文\n\n{}",
msgs_text
)
} else {
format!(
"请将以下新对话内容整合到现有总结中保持总结在约4000字符以内。\
保留重要信息,让较旧的话题自然淡出。\n\n\
现有总结:\n{}\n\n新对话:\n{}",
existing_summary, msgs_text
)
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.unwrap();
let url = format!("{}/chat/completions", endpoint.trim_end_matches('/'));
let body = serde_json::json!({
"model": model,
"messages": [
{"role": "system", "content": "你是一个对话总结助手。请生成简洁但信息丰富的总结。"},
{"role": "user", "content": prompt}
],
});
let resp = client
.post(&url)
.header("Authorization", format!("Bearer {api_key}"))
.json(&body)
.send()
.await?
.error_for_status()?;
let json: serde_json::Value = resp.json().await?;
let summary = json["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("")
.to_string();
Ok(summary)
}

866
src/tools.rs Normal file
View File

@@ -0,0 +1,866 @@
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use anyhow::Result;
use tokio::io::AsyncBufReadExt;
use tokio::process::Command;
use tokio::sync::RwLock;
use tracing::{info, warn};
use crate::config::Config;
use crate::display::truncate_at_char_boundary;
use crate::output::Output;
use crate::state::AppState;
// ── subagent & tool call ───────────────────────────────────────────
pub struct SubAgent {
pub task: String,
pub output: Arc<RwLock<String>>,
pub completed: Arc<AtomicBool>,
pub exit_code: Arc<RwLock<Option<i32>>>,
pub pid: Option<u32>,
}
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
pub fn tools_dir() -> PathBuf {
// tools/ relative to the config file location
let config_path = std::env::var("NOC_CONFIG").unwrap_or_else(|_| "config.yaml".into());
let config_dir = Path::new(&config_path)
.parent()
.unwrap_or(Path::new("."));
config_dir.join("tools")
}
/// Scan tools/ directory for scripts with --schema, merge with built-in tools.
/// Called on every API request so new/updated scripts take effect immediately.
pub fn discover_tools() -> serde_json::Value {
let mut tools = vec![
serde_json::json!({
"type": "function",
"function": {
"name": "spawn_agent",
"description": "启动一个 Claude Code 子代理异步执行复杂任务。子代理可使用 shell、浏览器和搜索引擎适合网页搜索、资料查找、技术调研、代码任务等。完成后会收到通知。",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "简短唯一标识符(如 'research'、'fix-bug'"},
"task": {"type": "string", "description": "给子代理的详细任务描述"}
},
"required": ["id", "task"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "agent_status",
"description": "查看正在运行或已完成的子代理的状态和输出",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "子代理标识符"}
},
"required": ["id"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "kill_agent",
"description": "终止一个正在运行的子代理",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "子代理标识符"}
},
"required": ["id"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "send_file",
"description": "通过 Telegram 向用户发送服务器上的文件,文件必须存在于服务器文件系统中。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "服务器上文件的绝对路径"},
"caption": {"type": "string", "description": "可选的文件说明/描述"}
},
"required": ["path"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "update_inner_state",
"description": "更新你的内在状态。这是你自己的持续意识跨会话保留Life Loop 和对话都能看到。记录你对当前情况的理解、正在跟踪的事、对 Fam 状态的感知等。",
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "完整的内在状态文本(替换之前的)"}
},
"required": ["content"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "update_scratch",
"description": "更新你的草稿区(工作笔记、状态、提醒)。草稿区内容会附加到每条用户消息中,确保你始终可见。用于跨轮次跟踪上下文。",
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "完整的草稿区内容(替换之前的内容)"}
},
"required": ["content"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "set_timer",
"description": "Set a timer that will fire in the future. Supports: '5min'/'2h' (relative), 'once:2026-04-10 09:00' (absolute), 'cron:0 8 * * *' (recurring). When fired, you'll receive the label as a prompt.",
"parameters": {
"type": "object",
"properties": {
"schedule": {"type": "string", "description": "Timer schedule: e.g. '5min', '1h', 'once:2026-04-10 09:00', 'cron:30 8 * * *'"},
"label": {"type": "string", "description": "What this timer is for — this text will be sent to you when it fires"}
},
"required": ["schedule", "label"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "list_timers",
"description": "List all active timers",
"parameters": {
"type": "object",
"properties": {},
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "cancel_timer",
"description": "Cancel a timer by ID",
"parameters": {
"type": "object",
"properties": {
"timer_id": {"type": "integer", "description": "Timer ID from list_timers"}
},
"required": ["timer_id"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "update_memory",
"description": "写入持久记忆槽。共 100 个槽位0-99跨会话保留。记忆槽内容会注入到每次对话的 system prompt 中。用于存储关键事实、用户偏好或重要上下文。内容设为空字符串可清除槽位。",
"parameters": {
"type": "object",
"properties": {
"slot_nr": {"type": "integer", "description": "槽位编号0-99"},
"content": {"type": "string", "description": "要存储的内容最多200字符空字符串表示清除该槽位"}
},
"required": ["slot_nr", "content"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "gen_voice",
"description": "将文字合成为语音并直接发送给用户。",
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "要合成语音的文字内容"}
},
"required": ["text"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "run_shell",
"description": "在服务器上执行 shell 命令。可执行任意 bash 命令,支持管道和重定向。超时 60 秒。",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "要执行的 shell 命令"},
"timeout": {"type": "integer", "description": "超时秒数(默认 60最大 300"}
},
"required": ["command"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "run_python",
"description": "用 uv run 执行 Python 代码。支持 inline dependencies通过 deps 参数自动安装),无需手动管理虚拟环境。超时 120 秒。",
"parameters": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "要执行的 Python 代码"},
"deps": {
"type": "array",
"items": {"type": "string"},
"description": "依赖包列表(如 [\"requests\", \"pandas\"]),会自动通过 uv 安装"
},
"timeout": {"type": "integer", "description": "超时秒数(默认 120最大 300"}
},
"required": ["code"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "write_file",
"description": "将内容写入服务器上的文件。如果文件已存在会被覆盖,目录不存在会自动创建。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件的绝对路径"},
"content": {"type": "string", "description": "要写入的完整内容"}
},
"required": ["path", "content"]
}
}
}),
serde_json::json!({
"type": "function",
"function": {
"name": "call_gitea_api",
"description": "调用 Gitea REST API。以 noc_bot 身份操作,拥有 admin 权限。可管理 repo、issue、PR、comment、webhook 等一切。",
"parameters": {
"type": "object",
"properties": {
"method": {"type": "string", "description": "HTTP method: GET, POST, PATCH, PUT, DELETE"},
"path": {"type": "string", "description": "API path after /api/v1/, e.g. repos/noc/myrepo/issues"},
"body": {"type": "object", "description": "Optional JSON body for POST/PATCH/PUT"}
},
"required": ["method", "path"]
}
}
}),
];
// discover script tools
let dir = tools_dir();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
// run --schema with a short timeout
let output = std::process::Command::new(&path)
.arg("--schema")
.output();
match output {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout);
match serde_json::from_str::<serde_json::Value>(stdout.trim()) {
Ok(schema) => {
let name = schema["name"].as_str().unwrap_or("?");
info!(tool = %name, path = %path.display(), "discovered script tool");
tools.push(serde_json::json!({
"type": "function",
"function": schema,
}));
}
Err(e) => {
warn!(path = %path.display(), "invalid --schema JSON: {e}");
}
}
}
_ => {} // not a tool script, skip silently
}
}
}
serde_json::Value::Array(tools)
}
// ── tool execution ─────────────────────────────────────────────────
pub async fn execute_tool(
name: &str,
arguments: &str,
state: &Arc<AppState>,
output: &mut dyn Output,
sid: &str,
config: &Arc<Config>,
chat_id: i64,
) -> String {
let args: serde_json::Value = match serde_json::from_str(arguments) {
Ok(v) => v,
Err(e) => return format!("Invalid arguments: {e}"),
};
match name {
"spawn_agent" => {
let id = args["id"].as_str().unwrap_or("agent");
let task = args["task"].as_str().unwrap_or("");
spawn_agent(id, task, state, output, sid, config, chat_id).await
}
"agent_status" => {
let id = args["id"].as_str().unwrap_or("");
check_agent_status(id, state).await
}
"kill_agent" => {
let id = args["id"].as_str().unwrap_or("");
kill_agent(id, state).await
}
"send_file" => {
let path_str = args["path"].as_str().unwrap_or("");
let caption = args["caption"].as_str().unwrap_or("");
let path = Path::new(path_str);
if !path.exists() {
return format!("File not found: {path_str}");
}
if !path.is_file() {
return format!("Not a file: {path_str}");
}
match output.send_file(path, caption).await {
Ok(true) => format!("File sent: {path_str}"),
Ok(false) => format!("File sending not supported in this context: {path_str}"),
Err(e) => format!("Failed to send file: {e:#}"),
}
}
"update_inner_state" => {
let content = args["content"].as_str().unwrap_or("");
state.set_inner_state(content).await;
format!("Inner state updated ({} chars)", content.len())
}
"update_scratch" => {
let content = args["content"].as_str().unwrap_or("");
state.push_scratch(content).await;
format!("Scratch updated ({} chars)", content.len())
}
"set_timer" => {
let schedule = args["schedule"].as_str().unwrap_or("");
let label = args["label"].as_str().unwrap_or("");
match parse_next_fire(schedule) {
Ok(next) => {
let next_str = next.format("%Y-%m-%d %H:%M:%S").to_string();
let id = state
.add_timer(chat_id, label, schedule, &next_str)
.await;
format!("Timer #{id} set: \"{label}\" → next fire at {next_str}")
}
Err(e) => format!("Invalid schedule '{schedule}': {e}"),
}
}
"list_timers" => {
let timers = state.list_timers(Some(chat_id)).await;
if timers.is_empty() {
"No active timers.".to_string()
} else {
timers
.iter()
.map(|(id, _, label, sched, next, enabled)| {
let status = if *enabled { "" } else { " [disabled]" };
format!("#{id}: \"{label}\" ({sched}) → {next}{status}")
})
.collect::<Vec<_>>()
.join("\n")
}
}
"cancel_timer" => {
let tid = args["timer_id"].as_i64().unwrap_or(0);
if state.cancel_timer(tid).await {
format!("Timer #{tid} cancelled")
} else {
format!("Timer #{tid} not found")
}
}
"update_memory" => {
let slot_nr = args["slot_nr"].as_i64().unwrap_or(-1) as i32;
let content = args["content"].as_str().unwrap_or("");
match state.set_memory_slot(slot_nr, content).await {
Ok(_) => {
if content.is_empty() {
format!("Memory slot {slot_nr} cleared")
} else {
format!("Memory slot {slot_nr} updated ({} chars)", content.len())
}
}
Err(e) => format!("Error: {e}"),
}
}
"run_shell" => {
let cmd = args["command"].as_str().unwrap_or("");
if cmd.is_empty() {
return "Error: command is required".to_string();
}
let timeout_secs = args["timeout"].as_u64().unwrap_or(60).min(300);
info!(cmd = %cmd, "run_shell");
let result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
Command::new("bash")
.args(["-c", cmd])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output(),
)
.await;
match result {
Ok(Ok(out)) => {
let mut s = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr);
if !stderr.is_empty() {
if !s.is_empty() {
s.push_str("\n[stderr]\n");
}
s.push_str(&stderr);
}
let exit = out.status.code().unwrap_or(-1);
if s.len() > 8000 {
s = format!("{}...(truncated)", &s[..8000]);
}
if exit != 0 {
s.push_str(&format!("\n[exit={exit}]"));
}
if s.is_empty() {
format!("(exit={exit})")
} else {
s
}
}
Ok(Err(e)) => format!("exec error: {e}"),
Err(_) => format!("timeout after {timeout_secs}s"),
}
}
"run_python" => {
let code = args["code"].as_str().unwrap_or("");
if code.is_empty() {
return "Error: code is required".to_string();
}
let timeout_secs = args["timeout"].as_u64().unwrap_or(120).min(300);
let deps = args["deps"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_default();
// Build uv run command with inline script metadata for deps
let script = if deps.is_empty() {
code.to_string()
} else {
let dep_lines: String = deps.iter().map(|d| format!("# \"{d}\",\n")).collect();
format!(
"# /// script\n# [project]\n# dependencies = [\n{dep_lines}# ]\n# ///\n{code}"
)
};
// Write script to temp file
let tmp = format!("/tmp/noc_py_{}.py", std::process::id());
if let Err(e) = std::fs::write(&tmp, &script) {
return format!("Failed to write temp script: {e}");
}
info!(deps = ?deps, "run_python");
let result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
Command::new("uv")
.args(["run", &tmp])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output(),
)
.await;
let _ = std::fs::remove_file(&tmp);
match result {
Ok(Ok(out)) => {
let mut s = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr);
if !stderr.is_empty() {
if !s.is_empty() {
s.push_str("\n[stderr]\n");
}
s.push_str(&stderr);
}
let exit = out.status.code().unwrap_or(-1);
if s.len() > 8000 {
s = format!("{}...(truncated)", &s[..8000]);
}
if exit != 0 {
s.push_str(&format!("\n[exit={exit}]"));
}
if s.is_empty() {
format!("(exit={exit})")
} else {
s
}
}
Ok(Err(e)) => format!("exec error: {e} (is uv installed?)"),
Err(_) => format!("timeout after {timeout_secs}s"),
}
}
"write_file" => {
let path_str = args["path"].as_str().unwrap_or("");
let content = args["content"].as_str().unwrap_or("");
if path_str.is_empty() {
return "Error: path is required".to_string();
}
let path = Path::new(path_str);
if let Some(parent) = path.parent() {
if !parent.exists() {
if let Err(e) = std::fs::create_dir_all(parent) {
return format!("Failed to create directory: {e}");
}
}
}
match std::fs::write(path, content) {
Ok(_) => format!("Written {} bytes to {path_str}", content.len()),
Err(e) => format!("Failed to write {path_str}: {e}"),
}
}
"call_gitea_api" => {
let method = args["method"].as_str().unwrap_or("GET").to_uppercase();
let path = args["path"].as_str().unwrap_or("").trim_start_matches('/');
let body = args.get("body");
let gitea_config = match &config.gitea {
Some(c) => c,
None => return "Gitea not configured".to_string(),
};
let url = format!("{}/api/v1/{}", gitea_config.url.trim_end_matches('/'), path);
let client = reqwest::Client::new();
let mut req = match method.as_str() {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PATCH" => client.patch(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
_ => return format!("Unsupported method: {method}"),
};
req = req.header("Authorization", format!("token {}", gitea_config.token));
if let Some(b) = body {
req = req.json(b);
}
match req.send().await {
Ok(resp) => {
let status = resp.status().as_u16();
let text = resp.text().await.unwrap_or_default();
// Truncate large responses
let text = if text.len() > 4000 {
format!("{}...(truncated)", &text[..4000])
} else {
text
};
format!("HTTP {status}\n{text}")
}
Err(e) => format!("Request failed: {e:#}"),
}
}
"gen_voice" => {
let text = args["text"].as_str().unwrap_or("");
info!("gen_voice text={:?} args={}", text, truncate_at_char_boundary(arguments, 200));
if text.is_empty() {
return "Error: text is required".to_string();
}
let script = tools_dir().join("gen_voice");
let result = tokio::time::timeout(
std::time::Duration::from_secs(120),
tokio::process::Command::new(&script)
.arg(arguments)
.output(),
)
.await;
match result {
Ok(Ok(out)) if out.status.success() => {
let path_str = String::from_utf8_lossy(&out.stdout).trim().to_string();
let path = Path::new(&path_str);
if path.exists() {
match output.send_file(path, "").await {
Ok(true) => format!("语音已发送: {path_str}"),
Ok(false) => format!("语音生成成功但当前通道不支持发送文件: {path_str}"),
Err(e) => format!("语音生成成功但发送失败: {e:#}"),
}
} else {
format!("语音生成失败: 输出文件不存在 ({path_str})")
}
}
Ok(Ok(out)) => {
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
warn!("gen_voice failed (exit={}): stdout={stdout} stderr={stderr}", out.status.code().unwrap_or(-1));
format!("gen_voice failed: {stdout} {stderr}")
}
Ok(Err(e)) => {
warn!("gen_voice exec error: {e}");
format!("gen_voice exec error: {e}")
}
Err(_) => "gen_voice timeout (120s)".to_string(),
}
}
_ => run_script_tool(name, arguments).await,
}
}
pub async fn spawn_agent(
id: &str,
task: &str,
state: &Arc<AppState>,
output: &mut dyn Output,
sid: &str,
_config: &Arc<Config>,
chat_id: i64,
) -> String {
// check if already exists
if state.agents.read().await.contains_key(id) {
return format!("Agent '{id}' already exists. Use agent_status to check it.");
}
let mut child = match Command::new("claude")
.args(["--dangerously-skip-permissions", "-p", task])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => return format!("Failed to spawn agent: {e}"),
};
let pid = child.id();
let agent_output = Arc::new(tokio::sync::RwLock::new(String::new()));
let completed = Arc::new(AtomicBool::new(false));
let exit_code = Arc::new(tokio::sync::RwLock::new(None));
let agent = Arc::new(SubAgent {
task: task.to_string(),
output: agent_output.clone(),
completed: completed.clone(),
exit_code: exit_code.clone(),
pid,
});
state.agents.write().await.insert(id.to_string(), agent);
// background task: collect output, then send event to life loop
let out = agent_output.clone();
let done = completed.clone();
let ecode = exit_code.clone();
let id_c = id.to_string();
let task_c = task.to_string();
let life_tx = state.life_tx.clone();
let sid_c = sid.to_string();
tokio::spawn(async move {
let stdout = child.stdout.take();
if let Some(stdout) = stdout {
let mut lines = tokio::io::BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
let mut o = out.write().await;
o.push_str(&line);
o.push('\n');
}
}
let status = child.wait().await;
let code = status.as_ref().ok().and_then(|s| s.code());
*ecode.write().await = code;
done.store(true, Ordering::SeqCst);
info!(agent = %id_c, "agent completed, exit={code:?}");
let output_text = out.read().await.clone();
let _ = life_tx.send(crate::life::LifeEvent::AgentDone {
id: id_c,
chat_id,
session_id: sid_c,
task: task_c,
output: output_text,
exit_code: code,
}).await;
});
let _ = output.status(&format!("Agent '{id}' spawned (pid={pid:?})")).await;
format!("Agent '{id}' spawned (pid={pid:?})")
}
pub async fn check_agent_status(id: &str, state: &AppState) -> String {
let agents = state.agents.read().await;
match agents.get(id) {
Some(agent) => {
let status = if agent.completed.load(Ordering::SeqCst) {
let code = agent.exit_code.read().await;
format!("completed (exit={})", code.unwrap_or(-1))
} else {
"running".to_string()
};
let output = agent.output.read().await;
let out_preview = truncate_at_char_boundary(&output, 3000);
format!(
"Agent '{id}': {status}\nTask: {}\nOutput ({} bytes):\n{out_preview}",
agent.task,
output.len()
)
}
None => format!("Agent '{id}' not found"),
}
}
pub async fn kill_agent(id: &str, state: &AppState) -> String {
let agents = state.agents.read().await;
match agents.get(id) {
Some(agent) => {
if agent.completed.load(Ordering::SeqCst) {
return format!("Agent '{id}' already completed");
}
if let Some(pid) = agent.pid {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
format!("Sent SIGTERM to agent '{id}' (pid={pid})")
} else {
format!("Agent '{id}' has no PID")
}
}
None => format!("Agent '{id}' not found"),
}
}
pub async fn run_script_tool(name: &str, arguments: &str) -> String {
// find script in tools/ that matches this tool name
let dir = tools_dir();
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => return format!("Unknown tool: {name}"),
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
// check if this script provides the requested tool
let schema_out = std::process::Command::new(&path)
.arg("--schema")
.output();
if let Ok(out) = schema_out {
if out.status.success() {
let stdout = String::from_utf8_lossy(&out.stdout);
if let Ok(schema) = serde_json::from_str::<serde_json::Value>(stdout.trim()) {
if schema["name"].as_str() == Some(name) {
// found it — execute
info!(tool = %name, path = %path.display(), "running script tool");
let result = tokio::time::timeout(
std::time::Duration::from_secs(60),
Command::new(&path).arg(arguments).output(),
)
.await;
return match result {
Ok(Ok(output)) => {
let mut s = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
if !s.is_empty() {
s.push_str("\n[stderr]\n");
}
s.push_str(&stderr);
}
if s.is_empty() {
format!("(exit={})", output.status.code().unwrap_or(-1))
} else {
s
}
}
Ok(Err(e)) => format!("Failed to execute {name}: {e}"),
Err(_) => "Timeout after 60s".to_string(),
};
}
}
}
}
}
format!("Unknown tool: {name}")
}
// ── schedule parsing ───────────────────────────────────────────────
pub fn parse_next_fire(schedule: &str) -> Result<chrono::DateTime<chrono::Local>> {
let now = chrono::Local::now();
// relative: "5min", "2h", "30s", "1d"
if let Some(val) = schedule
.strip_suffix("min")
.or_else(|| schedule.strip_suffix("m"))
{
let mins: i64 = val.trim().parse().map_err(|e| anyhow::anyhow!("{e}"))?;
return Ok(now + chrono::Duration::minutes(mins));
}
if let Some(val) = schedule.strip_suffix('h') {
let hours: i64 = val.trim().parse().map_err(|e| anyhow::anyhow!("{e}"))?;
return Ok(now + chrono::Duration::hours(hours));
}
if let Some(val) = schedule.strip_suffix('s') {
let secs: i64 = val.trim().parse().map_err(|e| anyhow::anyhow!("{e}"))?;
return Ok(now + chrono::Duration::seconds(secs));
}
if let Some(val) = schedule.strip_suffix('d') {
let days: i64 = val.trim().parse().map_err(|e| anyhow::anyhow!("{e}"))?;
return Ok(now + chrono::Duration::days(days));
}
// absolute: "once:2026-04-10 09:00"
if let Some(dt_str) = schedule.strip_prefix("once:") {
let dt = chrono::NaiveDateTime::parse_from_str(dt_str.trim(), "%Y-%m-%d %H:%M")
.or_else(|_| {
chrono::NaiveDateTime::parse_from_str(dt_str.trim(), "%Y-%m-%d %H:%M:%S")
})
.map_err(|e| anyhow::anyhow!("parse datetime: {e}"))?;
return Ok(dt.and_local_timezone(chrono::Local).unwrap());
}
// cron: "cron:30 8 * * *"
if let Some(expr) = schedule.strip_prefix("cron:") {
let cron_schedule = expr
.trim()
.parse::<cron::Schedule>()
.map_err(|e| anyhow::anyhow!("parse cron: {e}"))?;
let next = cron_schedule
.upcoming(chrono::Local)
.next()
.ok_or_else(|| anyhow::anyhow!("no upcoming time for cron"))?;
return Ok(next);
}
anyhow::bail!("unknown schedule format: {schedule}")
}
pub fn compute_next_cron_fire(schedule: &str) -> Option<String> {
let expr = schedule.strip_prefix("cron:")?;
let cron_schedule = expr.trim().parse::<cron::Schedule>().ok()?;
let next = cron_schedule.upcoming(chrono::Local).next()?;
Some(next.format("%Y-%m-%d %H:%M:%S").to_string())
}

View File

@@ -25,6 +25,7 @@ fn tools() -> serde_json::Value {
/// Test non-streaming tool call round-trip
#[tokio::test]
#[ignore] // requires Ollama on ailab
async fn test_tool_call_roundtrip_non_streaming() {
let client = reqwest::Client::new();
let url = format!("{OLLAMA_URL}/chat/completions");
@@ -101,6 +102,7 @@ async fn test_tool_call_roundtrip_non_streaming() {
/// Test tool call with conversation history (simulates real scenario)
#[tokio::test]
#[ignore] // requires Ollama on ailab
async fn test_tool_call_with_history() {
let client = reqwest::Client::new();
let url = format!("{OLLAMA_URL}/chat/completions");
@@ -199,6 +201,7 @@ async fn test_tool_call_with_history() {
/// Test multimodal image input
#[tokio::test]
#[ignore] // requires Ollama on ailab
async fn test_image_multimodal() {
let client = reqwest::Client::new();
let url = format!("{OLLAMA_URL}/chat/completions");
@@ -232,6 +235,7 @@ async fn test_image_multimodal() {
/// Test streaming tool call round-trip (matches our actual code path)
#[tokio::test]
#[ignore] // requires Ollama on ailab
async fn test_tool_call_roundtrip_streaming() {
let client = reqwest::Client::new();
let url = format!("{OLLAMA_URL}/chat/completions");

152
tools/gen_voice Executable file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["requests"]
# ///
"""Generate voice audio using IndexTTS2 with a fixed reference voice.
Usage:
./gen_voice --schema
./gen_voice '{"text":"你好世界"}'
./gen_voice 你好世界
"""
import json
import os
import sys
import time
import requests
INDEXTTS_URL = "http://100.107.41.75:7860"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REF_AUDIO = os.path.join(SCRIPT_DIR, "..", "assets", "ref_voice.mp3")
OUTPUT_DIR = os.path.expanduser("~/down")
# cache the uploaded ref path to avoid re-uploading
_CACHE_FILE = "/tmp/noc_gen_voice_ref_cache.json"
SCHEMA = {
"name": "gen_voice",
"description": "Generate speech audio from text using voice cloning (IndexTTS2). Returns the file path of the generated wav. Use send_file to send it to the user.",
"parameters": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The text to synthesize into speech",
},
},
"required": ["text"],
},
}
def get_ref_path():
"""Upload ref audio once, cache the server-side path. Invalidate if server restarted."""
# check cache — validate against server uptime
if os.path.exists(_CACHE_FILE):
try:
with open(_CACHE_FILE) as f:
cache = json.load(f)
# quick health check — if server is up and path exists, reuse
r = requests.head(f"{INDEXTTS_URL}/gradio_api/file={cache['path']}", timeout=3)
if r.status_code == 200:
return cache["path"]
except Exception:
pass
# upload
with open(REF_AUDIO, "rb") as f:
resp = requests.post(f"{INDEXTTS_URL}/gradio_api/upload", files={"files": f})
resp.raise_for_status()
ref_path = resp.json()[0]
# cache
with open(_CACHE_FILE, "w") as f:
json.dump({"path": ref_path}, f)
return ref_path
def synthesize(text):
ref = get_ref_path()
file_data = {"path": ref, "meta": {"_type": "gradio.FileData"}}
# submit job
resp = requests.post(
f"{INDEXTTS_URL}/gradio_api/call/synthesize",
json={
"data": [
text,
file_data, # spk_audio
file_data, # emo_audio
0.5, # emo_alpha
0, 0, 0, 0, 0, 0, 0, 0.8, # emotions (calm=0.8)
False, # use_emo_text
"", # emo_text
False, # use_random
]
},
)
resp.raise_for_status()
event_id = resp.json()["event_id"]
# poll result via SSE
result_resp = requests.get(
f"{INDEXTTS_URL}/gradio_api/call/synthesize/{event_id}", stream=True
)
for line in result_resp.iter_lines(decode_unicode=True):
if line.startswith("data: "):
data = json.loads(line[6:])
if isinstance(data, list) and data:
url = data[0].get("url", "")
if url:
# download the wav
wav = requests.get(url)
wav.raise_for_status()
os.makedirs(OUTPUT_DIR, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
out_path = os.path.join(OUTPUT_DIR, f"tts_{ts}.wav")
with open(out_path, "wb") as f:
f.write(wav.content)
return out_path
elif data is None:
raise RuntimeError("TTS synthesis failed (server returned null)")
raise RuntimeError("No result received from TTS server")
def main():
if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h"):
print(__doc__.strip())
sys.exit(0)
if sys.argv[1] == "--schema":
print(json.dumps(SCHEMA, ensure_ascii=False))
sys.exit(0)
arg = sys.argv[1]
if not arg.startswith("{"):
text = " ".join(sys.argv[1:])
else:
try:
args = json.loads(arg)
text = args.get("text", "")
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}")
sys.exit(1)
if not text:
print("Error: text is required")
sys.exit(1)
try:
path = synthesize(text)
print(path)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -19,7 +19,7 @@ import sys
import requests
APP_ID = "cli_a7f042e93d385013"
APP_SECRET = "ht4FCjQ8JJ65ZPUWlff6ldFBmaP0mxqY"
APP_SECRET = "6V3t5bFK4vRKsEG3VD6sQdAu2rmFEr2S"
APP_TOKEN = "SSoGbmGFoazJkUs7bbfcaSG8n7f"
TABLE_ID = "tblIA2biceDpvr35"
BASE_URL = "https://open.feishu.cn/open-apis"