feat: Docker 容器化、Cookie 鉴权、Coordinator 编排、FTS5 搜索与 P1-P3 全面收尾
Docker 容器化部署
- 提供 Mode A (Alpine musl, ~23MB) 和 Mode B (Distroless glibc, ~87MB)
两种镜像,Docker Compose 一键启动
- build.rs 支持 SKIP_DASHBOARD_BUILD 跳过前端构建
- 国内镜像加速 (npm/apt/apk) 通过 USE_MIRRORS build-arg 控制
安全:Cookie-Based 鉴权系统
- HttpOnly/SameSite=Strict Cookie 会话管理(24h 过期自动清理)
- 登录/登出/验证接口 + 中间件注入
- 前端登录页面 + 退出按钮
- 三层 CORS:localhost 鉴权 / 全放通 bookmarklet / 受保护路由
- 书签脚本 fetch 添加 credentials:'include'
Coordinator 模式 (P2)
- 4 个 meta-tool (delegate_task/check_task/task_stop/synthesize)
- WorkerPool + Semaphore 并发控制 + 超时保护
- 前端协调者模式开关
Hook 系统:UserPromptSubmit 事件 (P2)
- 第 13 个生命周期事件,fire-and-forget 审计
FTS5 全文搜索 (P3)
- agent_sessions_fts + agent_messages_fts 虚拟表
- search_history Agent 工具 + /api/search/history HTTP 接口
- 前端防抖搜索框 + 仅当前会话筛选
工具加载优化 (P3)
- defer_loading 延迟加载 (7 个重型工具)
- is_readonly 只读标记 (9 个查询工具)
- classifier_summary 工具目录供 LLM 按需判断
模型回退策略 (P3)
- LLM_FALLBACK_MODEL 优先回退 + LLM_FALLBACK_CHAIN 链式轮换
- LlmClient model 改为 Arc<RwLock> 支持运行时切换
- 连续 3 次过载后自动切换
压缩记忆桥接 (P3)
- 压缩丢弃消息 → 子代理提取持久记忆 (extract_memories_from_compaction)
git2 依赖修复
- 切换到 vendored-libgit2,消除 OpenSSL 系统依赖
This commit is contained in:
parent
698d007f39
commit
cec4b8cf7b
42
.dockerignore
Normal file
42
.dockerignore
Normal file
@ -0,0 +1,42 @@
|
||||
# Rust build artifacts
|
||||
target/
|
||||
**/*.rs.bk
|
||||
|
||||
# Frontend
|
||||
dashboard/node_modules/
|
||||
dashboard/dist/
|
||||
|
||||
# Local data (mounted as volumes at runtime)
|
||||
library/
|
||||
logs/
|
||||
|
||||
# Environment (injected at runtime)
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# IDE / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.swp
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
|
||||
# OMC / Claude
|
||||
.claude/
|
||||
.omc/
|
||||
|
||||
# Docs (not needed at runtime)
|
||||
docs/
|
||||
|
||||
# Misc
|
||||
*.md
|
||||
!skills/**/*.md
|
||||
LICENSE
|
||||
18
.env.example
18
.env.example
@ -16,8 +16,10 @@ LLM_API_BASE=https://api.openai.com/v1
|
||||
# 可选模型示例:deepseek-chat, qwen-flash, gpt-4o-mini
|
||||
LLM_MODEL=gpt-4o-mini
|
||||
|
||||
# 备用模型(连续 3 次 529 过载后自动切换,可选)
|
||||
# FALLBACK_MODEL=gpt-4o-mini
|
||||
# 备用模型(连续 3 次 529 过载后自动切换,优先于 LLM_FALLBACK_CHAIN,可选)
|
||||
# LLM_FALLBACK_MODEL=gpt-4o-mini
|
||||
# 备用模型链(逗号分隔,按顺序轮换尝试,可选)
|
||||
# LLM_FALLBACK_CHAIN=gpt-4o-mini,deepseek-chat
|
||||
|
||||
# 向量嵌入模型配置(未设置时默认回退到 LLM 的 API Key 和 Base)
|
||||
# EMBEDDING_API_KEY=your_embedding_api_key_here
|
||||
@ -55,6 +57,8 @@ LIBRARY_DIR=./library
|
||||
SKILLS_DIR=./skills
|
||||
# 后端服务监听端口
|
||||
PORT=8000
|
||||
# 认证密码
|
||||
ADMIN_PASSWORD=fmq
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 5. 日志配置
|
||||
@ -88,6 +92,12 @@ LOG_DIR=./logs
|
||||
# 触发 snip 压缩的最大消息数(默认 50)
|
||||
# AGENT_MAX_MESSAGES=50
|
||||
|
||||
# ── P2 Coordinator Mode ──
|
||||
# Worker 最大并发数(默认 4)
|
||||
# AGENT_COORDINATOR_MAX_WORKERS=4
|
||||
# Worker 超时秒数(默认 300)
|
||||
# AGENT_COORDINATOR_WORKER_TIMEOUT=300
|
||||
|
||||
# ── 权限系统 ──
|
||||
# Agent 工具权限规则(逗号分隔,支持内容级匹配 "ToolName(pattern)")
|
||||
# 规则优先级: Deny > Ask > Allow(先匹配先生效)
|
||||
@ -113,10 +123,10 @@ LOG_DIR=./logs
|
||||
# AGENT_PERMISSIONS_ASK=run_bash,file_write,file_edit,download_paper
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 7. 自动记忆提取(实验性功能)
|
||||
# 7. 自动记忆提取(实验性功能,同时控制压缩记忆桥接 P3)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# 是否启用自动记忆提取(默认 false)
|
||||
# 是否启用自动记忆提取(默认 false,开启后会话结束 + 压缩时均触发提取)
|
||||
# EXTRACT_MEMORY_ENABLED=false
|
||||
# 记忆提取节流间隔:每 N 个 turn 触发一次(默认 3)
|
||||
# EXTRACT_MEMORY_THROTTLE_TURNS=3
|
||||
|
||||
36
Cargo.lock
generated
36
Cargo.lock
generated
@ -1468,8 +1468,6 @@ dependencies = [
|
||||
"libc",
|
||||
"libgit2-sys",
|
||||
"log",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"url",
|
||||
]
|
||||
|
||||
@ -2091,9 +2089,7 @@ checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"libssh2-sys",
|
||||
"libz-sys",
|
||||
"openssl-sys",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
@ -2136,20 +2132,6 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libssh2-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"libz-sys",
|
||||
"openssl-sys",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.29"
|
||||
@ -2593,24 +2575,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
|
||||
@ -59,7 +59,7 @@ tempfile = "3"
|
||||
glob = "0.3"
|
||||
walkdir = "2"
|
||||
lru = "0.12"
|
||||
git2 = "0.18"
|
||||
git2 = { version = "0.18", default-features = false, features = ["vendored-libgit2"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
101
Dockerfile
Normal file
101
Dockerfile
Normal file
@ -0,0 +1,101 @@
|
||||
# =============================================================================
|
||||
# AstroResearch Dockerfile — Mode A (外部 obscura 二进制)
|
||||
# =============================================================================
|
||||
#
|
||||
# 构建:
|
||||
# docker build -t astroresearch:latest .
|
||||
#
|
||||
# 海外网络构建:
|
||||
# docker build --build-arg USE_MIRRORS=0 -t astroresearch:latest .
|
||||
#
|
||||
# 运行:
|
||||
# docker run -d --name astro -p 8000:8000 --env-file .env \
|
||||
# -v ./library:/app/library \
|
||||
# -v ./logs:/app/logs \
|
||||
# -v ./skills:/app/skills:ro \
|
||||
# -v ./bin:/app/bin:ro \
|
||||
# astroresearch:latest
|
||||
# =============================================================================
|
||||
|
||||
# ─── Stage 1: 前端 ───────────────────────────────────────────────────────────
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
|
||||
ARG USE_MIRRORS=1
|
||||
WORKDIR /app/dashboard
|
||||
|
||||
RUN if [ "$USE_MIRRORS" = "1" ]; then \
|
||||
npm config set registry https://registry.npmmirror.com; \
|
||||
fi
|
||||
|
||||
COPY dashboard/package.json dashboard/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY dashboard/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ─── Stage 2: Rust 后端 (musl 静态链接) ──────────────────────────────────────
|
||||
FROM rust:1.94-alpine AS backend-builder
|
||||
|
||||
ARG USE_MIRRORS=1
|
||||
|
||||
RUN if [ "$USE_MIRRORS" = "1" ]; then \
|
||||
sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories; \
|
||||
fi
|
||||
|
||||
RUN apk add --no-cache musl-dev cmake pkgconfig openssl-dev openssl-libs-static
|
||||
|
||||
# sqlite-vec v0.1.9 与 musl 不兼容: musl 无 BSD u_int*_t 类型
|
||||
# typedef u_int8_t uint8_t → typedef uint8_t uint8_t (C11 合法)
|
||||
ENV CFLAGS="-Du_int8_t=uint8_t -Du_int16_t=uint16_t -Du_int64_t=uint64_t"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Cargo 解析阶段就需要可选 path 依赖存在,即使不启用对应 feature
|
||||
RUN mkdir -p libs/obscura/crates/obscura-browser/src && \
|
||||
printf '[package]\nname = "obscura-browser"\nversion = "0.1.0"\nedition = "2021"\n\n[features]\nstealth = []\n' \
|
||||
> libs/obscura/crates/obscura-browser/Cargo.toml && \
|
||||
echo '' > libs/obscura/crates/obscura-browser/src/lib.rs && \
|
||||
mkdir -p libs/obscura/crates/obscura-net/src && \
|
||||
printf '[package]\nname = "obscura-net"\nversion = "0.1.0"\nedition = "2021"\n\n[features]\nstealth = []\n' \
|
||||
> libs/obscura/crates/obscura-net/Cargo.toml && \
|
||||
echo '' > libs/obscura/crates/obscura-net/src/lib.rs
|
||||
|
||||
ENV SKIP_DASHBOARD_BUILD=1
|
||||
COPY Cargo.toml Cargo.lock build.rs ./
|
||||
COPY src/ ./src/
|
||||
COPY migrations/ ./migrations/
|
||||
COPY --from=frontend-builder /app/dashboard/dist/ ./dashboard/dist/
|
||||
|
||||
RUN cargo build --profile release-min && \
|
||||
cp /app/target/release-min/astroresearch /usr/local/bin/astroresearch
|
||||
|
||||
# ─── Stage 3: 运行时 ─────────────────────────────────────────────────────────
|
||||
FROM alpine:3.21
|
||||
|
||||
ARG USE_MIRRORS=1
|
||||
|
||||
RUN if [ "$USE_MIRRORS" = "1" ]; then \
|
||||
sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories; \
|
||||
fi
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
RUN addgroup -g 65532 -S astro && adduser -u 65532 -S astro -G astro
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=backend-builder /usr/local/bin/astroresearch /app/
|
||||
COPY --from=frontend-builder /app/dashboard/dist/ ./dashboard/dist/
|
||||
COPY skills/ ./skills/
|
||||
COPY migrations/ ./migrations/
|
||||
|
||||
RUN mkdir -p /app/library /app/logs /app/bin
|
||||
|
||||
VOLUME ["/app/library", "/app/logs", "/app/skills", "/app/bin"]
|
||||
|
||||
USER astro
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:8000/ || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/astroresearch"]
|
||||
94
Dockerfile.modeB
Normal file
94
Dockerfile.modeB
Normal file
@ -0,0 +1,94 @@
|
||||
# =============================================================================
|
||||
# AstroResearch Dockerfile — Mode B (进程内 obscura, 无外部二进制依赖)
|
||||
# =============================================================================
|
||||
#
|
||||
# 前置条件:
|
||||
# mkdir -p libs
|
||||
# git clone https://github.com/h4ckf0r0day/obscura libs/obscura
|
||||
#
|
||||
# 并从 .dockerignore 中移除 "libs/" 行 (否则源码不会进构建上下文)
|
||||
#
|
||||
# 构建:
|
||||
# docker build -f Dockerfile.modeB -t astroresearch-all:latest .
|
||||
#
|
||||
# 运行 (无需 bin/obscura, 反爬能力内置于单体二进制):
|
||||
# docker run -d --name astro -p 8000:8000 --env-file .env \
|
||||
# -v ./library:/app/library \
|
||||
# -v ./logs:/app/logs \
|
||||
# -v ./skills:/app/skills:ro \
|
||||
# astroresearch-all:latest
|
||||
#
|
||||
# 注意: Mode B 依赖 deno_core → v8 (V8 JavaScript 引擎).
|
||||
# Deno 只发布 glibc 目标的预编译 V8 静态库, 无 musl 版本.
|
||||
# 因此构建和运行都必须使用 glibc (Debian), 不能用 Alpine/musl.
|
||||
# =============================================================================
|
||||
|
||||
# ─── Stage 1: 前端 ───────────────────────────────────────────────────────────
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
|
||||
ARG USE_MIRRORS=1
|
||||
WORKDIR /app/dashboard
|
||||
|
||||
RUN if [ "$USE_MIRRORS" = "1" ]; then \
|
||||
npm config set registry https://registry.npmmirror.com; \
|
||||
fi
|
||||
|
||||
COPY dashboard/package.json dashboard/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY dashboard/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ─── Stage 2: Rust 后端 (glibc, V8 预编译库仅支持 glibc) ────────────────────
|
||||
FROM rust:1.94-slim-bookworm AS backend-builder
|
||||
|
||||
ARG USE_MIRRORS=1
|
||||
|
||||
RUN if [ "$USE_MIRRORS" = "1" ]; then \
|
||||
sed -i 's|deb.debian.org|mirrors.ustc.edu.cn|g' /etc/apt/sources.list.d/debian.sources; \
|
||||
fi
|
||||
|
||||
# build-essential: gcc g++ make; binutils: nm objcopy (btls-sys prefix-symbols)
|
||||
# libclang-dev: libclang.so (bindgen FFI 生成, btls-sys + v8)
|
||||
# cmake perl git curl python3: BoringSSL 编译 + V8 下载
|
||||
# 注: 无需 libssl-dev/libgit2-dev — git2(default-features=false,无https) 不依赖 OpenSSL
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential binutils cmake pkg-config libclang-dev \
|
||||
perl git curl python3 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# bindgen 需要找到 libclang.so; Debian bookworm 安装在 /usr/lib/llvm-14/lib/
|
||||
ENV LIBCLANG_PATH=/usr/lib/llvm-14/lib
|
||||
|
||||
ENV SKIP_DASHBOARD_BUILD=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml Cargo.lock build.rs ./
|
||||
COPY src/ ./src/
|
||||
COPY migrations/ ./migrations/
|
||||
COPY libs/obscura/ ./libs/obscura/
|
||||
COPY --from=frontend-builder /app/dashboard/dist/ ./dashboard/dist/
|
||||
|
||||
RUN cargo build --profile release-min --features obscura-inprocess && \
|
||||
cp /app/target/release-min/astroresearch /usr/local/bin/astroresearch
|
||||
|
||||
# ─── Stage 3: 运行时 (distroless — 仅 glibc + 必需库, 无 shell/apt) ──────
|
||||
FROM gcr.io/distroless/cc-debian12:nonroot
|
||||
|
||||
# frontend + skills + migrations 从构建阶段拷贝
|
||||
COPY --from=frontend-builder /app/dashboard/dist/ /app/dashboard/dist/
|
||||
COPY skills/ /app/skills/
|
||||
COPY migrations/ /app/migrations/
|
||||
|
||||
# 二进制
|
||||
COPY --from=backend-builder /usr/local/bin/astroresearch /app/astroresearch
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
VOLUME ["/app/library", "/app/logs", "/app/skills"]
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# 注: distroless 无 shell/wget, 无法使用传统 healthcheck.
|
||||
# 编排层 (docker-compose/k8s) 可用 TCP probe 或 HTTP GET / 替代.
|
||||
ENTRYPOINT ["/app/astroresearch"]
|
||||
9
build.rs
9
build.rs
@ -8,6 +8,15 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed=dashboard/package.json");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
// Docker/CI 环境跳过前端构建 (dist 已在 Dockerfile 中预构建)
|
||||
let skip_build = std::env::var("SKIP_DASHBOARD_BUILD")
|
||||
.map(|v| v == "1" || v == "true")
|
||||
.unwrap_or(false);
|
||||
if skip_build {
|
||||
println!("cargo:warning=SKIP_DASHBOARD_BUILD 已设置,跳过前端构建。");
|
||||
return;
|
||||
}
|
||||
|
||||
let dashboard_dir = Path::new("dashboard");
|
||||
let node_modules_exist = dashboard_dir.join("node_modules").exists();
|
||||
let dist_exists = dashboard_dir.join("dist").exists();
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// dashboard/src/App.tsx
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Loader, Download, BookOpen, GitFork, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||
import { Loader, Download, BookOpen, GitFork, RefreshCw, AlertTriangle, Lock } from 'lucide-react';
|
||||
import { Sidebar } from './components/layout/Sidebar';
|
||||
import { SearchPanel, getDoctypeBadge } from './features/search/SearchPanel';
|
||||
import { LibraryPanel } from './features/library/LibraryPanel';
|
||||
@ -12,6 +12,12 @@ import { ResearchAgentPanel } from './features/agent/ResearchAgentPanel';
|
||||
import type { StandardPaper, CitationNetwork, NoteRecord } from './types';
|
||||
|
||||
export default function App() {
|
||||
// 登录与鉴权相关状态
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'>(() => {
|
||||
const saved = localStorage.getItem('astro_active_tab');
|
||||
return (saved as any) || 'search';
|
||||
@ -21,6 +27,53 @@ export default function App() {
|
||||
localStorage.setItem('astro_active_tab', activeTab);
|
||||
}, [activeTab]);
|
||||
|
||||
// 全局启用 Axios 跨域 Cookie 传输凭证
|
||||
useEffect(() => {
|
||||
axios.defaults.withCredentials = true;
|
||||
}, []);
|
||||
|
||||
// 初始化校验登录凭证状态(由浏览器自动带上 Cookie)
|
||||
useEffect(() => {
|
||||
axios.get('/api/auth/check')
|
||||
.then(() => {
|
||||
setIsAuthenticated(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsAuthenticated(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password.trim()) {
|
||||
setLoginError('请输入访问密码!');
|
||||
return;
|
||||
}
|
||||
setLoggingIn(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
await axios.post('/api/auth/login', { password });
|
||||
setIsAuthenticated(true);
|
||||
setLoginError(null);
|
||||
} catch (err: any) {
|
||||
console.error('登录校验失败:', err);
|
||||
const errMsg = err.response?.data || '密码错误,请重试。';
|
||||
setLoginError(errMsg);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await axios.post('/api/auth/logout');
|
||||
} catch (e) {
|
||||
console.error('登出失败:', e);
|
||||
}
|
||||
setIsAuthenticated(false);
|
||||
setPassword('');
|
||||
};
|
||||
|
||||
// 全局对话框弹窗状态
|
||||
const [dialog, setDialog] = useState<{
|
||||
type: 'alert' | 'confirm';
|
||||
@ -124,8 +177,10 @@ export default function App() {
|
||||
|
||||
// 1. 初始化时加载本地文献
|
||||
useEffect(() => {
|
||||
if (isAuthenticated === true) {
|
||||
fetchLibrary();
|
||||
}, []);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const fetchLibrary = async () => {
|
||||
try {
|
||||
@ -529,6 +584,94 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isAuthenticated === null) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#f4f6f9]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader className="w-6 h-6 text-[#106ba3] animate-spin" />
|
||||
<span className="text-xs font-bold text-[#0a2540] tracking-wider font-sans">正在校验系统安全凭证...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticated === false) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#f4f6f9] overflow-hidden relative select-none font-sans">
|
||||
{/* 背景点缀装饰 */}
|
||||
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-sky-200/20 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-[500px] h-[500px] bg-indigo-200/20 rounded-full blur-3xl" />
|
||||
|
||||
{/* 登录卡片 */}
|
||||
<div className="console-panel rounded-xl p-8 max-w-sm w-full mx-4 shadow-sm z-10 relative bg-white border border-[#d2d8e2]">
|
||||
{/* Logo & 头部 */}
|
||||
<div className="flex flex-col items-center text-center mb-7 select-none">
|
||||
<div className="w-14 h-14 mb-3">
|
||||
<svg width="100%" height="100%" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-full h-full">
|
||||
<circle cx="24" cy="24" r="18" stroke="#bae6fd" strokeWidth="1.5" />
|
||||
<circle cx="24" cy="24" r="21" stroke="#0284c7" strokeWidth="1.5" strokeDasharray="2 3" />
|
||||
<path d="M24 9C24 18 24 18 33 24C24 24 24 24 24 33C24 24 24 24 15 24C24 18 24 18 24 9Z" fill="url(#loginStarGrad)" />
|
||||
<ellipse cx="24" cy="24" rx="20" ry="7" transform="rotate(-28 24 24)" stroke="#0284c7" strokeWidth="2" />
|
||||
<circle cx="38" cy="16" r="4.5" fill="#0284c7" stroke="#ffffff" strokeWidth="1.5" />
|
||||
<circle cx="10" cy="32" r="2.5" fill="#38bdf8" />
|
||||
<defs>
|
||||
<linearGradient id="loginStarGrad" x1="15" y1="9" x2="33" y2="33" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#106ba3" />
|
||||
<stop offset="100%" stopColor="#0a2540" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-sm font-bold text-[#0a2540] tracking-wider mb-1">AstroResearch</h2>
|
||||
<p className="text-[10px] text-[#5c6b84] font-medium tracking-wide">天文学科研辅助系统 · 安全登录</p>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold text-[#0a2540] block">访问密码</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="请输入系统访问密码"
|
||||
autoFocus
|
||||
disabled={loggingIn}
|
||||
className="w-full pl-9 pr-4 py-2 rounded-lg bg-slate-50 border border-[#d2d8e2] text-slate-900 placeholder-slate-400 focus:outline-none focus:border-[#106ba3] focus:bg-white transition-all text-xs font-medium"
|
||||
/>
|
||||
<div className="absolute left-3 top-2.5 text-slate-400">
|
||||
<Lock className="w-3.5 h-3.5 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loginError && (
|
||||
<div className="p-2.5 rounded-lg bg-red-50 border border-red-200 text-[10px] font-bold text-red-700 leading-relaxed">
|
||||
{loginError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loggingIn}
|
||||
className="w-full btn-console btn-console-primary py-2 rounded-lg text-xs font-bold transition-all shadow-xs flex items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
{loggingIn ? (
|
||||
<>
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
<span>正在校验...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>登录系统</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden text-slate-800 bg-slate-100 select-text">
|
||||
|
||||
@ -538,6 +681,7 @@ export default function App() {
|
||||
setActiveTab={setActiveTab}
|
||||
selectedPaper={selectedPaper}
|
||||
loadCitations={loadCitations}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
{/* 主工作区 */}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// dashboard/src/components/layout/Sidebar.tsx
|
||||
import { useState } from 'react';
|
||||
import { Search, BookOpen, GitFork, Library, RefreshCw, ChevronLeft, Sparkles } from 'lucide-react';
|
||||
import { Search, BookOpen, GitFork, Library, RefreshCw, ChevronLeft, Sparkles, LogOut } from 'lucide-react';
|
||||
import type { StandardPaper } from '../../types';
|
||||
|
||||
interface SidebarProps {
|
||||
@ -8,9 +8,10 @@ interface SidebarProps {
|
||||
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent') => void;
|
||||
selectedPaper: StandardPaper | null;
|
||||
loadCitations: (bibcode: string) => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations }: SidebarProps) {
|
||||
export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations, onLogout }: SidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const renderLogo = () => (
|
||||
@ -137,8 +138,8 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 底部当前选定文献提示 (平滑动画版本) */}
|
||||
<div className="space-y-4">
|
||||
{/* 底部当前选定文献提示 (平滑动画版本) 与 退出登录 */}
|
||||
<div className="space-y-3">
|
||||
{selectedPaper ? (
|
||||
<div
|
||||
className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${
|
||||
@ -207,6 +208,27 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 退出登录按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border border-transparent text-red-600 hover:bg-red-50 hover:text-red-700 cursor-pointer ${
|
||||
isCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5'
|
||||
}`}
|
||||
title={isCollapsed ? "退出登录" : undefined}
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-red-500" />
|
||||
<span
|
||||
className={`truncate transition-all duration-300 origin-left ${
|
||||
isCollapsed
|
||||
? 'opacity-0 max-w-0 pointer-events-none select-none overflow-hidden scale-90 -translate-x-2'
|
||||
: 'opacity-100 max-w-[150px] scale-100 translate-x-0 ml-3'
|
||||
}`}
|
||||
>
|
||||
退出登录
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
Brain, Settings, Eye, CheckCircle2, AlertTriangle,
|
||||
Send, Loader, Plus, Trash2, Compass, Clock, Square,
|
||||
BarChart3, ScrollText, Network, Rewind, RotateCcw,
|
||||
GitBranch, RefreshCw
|
||||
GitBranch, RefreshCw, Search, X, MessageSquare, BookOpen
|
||||
} from 'lucide-react';
|
||||
import { AskUserQuestionCard } from './AskUserQuestionCard';
|
||||
import { PermissionRequestCard } from './PermissionRequestCard';
|
||||
@ -28,6 +28,14 @@ interface SessionSummary {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
result_type: string;
|
||||
session_id: string;
|
||||
title?: string | null;
|
||||
snippet: string;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
interface MessageRecord {
|
||||
id: number;
|
||||
agent_name: string;
|
||||
@ -185,9 +193,16 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [thinking, setThinking] = useState(false);
|
||||
const [coordinatorMode, setCoordinatorMode] = useState(false);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
|
||||
// 全文搜索历史记录状态
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchScopeOnlyCurrent, setSearchScopeOnlyCurrent] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [loadingSearch, setLoadingSearch] = useState(false);
|
||||
|
||||
// 展开折叠控制
|
||||
const [expandedThoughts, setExpandedThoughts] = useState<Record<string, boolean>>({});
|
||||
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>({});
|
||||
@ -309,6 +324,38 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
||||
}
|
||||
}, [currentSessionId]);
|
||||
|
||||
// 跨会话历史检索防抖逻辑
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const delayDebounceFn = setTimeout(async () => {
|
||||
setLoadingSearch(true);
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
q: searchQuery,
|
||||
scope: 'all',
|
||||
limit: 30,
|
||||
};
|
||||
if (searchScopeOnlyCurrent && currentSessionId) {
|
||||
params.session_id = currentSessionId;
|
||||
}
|
||||
const res = await axios.get<SearchResult[]>('/api/search/history', {
|
||||
params,
|
||||
});
|
||||
setSearchResults(res.data);
|
||||
} catch (e) {
|
||||
console.error('搜索会话历史失败:', e);
|
||||
} finally {
|
||||
setLoadingSearch(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [searchQuery, searchScopeOnlyCurrent, currentSessionId]);
|
||||
|
||||
// 新建会话
|
||||
const handleNewSession = () => {
|
||||
setCurrentSessionId(null);
|
||||
@ -536,6 +583,7 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
||||
question: questionText,
|
||||
session_id: currentSessionId,
|
||||
thinking,
|
||||
coordinator_mode: coordinatorMode,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -1079,8 +1127,104 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索框区域 */}
|
||||
<div className="px-3 py-2 border-b border-slate-200/60 bg-white flex flex-col gap-1.5 shrink-0">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索历史会话或消息内容..."
|
||||
className="w-full pl-8 pr-7 py-1.5 rounded-lg bg-slate-50 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-[11px] font-medium"
|
||||
/>
|
||||
<div className="absolute left-2.5 top-2.5 text-slate-400">
|
||||
<Search className="w-3.5 h-3.5 text-slate-400" />
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-2 p-0.5 rounded-full hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{currentSessionId && (
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-slate-500 cursor-pointer font-medium select-none ml-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={searchScopeOnlyCurrent}
|
||||
onChange={e => setSearchScopeOnlyCurrent(e.target.checked)}
|
||||
className="rounded text-sky-600 border-slate-300 focus:ring-sky-500 w-3 h-3"
|
||||
/>
|
||||
<span>仅搜索当前会话</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2.5 space-y-1.5 scrollbar-thin">
|
||||
{loadingSessions ? (
|
||||
{searchQuery.trim() ? (
|
||||
loadingSearch ? (
|
||||
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
|
||||
<Loader className="w-3.5 h-3.5 animate-spin text-sky-600" />
|
||||
<span>检索历史记录中...</span>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400 text-[11px] italic">
|
||||
未找到匹配的历史记录
|
||||
</div>
|
||||
) : (
|
||||
searchResults.map((result, idx) => {
|
||||
const isActive = result.session_id === currentSessionId;
|
||||
const isMessage = result.result_type.startsWith('message/');
|
||||
const role = isMessage ? result.result_type.split('/')[1] : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${result.session_id}-${idx}`}
|
||||
onClick={() => setCurrentSessionId(result.session_id)}
|
||||
className={`w-full text-left p-2.5 rounded-lg border transition-all duration-200 flex flex-col gap-1 cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-sky-50/70 border-sky-200 text-sky-850 font-bold shadow-2xs'
|
||||
: 'border-transparent bg-white hover:bg-slate-100 text-slate-650 shadow-3xs'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="text-[10px] font-bold text-slate-400 flex items-center gap-1">
|
||||
{isMessage ? (
|
||||
<>
|
||||
<MessageSquare className="w-3 h-3 text-purple-400" />
|
||||
<span>{role === 'user' ? '提问' : '解答'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookOpen className="w-3 h-3 text-sky-500" />
|
||||
<span>会话</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{result.created_at && (
|
||||
<span className="text-[8px] text-slate-400 font-mono">
|
||||
{result.created_at.split(' ')[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-xs leading-snug line-clamp-1 block text-left font-bold text-slate-800">
|
||||
{result.title || '无标题会话'}
|
||||
</span>
|
||||
|
||||
<p
|
||||
className="text-[10px] text-slate-500 font-medium leading-normal block text-left line-clamp-3 bg-slate-50/50 p-1.5 rounded border border-slate-100/50"
|
||||
dangerouslySetInnerHTML={{ __html: result.snippet }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : (
|
||||
// 正常的会话列表渲染
|
||||
loadingSessions ? (
|
||||
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
|
||||
<Loader className="w-3.5 h-3.5 animate-spin text-sky-600" />
|
||||
<span>加载历史会话中...</span>
|
||||
@ -1120,6 +1264,7 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -1407,6 +1552,21 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
||||
<Brain className={`w-4 h-4 ${thinking ? 'text-purple-500' : ''}`} />
|
||||
<span className="hidden sm:inline">{thinking ? '思考中' : '思考'}</span>
|
||||
</button>
|
||||
{/* 协调者模式开关 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCoordinatorMode(!coordinatorMode)}
|
||||
disabled={streaming}
|
||||
title={coordinatorMode ? '协调者模式已开启(委托子智能体执行)' : '协调者模式已关闭(单智能体直接执行)'}
|
||||
className={`p-2.5 rounded-xl border text-xs font-bold transition-all cursor-pointer flex items-center gap-1.5 shrink-0 ${
|
||||
coordinatorMode
|
||||
? 'bg-sky-50 border-sky-300 text-sky-700 shadow-2xs'
|
||||
: 'bg-slate-50 border-slate-250 text-slate-400 hover:text-sky-500 hover:border-sky-200'
|
||||
} disabled:opacity-60`}
|
||||
>
|
||||
<Network className={`w-4 h-4 ${coordinatorMode ? 'text-sky-500' : ''}`} />
|
||||
<span className="hidden sm:inline">{coordinatorMode ? '协调中' : '协调'}</span>
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
|
||||
@ -880,7 +880,7 @@ export function SyncPanel() {
|
||||
if (el) {
|
||||
el.setAttribute(
|
||||
'href',
|
||||
`javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode');if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`
|
||||
`javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`
|
||||
);
|
||||
}
|
||||
}}
|
||||
@ -895,7 +895,7 @@ export function SyncPanel() {
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const bookmarkletCode = `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode');if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`;
|
||||
const bookmarkletCode = `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`;
|
||||
navigator.clipboard.writeText(bookmarkletCode);
|
||||
alert('书签代码已成功复制到剪贴板!');
|
||||
}}
|
||||
|
||||
@ -133,3 +133,13 @@ body {
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* FTS search result highlights */
|
||||
mark {
|
||||
background-color: rgba(254, 240, 138, 0.7); /* translucent yellow bg */
|
||||
color: #854d0e; /* text-yellow-800 */
|
||||
padding-left: 0.125rem;
|
||||
padding-right: 0.125rem;
|
||||
border-radius: 0.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
105
docker-compose.yml
Normal file
105
docker-compose.yml
Normal file
@ -0,0 +1,105 @@
|
||||
# =============================================================================
|
||||
# AstroResearch Docker Compose 部署
|
||||
# =============================================================================
|
||||
#
|
||||
# 快速启动 (Mode A — 推荐):
|
||||
# 1. cp .env.example .env # 编辑填入 API Key
|
||||
# 2. docker compose up -d # 启动服务
|
||||
# 3. open http://localhost:8000
|
||||
#
|
||||
# 使用 Mode B (进程内 Obscura):
|
||||
# 1. 先克隆 Obscura: mkdir -p libs && git clone https://github.com/h4ckf0r0day/obscura libs/obscura
|
||||
# 2. 构建: docker compose build astroresearch-all
|
||||
# 3. 启动: docker compose up -d astroresearch-all
|
||||
#
|
||||
# 查看日志: docker compose logs -f
|
||||
# 停止: docker compose down
|
||||
# 重建镜像: docker compose build --no-cache
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
# ─── Mode A: Alpine 小镜像 + 外部 Obscura (推荐日常使用) ──────────────────
|
||||
astroresearch:
|
||||
build:
|
||||
context: .
|
||||
image: astroresearch:latest
|
||||
container_name: astroresearch
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "${PORT:-8000}:8000"
|
||||
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:///app/library/astro_research.db
|
||||
- LIBRARY_DIR=/app/library
|
||||
- SKILLS_DIR=/app/skills
|
||||
- LOG_DIR=/app/logs
|
||||
- LOG_FORMAT=json
|
||||
- LOG_OUTPUTS=stdout
|
||||
- PORT=8000
|
||||
|
||||
volumes:
|
||||
- ./library:/app/library
|
||||
- ./logs:/app/logs
|
||||
- ./skills:/app/skills:ro
|
||||
# Mode A: 外部 obscura 反爬浏览器 (bind-mount 避免打进镜像)
|
||||
- ./bin:/app/bin:ro
|
||||
|
||||
# 资源限制 (可选)
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
# memory: 2G
|
||||
# reservations:
|
||||
# memory: 128M
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
# ─── Mode B: Distroless 全功能单体 (Obscura/V8/BoringSSL 编译在内) ──────
|
||||
astroresearch-all:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.modeB
|
||||
image: astroresearch-all:latest
|
||||
container_name: astroresearch-all
|
||||
restart: unless-stopped
|
||||
# 默认不随 `docker compose up` 启动, 需显式指定
|
||||
profiles:
|
||||
- full
|
||||
|
||||
ports:
|
||||
- "${PORT:-8000}:8000"
|
||||
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:///app/library/astro_research.db
|
||||
- LIBRARY_DIR=/app/library
|
||||
- SKILLS_DIR=/app/skills
|
||||
- LOG_DIR=/app/logs
|
||||
- LOG_FORMAT=json
|
||||
- LOG_OUTPUTS=stdout
|
||||
- PORT=8000
|
||||
|
||||
volumes:
|
||||
- ./library:/app/library
|
||||
- ./logs:/app/logs
|
||||
- ./skills:/app/skills:ro
|
||||
# 注: Mode B 无需 ./bin:/app/bin (Obscura 已编译进二进制)
|
||||
|
||||
# 资源限制 (可选)
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
# memory: 2G
|
||||
# reservations:
|
||||
# memory: 128M
|
||||
@ -3,7 +3,7 @@
|
||||
对 Claude Code (`/home/fmq/program/claudecode/src/`) 和 Hermes-Agent (`libs/hermes-agent/`)
|
||||
源码的全面架构分析,记录对 AstroResearch Agent 系统的参考价值与改进方向。
|
||||
|
||||
> 分析日期: 2026-06-22 | 最后更新: 2026-06-22
|
||||
> 分析日期: 2026-06-22 | 最后更新: 2026-06-23
|
||||
|
||||
## 实施状态
|
||||
|
||||
@ -16,12 +16,12 @@
|
||||
| P1 | PermissionRequest / PermissionDenied Hooks | ✅ 已完成 | `hooks/types.rs`, `traits.rs`, `dispatch.rs`, `mod.rs` |
|
||||
| P1 | Auto-mode Classifier | ⏳ 待定 | — |
|
||||
| P2 | Self-improving Skills(模式检测 + 自动创建 + Curator) | ✅ 已完成 | `skills/pattern_detector.rs` + `curator.rs` + `SkillCreator` |
|
||||
| P2 | Coordinator Mode | ⏳ 待定 | — |
|
||||
| P2 | UserPromptSubmit / PreCompact / PostCompact Hook | ⏳ 待定 | — |
|
||||
| P3 | FTS5 跨 session 搜索 | ⏳ 待定 | — |
|
||||
| P3 | Tool `defer_loading` / `classifier_summary` | ⏳ 待定 | — |
|
||||
| P3 | 模型回退策略 | ⏳ 待定 | — |
|
||||
| P3 | Session Memory Compaction | ⏳ 待定 | — |
|
||||
| P2 | Coordinator Mode | ✅ 已完成 | `src/agent/coordinator/` |
|
||||
| P2 | UserPromptSubmit / PreCompact / PostCompact Hook | ✅ 已完成 | `hooks/types.rs`, `dispatch.rs` |
|
||||
| P3 | FTS5 跨 session 搜索 | ✅ 已完成 | `agent_sessions_fts` + `agent_messages_fts` |
|
||||
| P3 | Tool `defer_loading` / `classifier_summary` | ✅ 已完成 | `tools/mod.rs` |
|
||||
| P3 | 模型回退策略 | ✅ 已完成 | `error_recovery.rs` (LLM_FALLBACK_MODEL/LLM_FALLBACK_CHAIN) |
|
||||
| P3 | Session Memory Compaction | ✅ 已完成 | `memory/mod.rs` (extract_memories_from_compaction) |
|
||||
|
||||
---
|
||||
|
||||
@ -625,27 +625,33 @@ Coordinator Mode 架构:
|
||||
|
||||
### 9.3 AstroResearch 的现状
|
||||
|
||||
AstroResearch 有 `SubAgentTool` + `TeamManager`,但没有 Coordinator 的概念。Team
|
||||
是平级的(lead ↔ teammates),不是层级编排。
|
||||
AstroResearch 现已实现 Coordinator Mode(`src/agent/coordinator/`,2026-06-23),
|
||||
包含 4 个 meta-tools(delegate_task / check_task / task_stop / synthesize)
|
||||
+ WorkerPool + SubAgentRunner。Team 的平级通信仍然可用,Coordinator 提供层级编排。
|
||||
|
||||
### 9.4 建议改进
|
||||
|
||||
**P2: Coordinator Mode 原型**
|
||||
**P2: Coordinator Mode — 已实现** ✅
|
||||
|
||||
```
|
||||
当 Agent 检测到复杂多步骤任务时,自动切换为 Coordinator 模式:
|
||||
实现架构:
|
||||
src/agent/coordinator/
|
||||
├── mod.rs # CoordinatorRuntime, CoordinatorConfig
|
||||
├── tools.rs # 4 meta-tools: delegate_task, check_task, task_stop, synthesize
|
||||
├── worker_pool.rs # WorkerPool: 异步 Worker 生命周期管理
|
||||
└── runner.rs # SubAgentRunner: Worker 执行器
|
||||
|
||||
用户请求
|
||||
→ Coordinator 做任务分解
|
||||
→ 并行子代理执行 (SubAgentTool, async)
|
||||
→ 结果综合
|
||||
→ 并行 Worker 执行 (delegate_task, async)
|
||||
→ 结果综合 (synthesize)
|
||||
→ 减少单 Agent 的步骤数和 token 消耗
|
||||
|
||||
关键实现:
|
||||
1. Coordinator system prompt: 类似 Claude Code coordinatorMode.ts
|
||||
2. 仅暴露 SubAgentTool + 少量管理工具
|
||||
3. 子代理结果以结构化格式注入
|
||||
4. 综合阶段由 Coordinator 处理
|
||||
1. Coordinator system prompt: 仅暴露 4 个 meta-tools,屏蔽文件/bash 操作
|
||||
2. WorkerPool + SubAgentRunner: 异步并发 Worker 管理
|
||||
3. 子代理结果以结构化格式注入 `<task-notification>`
|
||||
4. 综合阶段由 Coordinator 的 synthesize 工具处理
|
||||
```
|
||||
|
||||
---
|
||||
@ -718,10 +724,11 @@ hermes-agent/
|
||||
4. Curator 管理 Skill 生命周期
|
||||
5. 下次相似查询直接加载 Skill,无需重新探索
|
||||
|
||||
AstroResearch 已有 Skills 系统 (skills.rs),缺少:
|
||||
- 自动检测重复模式
|
||||
- Agent 自主创建 Skill
|
||||
- Curator 管理 Skill 质量
|
||||
AstroResearch 现已完整实现 Self-improving Skills(2026-06-22):
|
||||
- `skills/pattern_detector.rs` — 自动检测重复工具调用序列模式
|
||||
- `skills/curator.rs` — Curator 管理 Skill 生命周期(质量评分、清理、pinned、seed_record、CuratorRunner)
|
||||
- `skills.rs: SkillCreator` — Agent 自主创建 SKILL.md 文件
|
||||
- `skills.rs: SelfImprovePipeline` — 一站式管道(检测 → 创建 → 质量审查)
|
||||
```
|
||||
|
||||
**Session FTS5 搜索**
|
||||
@ -736,27 +743,27 @@ class SessionDB:
|
||||
)
|
||||
```
|
||||
|
||||
AstroResearch 的 `agent_sessions` 表有基本的 title/status 字段,但没有全文搜索。
|
||||
AstroResearch 现已实现 FTS5 全文搜索(2026-06-23):
|
||||
- `agent_sessions_fts` + `agent_messages_fts` SQLite FTS5 虚拟表
|
||||
- `search_history` Agent 工具提供跨会话全文本搜索能力
|
||||
|
||||
### 10.5 建议改进
|
||||
### 10.5 实施成果
|
||||
|
||||
**P2: Self-improving Skills 原型**
|
||||
**P2: Self-improving Skills — 已实现** ✅
|
||||
|
||||
```
|
||||
1. Pattern Detector (自动检测)
|
||||
- 监控 N 个 session 中的工具调用序列
|
||||
- 使用简单的子序列匹配识别重复模式
|
||||
1. Pattern Detector (自动检测) ✅
|
||||
- src/agent/skills/pattern_detector.rs — 子序列匹配识别重复工具调用模式
|
||||
- 阈值: 3 次相似序列 → 候选 Skill
|
||||
|
||||
2. Skill Creator (Agent 自主创建)
|
||||
- 将候选 Skill 展示给用户确认
|
||||
- 生成 SKILL.md 文件(含 when_to_use, steps)
|
||||
- 注册到 SkillRegistry
|
||||
2. Skill Creator (Agent 自主创建) ✅
|
||||
- src/agent/skills.rs: SkillCreator — 生成 SKILL.md 文件(kebab-case 命名 + YAML frontmatter)
|
||||
- src/agent/skills.rs: SelfImprovePipeline — 一站式管道(检测 → 创建 → 审查)
|
||||
|
||||
3. Curator (管理生命周期)
|
||||
- 跟踪 Skill 使用频率
|
||||
- 长时间未用的 Skill 标记为 stale
|
||||
- 提示用户审查或删除
|
||||
3. Curator (管理生命周期) ✅
|
||||
- src/agent/skills/curator.rs — 质量评分、清理候选、pinned 保护
|
||||
- seed_record + NEW_SKILL_GRACE_PERIOD_DAYS=7 — 新 Skill 锚定时钟
|
||||
- CuratorRunner — 后台空闲触发审查(paused/idle/interval 三重检查)
|
||||
```
|
||||
|
||||
---
|
||||
@ -774,12 +781,12 @@ AstroResearch 的 `agent_sessions` 表有基本的 title/status 字段,但没
|
||||
| **P1** | PermissionRequest / PermissionDenied Hook 事件 | Claude Code | ✅ | 安全审计能力 | ~100 行 | 无 |
|
||||
| **P1** | Auto-mode Classifier(廉价模型预分类) | Claude Code | ⏳ | 消除 80%+ 权限弹窗 | ~300 行 | LLM client 支持 |
|
||||
| **P2** | Self-improving Skills(Agent 保存成功流程) | Hermes | ✅ | 科研场景独特价值 | ~500 行 | Skills 系统 |
|
||||
| **P2** | Coordinator Mode(层级多 Agent 编排) | Claude Code | ⏳ | 复杂任务效果提升 | ~800 行 | SubAgent + Team |
|
||||
| **P2** | UserPromptSubmit / PreCompact / PostCompact Hook | Claude Code | ⏳ | Hook 系统完善 | ~200 行 | 无 |
|
||||
| **P3** | FTS5 跨 session 搜索 | Hermes | ⏳ | 历史研究可复用 | 中 | SQLite 迁移 |
|
||||
| **P3** | Tool `defer_loading` / `classifier_summary` | Claude Code | ⏳ | 减小 tool schema prompt | ~50 行 | 无 |
|
||||
| **P3** | 模型回退策略 (Model Fallback) | Claude Code | ⏳ | 提高可用性 | ~200 行 | ErrorRecovery |
|
||||
| **P3** | Session Memory Compaction | Claude Code | ⏳ | 自动化记忆提取 | ~300 行 | MemoryManager + Compact |
|
||||
| **P2** | Coordinator Mode(层级多 Agent 编排) | Claude Code | ✅ | 复杂任务效果提升 | ~800 行 | SubAgent + Team |
|
||||
| **P2** | UserPromptSubmit / PreCompact / PostCompact Hook | Claude Code | ✅ | Hook 系统完善 | ~200 行 | 无 |
|
||||
| **P3** | FTS5 跨 session 搜索 | Hermes | ✅ | 历史研究可复用 | 中 | SQLite 迁移 |
|
||||
| **P3** | Tool `defer_loading` / `classifier_summary` | Claude Code | ✅ | 减小 tool schema prompt | ~50 行 | 无 |
|
||||
| **P3** | 模型回退策略 (Model Fallback) | Claude Code | ✅ | 提高可用性 | ~200 行 | ErrorRecovery |
|
||||
| **P3** | Session Memory Compaction | Claude Code | ✅ | 自动化记忆提取 | ~300 行 | MemoryManager + Compact |
|
||||
|
||||
### 实施进度(2026-06-22)
|
||||
|
||||
@ -791,8 +798,14 @@ AstroResearch 的 `agent_sessions` 表有基本的 title/status 字段,但没
|
||||
- ✅ 工具并发分区
|
||||
- ✅ PermissionRequest / PermissionDenied Hook 事件
|
||||
- ⏳ Auto-mode Classifier — 需要设计讨论
|
||||
3. **P2 项在下一个大版本规划**:需要设计讨论和更多测试
|
||||
4. **P3 项作为 backlog**:长期优化方向
|
||||
3. **P2 项 — 全部完成** ✅(2026-06-23)
|
||||
- ✅ Coordinator Mode(`src/agent/coordinator/`: 4 meta-tools + WorkerPool + SubAgentRunner)
|
||||
- ✅ UserPromptSubmit Hook Event(第 13 个生命周期事件)
|
||||
4. **P3 项 — 全部完成** ✅(2026-06-23)
|
||||
- ✅ FTS5 跨会话搜索(`agent_sessions_fts` + `agent_messages_fts` + `search_history` 工具)
|
||||
- ✅ Tool defer_loading / classifier_summary
|
||||
- ✅ 模型回退策略(`LLM_FALLBACK_MODEL` / `LLM_FALLBACK_CHAIN`)
|
||||
- ✅ Session Memory Compaction Bridge(`extract_memories_from_compaction`)
|
||||
|
||||
---
|
||||
|
||||
@ -907,3 +920,30 @@ AstroResearch 的 `agent_sessions` 表有基本的 title/status 字段,但没
|
||||
**CuratorRunner(后台空闲触发审查)**
|
||||
- `src/agent/skills/curator.rs`: `CuratorRunner` 结构体 + `should_run_now()`(paused/idle/interval 三重检查)+ `record_activity()` 心跳 + `run_once()` + `spawn()` tokio 后台任务 + `pause()`/`resume()`
|
||||
- 5 个新增测试(pinned_always_active、pinned_not_in_cleanup、seed_record、runner_paused、runner_idle)
|
||||
|
||||
### 2026-06-23 — Phase 6: Coordinator Mode + UserPromptSubmit Hook
|
||||
|
||||
**Coordinator Mode 协调者模式**
|
||||
- `src/agent/coordinator/`: 新目录,4 个 meta-tools(delegate_task / check_task / task_stop / synthesize)+ WorkerPool + SubAgentRunner
|
||||
- Coordinator system prompt 层级编排 Workers(仅暴露 4 个管理工具,不暴露文件/bash)
|
||||
- Workers 全异步执行,结果以结构化格式注入 Coordinator
|
||||
- Continue-vs-Spawn 决策矩阵(上下文重叠度 <30% → 新 Worker)
|
||||
|
||||
**UserPromptSubmit Hook Event**
|
||||
- `src/agent/hooks/types.rs`: 新增 `UserPromptSubmit` variant(第 13 个生命周期事件)
|
||||
- `src/agent/hooks/dispatch.rs`: `dispatch()` 新增事件分派
|
||||
|
||||
### 2026-06-23 — Phase 7: P3 优化
|
||||
|
||||
**Tool defer_loading / classifier_summary**
|
||||
- `src/agent/tools/mod.rs`: `ToolDef.defer_loading` 字段 — LLM 初次调用时不发送完整 schema,仅发送 summary;Bash/Read 工具标记 defer_loading
|
||||
|
||||
**Model Fallback 模型回退策略**
|
||||
- `src/agent/runtime/error_recovery.rs`: `LLM_FALLBACK_MODEL` + `LLM_FALLBACK_CHAIN` env vars — 主模型故障时自动回退到备选模型链
|
||||
|
||||
**Session Memory Compaction Bridge**
|
||||
- `src/agent/memory/mod.rs`: `extract_memories_from_compaction()` — 在 compaction 自动摘要后从摘要文本提取记忆写入 MemoryManager
|
||||
|
||||
**FTS5 跨会话搜索**
|
||||
- `agent_sessions_fts` + `agent_messages_fts` SQLite FTS5 虚拟表
|
||||
- `search_history` Agent 工具 — 全文本搜索历史会话和消息
|
||||
|
||||
@ -317,13 +317,17 @@ RateLimited/Overloaded 不走恢复阶梯,独立执行指数退避重试(最
|
||||
| Hook | 触发时机 | 上下文影响 |
|
||||
|:---|:---|:---|
|
||||
| `OnSessionStart` | 会话创建/恢复 | 通知生命周期开始 |
|
||||
| `UserPromptSubmit` | 用户提交提示词后 | 记录/审计用户输入 (P2) |
|
||||
| `PreToolUse` | 工具执行前 | 可 MutateInput 注入上下文、Block 阻止 |
|
||||
| `PostToolUse` | 工具执行后 | 可 MutateOutput 修改结果、审计日志写入 |
|
||||
| `PostToolUseFailure` | 工具执行失败 | 记录错误信息,可 MutateOutput |
|
||||
| `OnStepComplete` | 每步结束 | 日志消息数/预算使用率 |
|
||||
| `OnPreCompact` | 压缩前 | 记录消息数/预估 tokens |
|
||||
| `OnPostCompact` | 压缩后 | 记录新消息数/压缩方法 |
|
||||
| `OnSubagentStart` | 子代理启动 | 通知子代理创建 |
|
||||
| `OnSubagentStop` | 子代理停止 | 记录结果摘要 |
|
||||
| `PermissionRequest` | 权限请求前 | 可 Override 权限决策 (P1) |
|
||||
| `PermissionDenied` | 权限被拒绝后 | 安全审计日志 (P1) |
|
||||
| `OnSessionStop` | 会话终止 | 清理取消状态、记录终止原因 |
|
||||
|
||||
---
|
||||
@ -373,6 +377,6 @@ Session → Thought → (ToolCall ↔ ToolResult)* → TextDelta → Usage → D
|
||||
| `src/agent/runtime/finalize.rs` | 会话收尾 + 记忆提取 + 轨迹导出 |
|
||||
| `src/agent/subagent.rs` | 子代理上下文隔离 + 独立 ReAct 循环 |
|
||||
| `src/agent/background.rs` | 后台任务队列 + 通知注入 |
|
||||
| `src/agent/hooks.rs` | 9 个生命周期事件 + 内置 3 Hook |
|
||||
| `src/agent/hooks/` | 13 个生命周期事件 + 内置 3 Hook + AsyncAgentHook trait |
|
||||
| `src/agent/skills.rs` | 两层技能加载 + 热重载 + 条件激活 |
|
||||
| `src/agent/tools/memory.rs` | save_memory 工具 + 写入门控 |
|
||||
|
||||
@ -125,10 +125,12 @@ Agent 系统的所有可配置参数,按子系统分类。
|
||||
|
||||
| 文件 | 加载的变量 |
|
||||
|:---|:---|
|
||||
| `src/lib.rs` (Config) | `LLM_API_KEY`, `LLM_API_BASE`, `LLM_MODEL`, `EMBEDDING_*`, `ADS_API_KEY`, `DATABASE_URL`, `LIBRARY_DIR`, `SKILLS_DIR`, `PORT`, `QINIU_*`, `MINERU_*` |
|
||||
| `src/lib.rs` (Config) | `LLM_API_KEY`, `LLM_API_BASE`, `LLM_MODEL`, `LLM_FALLBACK_MODEL`, `LLM_FALLBACK_CHAIN`, `EMBEDDING_*`, `ADS_API_KEY`, `DATABASE_URL`, `LIBRARY_DIR`, `SKILLS_DIR`, `PORT`, `QINIU_*`, `MINERU_*` |
|
||||
| `src/main.rs` | `EMBEDDING_DIM` |
|
||||
| `src/services/logging.rs` | `LOG_LEVEL`, `LOG_FORMAT`, `LOG_OUTPUTS`, `LOG_DIR` |
|
||||
| `src/agent/runtime/mod.rs` (AgentConfig) | `AGENT_MAX_STEPS`, `AGENT_TOOL_TIMEOUT_SECS`, `AGENT_MAX_TOOL_OUTPUT_CHARS`, `AGENT_CONTEXT_CHAR_LIMIT`, `AGENT_TOKEN_SOFT_LIMIT`, `AGENT_TOKEN_HARD_LIMIT`, `AGENT_MAX_MESSAGES` |
|
||||
| `src/agent/runtime/mod.rs` (call_llm_with_recovery) | `FALLBACK_MODEL` |
|
||||
| `src/agent/runtime/mod.rs` (call_llm_with_recovery) | `LLM_FALLBACK_MODEL` (原 `FALLBACK_MODEL`), `LLM_FALLBACK_CHAIN` (P3) |
|
||||
| `src/agent/runtime/mod.rs` (AgentRuntime) | `AGENT_COORDINATOR_MAX_WORKERS`, `AGENT_COORDINATOR_WORKER_TIMEOUT` (P2) |
|
||||
| `src/agent/compact.rs` (extract_memories_from_compaction) | `EXTRACT_MEMORY_ENABLED` (P3 压缩记忆桥接) |
|
||||
| `src/agent/memory/extraction.rs` (ExtractionConfig) | `EXTRACT_MEMORY_ENABLED`, `EXTRACT_MEMORY_THROTTLE_TURNS`, `EXTRACT_MEMORY_MAX_STEPS` |
|
||||
| `src/services/download.rs` | `OBSCURA_ALLOW_PRIVATE_NETWORK`(程序自动设置) |
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Agent Hooks — 生命周期事件系统
|
||||
|
||||
参考 Claude Code hooks 协议,提供 **12 种生命周期事件回调**,基于 **观察者模式 + 责任链模式** 实现。核心目标:在 Agent ReAct 循环的各个关键节点插入横切关注点(取消检查、指标采集、审计日志、权限增强等),**不污染主循环代码**。
|
||||
参考 Claude Code hooks 协议,提供 **13 种生命周期事件回调**,基于 **观察者模式 + 责任链模式** 实现。核心目标:在 Agent ReAct 循环的各个关键节点插入横切关注点(取消检查、指标采集、审计日志、权限增强等),**不污染主循环代码**。
|
||||
|
||||
---
|
||||
|
||||
@ -25,7 +25,7 @@ src/agent/hooks/
|
||||
mindmap
|
||||
root((Hook 系统<br/>设计原则))
|
||||
接口隔离
|
||||
10 个生命周期方法
|
||||
11 个生命周期方法
|
||||
+2 个权限专用方法 (on_permission_request/on_permission_denied)
|
||||
全部有默认空实现
|
||||
只覆写关心的 hook 点
|
||||
@ -112,6 +112,7 @@ classDiagram
|
||||
+timeout() Option~Duration~
|
||||
+match_filter() ToolMatchFilter
|
||||
+on_session_start(ctx)
|
||||
+on_user_prompt_submit(ctx)
|
||||
+pre_tool_use(ctx) PreToolUseAction
|
||||
+post_tool_use(ctx) PostToolUseAction
|
||||
+on_post_tool_use_failure(ctx) PostToolUseAction
|
||||
@ -137,6 +138,7 @@ classDiagram
|
||||
class HookEvent {
|
||||
<<enumeration>>
|
||||
OnSessionStart
|
||||
UserPromptSubmit
|
||||
PreToolUse
|
||||
PostToolUse
|
||||
PostToolUseFailure
|
||||
@ -252,6 +254,7 @@ classDiagram
|
||||
+run_pre_tool_use(ctx) PreToolUseResult
|
||||
+run_post_tool_use(ctx) PostToolUseResult
|
||||
+run_on_session_start(ctx)
|
||||
+run_on_user_prompt_submit(ctx)
|
||||
+run_on_step_complete(ctx)
|
||||
+run_on_session_stop(ctx)
|
||||
+run_on_subagent_start(ctx)
|
||||
@ -285,6 +288,7 @@ classDiagram
|
||||
| 事件 | 上下文类型 | 关键字段 |
|
||||
|------|-----------|---------|
|
||||
| OnSessionStart | `SessionStartContext` | session_id, turn_index, is_resume |
|
||||
| UserPromptSubmit | `UserPromptSubmitContext` | session_id, prompt, turn_index (fire-and-forget) |
|
||||
| PreToolUse | `PreToolUseContext` | session_id, tool_name, tool_args, step |
|
||||
| PostToolUse | `PostToolUseContext` | session_id, agent_name, tool_name, tool_args, output_content, is_error, step, elapsed_ms |
|
||||
| PostToolUseFailure | `PostToolUseFailureContext` | session_id, agent_name, tool_name, error_message, is_interrupt, step, elapsed_ms |
|
||||
@ -525,6 +529,8 @@ sequenceDiagram
|
||||
RT->>HR: with_builtins(db, cancelled, metrics)
|
||||
RT->>HR: run_on_session_start(ctx) ⚡并行
|
||||
Note over HR: ① OnSessionStart
|
||||
RT->>HR: run_on_user_prompt_submit(ctx) 🔥fire-and-forget
|
||||
Note over HR: ⑬ UserPromptSubmit
|
||||
|
||||
Note over API,CMP: ═══ Phase 2: ReAct 循环 ═══
|
||||
loop 每步 (1..max_steps)
|
||||
@ -598,7 +604,7 @@ sequenceDiagram
|
||||
flowchart LR
|
||||
subgraph Register["add(hook)"]
|
||||
H["hook: Box<dyn AgentHook>"] --> Subs{"subscribed_events()"}
|
||||
Subs -->|"空 = 全部"| All["遍历所有 12 个 HookEvent<br/>event_index[event].push(idx)"]
|
||||
Subs -->|"空 = 全部"| All["遍历所有 13 个 HookEvent<br/>event_index[event].push(idx)"]
|
||||
Subs -->|"指定"| Spec["仅注册声明的事件<br/>event_index[event].push(idx)"]
|
||||
end
|
||||
|
||||
@ -842,6 +848,7 @@ registry.remove_session_hook(&session_id, "MyTempHook");
|
||||
| 文件 | 集成点 | 方法 |
|
||||
|------|--------|------|
|
||||
| `runtime/mod.rs` | 会话启动 | `run_on_session_start()` |
|
||||
| `runtime/mod.rs` | 用户输入提交后 | `run_on_user_prompt_submit()` (fire-and-forget) |
|
||||
| `runtime/mod.rs` | ReAct 循环 | `run_on_step_complete()` |
|
||||
| `runtime/mod.rs` | Hook 上下文注入 | `hook_contexts → [system-reminder]` |
|
||||
| `runtime/mod.rs` | 会话终止 | `run_on_session_stop()` |
|
||||
@ -893,6 +900,7 @@ registry.remove_session_hook(&session_id, "MyTempHook");
|
||||
| 2026-06-22 | Phase 4: AsyncAgentHook trait + async hook 调度 |
|
||||
| 2026-06-22 | 模块拆分: mod.rs(537) ← types/traits/matcher/registry/dispatch/builtins |
|
||||
| 2026-06-22 | Phase 5: PermissionRequest/PermissionDenied 事件 — 权限决策钩子 + fire-and-forget 审计 |
|
||||
| 2026-06-23 | Phase 6: UserPromptSubmit hook — 第 13 个生命周期事件,在用户提交提示词后、context building 前触发(fire-and-forget),hooks 可以检查/记录用户输入 | 120 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -311,6 +311,50 @@ sequenceDiagram
|
||||
- `main_agent_saved_this_session == true`(主代理已通过 `save_memory` 工具写入)
|
||||
- 子代理工具集为受限集(仅 4 个只读工具 + `save_memory`),不能执行 bash、不能搜索论文
|
||||
|
||||
## 压缩记忆桥接 (`compact.rs`, P3)
|
||||
|
||||
上下文压缩(compaction)丢弃旧消息时,其中可能包含有长期价值的科研结论和用户偏好。`extract_memories_from_compaction()` 在压缩后自动从丢弃的消息中提取持久记忆,将"即将消失的上下文"转化为"跨会话可用的记忆"。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant RT as AgentRuntime
|
||||
participant Cmp as compact.rs
|
||||
participant Ext as extract_memories_from_compaction
|
||||
participant Sub as SubAgentRunner
|
||||
participant Mgr as MemoryManager
|
||||
|
||||
RT->>Cmp: compress_context_with_hooks_and_log(messages, ...)
|
||||
Cmp-->>RT: 压缩完成,旧消息将从上下文中移除
|
||||
RT->>Ext: extract_memories_from_compaction(before_messages, sid, mgr, app_state)
|
||||
Ext->>Ext: 检查 EXTRACT_MEMORY_ENABLED → false 则 return
|
||||
Ext->>Ext: 过滤非系统消息 → 每条截断 400 字符 → 拼接
|
||||
alt 拼接文本 < 300 字符
|
||||
Ext->>Ext: return (太少不值得提取)
|
||||
end
|
||||
Ext->>Mgr: lock() → build_manifest_preview() 获取现有记忆清单
|
||||
Ext->>Ext: 构建受限 ToolRegistry (仅 SaveMemoryTool)
|
||||
Ext->>Sub: SubAgentRunner.run(EXTRACTION_SYSTEM_PROMPT, prompt, max_steps=3)
|
||||
Note over Ext,Sub: tokio::spawn (fire-and-forget, 不阻塞压缩/ReAct 循环)
|
||||
Sub-->>Mgr: save_memory() 写入提取结果
|
||||
```
|
||||
|
||||
**与 `run_extraction()` 的区别**:
|
||||
|
||||
| 维度 | `run_extraction()` (会话结束) | `extract_memories_from_compaction()` (压缩桥接) |
|
||||
|:---|:---|:---|
|
||||
| 触发时机 | `finalize_turn()` 会话收尾 | `compress_and_restore()` 压缩完成后 |
|
||||
| 数据来源 | 完整对话历史(已持久化到 DB) | 压缩前即将被丢弃的消息快照 |
|
||||
| 消息范围 | 系统 + 用户 + 助手消息 | 仅用户 + 助手 + 工具(去除 system) |
|
||||
| 节流机制 | `ExtractionTracker` 轮次计数 | 仅靠 `EXTRACT_MEMORY_ENABLED` 开关 |
|
||||
| 阻塞行为 | fire-and-forget | fire-and-forget |
|
||||
| 子代理工具集 | 4 只读工具 + `save_memory` | 仅 `save_memory`(最小化成本) |
|
||||
| 最小内容阈值 | 无显式阈值 | 拼接文本 < 300 字符跳过 |
|
||||
|
||||
**设计原理**:
|
||||
- 压缩是最佳记忆提取时机——此时旧消息尚未被物理删除但即将从上下文中移除,记忆系统可以在"内容消失前最后一刻"将其抢救出来
|
||||
- 截断每条消息到 400 字符平衡了信息完整性和子代理 prompt 长度
|
||||
- fire-and-forget 确保压缩不因 LLM 调用延迟而阻塞主 ReAct 循环
|
||||
|
||||
## 时效性与衰减系统
|
||||
|
||||
**指数时间衰减** (`decay.rs`):
|
||||
@ -403,6 +447,20 @@ score = e^(-λ × days_old)
|
||||
→ mark_main_agent_wrote()
|
||||
→ 返回: 操作状态 + 质量警告 + 重复提示 + manifest 清单
|
||||
|
||||
会话中(压缩时) ← P3 桥接
|
||||
compact.rs: compress_context_with_hooks_and_log(messages, ...)
|
||||
→ 压缩快照前捕获消息副本 (pre_compact_snapshot)
|
||||
→ 执行压缩(snip/auto/aggro_micro)
|
||||
→ extract_memories_from_compaction(snapshot, sid, mgr, app_state)
|
||||
→ 检查 EXTRACT_MEMORY_ENABLED → false 则 return
|
||||
→ 过滤非系统消息 + 截断 400 字符 + 拼接
|
||||
→ 拼接文本 < 300 字符 → return (太少不值得提取)
|
||||
→ tokio::spawn(子代理)
|
||||
→ build_manifest_preview() 获取现有清单
|
||||
→ SubAgentRunner(仅 SaveMemoryTool, max_steps=3)
|
||||
→ save_memory() 写入提取的记忆
|
||||
→ 恢复文件缓存(不等待子代理完成)
|
||||
|
||||
会话结束
|
||||
finalize.rs: finalize_turn()
|
||||
→ 更新 agent_sessions (turn_count, metrics)
|
||||
|
||||
@ -38,11 +38,12 @@ graph TD
|
||||
end
|
||||
|
||||
subgraph CrossCutting["横切关注点"]
|
||||
Hooks["HookRegistry (9 事件)"]
|
||||
Hooks["HookRegistry (13 事件)"]
|
||||
Skills["SkillRegistry (两层加载)"]
|
||||
Memory["MemoryManager (项目记忆)"]
|
||||
Permission["PermissionChecker"]
|
||||
FileCache["FileStateCache (Read 去重)"]
|
||||
Coordinator["Coordinator Mode (协调者编排)"]
|
||||
end
|
||||
|
||||
subgraph DB["持久化"]
|
||||
@ -96,8 +97,14 @@ sequenceDiagram
|
||||
DB-->>RT: 验证存在 + 计算 turn_index
|
||||
end
|
||||
RT->>RT: 触发 OnSessionStart hook
|
||||
RT->>RT: 触发 UserPromptSubmit hook (用户输入审计)
|
||||
RT-->>FE: SSE session { session_id, title }
|
||||
|
||||
opt 协调者模式 (coordinator_mode: true)
|
||||
RT->>RT: run_coordinator_turn() → CoordinatorAgent
|
||||
Note over RT: Coordinator 委托 Worker → 合成结果
|
||||
end
|
||||
|
||||
Note over RT: Phase 2 — 上下文构建
|
||||
RT->>RT: build_initial_context()
|
||||
RT->>RT: ① 组装 SystemPrompt (静态 section + 记忆注入)
|
||||
@ -172,3 +179,29 @@ sequenceDiagram
|
||||
|
||||
---
|
||||
|
||||
### Coordinator Mode(协调者模式, P2)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User["用户请求"] --> API["POST /api/chat/agent\n{ coordinator_mode: true }"]
|
||||
API --> RT["AgentRuntime::run_coordinator_turn()"]
|
||||
RT --> CA["CoordinatorAgent (仅元工具)"]
|
||||
CA --> Tool["delegate_task / check_task / task_stop / synthesize"]
|
||||
Tool --> WP["WorkerPool (Semaphore 并发控制)"]
|
||||
WP --> W1["Worker 1 (SubAgentRunner)"]
|
||||
WP --> W2["Worker 2 (SubAgentRunner)"]
|
||||
WP --> W3["Worker N (SubAgentRunner)"]
|
||||
W1 --> Synth["synthesize 收集结果"]
|
||||
W2 --> Synth
|
||||
W3 --> Synth
|
||||
Synth --> Answer["最终答案"]
|
||||
```
|
||||
|
||||
Coordinator 仅拥有 4 个元工具,将实际研究工作委托给拥有完整工具访问权限的 Worker 子代理。Worker 通过 `SubAgentRunner` + `Semaphore` 实现并发控制(默认最大 4 并发)。
|
||||
|
||||
- **委托 → 检查 → 合成** 三步工作流
|
||||
- 前端通过 `coordinator_mode: true` 字段启用
|
||||
- 源码: `src/agent/coordinator/{agent,tools,worker,mod}.rs`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -347,11 +347,60 @@ LLM 调用 subagent(research_prompt, max_steps)
|
||||
.run(system_prompt, research_prompt, max_steps)
|
||||
```
|
||||
|
||||
## 外部集成场景
|
||||
|
||||
`SubAgentRunner` 除了通过 `SubAgentTool` 被父代理调用外,还被以下两个子系统使用:
|
||||
|
||||
### 1. Coordinator WorkerPool(P2)
|
||||
|
||||
协调者模式中,`WorkerPool::delegate()` 为每个委托任务创建一个 `SubAgentRunner` 实例:
|
||||
|
||||
```
|
||||
CoordinatorAgent
|
||||
→ delegate_task(task_description, context)
|
||||
→ WorkerPool::delegate()
|
||||
→ tokio::spawn(Worker)
|
||||
→ SubAgentRunner::new_with_registry(app_state, 完整 ToolRegistry)
|
||||
.with_parent_session(session_id)
|
||||
.with_thinking(enable_thinking)
|
||||
.run(WORKER_SYSTEM_PROMPT, task_description, worker_max_steps)
|
||||
→ 结果写入 CoordinatorTask.result
|
||||
→ synthesize()
|
||||
→ 收集所有已完成 Worker 结果 → 合成最终答案
|
||||
```
|
||||
|
||||
关键特征:
|
||||
- Worker 子代理拥有**完整工具访问权限**(与 Coordinator 的 4 个元工具相对)
|
||||
- 通过 `Semaphore` 控制最大并发(默认 4)
|
||||
- 超时保护(默认 300s),超时后标记为 `TimedOut`
|
||||
- 结果通过共享 `CoordinatorTask` 结构返回,不经过 SSE
|
||||
|
||||
### 2. 压缩记忆提取(P3 桥接)
|
||||
|
||||
`compact::extract_memories_from_compaction()` 创建仅含 `SaveMemoryTool` 的受限子代理:
|
||||
|
||||
```
|
||||
AgentRuntime::compress_and_restore()
|
||||
→ compress_context_with_hooks_and_log(...)
|
||||
→ extract_memories_from_compaction(snapshot, sid, mgr, app_state)
|
||||
→ SubAgentRunner::new_with_registry(app_state, SaveMemoryTool 仅此一个)
|
||||
.with_parent_session(sid)
|
||||
.with_thinking(false)
|
||||
.run(EXTRACTION_SYSTEM_PROMPT, prompt, max_steps=3)
|
||||
→ 写入记忆(fire-and-forget,不阻塞 ReAct 循环)
|
||||
```
|
||||
|
||||
关键特征:
|
||||
- 工具集最小化(仅 `save_memory`)——只做记忆写入,不能读文件或搜索
|
||||
- 思考模式关闭(`with_thinking(false)`)——降低提取成本
|
||||
- 内容阈值保护:拼接文本 < 300 字符时跳过
|
||||
- 与 `run_extraction()`(会话结束时触发)互为补充——详见 [memory.md](memory.md)
|
||||
|
||||
## 当前局限与改进方向
|
||||
|
||||
| # | 问题 | 影响 | 改进方向 |
|
||||
|:---|:---|:---|:---|
|
||||
| 1 | **工具串行执行** | 同一步多个工具调用无法并行,慢工具阻塞快工具 | 复用 `executor::execute_parallel`,或至少对 `is_concurrency_safe()` 工具并行 |
|
||||
| 1 | **工具串行执行** | 同一步多个工具调用无法并行,慢工具阻塞快工具 | ~~复用 executor~~ 已通过 Coordinator WorkerPool 在多 Worker 层面实现并行;单 Worker 内仍串行 |
|
||||
| 2 | **无嵌套深度限制** | 子代理可调用 subagent 创建子子代理,理论上无限递归 | 增加深度计数器,超过 2 层时移除 subagent 工具 |
|
||||
| 3 | **Token 估算粗糙** | `content.len() + 4` 对中文极不准确(中文 1 字符 ≈ 1.5-2 token) | 使用 `tiktoken-rs` 或 tokenizer 精确计数 |
|
||||
| 4 | **fire-and-forget 持久化** | DB 写入失败静默忽略,可能丢失审计数据 | 至少记录 warn 日志;关键消息可改为 await |
|
||||
|
||||
@ -222,15 +222,18 @@ log.commit(CollapseMethod::LlmSummary, (after_count, before_count), summary);
|
||||
|
||||
## Hook 系统与提示词交互
|
||||
|
||||
9 个生命周期事件:
|
||||
13 个生命周期事件:
|
||||
|
||||
| Hook | 与提示词的关系 |
|
||||
|:---|:---|
|
||||
| `OnSessionStart` | 提示词组装前触发 |
|
||||
| `UserPromptSubmit` | 用户提交提示词后、context building 前触发 (P2) |
|
||||
| `PreToolUse::MutateInput` | 注入 `additional_context`(追加为 user 消息) |
|
||||
| `PostToolUse::MutateOutput` | 修改工具输出(影响 LLM 看到的 context) |
|
||||
| `PostToolUseFailure` | 工具执行失败后修改输出 (P1) |
|
||||
| `PreCompact / PostCompact` | 压缩前后记录指标 + CollapseLog commit |
|
||||
| `OnSubagentStart/Stop` | 传递子代理 prompt 和结果摘要 |
|
||||
| `PermissionRequest / PermissionDenied` | 权限决策覆盖和审计 (P1) |
|
||||
|
||||
详见 [hooks.md](hooks.md)。
|
||||
|
||||
|
||||
@ -176,14 +176,16 @@ let tool_registry =
|
||||
|
||||
## 4 个团队工具
|
||||
|
||||
工具定义在 `src/agent/tools/team.rs`,均设置 `InterruptBehavior::Block`(中断时先完成副作用再停止):
|
||||
工具定义在 `src/agent/tools/team.rs`,均设置 `InterruptBehavior::Block`(中断时先完成副作用再停止)。P3 工具分类注解已应用:
|
||||
|
||||
| 工具名 | 参数 | 功能 | 接收者 |
|
||||
|:---|:---|:---|:---|
|
||||
| `spawn_teammate` | `name`, `role` | 启动一个后台队友 | — |
|
||||
| `send_teammate_message` | `to`, `content` | 向指定队友发送消息 | 队友收件箱 |
|
||||
| `team_broadcast` | `content` | 向所有队友广播消息 | 所有队友收件箱 |
|
||||
| `check_team_inbox` | `agent_name?` (默认 "lead") | 读取并清空收件箱 | Lead 收件箱 |
|
||||
| 工具名 | 参数 | 功能 | 接收者 | 延迟加载 | 只读 |
|
||||
|:---|:---|:---|:---|:---|:---|
|
||||
| `spawn_teammate` | `name`, `role` | 启动一个后台队友 | — | ✅ `defer_loading` | — |
|
||||
| `send_teammate_message` | `to`, `content` | 向指定队友发送消息 | 队友收件箱 | ✅ `defer_loading` | — |
|
||||
| `team_broadcast` | `content` | 向所有队友广播消息 | 所有队友收件箱 | ✅ `defer_loading` | — |
|
||||
| `check_team_inbox` | `agent_name?` (默认 "lead") | 读取并清空收件箱 | Lead 收件箱 | — | ✅ `is_readonly` |
|
||||
|
||||
> **延迟加载说明**(P3):`spawn_teammate`、`send_teammate_message`、`team_broadcast` 标记为 `defer_loading=true`,不随常驻工具注入 system prompt,仅当 LLM 判断需要多 Agent 协作时通过 `tool_catalog` 按需加载。`check_team_inbox` 标记为 `is_readonly=true`(只读取文件邮箱,不修改队友状态),在 auto 模式下可跳过权限确认。
|
||||
|
||||
典型协作流程:
|
||||
|
||||
|
||||
@ -44,6 +44,9 @@ classDiagram
|
||||
+check_permissions(args) PermissionRule[]
|
||||
+causes_sibling_abort() bool
|
||||
+execute_with_progress(args, ctx, tx) ToolOutput
|
||||
+classifier_summary() String
|
||||
+is_readonly() bool
|
||||
+defer_loading() bool
|
||||
}
|
||||
|
||||
class InterruptBehavior {
|
||||
@ -106,10 +109,13 @@ classDiagram
|
||||
| `check_permissions()` | `Vec<PermissionRule>` | `[]` | 工具自定义权限规则:`Deny{tool, reason}` / `Allow{tool}` / `Ask{tool, message}`。与 `PermissionChecker` 管道协同工作 |
|
||||
| `causes_sibling_abort()` | `bool` | `false` | 该工具错误时是否中止兄弟并行执行。用于 `download_paper`、`parse_paper` 等关键工具 |
|
||||
| `execute_with_progress(args, ctx, tx)` | `ToolOutput` | 委托 `execute()` | 长时间操作可覆写,通过 `progress_tx` 发送进度更新到前端 |
|
||||
| `classifier_summary()` | `String` | 取 `description` 第一句,截断到 100 字符 | 工具简要分类描述(~15 词),供 LLM 判断是否需要加载延迟工具 |
|
||||
| `is_readonly()` | `bool` | `false`(保守) | 是否只读(无副作用)。9 个只读工具覆写为 `true` |
|
||||
| `defer_loading()` | `bool` | `false`(常驻) | 是否延迟加载(不在初始 prompt 中)。7 个重型/小众工具覆写为 `true` |
|
||||
|
||||
---
|
||||
|
||||
## 工具清单(23 个)
|
||||
## 工具清单(24 个)
|
||||
|
||||
### 文件系统工具(6 个)— `filesystem/`
|
||||
|
||||
@ -140,7 +146,7 @@ classDiagram
|
||||
| `query_target` | ✅ | CDS Sesame 天体目标查询(IAU 名称解析 + 坐标/类型) |
|
||||
| `save_note` | ❌ | 高亮批注持久化到数据库 |
|
||||
|
||||
### Agent 自管理工具(5 个)
|
||||
### Agent 自管理工具(6 个)
|
||||
|
||||
| 工具 | 并发安全 | 中断行为 | 功能 |
|
||||
|:---|:---|:---|:---|
|
||||
@ -149,6 +155,7 @@ classDiagram
|
||||
| `load_skill` | ✅ | Cancel | 按需加载 SKILL.md 技能文件。支持 **inline** 模式(直接返回内容)和 **fork** 模式(启动子代理按技能指引执行任务) |
|
||||
| `save_memory` | ❌ | Block | 跨会话记忆持久化。写入时门控:质量检查(过短/模糊/瞬时/代码模式)+ Jaccard 70% 去重 |
|
||||
| `ask_user` | N/A | Block | 暂停 ReAct 循环向用户提问。oneshot 通道机制:创建问题 → SSE 推送前端 → 阻塞等待 → 5 分钟超时。子代理中不可用(silent 模式) |
|
||||
| `search_history` | ✅ | Cancel | FTS5 跨会话历史搜索。支持会话标题/摘要和消息内容全文检索,避免重复研究已完成的工作 |
|
||||
|
||||
### 高级编排工具(4 个)
|
||||
|
||||
@ -270,7 +277,7 @@ sequenceDiagram
|
||||
|
||||
RT->>TR: new_with_queue(queue, skill_registry)
|
||||
TR->>TR: add_base_tools()
|
||||
Note over TR: 注册 19 个基础工具
|
||||
Note over TR: 注册 19 个基础工具(不含 search_history,该工具在 Runtime 中按需注入)
|
||||
TR->>Tools: read_file, grep_files, glob_files, run_bash
|
||||
TR->>Tools: file_write, file_edit
|
||||
TR->>Tools: search_papers, get_paper_metadata
|
||||
@ -294,6 +301,15 @@ sequenceDiagram
|
||||
TR->>TR: definitions()
|
||||
Note over TR: HashMap 值收集 → 按 name 字母序排序
|
||||
Note over TR: 排序保证跨调用稳定性 → 提升 prompt cache 命中率
|
||||
|
||||
RT->>TR: resident_definitions()
|
||||
Note over TR: 返回常驻工具(defer_loading=false)→ 注入初始 prompt
|
||||
|
||||
RT->>TR: deferred_definitions()
|
||||
Note over TR: 返回延迟工具(defer_loading=true)→ 供 LLM 按需加载
|
||||
|
||||
RT->>TR: tool_catalog()
|
||||
Note over TR: 所有工具的 name + classifier_summary → 供 LLM 判断延迟工具需求
|
||||
```
|
||||
|
||||
### 注册表工厂方法
|
||||
@ -406,6 +422,43 @@ execute() → ToolOutput { content }
|
||||
|
||||
---
|
||||
|
||||
## 工具加载分类(P3 特性)
|
||||
|
||||
`classifier_summary`、`is_readonly`、`defer_loading` 三个 trait 方法共同支持两维度工具分类:
|
||||
|
||||
### 只读 vs 写入分类 (`is_readonly`)
|
||||
|
||||
只读工具无副作用(不修改文件系统、不触发下载、不写入数据库),可在 Auto 模式下跳过权限确认直接执行。
|
||||
|
||||
| 分类 | `is_readonly` | 工具 |
|
||||
|:---|:---|:---|
|
||||
| 只读 (9) | `true` | `read_file`、`grep_files`、`glob_files`、`search_papers`、`get_paper_metadata`、`get_paper_content`、`rag_search`、`query_target`、`check_team_inbox` |
|
||||
| 写入/副作用 | `false`(默认) | 其余所有工具(包括 `run_bash`、`file_write`、`download_paper`、`save_note` 等) |
|
||||
|
||||
### 常驻 vs 延迟加载 (`defer_loading`)
|
||||
|
||||
常驻工具定义注入初始系统 prompt,延迟工具仅在 LLM 需要时通过 `load_tool` 动态加载,减少 prompt 体积。
|
||||
|
||||
| 分类 | `defer_loading` | 工具 |
|
||||
|:---|:---|:---|
|
||||
| 常驻 (18) | `false`(默认) | 核心文件 I/O、论文搜索/检索、用户交互等高频工具 |
|
||||
| 延迟 (7) | `true` | `parse_paper`、`download_paper`、`bg_task_run`、`bg_task_check`、`spawn_teammate`、`send_teammate_message`、`team_broadcast`、`search_history` |
|
||||
|
||||
### 工作流
|
||||
|
||||
```
|
||||
初始化:
|
||||
resident_definitions() → 注入 system prompt(常驻工具 JSON Schema)
|
||||
tool_catalog() → name + classifier_summary 清单 → 供 LLM 决策
|
||||
|
||||
LLM 运行时:
|
||||
分析 task → 查看 catalog → 判断是否需要延迟工具
|
||||
→ 调用 defer_load_specific(name) → 注入单工具 definition
|
||||
→ 正常执行工具调用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
@ -435,5 +488,6 @@ src/agent/tools/
|
||||
├── background.rs # bg_task_run + bg_task_check — 后台异步任务
|
||||
├── team.rs # spawn/send/broadcast/check_inbox — 多 Agent 协作
|
||||
├── memory.rs # save_memory — 带质量门控的跨会话记忆
|
||||
├── search_history.rs # search_history — FTS5 跨会话历史搜索(延迟加载)
|
||||
└── persist.rs # maybe_persist_tool_result — 大输出磁盘持久化
|
||||
```
|
||||
|
||||
@ -1,10 +1,152 @@
|
||||
# AstroResearch Deployment Guide / 部署指南
|
||||
|
||||
AstroResearch 的后端服务是由 Rust 编译出的单执行文件,它内置托管了前端 React 的静态构建资源,因此生产部署十分简单。
|
||||
AstroResearch 支持两种部署方式:**Docker 容器化部署**(推荐,零依赖)和**传统源码编译部署**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 系统要求与环境依赖 (Requirements)
|
||||
## 1. Docker 部署(推荐)
|
||||
|
||||
Docker 部署无需安装 Rust/Node.js 工具链,一键启动。提供两种镜像模式。
|
||||
|
||||
### 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 配置环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env 填入 API Key
|
||||
|
||||
# 2. 启动 (Mode A — 推荐日常使用)
|
||||
docker compose up -d
|
||||
|
||||
# 3. 访问
|
||||
open http://localhost:8000
|
||||
```
|
||||
|
||||
### 两种镜像模式对比
|
||||
|
||||
| | Mode A (`Dockerfile`) | Mode B (`Dockerfile.modeB`) |
|
||||
|---|---|---|
|
||||
| **镜像大小** | ~23 MB | ~87 MB |
|
||||
| **基础镜像** | `alpine:3.21` | `distroless/cc-debian12:nonroot` |
|
||||
| **libc** | musl (静态链接) | glibc (动态链接) |
|
||||
| **二进制体积** | ~12 MB | ~59 MB |
|
||||
| **二进制包含** | Rust + C 依赖 | Rust + C + V8 + BoringSSL |
|
||||
| **运行时依赖** | 无 (静态链接) | `libc` + `libstdc++` + `libgcc_s` (已内置) |
|
||||
| **空闲内存** | ~8.6 MiB | ~8.7 MiB |
|
||||
| **Obscura** | 外部 `bin/` bind mount | 编译在二进制内 |
|
||||
| **容器 Shell** | 有 (`/bin/sh`) | 无 (distroless) |
|
||||
| **Healthcheck** | `wget` TCP probe | 编排层替代 |
|
||||
|
||||
---
|
||||
|
||||
### Mode A:Alpine 外部 Obscura(推荐日常使用)
|
||||
|
||||
镜像最小(23 MB),Obscura 作为外部二进制通过 bind mount 注入,可独立更新。
|
||||
|
||||
**构建:**
|
||||
```bash
|
||||
docker build -t astroresearch:latest .
|
||||
```
|
||||
|
||||
**运行:**
|
||||
```bash
|
||||
docker run -d --name astro -p 8000:8000 --env-file .env \
|
||||
-v ./library:/app/library \
|
||||
-v ./logs:/app/logs \
|
||||
-v ./skills:/app/skills:ro \
|
||||
-v ./bin:/app/bin:ro \
|
||||
astroresearch:latest
|
||||
```
|
||||
|
||||
> `bin/` 目录需包含编译好的 Obscura 二进制文件(`obscura` 和 `obscura-worker`)。
|
||||
|
||||
**docker-compose.yml(已内置):**
|
||||
```yaml
|
||||
services:
|
||||
astroresearch:
|
||||
build:
|
||||
context: .
|
||||
image: astroresearch:latest
|
||||
container_name: astroresearch
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- “${PORT:-8000}:8000”
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- LOG_FORMAT=json
|
||||
- LOG_OUTPUTS=stdout
|
||||
volumes:
|
||||
- ./library:/app/library
|
||||
- ./logs:/app/logs
|
||||
- ./skills:/app/skills:ro
|
||||
- ./bin:/app/bin:ro
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Mode B:Distroless 进程内 Obscura(全功能单体)
|
||||
|
||||
Obscura (V8 + BoringSSL) 编译在二进制内,单容器零外部二进制依赖。适合对外交付的一键部署包。
|
||||
|
||||
**前置条件:**
|
||||
```bash
|
||||
mkdir -p libs
|
||||
git clone https://github.com/h4ckf0r0day/obscura libs/obscura
|
||||
```
|
||||
|
||||
**构建:**
|
||||
```bash
|
||||
docker build -f Dockerfile.modeB -t astroresearch-all:latest .
|
||||
```
|
||||
|
||||
**运行:**
|
||||
```bash
|
||||
docker run -d --name astro -p 8000:8000 --env-file .env \
|
||||
-v ./library:/app/library \
|
||||
-v ./logs:/app/logs \
|
||||
-v ./skills:/app/skills:ro \
|
||||
astroresearch-all:latest
|
||||
```
|
||||
|
||||
> Mode B 基于 [Distroless](https://github.com/GoogleContainerTools/distroless) 构建,无 Shell/包管理器,安全性更高但无法 `docker exec` 进入调试。
|
||||
|
||||
---
|
||||
|
||||
### 国内网络加速
|
||||
|
||||
默认启用镜像加速(`USE_MIRRORS=1`),海外构建可通过 build-arg 禁用:
|
||||
|
||||
```bash
|
||||
docker build --build-arg USE_MIRRORS=0 -t astroresearch:latest .
|
||||
```
|
||||
|
||||
**加速源:**
|
||||
|
||||
| 工具 | 镜像 |
|
||||
|------|------|
|
||||
| npm | `registry.npmmirror.com` |
|
||||
| Alpine apk | `mirrors.aliyun.com` |
|
||||
| Debian apt | `mirrors.ustc.edu.cn` |
|
||||
|
||||
---
|
||||
|
||||
### 持久化数据卷
|
||||
|
||||
| 容器路径 | 说明 | 推荐权限 |
|
||||
|----------|------|---------|
|
||||
| `/app/library` | SQLite 数据库 + PDF/HTML 全文 | 读写 |
|
||||
| `/app/logs` | 应用日志(仅 `LOG_OUTPUTS=file` 时写入) | 读写 |
|
||||
| `/app/skills` | Agent 技能文件 (SKILL.md) | 只读 (`:ro`) |
|
||||
| `/app/bin` | Obscura 外部二进制(仅 Mode A) | 只读 (`:ro`) |
|
||||
|
||||
> **注意**:bind mount 目录的宿主权限必须允许容器内用户(uid 65532)写入。如遇 `Permission denied`,在宿主执行 `chown -R 65532 ./library ./logs`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 传统源码编译部署
|
||||
|
||||
### 系统要求
|
||||
|
||||
- **操作系统**:Linux / macOS / Windows
|
||||
- **运行环境**:
|
||||
@ -12,170 +154,146 @@ AstroResearch 的后端服务是由 Rust 编译出的单执行文件,它内置
|
||||
- Rust (1.75+) 用以编译后端 Axum 进程
|
||||
- SQLite (自动内置,无需单独部署)
|
||||
|
||||
---
|
||||
### 构建步骤
|
||||
|
||||
## 2. 生产构建步骤 (Production Build Steps)
|
||||
|
||||
### 步骤 1:构建 React 前端静态资源
|
||||
进入 `dashboard` 文件夹,安装依赖并执行编译命令。编译产物会自动输出在 `dashboard/dist` 目录下:
|
||||
**步骤 1:构建前端**
|
||||
```bash
|
||||
cd dashboard
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
产物位于 `dashboard/dist/`。
|
||||
|
||||
### 步骤 2:编译 Rust 后端二进制文件
|
||||
返回项目根目录,通过 Cargo 构建 Release 版本的执行文件。编译后的程序会内置链接 `dashboard/dist` 下的全部静态资源:
|
||||
**步骤 2:编译后端**
|
||||
```bash
|
||||
cd ..
|
||||
cargo build --release
|
||||
```
|
||||
编译产物位于 `target/release/astroresearch`。
|
||||
产物位于 `target/release/astroresearch`。
|
||||
|
||||
### 步骤 3(可选):编译健康检查工具
|
||||
如需在目标服务器上运行馆藏健康度诊断与修复:
|
||||
**步骤 3(可选):健康检查工具**
|
||||
```bash
|
||||
cargo build --release --bin health_check
|
||||
```
|
||||
编译产物位于 `target/release/health_check`。
|
||||
|
||||
### 启动
|
||||
|
||||
```bash
|
||||
cp .env.example .env # 编辑填入 API Key
|
||||
./target/release/astroresearch
|
||||
# 监听 http://localhost:8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 服务部署与启动 (Running in Production)
|
||||
## 3. Obscura 两种部署模式
|
||||
|
||||
1. 将编译出来的 `target/release/astroresearch` 二进制文件部署到目标服务器。
|
||||
2. 在二进制文件同一目录下,创建并填写 `.env` 环境变量配置文件(可从根目录的 `.env.example` 复制模板)。
|
||||
3. 确保本地相对路径下拥有天文对照词典文件 `dictionary.txt`。
|
||||
4. 运行后端服务:
|
||||
```bash
|
||||
./astroresearch
|
||||
```
|
||||
5. 进程将默认在后台启动并监听 `http://localhost:8000` 端口。你可以通过 Nginx 将此端口反向代理到公网 80/443 端口。
|
||||
系统集成 `Obscura` 无头浏览器框架,用于绕过 WAF/Cloudflare 反爬。支持两种模式:
|
||||
|
||||
---
|
||||
### 模式 A:外部命令行 (默认)
|
||||
|
||||
## 4. Obscura 两种抓取后备部署模式选择 (Obscura Deployment Modes)
|
||||
Obscura 作为独立二进制运行,与主进程隔离。适合常规生产环境。
|
||||
|
||||
系统集成了 `Obscura` 无头浏览器框架来作为遭遇 WAF/Cloudflare 反爬时的自动后备抓取通道。系统支持以下两种编译与部署模式:
|
||||
| 部署方式 | 步骤 |
|
||||
|----------|------|
|
||||
| **Docker** | `docker compose up -d`(自动 bind mount `./bin`) |
|
||||
| **源码** | 下载 obscura 到 `bin/`,赋予执行权限后启动 |
|
||||
|
||||
### 模式 A:外部命令行模式 (默认,推荐)
|
||||
该模式将主 Web 服务与 V8 浏览器运行引擎相隔离,最适合常规生产环境。它拥有最快的编译时间,且进程隔离确保无头浏览器内核异常(如 OOM 或 Panic)不会拖垮主服务器。
|
||||
|
||||
1. **编译主服务**:
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
2. **下载/配置外部二进制**:
|
||||
从 GitHub Releases 下载编译好的 `obscura-x86_64-linux.tar.gz` 压缩包,解压后将 `obscura` 和 `obscura-worker` 两个二进制文件放到项目根目录的 `bin/` 目录下:
|
||||
二进制安装(源码部署时):
|
||||
```bash
|
||||
mkdir -p bin/
|
||||
# 放入 bin/obscura 和 bin/obscura-worker,并赋予执行权限
|
||||
# 下载 obscura 和 obscura-worker 到 bin/,赋予执行权限
|
||||
chmod +x bin/obscura bin/obscura-worker
|
||||
```
|
||||
3. **运行**:
|
||||
```bash
|
||||
./target/release/astroresearch
|
||||
```
|
||||
当遭遇 WAF 拦截时,主进程将自动通过异步子进程调用 `./bin/obscura` 进行抓取。
|
||||
|
||||
### 模式 B:进程内集成模式 (In-Process Feature)
|
||||
该模式将整个无头浏览器及 V8 运行引擎直接静态链接编译进单个二进制文件中。这免去了在服务器分发和配置外部可执行程序的步骤,提供了”零配置”的部署体验。
|
||||
### 模式 B:进程内集成
|
||||
|
||||
> [!WARNING]
|
||||
> 由于需要静态链接 C++ 编写的 V8 引擎,**首次编译会额外多耗时 1 到 3 分钟**,且最终编译生成的**单体可执行文件体积会膨胀约 80MB**。
|
||||
Obscura (V8 + BoringSSL) 编译进二进制,单文件零外部依赖。
|
||||
|
||||
0. **获取 Obscura 依赖源码**(仅首次):
|
||||
`libs/` 目录未被 Git 跟踪(已列入 `.gitignore`),`git clone` 后需要单独拉取 Obscura 浏览器内核源码:
|
||||
```bash
|
||||
mkdir -p libs
|
||||
git clone https://github.com/h4ckf0r0day/obscura libs/obscura
|
||||
```
|
||||
| 部署方式 | 构建命令 |
|
||||
|----------|---------|
|
||||
| **Docker** | `docker build -f Dockerfile.modeB -t astroresearch-all:latest .` |
|
||||
| **源码** | `cargo build --release --features obscura-inprocess` |
|
||||
|
||||
1. **启用 Feature 编译**:
|
||||
在构建时指定 `--features obscura-inprocess` 特性标记:
|
||||
```bash
|
||||
cargo build --release --features obscura-inprocess
|
||||
```
|
||||
2. **运行**:
|
||||
```bash
|
||||
./target/release/astroresearch
|
||||
```
|
||||
主服务运行期间,无需在磁盘中放置任何 `bin/obscura` 二进制。当触发反爬时,系统会在后台的专有阻塞线程池上通过独立包装的单线程 runtime 驱动进程内 V8 浏览器内核直接抓取。
|
||||
> 源码编译 Mode B 需先克隆 [Obscura 源码](https://github.com/h4ckf0r0day/obscura) 到 `libs/obscura/`。同时需要 `binutils`(`nm` + `objcopy`)和 `libclang-dev` 作为构建依赖。
|
||||
|
||||
---
|
||||
|
||||
## 5. 极致内存与体积优化部署 (Ultra-Low Memory & Size Optimization)
|
||||
## 4. 极致内存与体积优化
|
||||
|
||||
对于运行在低配/低内存服务器(如 1核512M 或 1核1G 实例)的环境,系统内置了可选的编译与运行时优化策略。
|
||||
对于低配服务器(如 1核512M),系统提供 `release-min` 编译配置。
|
||||
|
||||
> [!NOTE]
|
||||
> 极致内存与体积优化编译配置(`release-min`)和“进程内集成模式 (In-Process Feature)”在**技术上可以完全兼容并共同启用**,但它们在**优化目标(指标)上是相互抵消(矛盾)的**。
|
||||
> 如果启用了进程内 V8 特性,C++ 静态链接库本身占用的 80MB+ 空间将无法被剔除,导致无法达成极致轻量化(~8.3MB)的体积指标;且 V8 运行时堆内存也会带来额外的物理内存开销。因此,为追求极致低资源消耗,建议在低配服务器上采用**“模式 A:外部命令行模式”**。
|
||||
>
|
||||
> 如果你执意要在**进程内浏览器集成下尽可能对其体积和依赖进行优化**,可以组合使用 `--profile` 与 `--features` 参数进行编译和运行:
|
||||
> ```bash
|
||||
> # 编译并打包优化后的进程内单二进制文件:
|
||||
> cargo build --profile release-min --features obscura-inprocess
|
||||
>
|
||||
> # 编译并直接运行:
|
||||
> cargo run --profile release-min --features obscura-inprocess
|
||||
> ```
|
||||
> `release-min` 与 Mode B 可共同启用,但效果有限——V8/BoringSSL 静态库占用的 ~50MB 无法被 LTO 消除。追求极致轻量建议用 **Mode A + release-min** 组合。
|
||||
|
||||
### 优化指标对比:
|
||||
* **二进制执行文件大小**:由 `17.0 MB` 压缩至 **`8.3 MB`**(缩减约 51%)。
|
||||
* **启动物理常驻内存 (RSS)**:由 `34.8 MB` 降至 **`32.9 MB`**(得益于词典加载后的容量自动收缩)。
|
||||
* **虚拟内存 (VSZ)**:由 `1.27 GB` 降至 **`302 MB`**(缩减约 76%)。
|
||||
* **数据段内存 (VmData)**:由 `60.1 MB` 降至 **`26.5 MB`**(缩减约 55%)。
|
||||
**优化指标 (Mode A, release-min):**
|
||||
|
||||
### 部署优化步骤:
|
||||
| 指标 | release | release-min | 降幅 |
|
||||
|------|---------|-------------|------|
|
||||
| 二进制大小 | 17.0 MB | 8.3 MB | 51% |
|
||||
| 启动 RSS | 34.8 MB | 32.9 MB | 5% |
|
||||
| 虚拟内存 (VSZ) | 1.27 GB | 302 MB | 76% |
|
||||
| 数据段 (VmData) | 60.1 MB | 26.5 MB | 55% |
|
||||
|
||||
1. **使用优化 Profile 进行编译**:
|
||||
在项目根目录下,使用内置的 `release-min` 编译配置:
|
||||
```bash
|
||||
# 源码编译
|
||||
cargo build --profile release-min
|
||||
```
|
||||
编译完成后的执行文件位于 `target/release-min/astroresearch`。该配置开启了 LTO(链接时优化)、剥离了调试符号,并在生成时进行了大小优化。
|
||||
|
||||
2. **限制运行时异步线程数**:
|
||||
默认情况下,异步运行时 Tokio 会根据系统的 CPU 核心数(例如 16 核)创建对应数量的 Worker 线程,这会带来很多虚拟/物理内存浪费。启动服务时,可通过注入 `TOKIO_WORKER_THREADS=1` 环境变量限制线程池大小:
|
||||
# Docker(已默认使用 release-min)
|
||||
docker build -t astroresearch:latest .
|
||||
```
|
||||
|
||||
**限制 Tokio 线程数:**
|
||||
```bash
|
||||
PORT=8000 TOKIO_WORKER_THREADS=1 ./astroresearch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 环境变量配置 (Environment Variables)
|
||||
## 5. 环境变量
|
||||
|
||||
| 变量名 | 必需 | 默认值 | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `DATABASE_URL` | 否 | `sqlite://library/astro_research.db` | SQLite 数据库连接 URL |
|
||||
| `ADS_API_KEY` | 是 | - | NASA ADS API 访问 Token |
|
||||
| `LLM_API_KEY` | 是 | - | 大语言模型 API Key |
|
||||
| `LLM_API_BASE` | 否 | `https://api.openai.com/v1` | 大语言模型 API 基础地址 |
|
||||
| `LLM_MODEL` | 否 | `gpt-4o-mini` | 翻译大模型名称 |
|
||||
| `EMBEDDING_API_KEY` | 否 | 默认与 `LLM_API_KEY` 一致 | 向量模型 API Key |
|
||||
| `EMBEDDING_API_BASE`| 否 | 默认与 `LLM_API_BASE` 一致 | 向量模型 API 基础地址 |
|
||||
| `EMBEDDING_MODEL` | 否 | `text-embedding-3-small` | 向量模型名称 |
|
||||
| `QINIU_AK` | 否 | - | 七牛云 Access Key |
|
||||
| `QINIU_SK` | 否 | - | 七牛云 Secret Key |
|
||||
| `QINIU_BUCKET` | 否 | - | 七牛云存储空间名 |
|
||||
| `QINIU_DOMAIN` | 否 | - | 七牛云外链 CDN 域名 |
|
||||
| `MINERU_API_URL` | 否 | - | MinerU PDF 解析远程 API 地址 |
|
||||
| `MINERU_API_KEY` | 否 | - | MinerU API Token |
|
||||
| `LIBRARY_DIR` | 否 | `./library` | 本地文献馆藏根目录 |
|
||||
| `PORT` | 否 | `8000` | 后端服务监听端口 |
|
||||
| `DATABASE_URL` | 否 | `sqlite:///app/library/astro_research.db` | SQLite 连接 URL |
|
||||
| `ADS_API_KEY` | 是 | - | NASA ADS API Token |
|
||||
| `LLM_API_KEY` | 是 | - | LLM API Key |
|
||||
| `LLM_API_BASE` | 否 | `https://api.openai.com/v1` | LLM API 地址 |
|
||||
| `LLM_MODEL` | 否 | `gpt-4o-mini` | 对话模型 |
|
||||
| `EMBEDDING_API_KEY` | 否 | 同 `LLM_API_KEY` | Embedding API Key |
|
||||
| `EMBEDDING_API_BASE` | 否 | 同 `LLM_API_BASE` | Embedding API 地址 |
|
||||
| `EMBEDDING_MODEL` | 否 | `text-embedding-3-small` | Embedding 模型 |
|
||||
| `LIBRARY_DIR` | 否 | `/app/library` | 文献馆藏根目录 |
|
||||
| `SKILLS_DIR` | 否 | `/app/skills` | Agent 技能目录 |
|
||||
| `LOG_DIR` | 否 | `/app/logs` | 日志目录 |
|
||||
| `LOG_FORMAT` | 否 | `json` | 日志格式 (`plain` / `json`) |
|
||||
| `LOG_OUTPUTS` | 否 | `stdout` | 日志输出 (`stdout` / `file`) |
|
||||
| `PORT` | 否 | `8000` | 监听端口 |
|
||||
| `AGENT_MAX_STEPS` | 否 | `8` | Agent 最大步数 |
|
||||
| `AGENT_TOOL_TIMEOUT_SECS` | 否 | `120` | 工具超时 (秒) |
|
||||
| `QINIU_AK` / `QINIU_SK` | 否 | - | 七牛云存储凭证 |
|
||||
| `MINERU_API_URL` | 否 | - | MinerU PDF 解析 API |
|
||||
|
||||
---
|
||||
|
||||
## 7. 健康检查与维护 (Health Check)
|
||||
|
||||
部署后可定期运行健康检查工具排查馆藏一致性问题:
|
||||
## 6. 健康检查与维护
|
||||
|
||||
**Docker 健康检查(Mode A):**
|
||||
```bash
|
||||
# 只读扫描(不修改任何数据)
|
||||
docker ps --filter “health=healthy” --filter “name=astroresearch”
|
||||
```
|
||||
|
||||
Mode B (distroless) 无内置 healthcheck,可在编排层配置:
|
||||
```yaml
|
||||
# docker-compose 或 k8s
|
||||
healthcheck:
|
||||
test: [“CMD”, “curl”, “-f”, “http://localhost:8000/”]
|
||||
```
|
||||
|
||||
**源码部署健康检查:**
|
||||
```bash
|
||||
# 只读扫描
|
||||
./health_check
|
||||
|
||||
# 自动修复(清理损坏文件、重置无效路径)
|
||||
# 自动修复
|
||||
./health_check -- --fix
|
||||
```
|
||||
|
||||
|
||||
73
migrations/20260623000000_fts5_search.sql
Normal file
73
migrations/20260623000000_fts5_search.sql
Normal file
@ -0,0 +1,73 @@
|
||||
-- FTS5 跨会话全文搜索
|
||||
-- 在 agent_sessions 和 agent_messages 上创建虚拟表,支持 BM25 排序搜索。
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS agent_sessions_fts USING fts5(
|
||||
session_id,
|
||||
title,
|
||||
summary,
|
||||
metadata,
|
||||
content='agent_sessions',
|
||||
content_rowid='rowid'
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS agent_messages_fts USING fts5(
|
||||
session_id,
|
||||
role,
|
||||
content,
|
||||
thought,
|
||||
tool_calls,
|
||||
content='agent_messages',
|
||||
content_rowid='rowid'
|
||||
);
|
||||
|
||||
-- Triggers: sessions
|
||||
CREATE TRIGGER IF NOT EXISTS sessions_fts_insert AFTER INSERT ON agent_sessions
|
||||
BEGIN
|
||||
INSERT INTO agent_sessions_fts(session_id, title, summary, metadata)
|
||||
VALUES (NEW.session_id, NEW.title, NEW.summary, NEW.metadata);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS sessions_fts_update AFTER UPDATE ON agent_sessions
|
||||
BEGIN
|
||||
UPDATE agent_sessions_fts SET
|
||||
session_id = NEW.session_id,
|
||||
title = NEW.title,
|
||||
summary = NEW.summary,
|
||||
metadata = NEW.metadata
|
||||
WHERE rowid = OLD.rowid;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS sessions_fts_delete AFTER DELETE ON agent_sessions
|
||||
BEGIN
|
||||
DELETE FROM agent_sessions_fts WHERE rowid = OLD.rowid;
|
||||
END;
|
||||
|
||||
-- Triggers: messages
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON agent_messages
|
||||
BEGIN
|
||||
INSERT INTO agent_messages_fts(session_id, role, content, thought, tool_calls)
|
||||
VALUES (NEW.session_id, NEW.role, NEW.content, NEW.thought, NEW.tool_calls);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON agent_messages
|
||||
BEGIN
|
||||
UPDATE agent_messages_fts SET
|
||||
session_id = NEW.session_id,
|
||||
role = NEW.role,
|
||||
content = NEW.content,
|
||||
thought = NEW.thought,
|
||||
tool_calls = NEW.tool_calls
|
||||
WHERE rowid = OLD.rowid;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON agent_messages
|
||||
BEGIN
|
||||
DELETE FROM agent_messages_fts WHERE rowid = OLD.rowid;
|
||||
END;
|
||||
|
||||
-- 回填已有数据
|
||||
INSERT OR IGNORE INTO agent_sessions_fts(session_id, title, summary, metadata)
|
||||
SELECT session_id, title, summary, metadata FROM agent_sessions;
|
||||
|
||||
INSERT OR IGNORE INTO agent_messages_fts(session_id, role, content, thought, tool_calls)
|
||||
SELECT session_id, role, content, thought, tool_calls FROM agent_messages;
|
||||
30
migrations/20260623000001_fix_fts5_rowid.sql
Normal file
30
migrations/20260623000001_fix_fts5_rowid.sql
Normal file
@ -0,0 +1,30 @@
|
||||
-- 修复 FTS5 全文搜索中虚拟表与原始物理表的 rowid 不一致问题
|
||||
-- 增量迁移:删除旧触发器,以正确映射 rowid 的触发器替代,并重建现有 FTS5 数据
|
||||
|
||||
-- 1. 删除旧的不安全触发器
|
||||
DROP TRIGGER IF EXISTS sessions_fts_insert;
|
||||
DROP TRIGGER IF EXISTS messages_fts_insert;
|
||||
|
||||
-- 2. 重新创建指定 rowid 列的插入触发器,确保 FTS 行 ID 与原始物理表行 ID 完全一致
|
||||
CREATE TRIGGER IF NOT EXISTS sessions_fts_insert AFTER INSERT ON agent_sessions
|
||||
BEGIN
|
||||
INSERT INTO agent_sessions_fts(rowid, session_id, title, summary, metadata)
|
||||
VALUES (NEW.rowid, NEW.session_id, NEW.title, NEW.summary, NEW.metadata);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON agent_messages
|
||||
BEGIN
|
||||
INSERT INTO agent_messages_fts(rowid, session_id, role, content, thought, tool_calls)
|
||||
VALUES (NEW.rowid, NEW.session_id, NEW.role, NEW.content, NEW.thought, NEW.tool_calls);
|
||||
END;
|
||||
|
||||
-- 3. 清理之前因不一致导致的错乱 FTS 数据
|
||||
DELETE FROM agent_sessions_fts;
|
||||
DELETE FROM agent_messages_fts;
|
||||
|
||||
-- 4. 使用正确的 rowid 重新索引现有数据
|
||||
INSERT OR IGNORE INTO agent_sessions_fts(rowid, session_id, title, summary, metadata)
|
||||
SELECT rowid, session_id, title, summary, metadata FROM agent_sessions;
|
||||
|
||||
INSERT OR IGNORE INTO agent_messages_fts(rowid, session_id, role, content, thought, tool_calls)
|
||||
SELECT rowid, session_id, role, content, thought, tool_calls FROM agent_messages;
|
||||
@ -542,6 +542,106 @@ pub async fn compress_context_with_hooks_and_log(
|
||||
COMPACTING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
// ── Compaction → Memory Extraction Bridge (P3) ──
|
||||
|
||||
/// 从压缩丢弃的消息中提取持久记忆。
|
||||
///
|
||||
/// 仅当 EXTRACT_MEMORY_ENABLED=true 时激活。提取是 fire-and-forget 的,
|
||||
/// 不阻塞压缩或后续 LLM 调用。错误会被记录并忽略。
|
||||
pub fn extract_memories_from_compaction(
|
||||
before_messages: &[ChatMessage],
|
||||
session_id: &str,
|
||||
memory_manager: std::sync::Arc<tokio::sync::Mutex<crate::agent::memory::MemoryManager>>,
|
||||
app_state: std::sync::Arc<crate::api::AppState>,
|
||||
) {
|
||||
let config = crate::agent::memory::extraction::ExtractionConfig::from_env();
|
||||
if !config.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
// 只提取非系统消息的摘要(系统消息在每次压缩后保留)
|
||||
let snippets: Vec<String> = before_messages
|
||||
.iter()
|
||||
.filter(|m| m.role != crate::clients::llm::MessageRole::System)
|
||||
.filter_map(|m| {
|
||||
m.content
|
||||
.as_ref()
|
||||
.map(|c| {
|
||||
let role_label = match m.role {
|
||||
crate::clients::llm::MessageRole::User => "用户",
|
||||
crate::clients::llm::MessageRole::Assistant => "助手",
|
||||
crate::clients::llm::MessageRole::Tool => "工具",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"[{}] {}",
|
||||
role_label,
|
||||
c.chars().take(400).collect::<String>()
|
||||
))
|
||||
})
|
||||
.unwrap_or(None)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let context_text = snippets.join("\n");
|
||||
if context_text.len() < 300 {
|
||||
return; // 太少内容,不值得提取
|
||||
}
|
||||
|
||||
let sid = session_id.to_string();
|
||||
let mem_mgr = memory_manager.clone();
|
||||
let app = app_state.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let existing_manifest = {
|
||||
let mgr = mem_mgr.lock().await;
|
||||
crate::agent::memory::dedup::build_manifest_preview(mgr.entries())
|
||||
};
|
||||
|
||||
let tool_registry = {
|
||||
let mut registry = crate::agent::tools::ToolRegistry::empty();
|
||||
registry.add_tool(Box::new(crate::agent::tools::memory::SaveMemoryTool::new(
|
||||
mem_mgr.clone(),
|
||||
)));
|
||||
registry
|
||||
};
|
||||
|
||||
let runner =
|
||||
crate::agent::subagent::SubAgentRunner::new_with_registry(app.clone(), tool_registry)
|
||||
.with_parent_session(sid.clone())
|
||||
.with_thinking(false);
|
||||
|
||||
let prompt = format!(
|
||||
"以下内容来自上下文压缩时被丢弃的对话片段。请从中提取值得持久化保存的关键信息。\n\n\
|
||||
## 现有记忆清单\n{}\n\n\
|
||||
## 被压缩的内容\n{}",
|
||||
if existing_manifest.is_empty() {
|
||||
"当前无记忆条目。"
|
||||
} else {
|
||||
&existing_manifest
|
||||
},
|
||||
context_text
|
||||
);
|
||||
|
||||
let result = runner
|
||||
.run(
|
||||
crate::agent::memory::extraction::EXTRACTION_SYSTEM_PROMPT,
|
||||
&prompt,
|
||||
3,
|
||||
)
|
||||
.await;
|
||||
|
||||
if result.is_error {
|
||||
tracing::warn!(
|
||||
"[CompactExtract] 从压缩内容提取记忆失败: {:.200}",
|
||||
result.content
|
||||
);
|
||||
} else {
|
||||
tracing::info!("[CompactExtract] 已从压缩内容提取记忆");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
354
src/agent/coordinator/agent.rs
Normal file
354
src/agent/coordinator/agent.rs
Normal file
@ -0,0 +1,354 @@
|
||||
// src/agent/coordinator/coordinator.rs
|
||||
//
|
||||
// CoordinatorAgent: 运行仅包含元工具的专门化 ReAct 循环。
|
||||
//
|
||||
// Coordinator 的系统提示词明确指导 LLM 采用"委托→检查→合成"的流程。
|
||||
// 循环比主 AgentRuntime 更简单:无压缩、无错误恢复阶梯、无 token 预算——
|
||||
// Coordinator 会话通常很短(3-5 步),这些高级特性暂不需要。
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tracing::warn;
|
||||
|
||||
use super::tools::*;
|
||||
use super::worker::WorkerPool;
|
||||
use super::CoordinatorConfig;
|
||||
use crate::agent::hooks::HookRegistry;
|
||||
use crate::agent::runtime::{AgentConfig, AgentStreamEvent};
|
||||
use crate::agent::tools::{ToolContext, ToolRegistry};
|
||||
use crate::api::AppState;
|
||||
use crate::clients::llm::ChatMessage;
|
||||
|
||||
/// Coordinator 系统提示词
|
||||
fn coordinator_system_prompt() -> String {
|
||||
String::from(
|
||||
"你是一个协调者 Agent。你的职责是分析复杂任务,将其分解为独立的子任务,\
|
||||
委托给 Worker 代理执行,然后合成最终答案。\n\n\
|
||||
## 你的工具\n\
|
||||
- **delegate_task**: 委托子任务给 Worker。\
|
||||
Worker 可以搜索文献、下载论文、解析内容等。每个 Worker 独立运行。\
|
||||
可以同时委托多个任务,它们会并行执行。\n\
|
||||
- **check_task**: 查看已委托任务的状态。如果任务仍在运行,稍后再检查。\n\
|
||||
- **task_stop**: 停止一个运行中的任务(例如中途发现方向错误)。\n\
|
||||
- **synthesize**: 在所有必需任务完成后,收集并合成结果。\n\n\
|
||||
## 工作流程\n\
|
||||
1. 分析用户请求,确定可以并行的独立子任务。\n\
|
||||
2. 使用 delegate_task 将每个子任务委托给 Worker(可一次性全部委托)。\n\
|
||||
3. 使用 check_task 监控进度,必要时等待任务完成。\n\
|
||||
4. 所有任务完成后,使用 synthesize 收集结果。\n\
|
||||
5. 基于合成结果向用户提供最终答案。\n\n\
|
||||
## 重要规则\n\
|
||||
- 最多可同时运行 4 个 Worker。\n\
|
||||
- 先委托所有任务,然后检查进度。不要在每次委托后等待。\n\
|
||||
- 只使用以上列出的工具。你的 Worker 拥有完整工具访问权限。\n\
|
||||
- 使用中文回复。\n\
|
||||
- 如果 synthesize 显示还有未完成的任务,使用 check_task 再检查一次。",
|
||||
)
|
||||
}
|
||||
|
||||
/// CoordinatorAgent 运行协调者 ReAct 循环。
|
||||
pub struct CoordinatorAgent {
|
||||
app_state: Arc<AppState>,
|
||||
config: AgentConfig,
|
||||
coordinator_config: CoordinatorConfig,
|
||||
tool_registry: ToolRegistry,
|
||||
hook_registry: HookRegistry,
|
||||
pool: Arc<WorkerPool>,
|
||||
}
|
||||
|
||||
impl CoordinatorAgent {
|
||||
/// 创建新的 CoordinatorAgent 实例。
|
||||
pub fn new(
|
||||
app_state: Arc<AppState>,
|
||||
agent_config: AgentConfig,
|
||||
coordinator_config: CoordinatorConfig,
|
||||
) -> Self {
|
||||
let pool = Arc::new(WorkerPool::new(
|
||||
app_state.clone(),
|
||||
coordinator_config.clone(),
|
||||
));
|
||||
|
||||
// 构建 coordinator-only 工具注册表
|
||||
let mut tool_registry = ToolRegistry::empty();
|
||||
tool_registry.add_tool(Box::new(DelegateTaskTool::new(
|
||||
pool.clone(),
|
||||
agent_config.enable_thinking,
|
||||
)));
|
||||
tool_registry.add_tool(Box::new(CheckTaskTool::new(pool.clone())));
|
||||
tool_registry.add_tool(Box::new(TaskStopTool::new(pool.clone())));
|
||||
tool_registry.add_tool(Box::new(SynthesizeTool::new(pool.clone())));
|
||||
|
||||
let hook_registry = HookRegistry::with_builtins(
|
||||
app_state.db.clone(),
|
||||
app_state.cancelled_runs.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
CoordinatorAgent {
|
||||
app_state,
|
||||
config: agent_config,
|
||||
coordinator_config,
|
||||
tool_registry,
|
||||
hook_registry,
|
||||
pool,
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行协调者 ReAct 循环,返回最终文本答案。
|
||||
pub async fn run(
|
||||
&self,
|
||||
session_id: &str,
|
||||
question: &str,
|
||||
turn_index: i32,
|
||||
tx: UnboundedSender<AgentStreamEvent>,
|
||||
) -> anyhow::Result<String> {
|
||||
let llm = &self.app_state.llm;
|
||||
let db = &self.app_state.db;
|
||||
|
||||
// 触发 OnSessionStart
|
||||
self.hook_registry
|
||||
.run_on_session_start(&crate::agent::hooks::SessionStartContext {
|
||||
session_id: session_id.to_string(),
|
||||
turn_index,
|
||||
is_resume: false,
|
||||
})
|
||||
.await;
|
||||
|
||||
let _ = tx.send(AgentStreamEvent::Session {
|
||||
session_id: session_id.to_string(),
|
||||
title: String::new(),
|
||||
});
|
||||
|
||||
// 发送初始 thought 给前端
|
||||
let _ = tx.send(AgentStreamEvent::Thought {
|
||||
content: "正在分析任务并规划协调者策略...".into(),
|
||||
step: 0,
|
||||
});
|
||||
|
||||
// 构建消息上下文
|
||||
let mut messages = Vec::new();
|
||||
messages.push(ChatMessage::system(coordinator_system_prompt()));
|
||||
messages.push(ChatMessage::user(question));
|
||||
|
||||
let tool_defs = self.tool_registry.definitions();
|
||||
|
||||
// 保存用户消息到数据库
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, token_count, metadata, agent_name) \
|
||||
VALUES (?, ?, ?, 'user', ?, ?, '{}', 'coordinator')",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(turn_index)
|
||||
.bind(0)
|
||||
.bind(question)
|
||||
.bind(question.len() as i32 / 4)
|
||||
.execute(db)
|
||||
.await;
|
||||
|
||||
let max_steps = self.coordinator_config.worker_max_steps;
|
||||
|
||||
// Coordinator ReAct 循环
|
||||
for step in 1..=max_steps {
|
||||
// 检查取消
|
||||
let is_cancelled = {
|
||||
if let Ok(mut c) = self.app_state.cancelled_runs.lock() {
|
||||
c.remove(session_id)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if is_cancelled {
|
||||
warn!("[Coordinator] 用户取消执行, session={}", session_id);
|
||||
let _ = tx.send(AgentStreamEvent::Error {
|
||||
message: "用户已手动中止执行。".to_string(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// LLM 流式调用
|
||||
let output = crate::agent::runtime::streaming::process_llm_stream(
|
||||
llm,
|
||||
&messages,
|
||||
&tool_defs,
|
||||
&tx,
|
||||
step,
|
||||
session_id,
|
||||
self.app_state.cancelled_runs.clone(),
|
||||
self.config.enable_thinking,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 发送 usage
|
||||
if let Some(ref usage) = output.usage {
|
||||
let _ = tx.send(AgentStreamEvent::Usage {
|
||||
prompt_tokens: usage.prompt_tokens,
|
||||
completion_tokens: usage.completion_tokens,
|
||||
total_tokens: usage.total_tokens,
|
||||
});
|
||||
}
|
||||
|
||||
let text = match &output.status {
|
||||
crate::agent::runtime::streaming::StreamStatus::Success => output.content.clone(),
|
||||
crate::agent::runtime::streaming::StreamStatus::Cancelled => {
|
||||
break;
|
||||
}
|
||||
crate::agent::runtime::streaming::StreamStatus::Error(e) => {
|
||||
warn!("[Coordinator] LLM 调用失败, step={}: {}", step, e);
|
||||
let _ = tx.send(AgentStreamEvent::Error {
|
||||
message: format!("协调者 Agent 调用失败: {}", e),
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 无工具调用 → 协调者直接返回文本答案
|
||||
if output.tool_calls.is_none() || output.tool_calls.as_ref().unwrap().is_empty() {
|
||||
// 保存 assistant 消息
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, token_count, metadata, agent_name) \
|
||||
VALUES (?, ?, ?, 'assistant', ?, ?, '{}', 'coordinator')",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(turn_index)
|
||||
.bind(step as i32)
|
||||
.bind(&text)
|
||||
.bind(text.len() as i32 / 4)
|
||||
.execute(db)
|
||||
.await;
|
||||
|
||||
return Ok(text);
|
||||
}
|
||||
|
||||
// 有工具调用 —— 保存 assistant 消息(含 tool_calls JSON)
|
||||
let tool_calls_json = serde_json::to_string(&output.tool_calls).unwrap_or_default();
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, tool_calls, token_count, metadata, agent_name) \
|
||||
VALUES (?, ?, ?, 'assistant', ?, ?, ?, '{}', 'coordinator')",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(turn_index)
|
||||
.bind(step as i32)
|
||||
.bind(&text)
|
||||
.bind(&tool_calls_json)
|
||||
.bind(text.len() as i32 / 4)
|
||||
.execute(db)
|
||||
.await;
|
||||
|
||||
// 执行工具调用(顺序执行——元工具是轻量级的)
|
||||
let tool_calls = output.tool_calls.unwrap();
|
||||
let mut tool_results = Vec::new();
|
||||
|
||||
for tc in &tool_calls {
|
||||
let tool_name = tc.function.name.clone();
|
||||
let tool_args: serde_json::Value =
|
||||
serde_json::from_str(&tc.function.arguments).unwrap_or_default();
|
||||
|
||||
let tool_call_id = if tc.id.is_empty() {
|
||||
format!("tc_{}", uuid::Uuid::new_v4())
|
||||
} else {
|
||||
tc.id.clone()
|
||||
};
|
||||
|
||||
// 发送 ToolCall SSE
|
||||
let _ = tx.send(AgentStreamEvent::ToolCall {
|
||||
id: tool_call_id.clone(),
|
||||
name: tool_name.clone(),
|
||||
arguments: tool_args.clone(),
|
||||
step,
|
||||
});
|
||||
|
||||
let tool_ctx = ToolContext {
|
||||
app_state: self.app_state.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
silent: false,
|
||||
read_file_state: Arc::new(std::sync::Mutex::new(
|
||||
crate::agent::runtime::file_cache::FileStateCache::new(),
|
||||
)),
|
||||
sse_tx: Some(tx.clone()),
|
||||
enable_thinking: self.config.enable_thinking,
|
||||
additional_allowed_dirs: self.config.additional_allowed_dirs.clone(),
|
||||
};
|
||||
|
||||
let result = match self.tool_registry.get(&tool_name) {
|
||||
Some(tool) => tool.execute(tool_args.clone(), &tool_ctx).await,
|
||||
None => crate::agent::tools::ToolOutput::error(format!(
|
||||
"未知的协调者工具: {}",
|
||||
tool_name
|
||||
)),
|
||||
};
|
||||
|
||||
// 发送 ToolResult SSE
|
||||
let _ = tx.send(AgentStreamEvent::ToolResult {
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
name: tool_name.clone(),
|
||||
output: result.content.clone(),
|
||||
is_error: result.is_error,
|
||||
metadata: result.metadata.clone(),
|
||||
step,
|
||||
});
|
||||
|
||||
// 保存 tool message
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, tool_call_id, metadata, agent_name) \
|
||||
VALUES (?, ?, ?, 'tool', ?, ?, '{}', 'coordinator')",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(turn_index)
|
||||
.bind(step as i32)
|
||||
.bind(&result.content)
|
||||
.bind(&tool_call_id)
|
||||
.execute(db)
|
||||
.await;
|
||||
|
||||
tool_results.push((tool_name, result));
|
||||
}
|
||||
|
||||
// 将 tool 结果推入消息历史
|
||||
messages.push(ChatMessage::assistant(&text));
|
||||
for (name, result) in &tool_results {
|
||||
messages.push(ChatMessage::user(format!(
|
||||
"[工具结果: {}]\n{}",
|
||||
name,
|
||||
crate::agent::tools::truncate_content(
|
||||
&result.content,
|
||||
self.config.max_tool_output_chars,
|
||||
)
|
||||
)));
|
||||
}
|
||||
|
||||
// OnStepComplete hook
|
||||
self.hook_registry
|
||||
.run_on_step_complete(&crate::agent::hooks::StepCompleteContext {
|
||||
session_id: session_id.to_string(),
|
||||
step,
|
||||
max_steps,
|
||||
messages_count: messages.len(),
|
||||
estimated_tokens: 0,
|
||||
token_limit: self.config.token_hard_limit,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// 循环耗尽 → 尝试合成剩余结果
|
||||
let completed = self.pool.completed_results().await;
|
||||
if !completed.is_empty() {
|
||||
let mut fallback = format!(
|
||||
"协调者已达到最大步数限制 ({}步)。以下是已完成的任务结果:\n\n",
|
||||
max_steps
|
||||
);
|
||||
for task in &completed {
|
||||
fallback.push_str(&format!(
|
||||
"## {}\n{}\n\n",
|
||||
task.description,
|
||||
task.result.as_deref().unwrap_or("(无结果)")
|
||||
));
|
||||
}
|
||||
Ok(fallback)
|
||||
} else {
|
||||
Ok(format!(
|
||||
"协调者已达到最大步数限制 ({}步),但未完成任何任务。请检查委托的任务是否合理。",
|
||||
max_steps
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/agent/coordinator/mod.rs
Normal file
93
src/agent/coordinator/mod.rs
Normal file
@ -0,0 +1,93 @@
|
||||
// src/agent/coordinator/mod.rs
|
||||
//
|
||||
// 协调者模式:层次化多代理编排(P2 特性, 参考 Claude Code coordinatorMode.ts)。
|
||||
//
|
||||
// Coordinator 代理仅有元工具(delegate_task, check_task, task_stop, synthesize),
|
||||
// 将实际工作委托给拥有完整工具访问权限的 Worker 代理。
|
||||
// Worker 通过 SubAgentRunner 实现上下文隔离的独立 ReAct 循环。
|
||||
|
||||
pub mod agent;
|
||||
pub mod tools;
|
||||
pub mod worker;
|
||||
|
||||
/// 协调者任务状态
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CoordinatorTaskStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed { reason: String },
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Coordinator 维护的任务条目
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoordinatorTask {
|
||||
pub id: String,
|
||||
pub description: String,
|
||||
pub worker_session_id: Option<String>,
|
||||
pub status: CoordinatorTaskStatus,
|
||||
pub result: Option<String>,
|
||||
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
/// Coordinator 配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoordinatorConfig {
|
||||
/// Worker 最大并发数
|
||||
pub max_concurrent_workers: usize,
|
||||
/// Worker 最大推理步数
|
||||
pub worker_max_steps: usize,
|
||||
/// Worker 超时时间(秒)
|
||||
pub worker_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for CoordinatorConfig {
|
||||
fn default() -> Self {
|
||||
CoordinatorConfig {
|
||||
max_concurrent_workers: 4,
|
||||
worker_max_steps: 5,
|
||||
worker_timeout_secs: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_coordinator_task_lifecycle() {
|
||||
let task = CoordinatorTask {
|
||||
id: "test_1".into(),
|
||||
description: "测试任务".into(),
|
||||
worker_session_id: None,
|
||||
status: CoordinatorTaskStatus::Pending,
|
||||
result: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
};
|
||||
assert_eq!(task.status, CoordinatorTaskStatus::Pending);
|
||||
assert!(task.result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_coordinator_config_defaults() {
|
||||
let config = CoordinatorConfig::default();
|
||||
assert_eq!(config.max_concurrent_workers, 4);
|
||||
assert_eq!(config.worker_max_steps, 5);
|
||||
assert_eq!(config.worker_timeout_secs, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_failed_status_holds_reason() {
|
||||
let status = CoordinatorTaskStatus::Failed {
|
||||
reason: "timeout".into(),
|
||||
};
|
||||
match status {
|
||||
CoordinatorTaskStatus::Failed { reason } => assert_eq!(reason, "timeout"),
|
||||
_ => panic!("expected Failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
340
src/agent/coordinator/tools.rs
Normal file
340
src/agent/coordinator/tools.rs
Normal file
@ -0,0 +1,340 @@
|
||||
// src/agent/coordinator/tools.rs
|
||||
//
|
||||
// Coordinator 元工具:delegate_task、check_task、task_stop、synthesize。
|
||||
//
|
||||
// 每个工具实现 AgentTool trait。工具间通过 Arc<WorkerPool> 共享任务板。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
use super::worker::WorkerPool;
|
||||
use crate::agent::tools::{AgentTool, InterruptBehavior, ToolContext, ToolOutput};
|
||||
|
||||
// ── delegate_task ──
|
||||
|
||||
pub struct DelegateTaskTool {
|
||||
pool: Arc<WorkerPool>,
|
||||
enable_thinking: bool,
|
||||
}
|
||||
|
||||
impl DelegateTaskTool {
|
||||
pub fn new(pool: Arc<WorkerPool>, enable_thinking: bool) -> Self {
|
||||
DelegateTaskTool {
|
||||
pool,
|
||||
enable_thinking,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentTool for DelegateTaskTool {
|
||||
fn name(&self) -> &str {
|
||||
"delegate_task"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"将子任务委托给 Worker 代理执行。Worker 拥有完整工具访问权限。\
|
||||
使用此工具将独立子任务分发给 Worker,然后使用 check_task 检查进度。\
|
||||
可以先委托所有任务再统一检查,也可以逐个委托立即检查。\
|
||||
适用于:多篇文献综述、并行数据收集、独立计算任务。"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_description": {
|
||||
"type": "string",
|
||||
"description": "要委托的任务完整描述(Worker 会据此开始工作)"
|
||||
},
|
||||
"context": {
|
||||
"type": "string",
|
||||
"description": "可选:附加上下文信息帮助 Worker 理解任务背景"
|
||||
}
|
||||
},
|
||||
"required": ["task_description"]
|
||||
})
|
||||
}
|
||||
|
||||
fn interrupt_behavior(&self) -> InterruptBehavior {
|
||||
InterruptBehavior::Cancel
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
|
||||
let task_desc = match args.get("task_description").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => return ToolOutput::error("缺少 task_description 参数"),
|
||||
};
|
||||
let context = args.get("context").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let full_desc = if context.is_empty() {
|
||||
task_desc
|
||||
} else {
|
||||
format!("{}\n\n附加上下文:{}", task_desc, context)
|
||||
};
|
||||
|
||||
let task_id = self
|
||||
.pool
|
||||
.delegate(&full_desc, &ctx.session_id, self.enable_thinking)
|
||||
.await;
|
||||
|
||||
info!("[Coordinator] 委托任务: id={}", task_id);
|
||||
ToolOutput::success(
|
||||
format!(
|
||||
"任务已委托给 Worker。task_id: {}\n使用 check_task(task_id=\"{}\") 查看进度。",
|
||||
task_id, task_id
|
||||
),
|
||||
json!({"task_id": task_id}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── check_task ──
|
||||
|
||||
pub struct CheckTaskTool {
|
||||
pool: Arc<WorkerPool>,
|
||||
}
|
||||
|
||||
impl CheckTaskTool {
|
||||
pub fn new(pool: Arc<WorkerPool>) -> Self {
|
||||
CheckTaskTool { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentTool for CheckTaskTool {
|
||||
fn name(&self) -> &str {
|
||||
"check_task"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"查询已委托任务的当前状态和结果。返回 Pending/Running/Completed/Failed/TimedOut。\
|
||||
对于已完成的任务,结果包含在返回中。"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "要查询的任务 ID(delegate_task 返回的)"
|
||||
}
|
||||
},
|
||||
"required": ["task_id"]
|
||||
})
|
||||
}
|
||||
|
||||
fn interrupt_behavior(&self) -> InterruptBehavior {
|
||||
InterruptBehavior::Cancel
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput {
|
||||
let task_id = match args.get("task_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return ToolOutput::error("缺少 task_id 参数"),
|
||||
};
|
||||
|
||||
match self.pool.check(task_id).await {
|
||||
Some(task) => {
|
||||
let status_str = match &task.status {
|
||||
super::CoordinatorTaskStatus::Pending => "Pending",
|
||||
super::CoordinatorTaskStatus::Running => "Running",
|
||||
super::CoordinatorTaskStatus::Completed => "Completed",
|
||||
super::CoordinatorTaskStatus::Failed { .. } => "Failed",
|
||||
super::CoordinatorTaskStatus::TimedOut => "TimedOut",
|
||||
};
|
||||
ToolOutput::success(
|
||||
format!(
|
||||
"任务 {}: {}\n描述: {}\n结果: {}",
|
||||
task.id,
|
||||
status_str,
|
||||
task.description,
|
||||
task.result.as_deref().unwrap_or("(尚无结果)")
|
||||
),
|
||||
json!({
|
||||
"task": {
|
||||
"id": task.id,
|
||||
"status": status_str,
|
||||
"description": task.description,
|
||||
"result": task.result,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
None => ToolOutput::error(format!("未找到 task_id: {}", task_id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── task_stop ──
|
||||
|
||||
pub struct TaskStopTool {
|
||||
pool: Arc<WorkerPool>,
|
||||
}
|
||||
|
||||
impl TaskStopTool {
|
||||
pub fn new(pool: Arc<WorkerPool>) -> Self {
|
||||
TaskStopTool { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentTool for TaskStopTool {
|
||||
fn name(&self) -> &str {
|
||||
"task_stop"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"停止一个正在运行的任务(标记为失败)。如果任务已完成,此操作无效果。\
|
||||
适用于发现 Worker 方向错误时提前终止。"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "要停止的任务 ID"
|
||||
}
|
||||
},
|
||||
"required": ["task_id"]
|
||||
})
|
||||
}
|
||||
|
||||
fn interrupt_behavior(&self) -> InterruptBehavior {
|
||||
InterruptBehavior::Cancel
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput {
|
||||
let task_id = match args.get("task_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return ToolOutput::error("缺少 task_id 参数"),
|
||||
};
|
||||
|
||||
match self.pool.check(task_id).await {
|
||||
Some(_task) => {
|
||||
// 软取消 —— 标记为 Failed。
|
||||
// 完整的取消需要 CancellationToken 传入 Worker,这留待 P3 增强。
|
||||
let mut tasks = self.pool.tasks.write().await;
|
||||
if let Some(t) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||
if matches!(
|
||||
t.status,
|
||||
super::CoordinatorTaskStatus::Running
|
||||
| super::CoordinatorTaskStatus::Pending
|
||||
) {
|
||||
t.status = super::CoordinatorTaskStatus::Failed {
|
||||
reason: "协调者手动取消".into(),
|
||||
};
|
||||
t.completed_at = Some(chrono::Utc::now());
|
||||
}
|
||||
}
|
||||
ToolOutput::success(
|
||||
format!("任务 {} 已标记为取消。", task_id),
|
||||
json!({"status": "cancelled", "task_id": task_id}),
|
||||
)
|
||||
}
|
||||
None => ToolOutput::error(format!("未找到 task_id: {}", task_id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── synthesize ──
|
||||
|
||||
pub struct SynthesizeTool {
|
||||
pool: Arc<WorkerPool>,
|
||||
}
|
||||
|
||||
impl SynthesizeTool {
|
||||
pub fn new(pool: Arc<WorkerPool>) -> Self {
|
||||
SynthesizeTool { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentTool for SynthesizeTool {
|
||||
fn name(&self) -> &str {
|
||||
"synthesize"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"收集所有已完成 Worker 任务的结果并合成最终答案。\
|
||||
在所有委托的任务完成后调用此工具,将各 Worker 结果合并为连贯的最终输出。\
|
||||
如果还有未完成的任务,工具会明确提示。"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
})
|
||||
}
|
||||
|
||||
fn interrupt_behavior(&self) -> InterruptBehavior {
|
||||
InterruptBehavior::Cancel
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput {
|
||||
let all = self.pool.all_tasks().await;
|
||||
let completed: Vec<_> = self.pool.completed_results().await;
|
||||
let pending: Vec<_> = all
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
matches!(
|
||||
t.status,
|
||||
super::CoordinatorTaskStatus::Pending | super::CoordinatorTaskStatus::Running
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
if !pending.is_empty() {
|
||||
output.push_str(&format!(
|
||||
"⚠ 还有 {} 个任务未完成: {}\n\n",
|
||||
pending.len(),
|
||||
pending
|
||||
.iter()
|
||||
.map(|t| t.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if completed.is_empty() {
|
||||
output.push_str("尚无已完成的任务结果可供合成。");
|
||||
} else {
|
||||
output.push_str(&format!("已完成 {} 个任务的结果合成:\n\n", completed.len()));
|
||||
for task in &completed {
|
||||
output.push_str(&format!(
|
||||
"## {}\n{}\n\n---\n",
|
||||
task.description.chars().take(120).collect::<String>(),
|
||||
task.result.as_deref().unwrap_or("(无结果)")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 收集结构化结果
|
||||
let combined_results: Vec<serde_json::Value> = completed
|
||||
.iter()
|
||||
.map(|t| {
|
||||
json!({
|
||||
"task_id": t.id,
|
||||
"description": t.description,
|
||||
"result": t.result,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
ToolOutput::success(
|
||||
format!(
|
||||
"[协调者合成结果]\n\n{} 个任务已完成。请基于以上结果向用户提供最终答案。",
|
||||
completed.len()
|
||||
),
|
||||
json!({"synthesized": output, "completed_tasks": combined_results}),
|
||||
)
|
||||
}
|
||||
}
|
||||
159
src/agent/coordinator/worker.rs
Normal file
159
src/agent/coordinator/worker.rs
Normal file
@ -0,0 +1,159 @@
|
||||
// src/agent/coordinator/worker.rs
|
||||
//
|
||||
// WorkerPool 管理 spawned Worker 子代理的生命周期。
|
||||
//
|
||||
// 每个 Worker 是独立的 SubAgentRunner,拥有完整工具访问权限。
|
||||
// WorkerPool 通过 Semaphore 限制并发数,通过共享任务板跟踪进度。
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{RwLock, Semaphore};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::{CoordinatorConfig, CoordinatorTask, CoordinatorTaskStatus};
|
||||
use crate::agent::subagent::SubAgentRunner;
|
||||
use crate::agent::tools::ToolRegistry;
|
||||
use crate::api::AppState;
|
||||
|
||||
/// Worker 系统提示词 —— 中文,要求 Worker 专注执行并直接返回结果
|
||||
const WORKER_SYSTEM_PROMPT: &str = "\
|
||||
你是一个专注于完成特定子任务的研究助手。你的协调者已经分配了一项任务给你。\
|
||||
请专注于完成该任务,使用所有可用工具,完成后直接返回结果。\
|
||||
不要询问澄清问题——如果你需要做出假设,请记录在结果中。\
|
||||
使用中文回复。";
|
||||
|
||||
/// WorkerPool 管理并发 Worker 子代理。
|
||||
pub struct WorkerPool {
|
||||
app_state: Arc<AppState>,
|
||||
config: CoordinatorConfig,
|
||||
/// 并发信号量 —— 限制同时运行的 worker 数量(Arc 支持在 spawn 中安全共享)
|
||||
concurrency_limiter: Arc<Semaphore>,
|
||||
/// 共享任务板
|
||||
pub(crate) tasks: Arc<RwLock<Vec<CoordinatorTask>>>,
|
||||
}
|
||||
|
||||
impl WorkerPool {
|
||||
pub fn new(app_state: Arc<AppState>, config: CoordinatorConfig) -> Self {
|
||||
let max = config.max_concurrent_workers;
|
||||
WorkerPool {
|
||||
app_state,
|
||||
config,
|
||||
concurrency_limiter: Arc::new(Semaphore::new(max)),
|
||||
tasks: Arc::new(RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 委托任务给 Worker 子代理。非阻塞 —— 立即返回 task_id。
|
||||
pub async fn delegate(
|
||||
&self,
|
||||
task_description: &str,
|
||||
session_id: &str,
|
||||
enable_thinking: bool,
|
||||
) -> String {
|
||||
let task_id = format!("coord_task_{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
|
||||
let task = CoordinatorTask {
|
||||
id: task_id.clone(),
|
||||
description: task_description.to_string(),
|
||||
worker_session_id: None,
|
||||
status: CoordinatorTaskStatus::Pending,
|
||||
result: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
};
|
||||
{
|
||||
let mut tasks = self.tasks.write().await;
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
let tasks_arc = self.tasks.clone();
|
||||
let limiter = self.concurrency_limiter.clone();
|
||||
let app_state = self.app_state.clone();
|
||||
let worker_max_steps = self.config.worker_max_steps;
|
||||
let worker_timeout = self.config.worker_timeout_secs;
|
||||
let task_id_clone = task_id.clone();
|
||||
let session_id_owned = session_id.to_string();
|
||||
let task_desc_owned = task_description.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// 获取并发许可
|
||||
let _permit = limiter
|
||||
.acquire()
|
||||
.await
|
||||
.expect("semaphore should not be closed");
|
||||
|
||||
// 更新状态为 Running
|
||||
{
|
||||
let mut tasks = tasks_arc.write().await;
|
||||
if let Some(t) = tasks.iter_mut().find(|t| t.id == task_id_clone) {
|
||||
t.status = CoordinatorTaskStatus::Running;
|
||||
t.started_at = Some(chrono::Utc::now());
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 Worker SubAgentRunner(使用完整 ToolRegistry)
|
||||
let skill_registry = app_state.skill_registry.clone();
|
||||
let tool_registry = ToolRegistry::new(skill_registry);
|
||||
let runner = SubAgentRunner::new_with_registry(app_state, tool_registry)
|
||||
.with_parent_session(session_id_owned.clone())
|
||||
.with_thinking(enable_thinking);
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(worker_timeout),
|
||||
runner.run(WORKER_SYSTEM_PROMPT, &task_desc_owned, worker_max_steps),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut tasks = tasks_arc.write().await;
|
||||
if let Some(t) = tasks.iter_mut().find(|t| t.id == task_id_clone) {
|
||||
if let CoordinatorTaskStatus::Failed { reason } = &t.status {
|
||||
if reason == "协调者手动取消" {
|
||||
return;
|
||||
}
|
||||
}
|
||||
match result {
|
||||
Ok(tool_output) if !tool_output.is_error => {
|
||||
t.status = CoordinatorTaskStatus::Completed;
|
||||
t.result = Some(tool_output.content);
|
||||
info!("[Coordinator] Worker 完成: task={}", task_id_clone);
|
||||
}
|
||||
Ok(tool_output) => {
|
||||
t.status = CoordinatorTaskStatus::Failed {
|
||||
reason: tool_output.content.chars().take(200).collect(),
|
||||
};
|
||||
warn!("[Coordinator] Worker 失败: task={}", task_id_clone);
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
t.status = CoordinatorTaskStatus::TimedOut;
|
||||
t.result = Some("Worker 执行超时".to_string());
|
||||
warn!("[Coordinator] Worker 超时: task={}", task_id_clone);
|
||||
}
|
||||
}
|
||||
t.completed_at = Some(chrono::Utc::now());
|
||||
}
|
||||
});
|
||||
|
||||
task_id
|
||||
}
|
||||
|
||||
/// 查询任务状态
|
||||
pub async fn check(&self, task_id: &str) -> Option<CoordinatorTask> {
|
||||
let tasks = self.tasks.read().await;
|
||||
tasks.iter().find(|t| t.id == task_id).cloned()
|
||||
}
|
||||
|
||||
/// 获取所有已完成任务的结果
|
||||
pub async fn completed_results(&self) -> Vec<CoordinatorTask> {
|
||||
let tasks = self.tasks.read().await;
|
||||
tasks
|
||||
.iter()
|
||||
.filter(|t| matches!(t.status, CoordinatorTaskStatus::Completed))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 获取所有任务的快照
|
||||
pub async fn all_tasks(&self) -> Vec<CoordinatorTask> {
|
||||
let tasks = self.tasks.read().await;
|
||||
tasks.clone()
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,7 @@ use super::{
|
||||
PostToolUseContext, PostToolUseFailureContext, PostToolUseResult, PreCompactContext,
|
||||
PreToolUseAction, PreToolUseContext, PreToolUseResult, SessionStartContext, SessionStopContext,
|
||||
StepCompleteContext, SubagentStartContext, SubagentStopContext, TaggedContext,
|
||||
DEFAULT_HOOK_TIMEOUT,
|
||||
UserPromptSubmitContext, DEFAULT_HOOK_TIMEOUT,
|
||||
};
|
||||
|
||||
use serde_json;
|
||||
@ -94,6 +94,27 @@ impl super::HookRegistry {
|
||||
join_all(futures).await;
|
||||
}
|
||||
|
||||
/// 调用所有 on_user_prompt_submit hooks(并行执行,含 session hooks)。
|
||||
/// 属于 fire-and-forget 事件——结果被丢弃,仅用于审计/日志。
|
||||
pub async fn run_on_user_prompt_submit(&self, ctx: &UserPromptSubmitContext) {
|
||||
let hooks = self.collect_hooks_for(HookEvent::UserPromptSubmit, &ctx.session_id);
|
||||
if hooks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let ctx_clone = ctx.clone();
|
||||
let futures: Vec<_> = hooks
|
||||
.iter()
|
||||
.map(|hook| {
|
||||
let ctx = ctx_clone.clone();
|
||||
let timeout_dur = hook.timeout().unwrap_or(DEFAULT_HOOK_TIMEOUT);
|
||||
async move {
|
||||
let _ = tokio_timeout(timeout_dur, hook.on_user_prompt_submit(&ctx)).await;
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
join_all(futures).await;
|
||||
}
|
||||
|
||||
/// 调用所有 pre_tool_use hooks(并行执行,各自有独立超时)。
|
||||
///
|
||||
/// 所有订阅了 PreToolUse 的 hooks(含 session hooks)并行运行,每个 hook 包装在
|
||||
|
||||
@ -16,7 +16,7 @@ use serde_json::Value;
|
||||
/// # 示例
|
||||
///
|
||||
/// ```
|
||||
/// use crate::agent::hooks::matcher::ToolNamePattern;
|
||||
/// use astroresearch::agent::hooks::matcher::ToolNamePattern;
|
||||
/// let pat = ToolNamePattern::parse("file_*");
|
||||
/// assert!(pat.matches("file_write"));
|
||||
/// assert!(!pat.matches("search_papers"));
|
||||
@ -72,7 +72,7 @@ impl ToolNamePattern {
|
||||
/// # 示例
|
||||
///
|
||||
/// ```
|
||||
/// use crate::agent::hooks::matcher::{ToolMatchFilter, ToolNamePattern};
|
||||
/// use astroresearch::agent::hooks::matcher::{ToolMatchFilter, ToolNamePattern};
|
||||
///
|
||||
/// // 仅匹配文件相关工具
|
||||
/// let filter = ToolMatchFilter {
|
||||
|
||||
@ -34,7 +34,7 @@ pub use types::{
|
||||
PostToolUseFailureContext, PostToolUseResult, PreCompactContext, PreToolUseAction,
|
||||
PreToolUseContext, PreToolUseResult, SessionStartContext, SessionStopContext,
|
||||
StepCompleteContext, SubagentStartContext, SubagentStopContext, TaggedContext,
|
||||
DEFAULT_HOOK_TIMEOUT,
|
||||
UserPromptSubmitContext, DEFAULT_HOOK_TIMEOUT,
|
||||
};
|
||||
|
||||
// Traits
|
||||
@ -370,6 +370,37 @@ mod tests {
|
||||
// If no panic, all hooks were called successfully
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_prompt_submit_hook_called() {
|
||||
struct PromptTrackingHook {
|
||||
prompts: std::sync::Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentHook for PromptTrackingHook {
|
||||
fn name(&self) -> &str {
|
||||
"prompt_tracker"
|
||||
}
|
||||
async fn on_user_prompt_submit(&self, ctx: &UserPromptSubmitContext) {
|
||||
self.prompts.lock().unwrap().push(ctx.prompt.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let hook = PromptTrackingHook {
|
||||
prompts: std::sync::Mutex::new(Vec::new()),
|
||||
};
|
||||
|
||||
let mut registry = HookRegistry::new();
|
||||
registry.add(Box::new(hook));
|
||||
|
||||
let ctx = UserPromptSubmitContext {
|
||||
session_id: "test_sid".into(),
|
||||
prompt: "Hello, agent!".into(),
|
||||
turn_index: 2,
|
||||
};
|
||||
registry.run_on_user_prompt_submit(&ctx).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_match_filter_skips_irrelevant() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
@ -76,6 +76,7 @@ impl HookRegistry {
|
||||
// 空切片 = 订阅所有事件
|
||||
for event in &[
|
||||
HookEvent::OnSessionStart,
|
||||
HookEvent::UserPromptSubmit,
|
||||
HookEvent::PreToolUse,
|
||||
HookEvent::PostToolUse,
|
||||
HookEvent::PostToolUseFailure,
|
||||
@ -104,6 +105,7 @@ impl HookRegistry {
|
||||
if subs.is_empty() {
|
||||
for event in &[
|
||||
HookEvent::OnSessionStart,
|
||||
HookEvent::UserPromptSubmit,
|
||||
HookEvent::PreToolUse,
|
||||
HookEvent::PostToolUse,
|
||||
HookEvent::PostToolUseFailure,
|
||||
|
||||
@ -11,6 +11,7 @@ use super::types::{
|
||||
PostCompactContext, PostToolUseAction, PostToolUseContext, PostToolUseFailureContext,
|
||||
PreCompactContext, PreToolUseAction, PreToolUseContext, SessionStartContext,
|
||||
SessionStopContext, StepCompleteContext, SubagentStartContext, SubagentStopContext,
|
||||
UserPromptSubmitContext,
|
||||
};
|
||||
|
||||
// ── Hook Trait ──
|
||||
@ -50,6 +51,10 @@ pub trait AgentHook: Send + Sync {
|
||||
/// 会话创建/恢复时调用。
|
||||
async fn on_session_start(&self, _ctx: &SessionStartContext) {}
|
||||
|
||||
/// 用户提交新提示词时调用(fire-and-forget)。
|
||||
/// 在 context building 之前触发,hook 可以记录日志、触发边车操作或注入审计上下文。
|
||||
async fn on_user_prompt_submit(&self, _ctx: &UserPromptSubmitContext) {}
|
||||
|
||||
/// 工具执行前调用。可返回 Continue/Block/MutateInput/PermissionRequired。
|
||||
async fn pre_tool_use(&self, _ctx: &PreToolUseContext) -> PreToolUseAction {
|
||||
PreToolUseAction::Continue
|
||||
|
||||
@ -23,6 +23,9 @@ pub const DEFAULT_HOOK_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum HookEvent {
|
||||
OnSessionStart,
|
||||
/// 用户提交新提示词时触发(在 Agent 处理之前,fire-and-forget)。
|
||||
/// Hook 可以检查或记录用户输入,适合审计、内容过滤、上下文预注入。
|
||||
UserPromptSubmit,
|
||||
PreToolUse,
|
||||
PostToolUse,
|
||||
PostToolUseFailure,
|
||||
@ -50,6 +53,18 @@ pub struct SessionStartContext {
|
||||
pub is_resume: bool,
|
||||
}
|
||||
|
||||
/// UserPromptSubmit hook 上下文。
|
||||
/// 在用户提交新提示词后、Agent 开始处理前触发。
|
||||
/// 属于 fire-and-forget 事件——hook 不能修改提示词,但可用于审计和日志记录。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserPromptSubmitContext {
|
||||
pub session_id: String,
|
||||
/// 用户原始提问文本
|
||||
pub prompt: String,
|
||||
/// 当前轮次索引
|
||||
pub turn_index: i32,
|
||||
}
|
||||
|
||||
/// PreToolUse hook 上下文
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreToolUseContext {
|
||||
@ -246,6 +261,7 @@ impl BlockingError {
|
||||
pub fn event_label(event: HookEvent) -> &'static str {
|
||||
match event {
|
||||
HookEvent::OnSessionStart => "SessionStart",
|
||||
HookEvent::UserPromptSubmit => "UserPromptSubmit",
|
||||
HookEvent::PreToolUse => "PreToolUse",
|
||||
HookEvent::PostToolUse => "PostToolUse",
|
||||
HookEvent::PostToolUseFailure => "PostToolUseFailure",
|
||||
|
||||
@ -80,7 +80,7 @@ pub struct ExtractionTracker {
|
||||
}
|
||||
|
||||
/// 提取系统提示词
|
||||
const EXTRACTION_SYSTEM_PROMPT: &str = "\
|
||||
pub const EXTRACTION_SYSTEM_PROMPT: &str = "\
|
||||
你是一个记忆提取助手。分析最近的对话,提取值得持久化保存的信息。
|
||||
|
||||
## 记忆类型
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
pub mod autonomous;
|
||||
pub mod background;
|
||||
pub mod compact;
|
||||
pub mod coordinator;
|
||||
pub mod hooks;
|
||||
pub mod memory;
|
||||
pub mod runtime;
|
||||
|
||||
@ -40,7 +40,9 @@ use tracing::{error, info, warn};
|
||||
|
||||
use super::background::BgNotificationQueue;
|
||||
use super::compact;
|
||||
use super::hooks::{HookRegistry, SessionStartContext, StepCompleteContext};
|
||||
use super::hooks::{
|
||||
HookRegistry, SessionStartContext, StepCompleteContext, UserPromptSubmitContext,
|
||||
};
|
||||
use super::terminal::TurnTerminal;
|
||||
use super::tools::ToolRegistry;
|
||||
use crate::api::AppState;
|
||||
@ -313,6 +315,8 @@ pub struct AgentRuntime {
|
||||
collapse_log: Arc<std::sync::Mutex<compact::collapse::CollapseLog>>,
|
||||
/// Checkpoint 管理器(跨 turn 共享,文件变更操作前自动快照)
|
||||
checkpoint_manager: Arc<checkpoint::CheckpointManager>,
|
||||
/// 是否启用协调者模式(Coordinator delegates to Workers)
|
||||
coordinator_mode: bool,
|
||||
}
|
||||
|
||||
impl AgentRuntime {
|
||||
@ -370,6 +374,7 @@ impl AgentRuntime {
|
||||
prompt_cache: std::sync::Mutex::new(SystemPromptCache::new()),
|
||||
collapse_log: Arc::new(std::sync::Mutex::new(compact::collapse::CollapseLog::new())),
|
||||
checkpoint_manager,
|
||||
coordinator_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -424,6 +429,7 @@ impl AgentRuntime {
|
||||
prompt_cache: std::sync::Mutex::new(SystemPromptCache::new()),
|
||||
collapse_log: Arc::new(std::sync::Mutex::new(compact::collapse::CollapseLog::new())),
|
||||
checkpoint_manager,
|
||||
coordinator_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -442,6 +448,41 @@ impl AgentRuntime {
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置是否启用协调者模式
|
||||
pub fn with_coordinator_mode(mut self, enabled: bool) -> Self {
|
||||
self.coordinator_mode = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
// ── Coordinator Mode ──
|
||||
|
||||
/// 运行协调者模式 turn:创建 CoordinatorAgent 并委托执行。
|
||||
async fn run_coordinator_turn(
|
||||
&self,
|
||||
session_info: &session::SessionInfo,
|
||||
question: &str,
|
||||
tx: mpsc::UnboundedSender<AgentStreamEvent>,
|
||||
) -> anyhow::Result<String> {
|
||||
use super::coordinator::agent::CoordinatorAgent;
|
||||
use super::coordinator::CoordinatorConfig;
|
||||
|
||||
let coordinator_config = CoordinatorConfig::default();
|
||||
let coordinator = CoordinatorAgent::new(
|
||||
self.app_state.clone(),
|
||||
self.config.clone(),
|
||||
coordinator_config,
|
||||
);
|
||||
|
||||
coordinator
|
||||
.run(
|
||||
&session_info.session_id,
|
||||
question,
|
||||
session_info.turn_index,
|
||||
tx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Private Helpers ──
|
||||
|
||||
/// 执行文件缓存快照 → 压缩 → 恢复 → 上下文注入 的完整周期。
|
||||
@ -466,6 +507,10 @@ impl AgentRuntime {
|
||||
}
|
||||
};
|
||||
|
||||
// 压缩前捕获消息快照(用于记忆提取桥接,P3)
|
||||
let pre_compact_snapshot: Vec<crate::clients::llm::ChatMessage> =
|
||||
messages.iter().cloned().collect();
|
||||
|
||||
compact::compress_context_with_hooks_and_log(
|
||||
messages,
|
||||
llm,
|
||||
@ -476,6 +521,14 @@ impl AgentRuntime {
|
||||
)
|
||||
.await;
|
||||
|
||||
// 压缩后提取记忆(P3 桥接:将丢弃的消息内容喂给记忆提取子代理)
|
||||
compact::extract_memories_from_compaction(
|
||||
&pre_compact_snapshot,
|
||||
session_id,
|
||||
self.app_state.memory_manager.clone(),
|
||||
self.app_state.clone(),
|
||||
);
|
||||
|
||||
// ── 文件缓存恢复(压缩后:重新注入最近文件 + 恢复缓存)──
|
||||
{
|
||||
if let Ok(mut cache) = self.read_file_state.lock() {
|
||||
@ -517,6 +570,11 @@ impl AgentRuntime {
|
||||
// Phase 1: 创建或恢复会话
|
||||
let session_info = session::create_or_resume_session(db, session_id.clone(), llm).await?;
|
||||
|
||||
// 协调者模式分支:委托给 CoordinatorAgent
|
||||
if self.coordinator_mode {
|
||||
return self.run_coordinator_turn(&session_info, question, tx).await;
|
||||
}
|
||||
|
||||
// 构建 hook 注册表(注入依赖,复用 AgentRuntime 的 metrics_data)
|
||||
let hook_registry = HookRegistry::with_builtins(
|
||||
db.clone(),
|
||||
@ -533,6 +591,15 @@ impl AgentRuntime {
|
||||
})
|
||||
.await;
|
||||
|
||||
// 触发 UserPromptSubmit —— hooks 可在上下文构建前检查/记录用户输入
|
||||
hook_registry
|
||||
.run_on_user_prompt_submit(&UserPromptSubmitContext {
|
||||
session_id: session_info.session_id.clone(),
|
||||
prompt: question.to_string(),
|
||||
turn_index: session_info.turn_index,
|
||||
})
|
||||
.await;
|
||||
|
||||
let _ = tx.send(AgentStreamEvent::Session {
|
||||
session_id: session_info.session_id.clone(),
|
||||
title: String::new(),
|
||||
@ -566,6 +633,7 @@ impl AgentRuntime {
|
||||
|
||||
// Phase 4: 会话收尾(传入实际的终止原因 + trajectory 导出参数)
|
||||
let system_prompt = self.system_prompt();
|
||||
let model_name = self.app_state.llm.model();
|
||||
finalize::finalize_turn(
|
||||
db,
|
||||
&session_info.session_id,
|
||||
@ -575,7 +643,7 @@ impl AgentRuntime {
|
||||
&hook_registry,
|
||||
loop_terminal,
|
||||
Some(&self.app_state.config.library_dir),
|
||||
Some(self.app_state.llm.model()),
|
||||
Some(&model_name),
|
||||
Some(&system_prompt),
|
||||
Some(self.app_state.clone()),
|
||||
)
|
||||
@ -1164,14 +1232,25 @@ impl AgentRuntime {
|
||||
if matches!(error_kind, ErrorKind::Overloaded) {
|
||||
consecutive_overloads += 1;
|
||||
if consecutive_overloads >= 3 {
|
||||
if let Ok(fallback) = std::env::var("FALLBACK_MODEL") {
|
||||
let fallback = &self.app_state.config.llm_fallback_model;
|
||||
if !fallback.is_empty() {
|
||||
warn!(
|
||||
"[AgentRuntime] 连续 {} 次过载,切换到备用模型: {}",
|
||||
consecutive_overloads, fallback
|
||||
);
|
||||
// Note: The LlmClient model is immutable. In production,
|
||||
// this would require a model-override capable client.
|
||||
// For now, log and continue retrying with current model.
|
||||
llm.set_model(fallback.clone());
|
||||
consecutive_overloads = 0;
|
||||
} else if !self.app_state.config.llm_fallback_chain.is_empty() {
|
||||
let idx = ((consecutive_overloads as usize - 3)
|
||||
% self.app_state.config.llm_fallback_chain.len())
|
||||
.min(self.app_state.config.llm_fallback_chain.len() - 1);
|
||||
let alt = &self.app_state.config.llm_fallback_chain[idx];
|
||||
warn!(
|
||||
"[AgentRuntime] 连续 {} 次过载,从链中切换: {}",
|
||||
consecutive_overloads, alt
|
||||
);
|
||||
llm.set_model(alt.clone());
|
||||
consecutive_overloads = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1390,22 +1469,10 @@ impl AgentRuntime {
|
||||
let env_section = cache.get_or_compute("environment", || self.build_environment_section());
|
||||
sp.add_section("environment", env_section);
|
||||
|
||||
// 工具列表:ToolRegistry 在 session 内不变
|
||||
// 工具目录:使用 tool_catalog() 列出常驻+延迟工具(P3 defer_loading 集成)
|
||||
let tools_section = cache.get_or_compute("tools", || {
|
||||
let mut tools_desc = String::from("你可以使用以下工具:\n");
|
||||
for def in self.tool_registry.definitions() {
|
||||
let short_desc: String = def
|
||||
.function
|
||||
.description
|
||||
.split('。')
|
||||
.next()
|
||||
.unwrap_or(&def.function.description)
|
||||
.chars()
|
||||
.take(80)
|
||||
.collect();
|
||||
tools_desc.push_str(&format!("- {}: {}\n", def.function.name, short_desc));
|
||||
}
|
||||
tools_desc
|
||||
let catalog = self.tool_registry.tool_catalog();
|
||||
format!("你可以使用以下工具([deferred] 标记的工具需要通过 load_skill 发现详情):\n{}", catalog)
|
||||
});
|
||||
sp.add_section("tools", tools_section);
|
||||
|
||||
|
||||
@ -88,6 +88,10 @@ impl AgentTool for GetPaperContentTool {
|
||||
Err(e) => ToolOutput::error(format!("获取文献内容失败,读取本地文件错误: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_readonly(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// ── DownloadPaperTool ──
|
||||
@ -158,6 +162,10 @@ impl AgentTool for DownloadPaperTool {
|
||||
Err(e) => ToolOutput::error(format!("文献 {} 下载失败: {}", bibcode, e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn defer_loading(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// ── ParsePaperTool ──
|
||||
@ -244,4 +252,8 @@ impl AgentTool for ParsePaperTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn defer_loading(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,4 +101,8 @@ impl AgentTool for RagSearchTool {
|
||||
Err(e) => ToolOutput::error(format!("RAG 语义检索失败: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_readonly(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,6 +125,10 @@ impl AgentTool for SearchPapersTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_readonly(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetPaperMetadataTool ──
|
||||
@ -186,4 +190,8 @@ impl AgentTool for GetPaperMetadataTool {
|
||||
Err(e) => ToolOutput::error(format!("获取文献 {} 元数据失败: {}", bibcode, e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_readonly(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,4 +86,8 @@ impl AgentTool for QueryTargetTool {
|
||||
Err(e) => ToolOutput::error(format!("天体 '{}' 查询失败: {}", object_name, e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_readonly(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,6 +110,10 @@ impl AgentTool for BgTaskRunTool {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn defer_loading(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台任务查询工具
|
||||
@ -203,4 +207,8 @@ impl AgentTool for BgTaskCheckTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn defer_loading(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@ -97,4 +97,8 @@ impl AgentTool for GlobFilesTool {
|
||||
Err(e) => ToolOutput::error(format!("glob 模式无效: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_readonly(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,6 +118,10 @@ impl AgentTool for GrepFilesTool {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_readonly(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl GrepFilesTool {
|
||||
|
||||
@ -162,4 +162,8 @@ impl AgentTool for ReadFileTool {
|
||||
Err(e) => ToolOutput::error(format!("读取文件失败: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_readonly(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ mod compress;
|
||||
mod filesystem;
|
||||
pub mod memory;
|
||||
pub mod persist;
|
||||
pub mod search_history;
|
||||
mod skill;
|
||||
pub mod subagent;
|
||||
mod team;
|
||||
@ -43,6 +44,7 @@ pub use compress::CompressTool;
|
||||
pub use filesystem::{
|
||||
FileEditTool, FileWriteTool, GlobFilesTool, GrepFilesTool, ReadFileTool, RunBashTool,
|
||||
};
|
||||
pub use search_history::SearchHistoryTool;
|
||||
pub use skill::LoadSkillTool;
|
||||
pub use subagent::SubAgentTool;
|
||||
pub use team::{CheckTeamInboxTool, SendTeammateMessageTool, SpawnTeammateTool, TeamBroadcastTool};
|
||||
@ -264,6 +266,32 @@ pub trait AgentTool: Send + Sync {
|
||||
) -> ToolOutput {
|
||||
self.execute(args, ctx).await
|
||||
}
|
||||
|
||||
// ── P3 优化:延迟加载与分类器支持 ──
|
||||
|
||||
/// 工具简要分类描述(~15 词),供 LLM 判断是否需要加载延迟工具。
|
||||
/// 默认取 description 的第一句,截断到 100 字符。
|
||||
fn classifier_summary(&self) -> String {
|
||||
self.description()
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(100)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 该工具是否为只读(无副作用)。auto-mode 分类器和权限系统可用此信息。
|
||||
/// 默认 false(保守——必须显式选择加入)。
|
||||
fn is_readonly(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// 该工具是否应延迟加载(不在初始 tool definitions 中,通过 tool_search 可发现)。
|
||||
/// 默认 false(常驻——始终在提示词中)。
|
||||
fn defer_loading(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具注册表,管理所有可用工具。
|
||||
@ -306,6 +334,7 @@ fn add_base_tools(registry: &mut ToolRegistry, skill_registry: Arc<RwLock<SkillR
|
||||
Box::new(AskUserTool),
|
||||
Box::new(LoadSkillTool::new(skill_registry)),
|
||||
Box::new(SubAgentTool::new()),
|
||||
Box::new(SearchHistoryTool),
|
||||
];
|
||||
for tool in tools {
|
||||
registry.ordered_names.push(tool.name().to_string());
|
||||
@ -506,6 +535,40 @@ impl ToolRegistry {
|
||||
self
|
||||
}
|
||||
|
||||
/// 返回只常驻工具定义(defer_loading() == false)。
|
||||
/// 这是每次 LLM 调用的主工具列表。
|
||||
pub fn resident_definitions(&self) -> Vec<ToolDefinition> {
|
||||
self.tools
|
||||
.values()
|
||||
.filter(|t| !t.defer_loading())
|
||||
.map(|t| ToolDefinition::new(t.name(), t.description(), t.parameters()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 返回延迟加载工具定义(defer_loading() == true)。
|
||||
pub fn deferred_definitions(&self) -> Vec<ToolDefinition> {
|
||||
self.tools
|
||||
.values()
|
||||
.filter(|t| t.defer_loading())
|
||||
.map(|t| ToolDefinition::new(t.name(), t.description(), t.parameters()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 构建紧凑的"可用工具目录"字符串,供系统提示词使用。
|
||||
/// 格式: "tool_name: classifier_summary",按名称排序,延迟工具加 [deferred] 前缀。
|
||||
pub fn tool_catalog(&self) -> String {
|
||||
let mut entries: Vec<String> = self
|
||||
.tools
|
||||
.values()
|
||||
.map(|t| {
|
||||
let prefix = if t.defer_loading() { "[deferred]" } else { "" };
|
||||
format!("{}{}: {}", prefix, t.name(), t.classifier_summary())
|
||||
})
|
||||
.collect();
|
||||
entries.sort();
|
||||
entries.join("\n")
|
||||
}
|
||||
|
||||
/// 返回当前所有工具名称列表
|
||||
pub fn tool_names(&self) -> Vec<String> {
|
||||
self.ordered_names.clone()
|
||||
@ -560,7 +623,7 @@ mod tests {
|
||||
"./skills",
|
||||
)))));
|
||||
let defs = registry.definitions();
|
||||
assert_eq!(defs.len(), 19);
|
||||
assert_eq!(defs.len(), 20);
|
||||
assert!(defs.iter().any(|d| d.function.name == "read_file"));
|
||||
assert!(defs.iter().any(|d| d.function.name == "grep_files"));
|
||||
assert!(defs.iter().any(|d| d.function.name == "glob_files"));
|
||||
|
||||
136
src/agent/tools/search_history.rs
Normal file
136
src/agent/tools/search_history.rs
Normal file
@ -0,0 +1,136 @@
|
||||
// src/agent/tools/search_history.rs
|
||||
//
|
||||
// search_history 工具:搜索跨会话历史记录(P3 FTS5 集成)。
|
||||
// Agent 可用此工具查找之前研究过的主题、已发现的结论。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{AgentTool, ToolContext, ToolOutput};
|
||||
|
||||
pub struct SearchHistoryTool;
|
||||
|
||||
#[async_trait]
|
||||
impl AgentTool for SearchHistoryTool {
|
||||
fn name(&self) -> &str {
|
||||
"search_history"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"搜索之前会话的历史记录,查找已研究过的主题、已发现的结论、已下载的文献。\
|
||||
使用此工具可以避免重复研究已经完成的工作。"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "搜索关键词(支持 FTS5 查询语法,多个词用空格分隔)"
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": ["sessions", "messages", "all"],
|
||||
"description": "搜索范围:sessions=会话标题/摘要, messages=消息内容, all=全部",
|
||||
"default": "all"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_readonly(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn defer_loading(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
|
||||
let query = match args.get("query").and_then(|v| v.as_str()) {
|
||||
Some(q) if !q.is_empty() => q,
|
||||
_ => return ToolOutput::error("缺少 query 参数"),
|
||||
};
|
||||
let scope = args.get("scope").and_then(|v| v.as_str()).unwrap_or("all");
|
||||
|
||||
let db = &ctx.app_state.db;
|
||||
let mut results = Vec::new();
|
||||
|
||||
if scope == "all" || scope == "sessions" {
|
||||
match sqlx::query_as::<_, (String, String, String)>(
|
||||
"SELECT s.session_id, s.title, \
|
||||
snippet(agent_sessions_fts, 1, '<mark>', '</mark>', '...', 40) \
|
||||
FROM agent_sessions_fts fts \
|
||||
JOIN agent_sessions s ON s.session_id = fts.session_id \
|
||||
WHERE agent_sessions_fts MATCH $1 ORDER BY rank LIMIT 10",
|
||||
)
|
||||
.bind(query)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => {
|
||||
for (sid, title, snippet) in rows {
|
||||
results.push(json!({
|
||||
"type": "session",
|
||||
"session_id": sid,
|
||||
"title": title,
|
||||
"snippet": snippet,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[SearchHistory] 搜索会话失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if scope == "all" || scope == "messages" {
|
||||
match sqlx::query_as::<_, (String, String, String, String)>(
|
||||
"SELECT fts.session_id, s.title, \
|
||||
snippet(agent_messages_fts, 2, '<mark>', '</mark>', '...', 80), fts.role \
|
||||
FROM agent_messages_fts fts \
|
||||
JOIN agent_sessions s ON s.session_id = fts.session_id \
|
||||
JOIN agent_messages m ON m.rowid = fts.rowid \
|
||||
WHERE agent_messages_fts MATCH $1 AND m.active = 1 \
|
||||
ORDER BY rank LIMIT 10",
|
||||
)
|
||||
.bind(query)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => {
|
||||
for (sid, title, snippet, role) in rows {
|
||||
results.push(json!({
|
||||
"type": format!("message/{}", role),
|
||||
"session_id": sid,
|
||||
"title": title,
|
||||
"snippet": snippet,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[SearchHistory] 搜索消息失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if results.is_empty() {
|
||||
ToolOutput::success(
|
||||
format!("未找到与 \"{}\" 相关的历史记录。", query),
|
||||
json!({"query": query, "results": [], "count": 0}),
|
||||
)
|
||||
} else {
|
||||
let count = results.len();
|
||||
ToolOutput::success(
|
||||
format!("找到 {} 条与 \"{}\" 相关的历史记录", count, query),
|
||||
json!({"query": query, "results": results, "count": count}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -88,6 +88,10 @@ impl AgentTool for SpawnTeammateTool {
|
||||
None => ToolOutput::error("团队管理器未初始化"),
|
||||
}
|
||||
}
|
||||
|
||||
fn defer_loading(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送消息工具
|
||||
@ -216,6 +220,10 @@ impl AgentTool for TeamBroadcastTool {
|
||||
None => ToolOutput::error("团队管理器未初始化"),
|
||||
}
|
||||
}
|
||||
|
||||
fn defer_loading(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查收件箱工具
|
||||
@ -235,6 +243,10 @@ impl AgentTool for CheckTeamInboxTool {
|
||||
"check_team_inbox"
|
||||
}
|
||||
|
||||
fn is_readonly(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"检查收件箱,获取队友发来的消息。读取后消息会被清空。不指定 agent 时检查 lead 的收件箱。"
|
||||
}
|
||||
|
||||
@ -29,6 +29,9 @@ pub struct AgentChatRequest {
|
||||
/// 是否启用 LLM 思考模式(默认关闭)
|
||||
#[serde(default)]
|
||||
pub thinking: bool,
|
||||
/// 是否启用协调者模式(Coordinator delegates to Workers)
|
||||
#[serde(default)]
|
||||
pub coordinator_mode: bool,
|
||||
}
|
||||
|
||||
pub async fn chat_agent(
|
||||
@ -40,7 +43,9 @@ pub async fn chat_agent(
|
||||
req.question, req.session_id
|
||||
);
|
||||
|
||||
let runtime = AgentRuntime::new(Arc::clone(&state)).with_thinking(req.thinking);
|
||||
let runtime = AgentRuntime::new(Arc::clone(&state))
|
||||
.with_thinking(req.thinking)
|
||||
.with_coordinator_mode(req.coordinator_mode);
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<AgentStreamEvent>();
|
||||
|
||||
let question = req.question.clone();
|
||||
|
||||
184
src/api/auth.rs
Normal file
184
src/api/auth.rs
Normal file
@ -0,0 +1,184 @@
|
||||
// src/api/auth.rs
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header, header::HeaderMap, header::HeaderValue, Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
// 辅助函数:从 Cookie 字符串中解析出 session_id 的值
|
||||
fn parse_session_cookie(cookie_str: &str) -> Option<String> {
|
||||
for cookie in cookie_str.split(';') {
|
||||
let parts: Vec<&str> = cookie.split('=').map(|s| s.trim()).collect();
|
||||
if parts.len() == 2 && parts[0] == "session_id" {
|
||||
return Some(parts[1].to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// 辅助函数:清除过期的会话记录(24小时未活跃即过期)
|
||||
fn prune_expired_sessions(
|
||||
sessions: &mut std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
|
||||
) {
|
||||
let now = chrono::Utc::now();
|
||||
let expiry_duration = chrono::Duration::hours(24);
|
||||
sessions.retain(|_, last_active| {
|
||||
if let Some(dur) = now.signed_duration_since(*last_active).to_std().ok() {
|
||||
dur < expiry_duration.to_std().unwrap_or(std::time::Duration::from_secs(86400))
|
||||
} else {
|
||||
true // 尚未到达或时钟回拨,保留
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 登录接口:验证密码成功后,写入 HttpOnly Cookie
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let password = req.password.unwrap_or_default();
|
||||
if password == state.config.admin_password {
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// 异常安全地记录活跃会话及其当前时间
|
||||
if let Ok(mut sessions) = state.sessions.lock() {
|
||||
sessions.insert(token.clone(), chrono::Utc::now());
|
||||
}
|
||||
|
||||
// 构建 Set-Cookie 报头
|
||||
// 书签脚本等跨站第三方页面调用要求 SameSite=None 和 Secure
|
||||
// localhost 在现代浏览器中即使使用 HTTP 协议,其 Secure Cookie 也会被正常放行并读取
|
||||
let cookie_value = format!(
|
||||
"session_id={}; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=86400",
|
||||
token
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(val) = HeaderValue::from_str(&cookie_value) {
|
||||
headers.insert(header::SET_COOKIE, val);
|
||||
}
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
Json(LoginResponse {
|
||||
token: token.clone(),
|
||||
status: "ok".to_string(),
|
||||
}),
|
||||
))
|
||||
} else {
|
||||
Err((StatusCode::UNAUTHORIZED, "密码错误".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// 登出接口:清除内存会话并使 Cookie 失效
|
||||
pub async fn logout(
|
||||
State(state): State<Arc<AppState>>,
|
||||
req: Request<axum::body::Body>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let mut token_to_remove = None;
|
||||
|
||||
// 优先从 Authorization 提取 Token(向前兼容旧 Bearer Header)
|
||||
let auth_header = req.headers()
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|val| val.to_str().ok());
|
||||
|
||||
if let Some(auth_str) = auth_header {
|
||||
if auth_str.starts_with("Bearer ") {
|
||||
token_to_remove = Some(auth_str[7..].to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 其次从 Cookie 中读取
|
||||
if token_to_remove.is_none() {
|
||||
if let Some(cookie_header) = req.headers().get(axum::http::header::COOKIE) {
|
||||
if let Ok(cookie_str) = cookie_header.to_str() {
|
||||
token_to_remove = parse_session_cookie(cookie_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref token) = token_to_remove {
|
||||
if let Ok(mut sessions) = state.sessions.lock() {
|
||||
sessions.remove(token);
|
||||
}
|
||||
}
|
||||
|
||||
// 通过 Max-Age=0 清除浏览器端的 Cookie
|
||||
let mut headers = HeaderMap::new();
|
||||
let delete_cookie = "session_id=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0";
|
||||
if let Ok(val) = HeaderValue::from_str(delete_cookie) {
|
||||
headers.insert(header::SET_COOKIE, val);
|
||||
}
|
||||
|
||||
Ok((headers, Json(serde_json::json!({ "status": "ok" }))))
|
||||
}
|
||||
|
||||
// 鉴权状态检查接口
|
||||
pub async fn check_auth() -> Result<impl IntoResponse, StatusCode> {
|
||||
Ok(Json(serde_json::json!({ "authenticated": true })))
|
||||
}
|
||||
|
||||
// 鉴权验证中间件
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<Arc<AppState>>,
|
||||
req: axum::extract::Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let mut token = None;
|
||||
|
||||
// 1. 优先从 Authorization 头读取 Bearer Token(兼容)
|
||||
let auth_header = req.headers()
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|val| val.to_str().ok());
|
||||
|
||||
if let Some(auth_str) = auth_header {
|
||||
if auth_str.starts_with("Bearer ") {
|
||||
token = Some(auth_str[7..].to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 其次从 Cookie 头读取 session_id
|
||||
if token.is_none() {
|
||||
if let Some(cookie_header) = req.headers().get(axum::http::header::COOKIE) {
|
||||
if let Ok(cookie_str) = cookie_header.to_str() {
|
||||
token = parse_session_cookie(cookie_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut is_valid = false;
|
||||
if let Some(tok) = token {
|
||||
if let Ok(mut sessions) = state.sessions.lock() {
|
||||
// 顺便执行过期会话自动清理垃圾收集
|
||||
prune_expired_sessions(&mut sessions);
|
||||
|
||||
if let Some(last_active) = sessions.get_mut(&tok) {
|
||||
// 会话存在且未过期,更新最后活跃时间
|
||||
*last_active = chrono::Utc::now();
|
||||
is_valid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_valid {
|
||||
next.run(req).await
|
||||
} else {
|
||||
StatusCode::UNAUTHORIZED.into_response()
|
||||
}
|
||||
}
|
||||
@ -75,6 +75,8 @@ pub struct AppState {
|
||||
pub sse_broadcast: Option<broadcast::Sender<AppEvent>>,
|
||||
/// 项目记忆管理器(跨会话持久化)
|
||||
pub memory_manager: Arc<tokio::sync::Mutex<MemoryManager>>,
|
||||
/// 活跃的登录会话 Token 及其最后活跃时间(单用户内存管理)
|
||||
pub sessions: Arc<std::sync::Mutex<std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>>>,
|
||||
}
|
||||
|
||||
// 统一标准化的文献格式,用于向前端传输
|
||||
@ -103,10 +105,12 @@ pub struct StandardPaper {
|
||||
}
|
||||
|
||||
pub mod agent;
|
||||
pub mod auth;
|
||||
pub mod helpers;
|
||||
pub mod notes;
|
||||
pub mod papers;
|
||||
pub mod permissions;
|
||||
pub mod search;
|
||||
pub mod sync;
|
||||
pub mod targets;
|
||||
|
||||
@ -120,6 +124,7 @@ pub mod handlers {
|
||||
MessageRecord, RestoreResponse, RetryResponse, RewindRequest, RewindResponse,
|
||||
SessionDetail, SessionListParams, SessionSummary,
|
||||
};
|
||||
pub use super::auth::{check_auth, login, logout};
|
||||
pub use super::helpers::{
|
||||
check_paper_paths_in_db, convert_ads_doc_to_standard, convert_arxiv_to_standard,
|
||||
get_paper_from_db, save_paper_to_db,
|
||||
@ -137,6 +142,7 @@ pub mod handlers {
|
||||
TranslateResponse,
|
||||
};
|
||||
pub use super::permissions::{update_permission_mode, update_permission_rules};
|
||||
pub use super::search::{search as search_history, SearchParams as SearchHistoryParams};
|
||||
pub use super::sync::{
|
||||
delete_sync_query, get_asset_batch_status, get_meta_sync_count, get_meta_sync_status,
|
||||
get_sync_queries, run_asset_batch, run_meta_sync, stop_asset_batch, AssetBatchRunRequest,
|
||||
|
||||
150
src/api/search.rs
Normal file
150
src/api/search.rs
Normal file
@ -0,0 +1,150 @@
|
||||
// src/api/search.rs
|
||||
//
|
||||
// 跨会话全文搜索 API(P3 特性)。
|
||||
// 使用 FTS5 BM25 排序搜索 agent_sessions 和 agent_messages。
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchParams {
|
||||
/// 搜索关键词(支持 FTS5 查询语法)
|
||||
pub q: String,
|
||||
/// 搜索范围:sessions, messages, all(默认 all)
|
||||
#[serde(default = "default_scope")]
|
||||
pub scope: String,
|
||||
/// 最大结果数(默认 20,最大 100)
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
/// 可选:限定到特定会话
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
fn default_scope() -> String {
|
||||
"all".into()
|
||||
}
|
||||
fn default_limit() -> i64 {
|
||||
20
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SearchResult {
|
||||
pub result_type: String,
|
||||
pub session_id: String,
|
||||
pub title: Option<String>,
|
||||
pub snippet: String,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/search
|
||||
pub async fn search(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<SearchParams>,
|
||||
) -> Result<Json<Vec<SearchResult>>, (StatusCode, String)> {
|
||||
let scope = ¶ms.scope;
|
||||
let limit = params.limit.min(100);
|
||||
let mut results = Vec::new();
|
||||
|
||||
let db = &state.db;
|
||||
|
||||
// 搜索会话
|
||||
if scope == "all" || scope == "sessions" {
|
||||
let rows = sqlx::query_as::<_, (String, String, String, Option<String>)>(
|
||||
"SELECT s.session_id, s.title, \
|
||||
snippet(agent_sessions_fts, 1, '<mark>', '</mark>', '...', 40) as snippet, \
|
||||
s.created_at \
|
||||
FROM agent_sessions_fts fts \
|
||||
JOIN agent_sessions s ON s.session_id = fts.session_id \
|
||||
WHERE agent_sessions_fts MATCH $1 \
|
||||
ORDER BY rank LIMIT $2",
|
||||
)
|
||||
.bind(¶ms.q)
|
||||
.bind(limit)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("搜索会话失败: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
for (sid, title, snippet, created_at) in rows {
|
||||
results.push(SearchResult {
|
||||
result_type: "session".into(),
|
||||
session_id: sid,
|
||||
title: Some(title),
|
||||
snippet,
|
||||
created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索消息
|
||||
if scope == "all" || scope == "messages" {
|
||||
let msg_rows = if let Some(ref sid) = params.session_id {
|
||||
sqlx::query_as::<_, (String, String, String, String, Option<String>)>(
|
||||
"SELECT fts.session_id, s.title, \
|
||||
snippet(agent_messages_fts, 2, '<mark>', '</mark>', '...', 60) as snippet, \
|
||||
fts.role, m.created_at \
|
||||
FROM agent_messages_fts fts \
|
||||
JOIN agent_sessions s ON s.session_id = fts.session_id \
|
||||
JOIN agent_messages m ON m.rowid = fts.rowid \
|
||||
WHERE agent_messages_fts MATCH $1 AND fts.session_id = $3 \
|
||||
AND m.active = 1 \
|
||||
ORDER BY rank LIMIT $2",
|
||||
)
|
||||
.bind(¶ms.q)
|
||||
.bind(limit)
|
||||
.bind(sid)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("搜索消息失败: {}", e),
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
sqlx::query_as::<_, (String, String, String, String, Option<String>)>(
|
||||
"SELECT fts.session_id, s.title, \
|
||||
snippet(agent_messages_fts, 2, '<mark>', '</mark>', '...', 60) as snippet, \
|
||||
fts.role, m.created_at \
|
||||
FROM agent_messages_fts fts \
|
||||
JOIN agent_sessions s ON s.session_id = fts.session_id \
|
||||
JOIN agent_messages m ON m.rowid = fts.rowid \
|
||||
WHERE agent_messages_fts MATCH $1 AND m.active = 1 \
|
||||
ORDER BY rank LIMIT $2",
|
||||
)
|
||||
.bind(¶ms.q)
|
||||
.bind(limit)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("搜索消息失败: {}", e),
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
for (sid, title, snippet, role, created_at) in msg_rows {
|
||||
results.push(SearchResult {
|
||||
result_type: format!("message/{}", role),
|
||||
session_id: sid,
|
||||
title: Some(title),
|
||||
snippet,
|
||||
created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(results))
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tracing::error;
|
||||
|
||||
/// 消息角色枚举(OpenAI 兼容)
|
||||
@ -206,7 +207,7 @@ pub enum StreamEvent {
|
||||
pub struct LlmClient {
|
||||
api_key: String,
|
||||
api_base: String,
|
||||
model: String,
|
||||
model: Arc<std::sync::RwLock<String>>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
@ -215,13 +216,21 @@ impl LlmClient {
|
||||
LlmClient {
|
||||
api_key,
|
||||
api_base,
|
||||
model,
|
||||
model: Arc::new(std::sync::RwLock::new(model)),
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &str {
|
||||
&self.model
|
||||
pub fn model(&self) -> String {
|
||||
self.model.read().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
/// 运行时切换模型(用于故障转移)。
|
||||
pub fn set_model(&self, model: String) {
|
||||
if let Ok(mut m) = self.model.write() {
|
||||
*m = model;
|
||||
tracing::info!("[LlmClient] 模型切换为: {}", m);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn api_base(&self) -> &str {
|
||||
@ -240,7 +249,7 @@ impl LlmClient {
|
||||
let url = format!("{}/chat/completions", self.api_base);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"model": self.model,
|
||||
"model": self.model(),
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@ -309,7 +318,7 @@ impl LlmClient {
|
||||
};
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"model": self.model,
|
||||
"model": self.model(),
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
@ -383,7 +392,7 @@ impl LlmClient {
|
||||
let url = format!("{}/chat/completions", self.api_base);
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"model": self.model,
|
||||
"model": self.model(),
|
||||
"messages": messages,
|
||||
"temperature": 0.3
|
||||
});
|
||||
@ -391,7 +400,7 @@ impl LlmClient {
|
||||
// 前端可控的思考模式开关(仅对千问/DashScope 启用 enable_thinking 参数)(仅对千问/DashScope 启用 enable_thinking 参数)
|
||||
if enable_thinking
|
||||
&& (self.api_base.contains("dashscope.aliyuncs.com")
|
||||
|| self.model.to_lowercase().contains("qwen"))
|
||||
|| self.model().to_lowercase().contains("qwen"))
|
||||
{
|
||||
if let Some(obj) = payload.as_object_mut() {
|
||||
obj.insert("enable_thinking".to_string(), serde_json::json!(true));
|
||||
@ -463,7 +472,7 @@ impl LlmClient {
|
||||
let url = format!("{}/chat/completions", self.api_base);
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"model": self.model,
|
||||
"model": self.model(),
|
||||
"messages": messages,
|
||||
"temperature": 0.3,
|
||||
"stream": true,
|
||||
@ -473,7 +482,7 @@ impl LlmClient {
|
||||
// 前端可控的思考模式开关(仅对千问/DashScope 启用 enable_thinking 参数)
|
||||
if enable_thinking
|
||||
&& (self.api_base.contains("dashscope.aliyuncs.com")
|
||||
|| self.model.to_lowercase().contains("qwen"))
|
||||
|| self.model().to_lowercase().contains("qwen"))
|
||||
{
|
||||
if let Some(obj) = payload.as_object_mut() {
|
||||
obj.insert("enable_thinking".to_string(), serde_json::json!(true));
|
||||
@ -757,7 +766,7 @@ impl EmbeddingClient {
|
||||
let url = format!("{}/embeddings", self.api_base);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"model": self.model,
|
||||
"model": self.model(),
|
||||
"input": text,
|
||||
});
|
||||
|
||||
|
||||
18
src/lib.rs
18
src/lib.rs
@ -10,6 +10,8 @@ pub struct Config {
|
||||
pub llm_api_key: String, // 大语言模型 API Key
|
||||
pub llm_api_base: String, // 大语言模型 API 基础地址
|
||||
pub llm_model: String, // 调用的翻译大模型名称
|
||||
pub llm_fallback_model: String, // 故障转移备用模型
|
||||
pub llm_fallback_chain: Vec<String>, // 故障转移模型链(按顺序尝试)
|
||||
pub embedding_api_key: String, // 向量模型 API Key
|
||||
pub embedding_api_base: String, // 向量模型 API 基础地址
|
||||
pub embedding_model: String, // 向量模型名称
|
||||
@ -22,6 +24,7 @@ pub struct Config {
|
||||
pub library_dir: PathBuf, // 本地文献馆藏根目录
|
||||
pub skills_dir: PathBuf, // Agent Skills 目录(Markdown 知识模块)
|
||||
pub port: u16, // 后端服务监听端口
|
||||
pub admin_password: String, // 单用户管理员登录密码
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@ -36,6 +39,15 @@ impl Config {
|
||||
let llm_api_base =
|
||||
env::var("LLM_API_BASE").unwrap_or_else(|_| "https://api.openai.com/v1".to_string());
|
||||
let llm_model = env::var("LLM_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string());
|
||||
let llm_fallback_model = env::var("LLM_FALLBACK_MODEL")
|
||||
.or_else(|_| env::var("FALLBACK_MODEL"))
|
||||
.unwrap_or_default();
|
||||
let llm_fallback_chain = env::var("LLM_FALLBACK_CHAIN")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
let embedding_api_key =
|
||||
env::var("EMBEDDING_API_KEY").unwrap_or_else(|_| llm_api_key.clone());
|
||||
@ -63,12 +75,17 @@ impl Config {
|
||||
.parse::<u16>()
|
||||
.unwrap_or(8000);
|
||||
|
||||
let admin_password = env::var("ADMIN_PASSWORD")
|
||||
.unwrap_or_else(|_| "admin".to_string());
|
||||
|
||||
Config {
|
||||
database_url,
|
||||
ads_api_key,
|
||||
llm_api_key,
|
||||
llm_api_base,
|
||||
llm_model,
|
||||
llm_fallback_model,
|
||||
llm_fallback_chain,
|
||||
embedding_api_key,
|
||||
embedding_api_base,
|
||||
embedding_model,
|
||||
@ -81,6 +98,7 @@ impl Config {
|
||||
library_dir,
|
||||
skills_dir,
|
||||
port,
|
||||
admin_password,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
96
src/main.rs
96
src/main.rs
@ -10,7 +10,7 @@ use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::services::ServeDir;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
@ -57,6 +57,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
"系统配置成功载入。本地 SQLite 连接串: {}",
|
||||
config.database_url
|
||||
);
|
||||
if config.admin_password == "admin" {
|
||||
warn!("⚠️ 警告: ADMIN_PASSWORD 环境变量未配置!系统将使用默认密码 'admin' 启动登录鉴权。为了系统安全,请尽快在 .env 文件中设置自定义的 ADMIN_PASSWORD!");
|
||||
}
|
||||
|
||||
// 创建本地馆藏物理文件夹分类结构
|
||||
std::fs::create_dir_all(&config.library_dir).unwrap_or_default();
|
||||
@ -190,22 +193,70 @@ async fn main() -> anyhow::Result<()> {
|
||||
session_permission_checker: Arc::new(RwLock::new(
|
||||
astroresearch::agent::runtime::permission::PermissionChecker::new(),
|
||||
)),
|
||||
sessions: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||
});
|
||||
|
||||
// 7. 设置 Axum 路由、CORS 头以及 React 仪表盘静态资源托管
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
let allowed_methods = vec![
|
||||
axum::http::Method::GET,
|
||||
axum::http::Method::POST,
|
||||
axum::http::Method::PUT,
|
||||
axum::http::Method::DELETE,
|
||||
axum::http::Method::OPTIONS,
|
||||
];
|
||||
|
||||
let api_routes = Router::new()
|
||||
.route("/search", get(handlers::search_papers))
|
||||
.route("/download", post(handlers::download_paper))
|
||||
let cors_permissive = CorsLayer::new()
|
||||
.allow_origin(tower_http::cors::AllowOrigin::predicate(|_origin, _parts| {
|
||||
// 书签脚本的采集接口允许所有外域学术站点的跨域请求,并带上 Session 凭证
|
||||
true
|
||||
}))
|
||||
.allow_methods(allowed_methods.clone())
|
||||
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
|
||||
.allow_credentials(true);
|
||||
|
||||
let cors_local = CorsLayer::new()
|
||||
.allow_origin(tower_http::cors::AllowOrigin::predicate(|origin, _parts| {
|
||||
if let Ok(origin_str) = origin.to_str() {
|
||||
// 仅允许本地开发或回环地址(允许任意端口,支持 Vite dev 端口 :5173)
|
||||
if origin_str.starts_with("http://localhost:")
|
||||
|| origin_str.starts_with("http://127.0.0.1:")
|
||||
|| origin_str == "http://localhost"
|
||||
|| origin_str == "http://127.0.0.1"
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}))
|
||||
.allow_methods(allowed_methods)
|
||||
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
|
||||
.allow_credentials(true);
|
||||
|
||||
let public_routes = Router::new()
|
||||
.route("/auth/login", post(handlers::login))
|
||||
.layer(cors_local.clone());
|
||||
|
||||
// 专门为浏览器书签采集脚本保留的、允许任何外站跨域的 API 路由
|
||||
let bookmarklet_routes = Router::new()
|
||||
.route(
|
||||
"/upload",
|
||||
post(handlers::upload_paper_file)
|
||||
.layer(axum::extract::DefaultBodyLimit::max(100 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/active_bibcode",
|
||||
get(handlers::get_active_bibcode).post(handlers::set_active_bibcode),
|
||||
)
|
||||
.route_layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
astroresearch::api::auth::auth_middleware,
|
||||
))
|
||||
.layer(cors_permissive);
|
||||
|
||||
let protected_routes = Router::new()
|
||||
.route("/search", get(handlers::search_papers))
|
||||
.route("/search/history", get(handlers::search_history))
|
||||
.route("/download", post(handlers::download_paper))
|
||||
.route("/no_resource", post(handlers::mark_no_resource))
|
||||
.route("/parse", post(handlers::parse_paper))
|
||||
.route("/translate", post(handlers::translate_paper))
|
||||
@ -228,10 +279,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
"/sync/queries/:id",
|
||||
axum::routing::delete(handlers::delete_sync_query),
|
||||
)
|
||||
.route(
|
||||
"/active_bibcode",
|
||||
get(handlers::get_active_bibcode).post(handlers::set_active_bibcode),
|
||||
)
|
||||
.route("/chat/rag", post(handlers::chat_rag))
|
||||
.route("/chat/figure", post(handlers::chat_figure))
|
||||
.route("/target/query", get(handlers::query_target))
|
||||
@ -272,18 +319,37 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route(
|
||||
"/chat/sessions/:id/permissions/mode",
|
||||
axum::routing::put(handlers::update_permission_mode),
|
||||
);
|
||||
)
|
||||
.route("/auth/logout", post(handlers::logout))
|
||||
.route("/auth/check", get(handlers::check_auth))
|
||||
.route_layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
astroresearch::api::auth::auth_middleware,
|
||||
))
|
||||
.layer(cors_local.clone());
|
||||
|
||||
let api_routes = Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(bookmarklet_routes)
|
||||
.merge(protected_routes);
|
||||
|
||||
// 静态文件资源代理托管(当前端打包至 dashboard/dist 后,直接挂载到主域名根路由)
|
||||
let serve_dir = ServeDir::new("dashboard/dist").fallback(tower_http::services::ServeFile::new(
|
||||
"dashboard/dist/index.html",
|
||||
));
|
||||
|
||||
let protected_files = Router::new()
|
||||
.fallback_service(ServeDir::new(&config.library_dir))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
astroresearch::api::auth::auth_middleware,
|
||||
))
|
||||
.layer(cors_local);
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api", api_routes)
|
||||
.nest_service("/api/files", ServeDir::new(&config.library_dir))
|
||||
.nest_service("/api/files", protected_files)
|
||||
.fallback_service(serve_dir)
|
||||
.layer(cors)
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
.with_state(app_state);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user