This commit is contained in:
vic
2026-06-11 13:31:37 +08:00
parent f84d206457
commit ea465de145
251 changed files with 21406 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.mypy_cache/
*.egg-info/
build/
dist/
.DS_Store
.langgraph_api/
+5
View File
@@ -0,0 +1,5 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/tasks.json" charset="UTF-8" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="langgraph_env" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/simple-agent-template.iml" filepath="$PROJECT_DIR$/.idea/simple-agent-template.iml" />
</modules>
</component>
</project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PyProjectModelSettings">
<option name="showConfigurationNotification" value="false" />
<option name="usePyprojectToml" value="true" />
</component>
</project>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="langgraph_env" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="py.test" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
View File
+1324
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
.PHONY: help install dev run test integration-tests lint format
help:
@echo 'Targets:'
@echo ' install Sync runtime dependencies with uv'
@echo ' dev Sync project + dev dependencies with uv'
@echo ' run Start the local LangGraph dev server'
@echo ' test Run unit tests'
@echo ' integration-tests Run integration tests'
@echo ' lint Run Ruff checks'
@echo ' format Format with Ruff'
install:
uv sync --no-dev
dev:
uv sync
run:
uv run langgraph dev
test:
uv run python -m pytest tests/unit_tests -q
integration-tests:
uv run python -m pytest tests/integration_tests -q
lint:
uv run python -m ruff check src tests
format:
uv run python -m ruff format src tests
+64
View File
@@ -0,0 +1,64 @@
# Agent 智能助手
基于 LangGraph 的多智能体系统,支持文件操作、代码执行、知识管理、定时任务、MCP 外部服务、技能插件。
## 功能
- 对话交互(Web 前端,Markdown 渲染,代码高亮)
- 文件读写(txt/log/json/csv/docx/xlsx/pptx/pdf/zip/mindmap
- HTTP 请求、代码沙箱(安全执行 Python)
- 定时任务调度(cron
- 知识库 + 上下文记忆(SQLite)
- 技能插件系统(SKILL.md 热加载)
- MCP 外部服务(高德地图等)
- 安全写入二次确认 + 审计日志
- 登录认证(PBKDF2 加盐哈希)
## 快速开始
```bash
# Windows
双击 start.bat
# Linux / Kylin / Ubuntu
bash start.sh
```
访问 `http://127.0.0.1:8765/static/chat.html`,账号 `admin` / `Ascii2013!`
## 目录结构
```
├── start.sh / start.bat # 启动脚本
├── start_all.py # 统一入口(HTTP + LangGraph + 调度器)
├── prepare_deps.sh # 离线依赖准备(有网机器跑一次)
├── deps/ # Python 离线包
├── fonts/ # 中文字体(PDF用)
├── static/lib/ # 前端 JS(已内嵌,无需外网)
├── src/simple_agent/
│ ├── graph.py # 主 Agent 图
│ ├── agents/ # 子智能体(Writer/MCP
│ ├── tools/ # 34 个工具
│ ├── skills/ # 技能系统
│ ├── prompts/ # 系统提示词
│ └── utils/ # 审计/日志/路径安全
├── deploy_tool/ # 独立部署监控程序
└── deploy_config.json # 部署配置
```
## 环境变量
| 变量 | 默认 | 说明 |
|------|------|------|
| `LLM_MODEL` | MiniMax-M2.7 | 模型名称 |
| `LLM_API_KEY` | - | API Key |
| `LLM_BASE_URL` | api.minimax.chat/v1 | API 地址 |
| `AGENT_HOST` | 127.0.0.1 | 绑定地址(远程访问设 0.0.0.0) |
| `WRITER_WORKSPACE` | /opt/app/AgentWorkspace | 安全写入目录 |
## 安全
- 文件写入:仅 `/opt/app/AgentWorkspace/``knowledge/` 免确认,其他路径弹窗审批
- 审计日志:所有写入操作记录到 SQLite(`AgentWorklogs/audit/`
- 认证:PBKDF2 加盐哈希,24h 会话超时,5 次失败锁定
- 代码沙箱:AST 安全检查 + 模块白名单
+44
View File
@@ -0,0 +1,44 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
echo ========================================
echo Agent Windows 打包
echo ========================================
:: 确保虚拟环境
if not exist ".venv\Scripts\python.exe" (
echo [ERROR] 未找到 .venv,请先创建虚拟环境
pause & exit /b 1
)
.venv\Scripts\python.exe -m pip install pyinstaller -q
.venv\Scripts\python.exe -m PyInstaller ^
--onedir ^
--name agent ^
--add-data "src/simple_agent/prompts;simple_agent/prompts" ^
--add-data "prompts;prompts" ^
--add-data "static;static" ^
--add-data "skills;skills" ^
--add-data "workspace;workspace" ^
--add-data "langgraph.json;." ^
--add-data "tasks.json;." ^
--add-data "fonts;fonts" ^
--hidden-import langchain_openai ^
--hidden-import pypdf ^
--hidden-import fpdf ^
--hidden-import openpyxl ^
--hidden-import docx ^
--hidden-import pptx ^
--hidden-import psutil ^
--collect-all langgraph ^
--collect-all langchain ^
--collect-all langchain_core ^
--collect-all langchain_openai ^
--noconfirm ^
start_all.py
echo ========================================
echo 打包完成: dist\agent\
echo ========================================
pause
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# ================================================================
# Agent 打包脚本 (在有网的 Linux 上运行一次)
# 生成 dist/agent/ 独立目录,复制到服务器直接运行
# ================================================================
set -e
cd "$(dirname "$0")"
echo "========================================"
echo " Agent 打包中..."
echo "========================================"
# 确保虚拟环境
if [ ! -d ".venv" ]; then
python3 -m venv .venv
fi
source .venv/bin/activate
# 安装依赖 + pyinstaller
pip install -r requirements-linux.txt -q
pip install pyinstaller -q
# 打包
pyinstaller \
--onedir \
--name agent \
--add-data "src/simple_agent/prompts:simple_agent/prompts" \
--add-data "prompts:prompts" \
--add-data "static:static" \
--add-data "skills:skills" \
--add-data "workspace:workspace" \
--add-data "langgraph.json:." \
--add-data "tasks.json:." \
--hidden-import langchain_openai \
--hidden-import langchain.agents \
--hidden-import pypdf \
--hidden-import fpdf \
--hidden-import openpyxl \
--hidden-import docx \
--hidden-import pptx \
--hidden-import psutil \
--collect-all langgraph \
--collect-all langchain \
--collect-all langchain_core \
--collect-all langchain_openai \
--noconfirm \
start_all.py
echo ""
echo "========================================"
echo " 打包完成: dist/agent/"
echo " 复制到服务器: scp -r dist/agent/ user@host:/opt/"
echo " 启动: /opt/agent/agent"
echo "========================================"
+381
View File
@@ -0,0 +1,381 @@
# PAM Agent API 接口文档
## 服务概览
| 服务 | 端口 | 说明 |
|------|------|------|
| Chat Server | `8765` | 聊天界面、文件服务、技能管理、MCP 查询 |
| LangGraph API | `2024` | Agent 对话图执行(LangGraph 标准 API |
---
## 鉴权
Chat Server8765)部分接口需 Bearer Token。
### POST /api/login — 登录
取得 Token,有效期 24 小时。
**请求**
```
POST http://127.0.0.1:8765/api/login
Content-Type: application/json
{
"username": "admin",
"password": "Ascii2013!"
}
```
**响应**
```json
{"token": "e63f3f2e16a73db3...", "user": "admin"}
```
**后续请求带 Token**
```
Authorization: Bearer e63f3f2e16a73db3...
```
默认用户名 `admin`,密码 `Ascii2013!`。可在 `.env` 中配置 `AGENT_ADMIN_PASS_HASH` + `AGENT_ADMIN_PASS_SALT` 修改。
---
## 一、主 Agent 对话(LangGraph API2024
### POST /threads — 创建对话线程
```
POST http://127.0.0.1:2024/threads
Content-Type: application/json
{}
```
**响应**
```json
{"thread_id": "019e8702-..."}
```
### POST /threads/search — 查询所有线程
```
POST http://127.0.0.1:2024/threads/search
Content-Type: application/json
{}
```
**响应**
```json
[
{"thread_id": "019e8702-...", ...},
...
]
```
### GET /threads/{thread_id}/state — 获取线程状态(含历史消息)
```
GET http://127.0.0.1:2024/threads/{thread_id}/state
```
**响应**
```json
{
"values": {
"messages": [
{"type": "human", "content": "查询天气"},
{"type": "ai", "content": "...", "tool_calls": [...]},
{"type": "tool", "name": "delegate_to_mcpmanageragent", "content": "..."}
]
}
}
```
### POST /threads/{thread_id}/runs/stream — 发送消息并流式获取回复
```
POST http://127.0.0.1:2024/threads/{thread_id}/runs/stream
Content-Type: application/json
{
"assistant_id": "simple_agent",
"thread_id": "{thread_id}",
"config": {"configurable": {"temperature": 0.7}},
"input": {
"messages": [{"role": "user", "content": "查询北京天气"}]
}
}
```
**响应(SSE 流)**
```
event: metadata
data: {"run_id":"...","attempt":1}
event: values
data: {"messages":[{"type":"ai","content":"...","tool_calls":[...]}]}
```
消息类型:`human`(用户) | `ai`(Agent) | `tool`(工具结果)
### POST /runs/stream — 无状态运行(调度器用)
```
POST http://127.0.0.1:2024/runs/stream
Content-Type: application/json
{
"assistant_id": "simple_agent",
"input": {
"messages": [{"role": "user", "content": "[定时任务执行] 任务描述"}]
}
}
```
### GET /ok — 健康检查
```
GET http://127.0.0.1:2024/ok
```
`200 {"ok": true}`
---
## 二、MCP 查询(8765
### POST /api/mcp_query — 直接调用高德地图等 MCP 服务
不经过 LLM,直接查询。依赖 `.env` 中的 `AMAP_KEY`
**请求**
```
POST http://127.0.0.1:8765/api/mcp_query
Authorization: Bearer {token}
Content-Type: application/json
{"query": "查询北京天气"}
```
**响应**
```json
{
"result": "{\"city\":\"北京市\",\"forecasts\":[{\"date\":\"2026-06-03\",\"dayweather\":\"多云\",\"daytemp_float\":\"34.0\",...}]}"
}
```
**支持的查询类型**:天气(自动识别城市)、地理编码、周边搜索、IP 定位、默认天气。
### POST /api/mcp_reconnect — 强制 MCP 重连
```
POST http://127.0.0.1:8765/api/mcp_reconnect
Authorization: Bearer {token}
```
**响应**
```json
{"success": true, "message": "MCP 重连成功"}
```
---
## 三、文件与工作空间(8765
### GET /workspace/{filename} — 获取工作空间文件(免鉴权)
```
GET http://127.0.0.1:8765/workspace/weather_chart.svg
```
**响应**:文件内容 + 对应 Content-Type`.svg``image/svg+xml``.png``image/png` 等)
支持中文文件名(自动 URL 解码)。搜索路径:桌面 AgentWorkspace → 项目 workspace 目录。
### POST /upload?type=knowledge|memory — 上传文件
```
POST http://127.0.0.1:8765/upload?type=knowledge
Authorization: Bearer {token}
Content-Type: multipart/form-data
file: <binary>
```
**响应**
```json
{"status": "ok", "path": "D:\\Desktop\\AgentWorkspace\\knowledge\\file.txt"}
```
### POST /api/write_file — 写入文件
```
POST http://127.0.0.1:8765/api/write_file
Authorization: Bearer {token}
Content-Type: application/json
{"path": "test.txt", "content": "Hello World"}
```
**响应**
```json
{"success": true, "path": "D:\\Desktop\\AgentWorkspace\\test.txt", "size": 11}
```
### 安全确认流程
```
GET /api/confirm_info?confirm_id={uuid} # 查询确认状态
POST /api/confirm_write # 批准或拒绝
{"confirm_id": "{uuid}", "choice": "approve|reject"}
```
---
## 四、技能管理(8765
### GET /skills/list — 列出已安装技能
```
GET http://127.0.0.1:8765/skills/list
Authorization: Bearer {token}
```
**响应**
```json
[
{"name": "WriterAgent", "description": "负责文件创建、写入..."},
{"name": "MCPManagerAgent", "description": "访问高德地图等外部服务..."}
]
```
### GET /skills/trash — 列出已删除技能
```
GET http://127.0.0.1:8765/skills/trash
Authorization: Bearer {token}
```
### POST /skills/reload — 热更新全部技能
```
POST http://127.0.0.1:8765/skills/reload
Authorization: Bearer {token}
```
**响应**
```json
{"status": "ok", "message": "所有配置和技能已从磁盘重新加载。"}
```
**注意**:此端点已升级为全量热重载,包括 `.env``mcp_config.json``remote_skills.json``prompts/*.md`
### POST /skills/delete?name={name} — 删除技能(移入垃圾桶)
```
POST http://127.0.0.1:8765/skills/delete?name=roll-dice
Authorization: Bearer {token}
```
### POST /skills/recover?name={name} — 从垃圾桶恢复
```
POST http://127.0.0.1:8765/skills/recover?name=roll-dice
Authorization: Bearer {token}
```
### POST /skills/permanent_delete?name={name} — 永久删除
```
POST http://127.0.0.1:8765/skills/permanent_delete?name=roll-dice
Authorization: Bearer {token}
```
### POST /skills/upload — 上传技能包
```
POST http://127.0.0.1:8765/skills/upload
Authorization: Bearer {token}
Content-Type: multipart/form-data
file: <skill.zip or SKILL.md>
```
---
## 五、系统管理(8765
### POST /api/restart — 应用配置并重启
```
POST http://127.0.0.1:8765/api/restart
Authorization: Bearer {token}
Content-Type: application/json
{
"m_model": "deepseek-v4-pro",
"m_url": "https://api.deepseek.com/v1",
"m_key": "sk-xxx"
}
```
**响应**
```json
{"success": true, "message": "配置已应用并备份,服务正在重启..."}
```
支持写入 `.env` 的字段:`m_model`, `m_url`, `m_key`, `w_model`, `w_url`, `w_key`, `mcp_model`, `mcp_url`, `mcp_key`
### POST /api/deploy — JAR 部署
```
POST http://127.0.0.1:8765/api/deploy
Authorization: Bearer {token}
Content-Type: multipart/form-data
jar_file: <binary>
target_dir: "/opt/app"
health_url: "http://127.0.0.1:8080/health"
```
**响应**
```json
{
"success": true,
"backup": "app.jar.bak.20260604_120000",
"saved": "/opt/app/app.jar",
"started": true,
"returncode": 0,
"health_ok": true,
"rolled_back": false
}
```
---
## 六、热更新机制
修改以下文件后 **2-3 秒自动生效**,无需重启:
| 文件 | 效果 |
|------|------|
| `.env` | 模型/Key/URL 即时切换 |
| `mcp_config.json` | MCP 服务增删自动重连 |
| `remote_skills.json` | 远端子智能体即时上/下线 |
| `prompts/*.md` | 提示词即时生效 |
也可手动触发:`POST /skills/reload`
---
## 七、静态文件
```
GET http://127.0.0.1:8765/static/chat.html # 主聊天界面
GET http://127.0.0.1:8765/static/mcp.html # MCP 只读查询界面(暂定)
GET http://127.0.0.1:8765/static/chat.js # 前端 JS
GET http://127.0.0.1:8765/static/lib/marked.min.js # Markdown 渲染
GET http://127.0.0.1:8765/static/lib/highlight.min.js # 代码高亮
```
+364
View File
@@ -0,0 +1,364 @@
# chat.html API 接口文档
## 服务地址
| 服务 | 变量名 | 实际地址 |
|------|--------|----------|
| LangGraph API | `API_BASE` | `http://服务器IP:2024` |
| 聊天管理 | `CHAT_SERVER` | `http://服务器IP:8765` |
---
## Auth 机制
```
POST /api/login → 获取 token
后续请求: Authorization: Bearer <token>
X-User: <currentUser>
```
token 存 `sessionStorage`,24h 过期。内嵌模式可 URL 传参 `?user=xxx&token=xxx` 跳过登录。
---
## 一、LangGraph API2024
### POST /threads — 创建线程
```
POST http://服务器IP:2024/threads
Content-Type: application/json
{}
```
**响应**
```json
{"thread_id": "019e8702-..."}
```
---
### POST /threads/search — 查询已有线程
```
POST http://服务器IP:2024/threads/search
Content-Type: application/json
{}
```
**响应**
```json
[
{"thread_id": "019e8702-...", ...}
]
```
**说明**:与本地 localStorage 线程列表合并,仅保留服务器上存在的线程。
---
### GET /threads/{thread_id}/state — 获取线程状态(历史消息)
```
GET http://服务器IP:2024/threads/{thread_id}/state
```
**响应**
```json
{
"values": {
"messages": [
{"type": "human", "content": "你好"},
{"type": "ai", "content": "你好!有什么可以帮助你的?", "tool_calls": [...]},
{"type": "tool", "name": "delegate_to_mcpmanageragent", "content": "..."}
]
}
}
```
**说明**:切换线程时调用,优先展示 localStorage 缓存,后台静默同步。消息缓存到 `msg_cache_{thread_id}`
---
### POST /threads/{thread_id}/runs/stream — 发送消息(SSE 流)
**正常发送**
```
POST http://服务器IP:2024/threads/{thread_id}/runs/stream
Content-Type: application/json
{
"assistant_id": "simple_agent",
"thread_id": "{thread_id}",
"config": {"configurable": {"temperature": 0.7}},
"input": {
"messages": [{"role": "user", "content": "查询北京天气"}]
}
}
```
**中断恢复**
```json
{
"assistant_id": "simple_agent",
"thread_id": "{thread_id}",
"input": null,
"command": {
"resume": {
"__interrupt_id__": "{id}",
"value": "approve"
}
}
}
```
**响应(SSE 流)**
```
event: metadata
data: {"run_id":"...","attempt":1}
event: values
data: {"messages":[{"type":"ai","content":"...","tool_calls":[...]}]}
event: values
data: {"messages":[...,{"type":"tool","name":"...","content":"..."}]}
```
**特殊事件**
```json
{"__interrupt__": {"message": "...", "__interrupt_id__": "..."}}
```
触发前端弹窗等待用户确认。
**消息类型**
| type | 角色 | 说明 |
|------|------|------|
| `human` | 用户 | 前端忽略(不发气泡) |
| `ai` | Agent | Markdown 渲染 |
| `ai` (带 tool_calls) | Agent | 显示工具调用 |
| `tool` | 工具结果 | 含 `![图表](...)` 时用 Markdown 渲染 |
---
## 二、聊天管理 API8765
### POST /api/login — 登录
```
POST http://服务器IP:8765/api/login
Content-Type: application/json
{"username": "admin", "password": "Ascii2013!"}
```
**响应**
```json
{"token": "e63f3f2e...", "user": "admin"}
```
401: `{"error": "用户名或密码错误"}`
429: `{"error": "登录尝试次数过多"}`5次/15分钟)
---
### POST /api/save_config — 保存配置(热更新,推荐)
```
POST http://服务器IP:8765/api/save_config
Authorization: Bearer <token>
Content-Type: application/json
{
"temperature": 0.7,
"m_model": "deepseek-v4-pro",
"m_url": "https://api.deepseek.com/v1",
"m_key": "sk-xxx",
"w_model": "deepseek-v4-pro",
"w_url": "https://api.deepseek.com/v1",
"w_key": "sk-xxx",
"mcp_model": "deepseek-v4-pro",
"mcp_url": "https://api.deepseek.com/v1",
"mcp_key": "sk-xxx"
}
```
**响应**
```json
{"success": true, "message": "配置已保存,热更新自动生效"}
```
**说明**:所有字段可选。写 `.env` 后文件监听自动重载,无需重启。
---
### POST /api/restart — 应用并重启(已弃用,推荐 /api/save_config
```
POST http://服务器IP:8765/api/restart
Authorization: Bearer <token>
Content-Type: application/json
{ ... 同 /api/save_config ... }
```
**响应**
```json
{"success": true, "message": "配置已应用并备份,服务正在重启..."}
```
---
### 安全确认流程
**查询确认状态**
```
GET http://服务器IP:8765/api/confirm_info?confirm_id={uuid}
Authorization: Bearer <token>
```
**响应**
```json
{"confirm_id": "{uuid}", "target_path": "...", "result": "pending", "risk_analysis": "..."}
```
**批准/拒绝**
```
POST http://服务器IP:8765/api/confirm_write
Authorization: Bearer <token>
Content-Type: application/json
{"confirm_id": "{uuid}", "choice": "approve"}
```
**响应(批准)**
```json
{"success": true, "message": "写入已确认并执行", "path": "...", "size": 123}
```
**响应(拒绝)**
```json
{"success": true, "message": "用户拒绝写入"}
```
---
### POST /upload?type=knowledge|memory — 上传文件
```
POST http://服务器IP:8765/upload?type=knowledge
Authorization: Bearer <token>
Content-Type: multipart/form-data
file: <binary>
```
**响应**
```json
{"status": "ok", "path": "/absolute/path"}
```
**type 说明**
- `knowledge``.txt .md .json .csv .yml .xml .html .css .js .py .java .cpp .c .h`
- `memory`:其他所有文件
---
## 三、技能管理 API8765
### GET /skills/list — 已安装技能
```
GET http://服务器IP:8765/skills/list
Authorization: Bearer <token>
```
**响应**
```json
[{"name": "WriterAgent", "description": "负责文件创建、写入..."}, ...]
```
### GET /skills/trash — 已删除技能
```
GET http://服务器IP:8765/skills/trash
Authorization: Bearer <token>
```
**响应**
```json
[{"name": "roll-dice", "deleted_at": 1717482000000, "description": "已删除"}]
```
### POST /skills/reload — 热更新全部配置
```
POST http://服务器IP:8765/skills/reload
Authorization: Bearer <token>
```
**响应**
```json
{"status": "ok", "message": "所有配置和技能已从磁盘重新加载。"}
```
### POST /skills/delete?name={name} — 删除(移至垃圾桶)
```
POST http://服务器IP:8765/skills/delete?name=roll-dice
Authorization: Bearer <token>
```
### POST /skills/recover?name={name} — 恢复
```
POST http://服务器IP:8765/skills/recover?name=roll-dice
Authorization: Bearer <token>
```
### POST /skills/permanent_delete?name={name} — 永久删除
```
POST http://服务器IP:8765/skills/permanent_delete?name=roll-dice
Authorization: Bearer <token>
```
### POST /skills/upload — 上传安装技能
```
POST http://服务器IP:8765/skills/upload
Authorization: Bearer <token>
Content-Type: multipart/form-data
file: <skill.zip 或 SKILL.md>
```
---
## 四、外部 API
### GET {base_url}/models — 获取模型列表
```
GET https://api.deepseek.com/v1/models
Authorization: Bearer <用户输入的 API Key>
```
**响应**
```json
{"data": [{"id": "deepseek-v4-pro"}, {"id": "deepseek-v4-flash"}]}
```
**说明**:前端设置面板点"获取"触发。用用户手动填入的 Key,不是会话 token。
---
## 五、静态资源
```
GET /static/chat.html 主聊天界面
GET /static/chat.js 前端逻辑
GET /static/lib/marked.min.js Markdown 渲染
GET /static/lib/highlight.min.js 代码高亮
GET /workspace/{filename} 工作空间文件(免鉴权)
```
+164
View File
@@ -0,0 +1,164 @@
# mcp.html API 接口文档
## 概述
mcp.html 是只读 MCP 查询页面——只能通过 Agent 调用 MCP 工具,无文件系统/技能管理权限。API 调用是 chat.html 的子集,**多了 `mcp_mode: true` 配置标记**。
## 服务地址
| 服务 | 变量名 | 实际地址 |
|------|--------|----------|
| LangGraph API | `API_BASE` | `http://服务器IP:2024` |
| 聊天管理 | `CHAT_SERVER` | `http://服务器IP:8765` |
---
## Auth 机制
与 chat.html 完全一致:`POST /api/login``Bearer <token>` + `X-User: <value>`
---
## 一、LangGraph API2024
### POST /threads — 创建线程
```
POST http://服务器IP:2024/threads
Content-Type: application/json
{}
```
**响应**
```json
{"thread_id": "019e8702-..."}
```
---
### POST /threads/search — 查询线程
```
POST http://服务器IP:2024/threads/search
Content-Type: application/json
{}
```
---
### GET /threads/{thread_id}/state — 获取线程状态
```
GET http://服务器IP:2024/threads/{thread_id}/state
```
---
### POST /threads/{thread_id}/runs/stream — 发送消息(SSE 流)
**与 chat.html 的唯一区别**`config.configurable.mcp_mode = true`
```
POST http://服务器IP:2024/threads/{thread_id}/runs/stream
Content-Type: application/json
{
"assistant_id": "simple_agent",
"thread_id": "{thread_id}",
"config": {"configurable": {"mcp_mode": true, "temperature": 0.7}},
"input": {
"messages": [{"role": "user", "content": "查询北京天气"}]
}
}
```
**中断恢复**(也带 `mcp_mode: true`
```json
{
"assistant_id": "simple_agent",
"thread_id": "{thread_id}",
"input": null,
"config": {"configurable": {"mcp_mode": true}},
"command": {
"resume": {
"__interrupt_id__": "{id}",
"value": "approve"
}
}
}
```
**响应**SSE 流,与 chat.html 完全相同。
---
## 二、聊天管理 API8765
### POST /api/login — 登录
```
POST http://服务器IP:8765/api/login
Content-Type: application/json
{"username": "admin", "password": "Ascii2013!"}
```
**响应**
```json
{"token": "e63f3f2e...", "user": "admin"}
```
### GET /skills/list — 鉴权验证
仅用于验证 token 有效性,不渲染技能列表。
```
GET http://服务器IP:8765/skills/list
Authorization: Bearer <token>
```
### 安全确认流程
**查询确认状态**
```
GET http://服务器IP:8765/api/confirm_info?confirm_id={uuid}
Authorization: Bearer <token>
```
**批准/拒绝**
```
POST http://服务器IP:8765/api/confirm_write
Authorization: Bearer <token>
Content-Type: application/json
{"confirm_id": "{uuid}", "choice": "approve"}
```
### POST /upload?type=knowledge|memory — 上传文件
```
POST http://服务器IP:8765/upload?type=knowledge
Authorization: Bearer <token>
Content-Type: multipart/form-data
file: <binary>
```
---
## mcp.html 与 chat.html 对比
| 功能 | chat.html | mcp.html |
|------|-----------|----------|
| Agent 对话 | ✅ | ✅ |
| 对话历史 | ✅(可删除) | ✅(禁止删除) |
| 文件上传 | ✅ | ✅ |
| 安全确认弹窗 | ✅ | ✅ |
| 技能管理面板 | ✅ | ❌ |
| 设置面板 | ✅ | ❌ |
| 保存配置/重启 | ✅ | ❌ |
| 获取模型列表 | ✅ | ❌ |
| 导出对话 | ✅ | ❌ |
| `mcp_mode` 标记 | ❌ | ✅ |
+88
View File
@@ -0,0 +1,88 @@
# PAM Agent - Linux RedHat 部署
## 前置要求
- RedHat 8+/CentOS 8+/Rocky 8+
- root 权限
- 网络连接(安装 Python 和 pip 依赖)
## 快速部署(用户级,无需 root)
```bash
# 1. 上传并解压
scp pam-aiagent.tar.gz prouser@your-server:/opt/app/
ssh prouser@your-server
cd /opt/app
mkdir -p pam-aiagent
tar xzf pam-aiagent.tar.gz -C pam-aiagent --strip-components=1
# 2. 安装(全程不联网,无需 root)
cd /opt/app/pam-aiagent/deploy_linux
chmod +x install.sh
./install.sh
# 3. 配置 API Key
vim /opt/app/pam-aiagent/.env
# 4. 启动
/opt/app/pam-aiagent/start.sh
# 5. 访问
浏览器打开 http://服务器IP:8765/static/chat.html
```
## 启停
```bash
/opt/app/pam-aiagent/start.sh # 启动
/opt/app/pam-aiagent/stop.sh # 停止
tail -f /opt/app/pam-aiagent/logs/server.log # 查看日志
```
## 依赖
服务器需预装(可用 RedHat ISO 离线装):
```bash
# 编译 Python 需要(如果用 dnf 直接装 python3.12 则不需要)
openssl-devel bzip2-devel libffi-devel zlib-devel readline-devel sqlite-devel
```
如服务器已有 python3.12 含 SSL,脚本会自动跳过编译。
## 离线部署(无网络环境)
在**有网络的机器**上下载依赖包:
```bash
pip download -r requirements-linux.txt -d wheels/
```
然后将 wheels 目录和项目文件一起传到服务器,手动安装:
```bash
pip install --no-index --find-links=wheels/ -r requirements-linux.txt
```
## 手动安装 Python 3.12(含 SSL
如果 `dnf install python3.12` 不可用或需要源码编译:
```bash
# 先装 SSL 编译依赖(必须,否则 pip 无法联网)
dnf install -y openssl-devel bzip2-devel libffi-devel zlib-devel readline-devel sqlite-devel
# 方案一:yum/dnf
dnf install -y epel-release
dnf module enable python312 -y
dnf install -y python312 python312-devel python312-pip
# 方案二:源码编译
wget https://www.python.org/ftp/python/3.12.9/Python-3.12.9.tgz
tar xzf Python-3.12.9.tgz && cd Python-3.12.9
./configure --enable-optimizations --with-ssl --prefix=/usr/local/python3.12
make -j$(nproc) && make install
ln -sf /usr/local/python3.12/bin/python3.12 /usr/bin/python3.12
# 验证 SSL
python3.12 -c "import ssl; print(ssl.OPENSSL_VERSION)"
# 应输出: OpenSSL 3.x.x ...
```
> ⚠️ **必须安装 SSL**:没有 `openssl-devel` 编译的 Python 无法 `pip install`,也无法调用 HTTPS API。
## 端口
| 服务 | 端口 |
|------|------|
| 聊天界面 | 8765 |
| LangGraph API | 2024 |
Binary file not shown.
+148
View File
@@ -0,0 +1,148 @@
#!/bin/bash
# PAM Agent 用户级部署脚本(无需 root)
# 用法: chmod +x install.sh && ./install.sh
set -e
echo "=== PAM Agent 部署 (prouser 用户级) ==="
APP_DIR="/opt/app/pam-aiagent"
PY_DIR="/opt/app/python3.12"
# 1. 安装 Python 3.12
echo "[1/4] 安装 Python 3.12..."
PY_DIR="/opt/app/python3.12"
export PATH="$PY_DIR/bin:$PATH"
# 先检查系统是否已有 Python 3.12 + SSL
if command -v python3.12 &>/dev/null && python3.12 -c "import ssl" 2>/dev/null; then
echo " 系统 Python 3.12 可用: $(python3.12 --version)"
PY_DIR=$(dirname $(dirname $(which python3.12)))
# 再检查预编译二进制
elif [ -f "$APP_DIR/deploy_linux/python-3.12-linux.tar.gz" ]; then
echo " 解压预编译 Python 3.12(免编译)..."
mkdir -p "$PY_DIR"
tar xzf "$APP_DIR/deploy_linux/python-3.12-linux.tar.gz" -C "$PY_DIR" --strip-components=1
echo " Python 3.12 已就绪: $($PY_DIR/bin/python3.12 --version)"
else
echo " [ERROR] 无系统 Python 3.12,也无预编译包"
echo " 请执行: dnf install -y gcc openssl-devel(从 RedHat ISO 离线装)"
echo " 然后重试编译: tar xzf Python-3.12.9.tgz && cd Python-3.12.9 && ./configure && make && make install"
exit 1
fi
# 验证 SSL
if ! "$PY_DIR/bin/python3.12" -c "import ssl" 2>/dev/null; then
echo " [ERROR] Python SSL 不可用"; exit 1
fi
# 2. 创建虚拟环境
echo "[2/4] 创建虚拟环境..."
"$PY_DIR/bin/python3.12" -m venv "$APP_DIR/venv" --without-pip
source "$APP_DIR/venv/bin/activate"
# 离线安装 pip
"$PY_DIR/bin/python3.12" -m ensurepip 2>/dev/null
python -m pip install --no-index --find-links="$APP_DIR/deploy_linux/wheels/" pip setuptools wheel 2>/dev/null || true
# 3. 安装依赖(仅本地wheels)
echo "[3/4] 安装 Python 依赖..."
# tiktoken 无 Linux wheel,装假包(不影响功能)
rm -f "$APP_DIR/deploy_linux/wheels/tiktoken"*
mkdir -p /tmp/faketiktoken/tiktoken
# 空模块让 import tiktoken 不报错
cat > /tmp/faketiktoken/tiktoken/__init__.py << 'INIT'
"""Fake tiktoken for offline deployment"""
def get_encoding(name='cl100k_base'):
class FakeEncoding:
def encode(self, text): return [1]*len(text)
def decode(self, tokens): return ''
return FakeEncoding()
def encoding_for_model(model): return get_encoding()
INIT
cat > /tmp/faketiktoken/setup.py << 'SETUP'
from setuptools import setup, find_packages
setup(name='tiktoken', version='0.7.0', packages=find_packages(), install_requires=[])
SETUP
pip install --no-index /tmp/faketiktoken/
rm -rf /tmp/faketiktoken
pip install --no-index --find-links="$APP_DIR/deploy_linux/wheels/" -r "$APP_DIR/requirements-linux.txt"
if [ $? -ne 0 ]; then
echo " [ERROR] 依赖安装失败"
exit 1
fi
deactivate
# 4. 配置
echo "[4/4] 配置文件..."
# .env
if [ ! -f "$APP_DIR/.env" ]; then
cat > "$APP_DIR/.env" << 'EOF'
# === 模型配置 ===
LLM_MODEL=deepseek-v4-pro
LLM_BASE_URL=https://api.deepseek.com/v1
LLM_API_KEY=你的KEY
LLM_MODEL_W=deepseek-v4-pro
LLM_BASE_URL_W=https://api.deepseek.com/v1
LLM_API_KEY_W=你的KEY
LLM_MODEL_M=deepseek-v4-pro
LLM_BASE_URL_M=https://api.deepseek.com/v1
LLM_API_KEY_M=你的KEY
# === 高德地图 ===
AMAP_KEY=你的KEY
# === 服务配置 ===
AGENT_HOST=0.0.0.0
AGENT_ADMIN_USER=admin
AGENT_ADMIN_PASS_HASH=43d5f493c42c3d3ad6cd692ea9f87e69e427c7f95aab3f76209dfa5ad602920d
AGENT_ADMIN_PASS_SALT=8293ce07ae02df60e4300e21802f8ffed9561b0f7679acda2bd1a407ad108282
LANGCHAIN_TRACING_V2=false
# === 工作空间 ===
WRITER_WORKSPACE=/opt/app/pam-aiagent/workspace
EOF
fi
mkdir -p "$APP_DIR/workspace/knowledge" "$APP_DIR/workspace/memory"
mkdir -p "$APP_DIR/logs"
# 启动脚本
cat > "$APP_DIR/start.sh" << EOF
#!/bin/bash
cd "$APP_DIR"
export PATH="$PY_DIR/bin:\$PATH"
export PYTHONUTF8=1
export PYTHONPATH="$APP_DIR/src:\$PYTHONPATH"
nohup "$APP_DIR/venv/bin/python" start_all.py > "$APP_DIR/logs/server.log" 2>&1 &
echo \$! > "$APP_DIR/logs/server.pid"
echo "PAM Agent started (PID: \$!)"
EOF
# 停止脚本
cat > "$APP_DIR/stop.sh" << EOF
#!/bin/bash
if [ -f "$APP_DIR/logs/server.pid" ]; then
PID=\$(cat "$APP_DIR/logs/server.pid")
kill \$PID 2>/dev/null && echo "PAM Agent stopped (PID: \$PID)"
rm -f "$APP_DIR/logs/server.pid"
else
echo "No PID file found. Try: pkill -f start_all.py"
fi
EOF
chmod +x "$APP_DIR/start.sh" "$APP_DIR/stop.sh"
echo ""
echo "========================================"
echo " PAM Agent 部署完成(用户级)"
echo "========================================"
echo " 安装目录: $APP_DIR"
echo " Python: $PY_DIR"
echo " 配置 Key: vim $APP_DIR/.env"
echo ""
echo " 启动: $APP_DIR/start.sh"
echo " 停止: $APP_DIR/stop.sh"
echo " 日志: tail -f $APP_DIR/logs/server.log"
echo " 前端: http://服务器IP:8765/static/chat.html"
echo ""
@@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>Agent 对话</title>
<script src="lib/marked.min.js"></script>
<script src="lib/highlight.min.js"></script>
<style>
:root { --bg: #f0f2f5; --chat-bg: #ffffff; --accent: #4f6ef7; --accent-dark: #3b54d4; --text: #1a1a2e; --text-secondary: #6b7280; --border: #e5e7eb; --sidebar-w: 220px; --skillbar-w: 240px; --radius: 12px; --radius-sm: 8px; --shadow: 0 1px 3px rgba(0,0,0,0.06); }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif; background:var(--bg); display:flex; height:100vh; }
::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:transparent; } ::-webkit-scrollbar-thumb { background:#d1d5db; border-radius:3px; }
.app-container { display:flex; width:100%; height:100vh; background:var(--chat-bg); overflow:hidden; }
.sidebar { width:var(--sidebar-w); min-width:var(--sidebar-w); max-width:var(--sidebar-w); background:#f8f9fb; border-right:1px solid var(--border); display:flex; flex-direction:column; transition:width 0.25s ease; overflow:hidden; flex-shrink:0; }
.sidebar.collapsed { width:0; min-width:0; max-width:0; border-right:none; }
.sidebar-header { padding:16px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border); }
.sidebar-header span { font-weight:600; font-size:15px; color:var(--text); }
.new-chat-btn { background:var(--accent); color:white; border:none; padding:7px 14px; border-radius:var(--radius-sm); cursor:pointer; font-size:13px; font-weight:500; }
.new-chat-btn:hover { background:var(--accent-dark); }
.thread-list { flex:1; overflow-y:auto; padding:6px; }
.thread-item { padding:10px 12px; border-radius:var(--radius-sm); cursor:pointer; font-size:13px; display:flex; align-items:center; justify-content:space-between; margin:2px 0; color:var(--text-secondary); }
.thread-item:hover { background:#eef0f5; color:var(--text); }
.thread-item.active { background:#e8edff; color:var(--accent); font-weight:500; }
.thread-item .del-btn { background:none; border:none; color:#d1d5db; cursor:pointer; font-size:14px; padding:2px 4px; border-radius:4px; }
.thread-item .del-btn:hover { color:#ef4444; background:#fee; }
.chat-container { flex:1; min-width:0; display:flex; flex-direction:column; background:var(--chat-bg); }
.chat-header { padding:14px 20px; background:linear-gradient(135deg,var(--accent) 0%,#6366f1 100%); color:white; font-size:16px; font-weight:600; display:flex; align-items:center; gap:8px; }
.chat-header .title { flex:1; }
.toggle-btn { background:rgba(255,255,255,0.15); border:none; color:white; padding:6px 10px; border-radius:6px; cursor:pointer; font-size:14px; }
.toggle-btn:hover { background:rgba(255,255,255,0.25); }
.export-btn { background:rgba(255,255,255,0.15); border:none; color:white; cursor:pointer; font-size:13px; padding:6px 10px; border-radius:6px; }
.export-btn:hover,.settings-btn:hover { background:rgba(255,255,255,0.25); }
.settings-btn { background:rgba(255,255,255,0.15); border:none; color:white; cursor:pointer; font-size:16px; padding:6px 10px; border-radius:6px; }
.messages { flex:1; overflow-y:auto; padding:20px 24px; display:flex; flex-direction:column; gap:16px; }
.message { display:flex; flex-direction:column; max-width:85%; animation:fadeIn 0.2s ease; }
@keyframes fadeIn { from{opacity:0;transform:translateY(4px);} to{opacity:1;transform:translateY(0);} }
.message.user { align-self:flex-end; }
.message.agent,.message.tool,.message.system { align-self:flex-start; }
.message .bubble { padding:12px 16px; border-radius:var(--radius); word-break:break-word; overflow-wrap:break-word; white-space:pre-wrap; line-height:1.7; font-size:14.5px; position:relative; box-shadow:var(--shadow); }
.user .bubble { background:linear-gradient(135deg,#e8edff 0%,#dce4ff 100%); color:var(--text); border-bottom-right-radius:4px; }
.agent .bubble { background:#fff; color:var(--text); border:1px solid #f0f0f0; border-bottom-left-radius:4px; }
.tool .bubble { background:#fffdf5; color:#8b6914; font-size:13px; border:1px solid #fef3c7; }
.system .bubble { background:#f9fafb; color:var(--text-secondary); font-size:13px; border:1px solid #f3f4f6; }
.message .label { font-size:11px; margin-bottom:4px; color:var(--text-secondary); padding-left:4px; font-weight:500; }
.agent .bubble h1,.agent .bubble h2,.agent .bubble h3 { margin:16px 0 8px; font-weight:700; color:var(--text); }
.agent .bubble h1 { font-size:1.3em; border-bottom:1px solid #eee; padding-bottom:6px; }
.agent .bubble h2 { font-size:1.15em; } .agent .bubble h3 { font-size:1.05em; }
.agent .bubble p { margin:0 0 8px; } .agent .bubble ul,.agent .bubble ol { padding-left:20px; margin:8px 0; }
.agent .bubble li { margin:3px 0; }
.agent .bubble a { color:var(--accent); text-decoration:none; font-weight:500; }
.agent .bubble a:hover { text-decoration:underline; }
.agent .bubble blockquote { border-left:3px solid var(--accent); margin:10px 0; padding:6px 14px; color:var(--text-secondary); background:#f8f9ff; border-radius:0 6px 6px 0; font-style:italic; }
.agent .bubble :not(pre)>code { background:#f1f3f5; color:#e53e3e; padding:2px 6px; border-radius:4px; font-size:0.88em; font-weight:500; }
.agent .bubble pre { background:#1a1b26; border-radius:var(--radius-sm); margin:12px 0; overflow:hidden; box-shadow:0 2px 8px rgba(0,0,0,0.12); }
.agent .bubble pre .code-header { display:flex; justify-content:space-between; align-items:center; padding:8px 14px; background:#252636; color:#9ca3af; font-size:11.5px; border-bottom:1px solid #313244; }
.agent .bubble pre .code-header .lang { text-transform:uppercase; }
.agent .bubble pre .code-header .copy-code { background:rgba(255,255,255,0.06); border:none; color:#9ca3af; cursor:pointer; font-size:11px; padding:4px 8px; border-radius:4px; }
.agent .bubble pre .code-header .copy-code:hover { color:#fff; background:rgba(255,255,255,0.12); }
.agent .bubble pre>code { display:block; padding:14px 16px; color:#cdd6f4; line-height:1.65; font-size:13.5px; white-space:pre-wrap; word-break:break-word; }
.agent .bubble table { border-collapse:collapse; margin:12px 0; width:100%; font-size:13.5px; border-radius:var(--radius-sm); overflow:hidden; box-shadow:var(--shadow); }
.agent .bubble th { background:#f0f3ff; color:var(--text); font-weight:600; padding:10px 14px; border:1px solid #e2e6f0; text-align:left; }
.agent .bubble td { padding:9px 14px; border:1px solid #eef1f7; }
.agent .bubble tr:nth-child(even) td { background:#fafbff; }
.agent .bubble tr:hover td { background:#f0f3ff; }
.agent .bubble hr { border:none; border-top:1px solid #e5e7eb; margin:16px 0; }
.agent .bubble img { max-width:100%; border-radius:6px; }
.agent .bubble strong { font-weight:700; }
.input-area { display:flex; padding:14px 20px; background:var(--chat-bg); border-top:1px solid var(--border); gap:8px; align-items:center; }
#user-input { flex:1; padding:11px 16px; border:1.5px solid #e5e7eb; border-radius:24px; background:#f8f9fb; color:var(--text); outline:none; font-size:14px; }
#user-input:focus { border-color:var(--accent); background:#fff; box-shadow:0 0 0 3px rgba(79,110,247,0.1); }
.send-btn { padding:10px 18px; background:var(--accent); border:none; border-radius:20px; color:white; font-weight:600; cursor:pointer; font-size:13.5px; }
.send-btn:hover { background:var(--accent-dark); box-shadow:0 2px 8px rgba(79,110,247,0.3); }
.send-btn:disabled { opacity:0.5; cursor:not-allowed; }
.stop-btn { padding:10px 16px; background:#ef4444; border:none; border-radius:20px; color:white; font-weight:600; cursor:pointer; font-size:13.5px; display:none; }
.stop-btn:hover { background:#dc2626; }
.copy-btn { background:none; border:none; color:#cbd5e0; cursor:pointer; font-size:11px; padding:3px 6px; position:absolute; top:8px; right:10px; opacity:0; transition:all 0.2s; border-radius:4px; }
.message:hover .copy-btn { opacity:1; }
.copy-btn:hover { color:var(--accent); background:#f0f3ff; }
.drop-zone { border:2px dashed #d1d5db; border-radius:var(--radius-sm); padding:18px; text-align:center; color:#9ca3af; margin:0 20px 8px; display:none; }
.drop-zone.active { border-color:var(--accent); background:#f0f4ff; color:var(--accent); }
.skillbar { width:var(--skillbar-w); min-width:var(--skillbar-w); max-width:var(--skillbar-w); background:#f8f9fb; border-left:1px solid var(--border); display:flex; flex-direction:column; transition:width 0.25s; overflow:hidden; flex-shrink:0; }
.skillbar.collapsed { width:0; min-width:0; max-width:0; border-left:none; }
.skillbar-header { padding:16px; font-weight:600; font-size:15px; border-bottom:1px solid var(--border); background:linear-gradient(135deg,var(--accent) 0%,#6366f1 100%); color:white; display:flex; justify-content:space-between; align-items:center; }
.skill-actions { padding:10px; border-bottom:1px solid var(--border); display:flex; flex-wrap:wrap; gap:6px; }
.skill-actions button,.skill-actions label.btn { background:var(--accent); color:white; border:none; padding:6px 10px; border-radius:6px; cursor:pointer; font-size:12px; }
.skill-actions button:hover,.skill-actions label.btn:hover { background:var(--accent-dark); }
.skill-item { padding:9px 12px; border-bottom:1px solid #f3f4f6; font-size:13px; display:flex; flex-wrap:wrap; align-items:flex-start; }
.skill-item:hover { background:#f0f2f5; }
.skill-item .skill-info { flex:1; min-width:0; }
.skill-item .skill-name { font-weight:600; color:var(--text); }
.skill-item .skill-desc { font-size:11.5px; color:var(--text-secondary); word-break:break-word; margin-top:2px; }
.skill-item button { background:none; border:none; color:#d1d5db; cursor:pointer; font-size:12px; margin-left:8px; flex-shrink:0; padding:2px 4px; border-radius:4px; }
.skill-item button:hover { color:#ef4444; background:#fee; }
.confirm-popup { position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.4); display:flex; align-items:center; justify-content:center; z-index:9999; backdrop-filter:blur(2px); }
.confirm-box { background:white; padding:28px; border-radius:var(--radius); max-width:500px; width:90%; box-shadow:0 8px 30px rgba(0,0,0,0.15); }
.confirm-text { white-space:pre-wrap; word-break:break-word; margin-bottom:20px; max-height:300px; overflow-y:auto; font-size:14.5px; color:var(--text); }
.confirm-buttons { display:flex; gap:10px; justify-content:flex-end; }
.confirm-buttons button { padding:9px 22px; border:none; border-radius:var(--radius-sm); cursor:pointer; font-weight:600; font-size:14px; }
.btn-approve { background:#10b981; color:white; } .btn-approve:hover { background:#059669; }
.btn-reject { background:#ef4444; color:white; } .btn-reject:hover { background:#dc2626; }
.timeout-note { font-size:12px; color:#9ca3af; margin-top:12px; text-align:right; }
.spinner { border:3px solid #e5e7eb; border-top:3px solid var(--accent); border-radius:50%; width:20px; height:20px; animation:spin 0.8s linear infinite; }
@keyframes spin { to{transform:rotate(360deg);} }
.login-overlay { position:fixed; top:0; left:0; width:100%; height:100%; background:linear-gradient(135deg,#667eea 0%,#764ba2 100%); display:flex; align-items:center; justify-content:center; z-index:10000; }
.login-box { background:white; padding:36px; border-radius:16px; width:360px; text-align:center; box-shadow:0 16px 48px rgba(0,0,0,0.2); animation:fadeIn 0.3s ease; }
.login-box h2 { margin-bottom:24px; color:var(--text); font-size:22px; }
.login-box input { width:100%; padding:11px 14px; margin:8px 0; border:1.5px solid #e5e7eb; border-radius:10px; font-size:15px; outline:none; }
.login-box input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(79,110,247,0.1); }
.login-box .login-btn { width:100%; padding:11px; background:linear-gradient(135deg,#667eea 0%,#764ba2 100%); color:white; border:none; border-radius:10px; font-size:16px; font-weight:600; cursor:pointer; margin-top:14px; }
.login-box .login-btn:hover { box-shadow:0 4px 16px rgba(102,126,234,0.4); }
.login-error { color:#ef4444; font-size:13px; margin-top:10px; min-height:20px; }
.settings-panel { position:fixed; top:0; right:0; width:340px; height:100vh; background:white; box-shadow:-4px 0 20px rgba(0,0,0,0.12); z-index:9998; transform:translateX(100%); transition:transform 0.25s ease; padding:20px; overflow-y:auto; }
.settings-panel.open { transform:translateX(0); }
.settings-panel h3 { font-size:16px; margin-bottom:12px; color:var(--text); }
.settings-panel label { display:block; font-size:12px; color:var(--text-secondary); margin-bottom:2px; margin-top:8px; }
.settings-panel input { width:100%; padding:7px 10px; border:1.5px solid #e5e7eb; border-radius:6px; font-size:13px; outline:none; }
.settings-panel input:focus { border-color:var(--accent); }
.settings-panel .save-btn { margin-top:16px; width:100%; padding:10px; background:var(--accent); color:white; border:none; border-radius:8px; font-weight:600; cursor:pointer; }
.settings-panel .close-btn { position:absolute; top:12px; right:12px; background:none; border:none; font-size:20px; cursor:pointer; color:#999; }
.settings-panel .agent-section { border:1px solid #e5e7eb; border-radius:8px; padding:10px; margin-bottom:10px; }
.settings-panel .agent-section h4 { font-size:13px; margin-bottom:6px; color:var(--accent); }
.settings-panel .row { display:flex; gap:6px; }
.settings-panel .row input { flex:1; }
.settings-panel .eye-btn { background:var(--accent); border:none; cursor:pointer; font-size:12px; padding:6px 8px; color:white; border-radius:4px; white-space:nowrap; }
.settings-panel .eye-btn:hover { background:var(--accent-dark); }.model-select { width:100%; margin-top:4px; padding:6px; border:1px solid var(--border); border-radius:4px; font-size:12px; background:white; }
.hljs { color:#cdd6f4; background:#1a1b26; }
.hljs-keyword,.hljs-selector-tag,.hljs-literal,.hljs-section,.hljs-link { color:#cba6f7; }
.hljs-string,.hljs-title,.hljs-name,.hljs-type,.hljs-attr,.hljs-symbol,.hljs-bullet,.hljs-addition,.hljs-variable,.hljs-template-tag,.hljs-template-variable { color:#a6e3a1; }
.hljs-comment,.hljs-quote,.hljs-deletion,.hljs-meta { color:#6c7086; font-style:italic; }
.hljs-number,.hljs-regexp,.hljs-built_in,.hljs-builtin-name { color:#fab387; }
.hljs-attribute,.hljs-tag,.hljs-selector-class,.hljs-selector-id { color:#89b4fa; }
.hljs-params { color:#cdd6f4; }
.hljs-function .hljs-title,.hljs-class .hljs-title { color:#89b4fa; }
.hljs-property,.hljs-selector-pseudo { color:#89dceb; }
</style>
</head>
<body>
<div class="app-container">
<div class="sidebar" id="sidebar">
<div class="sidebar-header"><span>对话记录</span><button class="new-chat-btn" onclick="createNewChat()"> 新对话</button></div>
<div class="thread-list" id="threadList"></div>
</div>
<div class="chat-container">
<div class="chat-header">
<button class="toggle-btn" onclick="toggleSidebar('sidebar')" id="toggle-sidebar-btn"></button>
<span class="title">Agent 对话</span>
<button class="export-btn" onclick="exportChat()">↓ 导出</button>
<button class="settings-btn" onclick="toggleSettings()"></button>
<button class="toggle-btn" onclick="toggleSidebar('skillbar')" id="toggle-skillbar-btn"></button>
</div>
<div class="messages" id="messages"></div>
<div id="loading-indicator" style="display:none;margin:0 20px 8px;align-items:center;"><div class="spinner"></div><span style="color:#666;margin-left:8px;">思考中...</span></div>
<div class="drop-zone" id="drop-zone">拖拽文件到此处上传</div>
<div class="input-area">
<input type="text" id="user-input" placeholder="输入消息..." />
<input type="file" id="file-input" multiple style="display:none" />
<button class="send-btn" onclick="document.getElementById('file-input').click()">📎</button>
<button class="send-btn" id="send-btn" onclick="sendMessage()">发送</button>
<button class="stop-btn" id="stop-btn" onclick="stopGeneration()">⏹ 停止</button>
</div>
<div style="padding:4px 20px;font-size:11px;color:var(--text-secondary);text-align:center;" id="token-info"></div>
</div>
<div class="skillbar" id="skillbar">
<div class="skillbar-header">技能管理</div>
<div class="skill-actions">
<label class="btn" for="skill-zip-upload">上传技能包</label>
<input type="file" id="skill-zip-upload" accept=".zip,.md" style="display:none" onchange="uploadSkillZip(this)">
<button onclick="reloadSkills()">热更新</button>
</div>
<div style="flex:1;overflow-y:auto;" id="skillList"><div style="padding:12px;color:#666;">加载中...</div></div>
</div>
</div>
<div class="settings-panel" id="settings-panel">
<button class="close-btn" onclick="toggleSettings()">x</button>
<h3>Agent 配置</h3>
<p style="font-size:11px;color:#999;margin-bottom:10px;">Temperature 即时生效。模型/URL/Key 对应 .env 变量,修改后需重启。</p>
<div class="agent-section">
<h4>主 Agent (LLM_MODEL / LLM_API_KEY / LLM_BASE_URL)</h4>
<label>模型</label><div class="row"><input type="text" class="model-select" id="cfg-m-model" placeholder="输入模型名或点击获取" onclick="showModelOptions('cfg-m-model','dl-m-model')" /><datalist id="dl-m-model" style="display:none"></datalist><button class="eye-btn" onclick="fetchModels('cfg-m-model','dl-m-model','cfg-m-url','cfg-m-key')" title="获取模型列表">获取</button></div>
<label>API URL</label><input type="text" id="cfg-m-url" placeholder="https://open.bigmodel.cn/api/paas/v4" />
<label>API Key</label><input type="password" id="cfg-m-key" placeholder="sk-xxx" />
<label>Temperature</label><input type="range" id="cfg-m-temp" min="0" max="2" step="0.1" value="0.7" style="width:100%" /><span id="cfg-m-temp-val" style="font-size:12px;color:var(--text-secondary)">0.7</span>
<label>上下文窗口 (tokens)</label><input type="number" id="cfg-m-maxtk" value="256000" min="0" style="width:100%" />
<button class="save-btn" onclick="testAgent('main')" style="background:#10b981;margin-top:8px;">测试连接</button>
</div>
<div class="agent-section">
<h4>Writer Agent (LLM_MODEL_W / LLM_API_KEY_W / LLM_BASE_URL_W)</h4>
<label>模型</label><div class="row"><input type="text" class="model-select" id="cfg-w-model" placeholder="输入模型名或点击获取" onclick="showModelOptions('cfg-w-model','dl-w-model')" /><datalist id="dl-w-model" style="display:none"></datalist><button class="eye-btn" onclick="fetchModels('cfg-w-model','dl-w-model','cfg-w-url','cfg-w-key')" title="获取模型列表">获取</button></div>
<label>API URL</label><input type="text" id="cfg-w-url" placeholder="https://api.minimax.chat/v1" />
<label>API Key</label><input type="password" id="cfg-w-key" placeholder="sk-xxx" />
<button class="save-btn" onclick="testAgent('writer')" style="background:#10b981;margin-top:8px;">测试连接</button>
</div>
<div class="agent-section">
<h4>MCP Agent (LLM_MODEL_M / LLM_API_KEY_M / LLM_BASE_URL_M)</h4>
<label>模型</label><div class="row"><input type="text" class="model-select" id="cfg-mcp-model" placeholder="输入模型名或点击获取" onclick="showModelOptions('cfg-mcp-model','dl-mcp-model')" /><datalist id="dl-mcp-model" style="display:none"></datalist><button class="eye-btn" onclick="fetchModels('cfg-mcp-model','dl-mcp-model','cfg-mcp-url','cfg-mcp-key')" title="获取模型列表">获取</button></div>
<label>API URL</label><input type="text" id="cfg-mcp-url" placeholder="https://api.minimax.chat/v1" />
<label>API Key</label><input type="password" id="cfg-mcp-key" placeholder="sk-xxx" />
<button class="save-btn" onclick="testAgent('mcp')" style="background:#10b981;margin-top:8px;">测试连接</button>
</div>
<button class="save-btn" onclick="saveSettings()">保存全部</button>
<button class="save-btn" onclick="applyAndRestart()" style="background:#f59e0b;margin-top:8px;">应用并重启</button>
</div>
<div class="login-overlay" id="login-overlay">
<div class="login-box">
<h2>登录</h2>
<input type="text" id="login-username" placeholder="用户名" autocomplete="username" />
<input type="password" id="login-password" placeholder="密码" autocomplete="current-password" />
<button class="login-btn" onclick="doLogin()">登 录</button>
<div class="login-error" id="login-error"></div>
</div>
</div>
<script src="chat.js?v=20260611"></script>
</body>
</html>
@@ -0,0 +1,570 @@
// Agent Chat - 所有前端逻辑
const ASSISTANT_ID = 'simple_agent';
const API_BASE = window.location.protocol + '//' + window.location.hostname + ':2024';
const CHAT_SERVER = window.location.protocol + '//' + window.location.hostname + ':8765';
const messagesDiv = document.getElementById('messages');
const threadListDiv = document.getElementById('threadList');
const skillListDiv = document.getElementById('skillList');
const loadingIndicator = document.getElementById('loading-indicator');
let currentThreadId = null, threads = [], authToken = sessionStorage.getItem('auth_token') || '';
let abortController = null;
const messageMap = new Map(), processedConfirms = new Set(), pendingConfirms = new Map(), existingMsgTexts = new Set();
var pendingCharts = [], streamMsgIndex = -1; // -1=不跳过,>=0=跳过该索引之前的消息
// URL user identity
var urlParams = new URLSearchParams(window.location.search);
var embedUser = urlParams.get('user') || 'default';
var embedToken = urlParams.get('token') || '';
var currentUser = embedUser || 'default';
// 迁移旧的无前缀 localStorage 数据
if (!localStorage.getItem(userKey('agent_threads')) && localStorage.getItem('agent_threads')) {
var keysToMigrate = [];
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i);
if (k && (k === 'agent_threads' || k === 'agent_config' || k.startsWith('msg_cache_') || k === 'last_thread')) {
keysToMigrate.push(k);
}
}
keysToMigrate.forEach(function(k) {
localStorage.setItem(userKey(k), localStorage.getItem(k));
localStorage.removeItem(k);
});
}
if (embedUser && embedToken) {
authToken = embedToken;
sessionStorage.setItem('auth_token', embedToken);
document.getElementById('login-overlay').style.display = 'none';
}
function userKey(k) { return currentUser + '_' + k; }
function localGet(k) { return localStorage.getItem(userKey(k)); }
function localSet(k, v) { localStorage.setItem(userKey(k), v); }
// ====== UI Helpers ======
function toggleSidebar(id) {
var el = document.getElementById(id);
el.classList.toggle('collapsed');
if (id === 'sidebar') document.getElementById('toggle-sidebar-btn').textContent = el.classList.contains('collapsed') ? '\u25b6' : '\u25c0';
else document.getElementById('toggle-skillbar-btn').textContent = el.classList.contains('collapsed') ? '\u25c0' : '\u25b6';
}
function escapeHtml(text) {
var map = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'};
return String(text).replace(/[&<>"']/g, function(m) { return map[m]; });
}
function showError(msg) {
addMessage('system', msg, '\u7cfb\u7edf');
loadingIndicator.style.display = 'none';
document.getElementById('stop-btn').style.display = 'none';
document.getElementById('send-btn').disabled = false;
}
// ====== Markdown ======
if (typeof marked !== 'undefined') {
marked.setOptions({ breaks: true, gfm: true });
if (typeof hljs !== 'undefined') {
marked.setOptions({ highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) { try { return hljs.highlight(code, { language: lang }).value; } catch (e) {} }
return code;
}});
}
var renderer = new marked.Renderer();
renderer.code = function(code, lang) {
var langLabel = lang || 'code';
var escaped = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var highlighted = (typeof hljs !== 'undefined' && lang && hljs.getLanguage(lang)) ? hljs.highlight(code, { language: lang }).value : escaped;
return '<pre><div class="code-header"><span class="lang">' + langLabel + '</span><button class="copy-code" onclick="copyCodeBlock(this)">\u590d\u5236</button></div><code class="hljs">' + highlighted + '</code></pre>';
};
marked.setOptions({ renderer: renderer });
}
function renderMarkdown(text) {
if (!text) return '';
try { return marked.parse(text); } catch (e) { return escapeHtml(text).replace(/\n/g, '<br>'); }
}
function copyCodeBlock(btn) {
var code = btn.parentElement.nextElementSibling;
if (code) {
navigator.clipboard.writeText(code.textContent).then(function() {
btn.textContent = '\u5df2\u590d\u5236';
setTimeout(function() { btn.textContent = '\u590d\u5236'; }, 1500);
}).catch(function() {});
}
}
// ====== Message Rendering ======
function addMessage(role, content, label, msgId) {
msgId = msgId || ('msg-' + Date.now() + '-' + Math.random().toString(36).substr(2, 6));
var div = document.createElement('div');
div.className = 'message ' + role;
var html = label ? '<div class="label">' + label + '</div>' : '';
var body;
if (role === 'agent') body = renderMarkdown(content);
else if (role === 'tool') body = '<pre><code>' + escapeHtml(content) + '</code></pre>';
else body = escapeHtml(content).replace(/\n/g, '<br>');
html += '<div class="bubble" id="' + msgId + '">' + body + '<button class="copy-btn" onclick="copyMsg(\'' + msgId + '\')">\ud83d\udccb</button></div>';
div.innerHTML = html;
messagesDiv.appendChild(div);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function copyMsg(id) {
var el = document.getElementById(id); if (!el) return;
var clone = el.cloneNode(true); var cb = clone.querySelector('.copy-btn'); if (cb) cb.remove();
navigator.clipboard.writeText(clone.innerText).catch(function() {});
}
function addMessagePlain(role, content, label) {
var div = document.createElement('div');
div.className = 'message ' + role;
var html = label ? '<div class="label">' + label + '</div>' : '';
html += '<div class="bubble">' + escapeHtml(content).replace(/\n/g, '<br>') + '</div>';
div.innerHTML = html;
messagesDiv.appendChild(div);
}
function renderHistoryMessage(msg) {
// 写去重表,防止 stream 重放
var dk = currentThreadId + '|' + (msg.type||'') + '|' + (msg.content||'').substring(0, 100);
messageMap.set(dk, true);
if (msg.type === 'human' || msg.role === 'user') addMessagePlain('user', msg.content, '\u4f60');
else if (msg.type === 'ai') {
var c = (msg.content || '').replace(/<think>.*?<\/think>/gs, '').trim();
if (c) addMessage('agent', c, 'Agent');
if (msg.tool_calls) {
for (var i = 0; i < msg.tool_calls.length; i++) {
var tc = msg.tool_calls[i];
addMessagePlain('tool', tc.name + '(' + JSON.stringify(tc.args) + ')', '\u5de5\u5177\u8c03\u7528');
}
}
} else if (msg.type === 'tool') {
var tc = msg.name + ': ' + (msg.content || '');
if ((msg.content||'').indexOf('![图表]') >= 0) {
addMessage('agent', msg.content, msg.name || '\u5de5\u5177\u7ed3\u679c');
} else {
addMessagePlain('tool', tc, '\u5de5\u5177\u7ed3\u679c');
}
}
}
// ====== Thread Management ======
function loadThreads() {
var stored = localGet('agent_threads');
threads = stored ? JSON.parse(stored) : [];
renderThreadList();
fetch(API_BASE + '/threads/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (Array.isArray(data) && data.length > 0) {
var serverIds = new Set();
data.forEach(function(t) { serverIds.add(t.thread_id);
if (!threads.find(function(l) { return l.id === t.thread_id; })) {
threads.unshift({ id: t.thread_id, title: '', createdAt: Date.now() });
}
});
threads = threads.filter(function(t) { return serverIds.has(t.id); });
saveThreads();
renderThreadList();
}
}).catch(function() {
// 无法连接服务器时,保留本地缓存的历史记录
});
}
function saveThreads() { localSet('agent_threads', JSON.stringify(threads)); }
function renderThreadList() {
threadListDiv.innerHTML = '';
threads.forEach(function(t) {
var div = document.createElement('div');
div.className = 'thread-item' + (t.id === currentThreadId ? ' active' : '');
div.innerHTML = '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;" onclick="switchThread(\'' + t.id + '\')">' + escapeHtml(t.title || t.id.substring(0, 8)) + '</span><button class="del-btn" onclick="deleteThread(event,\'' + t.id + '\')">\u00d7</button>';
threadListDiv.appendChild(div);
});
}
function deleteThread(e, threadId) {
e.stopPropagation();
if (!confirm('\u786e\u5b9a\u5220\u9664\uff1f')) return;
threads = threads.filter(function(t) { return t.id !== threadId; });
saveThreads();
if (currentThreadId === threadId) { messagesDiv.innerHTML = ''; messageMap.clear(); currentThreadId = null;
threads.length > 0 ? switchThread(threads[0].id) : createNewChat(); }
else renderThreadList();
}
async function createThread() {
var resp = await apiFetch(API_BASE + '/threads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!resp.ok) throw new Error('\u521b\u5efa\u5931\u8d25');
return (await resp.json()).thread_id;
}
async function createNewChat() {
try { var threadId = await createThread(); threads.unshift({ id: threadId, title: '', createdAt: Date.now() });
saveThreads(); currentThreadId = threadId; renderThreadList(); messagesDiv.innerHTML = ''; messageMap.clear(); }
catch (err) { addMessage('system', '\u521b\u5efa\u65b0\u5bf9\u8bdd\u5931\u8d25: ' + err.message, '\u7cfb\u7edf'); }
}
async function switchThread(threadId) {
currentThreadId = threadId; localSet('last_thread', threadId);
renderThreadList(); messagesDiv.innerHTML = ''; existingMsgTexts.clear(); streamMsgIndex = -1;
// 优先从本地缓存秒出
var cached = localGet('msg_cache_' + threadId) || localStorage.getItem('msg_cache_' + threadId);
var hasCache = false;
if (cached) {
try {
var cmsgs = JSON.parse(cached);
if (cmsgs.length > 0) {
for (var j = 0; j < cmsgs.length; j++) renderHistoryMessage(cmsgs[j]);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
hasCache = true;
}
} catch (e) {}
}
if (!hasCache) {
messagesDiv.innerHTML = '<div class="message system"><div class="bubble" style="display:flex;align-items:center;gap:8px;"><div class="spinner"></div><span>加载中...</span></div></div>';
// 3秒超时后显示空状态
setTimeout(function() {
if (currentThreadId === threadId && messagesDiv.children.length <= 1) {
messagesDiv.innerHTML = '<div class="message system"><div class="bubble">暂无对话内容</div></div>';
}
}, 3000);
}
// 后台静默同步服务器最新数据
apiFetch(API_BASE + '/threads/' + threadId + '/state').then(async function(resp) {
if (!resp.ok) return;
var data = await resp.json();
var msgs = data && data.values ? data.values.messages || [] : [];
if (msgs.length === 0) return;
localSet('msg_cache_' + threadId, JSON.stringify(msgs));
if (!hasCache || msgs.length !== JSON.parse(cached).length) {
messagesDiv.innerHTML = ''; messageMap.clear();
for (var i = 0; i < msgs.length; i++) renderHistoryMessage(msgs[i]);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
}).catch(function(){});
}
// ====== Confirm Flow ======
async function checkConfirmPending(confirmId) {
if (pendingConfirms.has(confirmId)) return pendingConfirms.get(confirmId);
var p = apiFetch(CHAT_SERVER + '/api/confirm_info?confirm_id=' + encodeURIComponent(confirmId))
.then(async function(r) { if (r.ok) { var d = await r.json(); return d.result === 'pending'; } return true; })
.catch(function() { return true; });
pendingConfirms.set(confirmId, p); return p;
}
async function processMessage(msg) {
if (msg.type === 'human' || msg.role === 'user') return;
var content = msg.content || '';
var confirmMatch = content.match(/\[NEED_USER_CONFIRM_FILE\|([a-f0-9\-]+)\]/);
if (confirmMatch) { var cid = confirmMatch[1]; if (processedConfirms.has(cid)) return; processedConfirms.add(cid);
var isPending = await checkConfirmPending(cid); if (!isPending) return; showConfirmPopupForWrite(cid); throw { type: 'CONFIRM_PENDING' }; }
// 内容去重:用内容前100字符+thread做key,防止LangGraph重放历史消息
var dedupKey = currentThreadId + '|' + (msg.type||'') + '|' + content.substring(0, 100);
if (messageMap.has(dedupKey)) return; messageMap.set(dedupKey, true);
var txt = (msg.content || '').substring(0, 100);
if (existingMsgTexts.has(txt)) return;
if (msg.type === 'ai') {
var c = (msg.content || '').replace(/<think>.*?<\/think>/gs, '').trim();
// 如果有待展示的图表,插入到总结之后
if (c && pendingCharts.length > 0) {
var parts = c.split(/(?<=[。!?\n])/); // 按句号/感叹号/疑问号/换行分割
var summary = parts.slice(0, Math.min(2, parts.length)).join('').trim();
var rest = parts.slice(Math.min(2, parts.length)).join('').trim();
if (summary) addMessage('agent', summary, 'Agent');
pendingCharts.forEach(function(chart) { addMessage('agent', chart, '\u56fe\u8868'); });
pendingCharts = [];
if (rest) addMessage('agent', rest, 'Agent');
} else if (c) {
addMessage('agent', c, 'Agent');
}
if (msg.tool_calls) { for (var i = 0; i < msg.tool_calls.length; i++) { var tc = msg.tool_calls[i]; addMessage('tool', tc.name + '(' + JSON.stringify(tc.args) + ')', '\u5de5\u5177\u8c03\u7528'); } }
} else if (msg.type === 'tool') {
var tc = msg.content || '';
if (tc.indexOf('![图表]') >= 0) {
// 图表不立即渲染,暂存队列等待 Agent 回复时插入
var imgs = tc.match(/!\[.*?\]\(\/workspace\/.+?\.svg\)/g);
if (imgs) imgs.forEach(function(img) { pendingCharts.push(img); });
} else {
addMessage('tool', msg.name + ': ' + tc, '\u5de5\u5177\u7ed3\u679c');
}
}
}
function showConfirmPopupForWrite(confirmId) {
var popup = document.createElement('div'); popup.id = 'confirm-popup'; popup.className = 'confirm-popup';
popup.innerHTML = '<div class="confirm-box"><div class="confirm-text">\u6587\u4ef6\u4e0d\u5728\u5b89\u5168\u533a\u57df\u5185\uff0c\u662f\u5426\u5199\u5165\uff1f</div><div class="confirm-buttons"><button class="btn-approve">\u540c\u610f\u5199\u5165</button><button class="btn-reject">\u62d2\u7edd</button></div></div>';
document.body.appendChild(popup);
popup.querySelector('.btn-approve').addEventListener('click', async function() { popup.remove();
try { var r = await apiFetch(CHAT_SERVER + '/api/confirm_write', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({confirm_id:confirmId, choice:'approve'}) });
var d = await r.json(); addMessage('system', d.success ? '\u2705 \u5df2\u5199\u5165: ' + (d.path||'') : '\u5199\u5165\u5931\u8d25: ' + (d.error||d.message), '\u7cfb\u7edf'); }
catch(e) { addMessage('system', '\u8bf7\u6c42\u5931\u8d25: ' + e.message, '\u7cfb\u7edf'); }
});
popup.querySelector('.btn-reject').addEventListener('click', async function() { popup.remove();
try { await apiFetch(CHAT_SERVER + '/api/confirm_write', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({confirm_id:confirmId, choice:'reject'}) }); } catch(e){}
addMessage('system', '\u5df2\u62d2\u7edd\u5199\u5165', '\u7cfb\u7edf');
});
document.addEventListener('keydown', function esc(e) { if (e.key === 'Escape') popup.remove(); }, { once: true });
}
// ====== Stream Processing ======
async function processStream(response) {
var reader = response.body.getReader(); var decoder = new TextDecoder(); var buffer = '', first = false;
try { while (true) { var result = await reader.read(); if (result.done) break;
buffer += decoder.decode(result.value, { stream: true }); var lines = buffer.split('\n'); buffer = lines.pop();
for (var i = 0; i < lines.length; i++) { var line = lines[i]; if (!line.startsWith('data: ')) continue;
try { var data = JSON.parse(line.slice(6));
if (data.__interrupt__) { showInterruptPopup(data.__interrupt__); reader.cancel(); loadingIndicator.style.display='none'; stopUI(); return; }
if (data.messages) { if (!first) { loadingIndicator.style.display='none'; first=true; }
for (var j=0;j<data.messages.length;j++) { try { await processMessage(data.messages[j]); } catch(e) { if (e&&e.type==='CONFIRM_PENDING') { reader.cancel(); loadingIndicator.style.display='none'; stopUI(); return; } throw e; } }
}
} catch(e) { if (e&&e.type==='CONFIRM_PENDING') { reader.cancel(); loadingIndicator.style.display='none'; stopUI(); return; } }
}
}
} catch(e) { if (e.name!=='AbortError') console.error('Stream error:',e); }
loadingIndicator.style.display='none'; stopUI();
// 如果还有未展示的图表,直接显示
if (pendingCharts.length > 0) {
pendingCharts.forEach(function(chart) { addMessage('agent', chart, '\u56fe\u8868'); });
pendingCharts = [];
}
// 缓存最新消息到本地
if (currentThreadId) {
var allBubbles = messagesDiv.querySelectorAll('.message');
var cachedMsgs = [];
allBubbles.forEach(function(m) {
if (m.classList.contains('user')) cachedMsgs.push({type:'human',role:'user',content:m.querySelector('.bubble').innerText.trim()});
else if (m.classList.contains('agent')) cachedMsgs.push({type:'ai',role:'assistant',content:m.querySelector('.bubble').innerText.trim()});
else if (m.classList.contains('tool')) cachedMsgs.push({type:'tool',role:'tool',name:'',content:m.querySelector('.bubble').innerText.trim()});
});
localSet('msg_cache_' + currentThreadId, JSON.stringify(cachedMsgs));
}
}
function showInterruptPopup(data) {
var popup = document.createElement('div'); popup.id='confirm-popup'; popup.className='confirm-popup';
popup.innerHTML = '<div class="confirm-box"><div class="confirm-text">' + escapeHtml(data.message||'\u662f\u5426\u7ee7\u7eed\uff1f') + '</div><div class="confirm-buttons"><button class="btn-approve">\u540c\u610f</button><button class="btn-reject">\u62d2\u7edd</button></div><div class="timeout-note">30\u5206\u949f\u540e\u8d85\u65f6</div></div>';
document.body.appendChild(popup);
popup.querySelectorAll('button').forEach(function(b) { b.addEventListener('click', async function() { popup.remove(); await resumeWithCommand(data, b.classList.contains('btn-approve')?'approve':'reject'); }); });
document.addEventListener('keydown', function esc(e) { if (e.key==='Escape') popup.remove(); }, { once:true });
}
async function resumeWithCommand(data, val) {
var payload = { assistant_id:ASSISTANT_ID, thread_id:currentThreadId, input:null, command:{resume:{__interrupt_id__:data.__interrupt_id__,value:val}} };
startUI(); try { var resp = await apiFetch(API_BASE+'/threads/'+currentThreadId+'/runs/stream',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); await processStream(resp); } catch(e) { showError('\u6062\u590d\u5931\u8d25: '+e.message); }
}
function stopGeneration() { if (abortController) { abortController.abort(); abortController=null; } stopUI(); }
function startUI() { loadingIndicator.style.display='flex'; document.getElementById('stop-btn').style.display='inline-block'; document.getElementById('send-btn').disabled=true; }
function stopUI() { loadingIndicator.style.display='none'; document.getElementById('stop-btn').style.display='none'; document.getElementById('send-btn').disabled=false; }
// ====== Send Message ======
async function sendMessage() {
if (!currentThreadId) { try { currentThreadId=await createThread(); threads.unshift({id:currentThreadId,title:'',createdAt:Date.now()}); saveThreads(); renderThreadList(); messagesDiv.innerHTML=''; messageMap.clear(); } catch(err) { addMessage('system','\u521b\u5efa\u5bf9\u8bdd\u5931\u8d25','\u7cfb\u7edf'); return; } }
var input = document.getElementById('user-input'); var text = input.value.trim(); if (!text) return;
addMessage('user', text, '\u4f60'); input.value='';
pendingCharts = []; // 新消息清空图表队列
var ct = threads.find(function(t){return t.id===currentThreadId;}); if (ct&&!ct.title){ct.title=text.substring(0,30);saveThreads();renderThreadList();}
existingMsgTexts.clear();
messagesDiv.querySelectorAll('.bubble').forEach(function(b){ existingMsgTexts.add(b.innerText.trim().substring(0,100)); });
startUI(); abortController = new AbortController();
var cfg = JSON.parse(localGet('agent_config')||'{}'); var config={configurable:{}}; if(cfg.temperature) config.configurable.temperature=cfg.temperature;
try {
var resp = await fetch(API_BASE+'/threads/'+currentThreadId+'/runs/stream',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({assistant_id:ASSISTANT_ID,thread_id:currentThreadId,config:config,input:{messages:[{role:'user',content:text}]}}),signal:abortController.signal});
await processStream(resp);
} catch(e) { if (e.name==='AbortError') addMessage('system','\u5df2\u505c\u6b62\u751f\u6210','\u7cfb\u7edf'); else showError('\u8bf7\u6c42\u5931\u8d25: '+e.message); }
abortController=null;
}
// ====== Export ======
function exportChat() {
var msgs=[]; messagesDiv.querySelectorAll('.message').forEach(function(m){var bubble=m.querySelector('.bubble');if(!bubble)return;var clone=bubble.cloneNode(true);var cb=clone.querySelector('.copy-btn');if(cb)cb.remove();var role=m.classList.contains('user')?'\u7528\u6237':m.classList.contains('agent')?'Agent':m.classList.contains('tool')?'\u5de5\u5177':'\u7cfb\u7edf';msgs.push('## '+role+'\n\n'+clone.innerText+'\n');});
var text='# Agent \u5bf9\u8bdd\u8bb0\u5f55\n\n'+new Date().toLocaleString()+'\n\n---\n\n'+msgs.join('\n---\n\n');
var blob=new Blob([text],{type:'text/markdown;charset=utf-8'}); var a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='chat-'+new Date().toISOString().slice(0,10)+'.md'; a.click(); URL.revokeObjectURL(a.href);
}
// ====== File Upload ======
var dropZone=document.getElementById('drop-zone');
['dragenter','dragover'].forEach(function(e){document.addEventListener(e,function(ev){ev.preventDefault();dropZone.style.display='block';dropZone.classList.add('active');});});
['dragleave','drop'].forEach(function(e){document.addEventListener(e,function(ev){ev.preventDefault();if(e==='dragleave'){dropZone.style.display='none';dropZone.classList.remove('active');}});});
dropZone.addEventListener('drop',function(e){e.preventDefault();dropZone.style.display='none';dropZone.classList.remove('active');uploadFiles(e.dataTransfer.files);});
document.getElementById('file-input').addEventListener('change',function(){uploadFiles(this.files);this.value='';});
async function uploadFiles(files){for(var i=0;i<files.length;i++){var f=files[i];var fd=new FormData();fd.append('file',f);var type=f.name.match(/\.(txt|log|md|json|csv|yml|yaml|xml|html|css|js|py|java|cpp|c|h)$/i)?'knowledge':'memory';try{var r=await apiFetch(CHAT_SERVER+'/upload?type='+type,{method:'POST',body:fd});if(r.ok)addMessage('system','\u5df2\u4e0a\u4f20: '+f.name,'\u7cfb\u7edf');}catch(e){addMessage('system','\u4e0a\u4f20\u5931\u8d25: '+f.name,'\u7cfb\u7edf');}}}
// ====== Shortcuts ======
document.addEventListener('keydown',function(e){if(e.target.tagName==='INPUT'&&e.target.id==='user-input'&&e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}else if(e.ctrlKey&&e.key==='n'){e.preventDefault();createNewChat();}else if(e.ctrlKey&&e.key==='e'){e.preventDefault();exportChat();}else if(e.key==='Escape'){var popup=document.getElementById('confirm-popup');if(popup){popup.remove();stopUI();}}});
// ====== API + Auth ======
async function apiFetch(url, options) {
options = options || {};
if (authToken) { options.headers = options.headers || {}; options.headers['Authorization'] = 'Bearer ' + authToken; options.headers['X-User'] = currentUser; }
return fetch(url, options);
}
function checkLogin() {
if (authToken) {
apiFetch(CHAT_SERVER + '/skills/list').then(function(r) {
if (r.ok) {
document.getElementById('login-overlay').style.display = 'none';
loadThreads();
var lt = localGet('last_thread');
if (lt && threads.find(function(t) { return t.id === lt; })) switchThread(lt);
else if (threads.length === 0) createNewChat();
fetchSkills();
} else { authToken = ''; sessionStorage.removeItem('auth_token'); }
}).catch(function() {});
}
}
async function doLogin() {
var username = document.getElementById('login-username').value.trim();
var password = document.getElementById('login-password').value;
var errorEl = document.getElementById('login-error');
if (!username || !password) { errorEl.textContent = '\u8bf7\u8f93\u5165\u7528\u6237\u540d\u548c\u5bc6\u7801'; return; }
try {
var resp = await fetch(CHAT_SERVER + '/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: username, password: password }) });
var data = await resp.json();
if (resp.ok && data.token) {
authToken = data.token;
sessionStorage.setItem('auth_token', data.token);
document.getElementById('login-overlay').style.display = 'none';
loadThreads();
if (threads.length === 0) createNewChat();
fetchSkills();
} else { errorEl.textContent = data.error || '\u767b\u5f55\u5931\u8d25'; }
} catch (e) { errorEl.textContent = '\u8fde\u63a5\u5931\u8d25: ' + e.message; }
}
// ====== Skills ======
async function fetchSkills() {
try { var results = await Promise.all([apiFetch(CHAT_SERVER+'/skills/list'), apiFetch(CHAT_SERVER+'/skills/trash')]); var normal = await results[0].json(); var trash = []; if (results[1].ok) trash = await results[1].json(); renderSkillList(normal, trash); }
catch (e) { skillListDiv.innerHTML = '<div style="padding:12px;color:#d666;">\u83b7\u53d6\u5931\u8d25</div>'; }
}
function renderSkillList(normal, trash) {
var html = '<div style="font-weight:bold;padding:8px 12px;border-bottom:1px solid #ddd;">\u5df2\u5b89\u88c5\u6280\u80fd</div>';
if (!normal || !normal.length) html += '<div style="padding:12px;color:#666;">\u6682\u65e0</div>';
else normal.forEach(function(s) { html += '<div class="skill-item"><div class="skill-info"><div class="skill-name">' + escapeHtml(s.name) + '</div><div class="skill-desc">' + escapeHtml(s.description || '') + '</div></div><button class="btn-delete-skill" data-skill-name="' + escapeHtml(s.name) + '">\u5220\u9664</button></div>'; });
html += '<div style="font-weight:bold;padding:8px 12px;border-bottom:1px solid #ddd;margin-top:10px;">\u5783\u573e\u6876</div>';
if (!trash || !trash.length) html += '<div style="padding:12px;color:#666;">\u7a7a</div>';
else trash.forEach(function(s) { html += '<div class="skill-item" style="opacity:0.7;"><div class="skill-name">' + escapeHtml(s.name) + ' <span style="font-size:11px;color:#999;">(\u5df2\u5220\u9664)</span></div><button class="btn-recover-skill" data-skill-name="' + escapeHtml(s.name) + '">\u6062\u590d</button><button class="btn-delete-permanent" data-skill-name="' + escapeHtml(s.name) + '">\u6c38\u4e45\u5220\u9664</button></div>'; });
skillListDiv.innerHTML = html;
skillListDiv.querySelectorAll('.btn-delete-skill').forEach(function(b) { b.addEventListener('click', function() { deleteSkill(b.dataset.skillName); }); });
skillListDiv.querySelectorAll('.btn-recover-skill').forEach(function(b) { b.addEventListener('click', function() { recoverSkill(b.dataset.skillName); }); });
skillListDiv.querySelectorAll('.btn-delete-permanent').forEach(function(b) { b.addEventListener('click', function() { permanentDeleteSkill(b.dataset.skillName); }); });
}
async function reloadSkills() { try { var r = await apiFetch(CHAT_SERVER+'/skills/reload',{method:'POST'}); var d = await r.json(); if (r.ok) { addMessage('system', d.message, '\u7cfb\u7edf'); fetchSkills(); } } catch (e) { addMessage('system', '\u70ed\u66f4\u65b0\u5931\u8d25', '\u7cfb\u7edf'); } }
async function deleteSkill(name) { if (!confirm('\u5c06 \"' + name + '\" \u79fb\u81f3\u5783\u573e\u6876\uff1f')) return;
try { var r = await apiFetch(CHAT_SERVER+'/skills/delete?name='+encodeURIComponent(name),{method:'POST'}); var d = await r.json(); if (r.ok) { addMessage('system', d.message, '\u7cfb\u7edf'); fetchSkills(); } } catch (e) { addMessage('system', '\u5220\u9664\u5931\u8d25', '\u7cfb\u7edf'); } }
async function recoverSkill(name) { try { var r = await apiFetch(CHAT_SERVER+'/skills/recover?name='+encodeURIComponent(name),{method:'POST'}); var d = await r.json(); if (r.ok) { addMessage('system', d.message, '\u7cfb\u7edf'); fetchSkills(); } } catch (e) { addMessage('system', '\u6062\u590d\u5931\u8d25', '\u7cfb\u7edf'); } }
async function permanentDeleteSkill(name) { if (!confirm('\u6c38\u4e45\u5220\u9664 \"' + name + '\"\uff1f\u4e0d\u53ef\u6062\u590d\uff01')) return;
try { var r = await apiFetch(CHAT_SERVER+'/skills/permanent_delete?name='+encodeURIComponent(name),{method:'POST'}); var d = await r.json(); if (r.ok) { addMessage('system', d.message, '\u7cfb\u7edf'); fetchSkills(); } } catch (e) { addMessage('system', '\u5220\u9664\u5931\u8d25', '\u7cfb\u7edf'); } }
async function uploadSkillZip(input) { var file = input.files[0]; if (!file) return; var fd = new FormData(); fd.append('file', file);
try { var r = await apiFetch(CHAT_SERVER+'/skills/upload',{method:'POST',body:fd}); var d = await r.json(); if (r.ok) { addMessage('system', d.message, '\u7cfb\u7edf'); fetchSkills(); } } catch (e) { addMessage('system', '\u4e0a\u4f20\u5931\u8d25', '\u7cfb\u7edf'); } input.value = ''; }
// ====== Settings ======
function toggleSettings() {
var p = document.getElementById('settings-panel'); p.classList.toggle('open');
if (p.classList.contains('open')) {
var cfg = JSON.parse(localGet('agent_config') || '{}');
var mSel = document.getElementById('cfg-m-model'); ensureOption(mSel, cfg.m_model);
document.getElementById('cfg-m-url').value = cfg.m_url || ''; document.getElementById('cfg-m-key').value = cfg.m_key || '';
document.getElementById('cfg-m-temp').value = cfg.temperature || 0.7; document.getElementById('cfg-m-temp-val').textContent = cfg.temperature || 0.7;
var wSel = document.getElementById('cfg-w-model'); ensureOption(wSel, cfg.w_model);
document.getElementById('cfg-w-url').value = cfg.w_url || ''; document.getElementById('cfg-w-key').value = cfg.w_key || '';
var mcpSel = document.getElementById('cfg-mcp-model'); ensureOption(mcpSel, cfg.mcp_model);
document.getElementById('cfg-mcp-url').value = cfg.mcp_url || ''; document.getElementById('cfg-mcp-key').value = cfg.mcp_key || '';
document.getElementById('cfg-m-maxtk').value = cfg.max_tokens || 256000;
}
}
function ensureOption(inp, val) {
if (!val || !inp) return;
if (!inp.value) inp.value = val;
}
document.getElementById('cfg-m-temp').addEventListener('input', function() { document.getElementById('cfg-m-temp-val').textContent = this.value; });
function saveSettings() {
var cfg = { temperature: parseFloat(document.getElementById('cfg-m-temp').value), m_model: document.getElementById('cfg-m-model').value.trim(), m_url: document.getElementById('cfg-m-url').value.trim(), m_key: document.getElementById('cfg-m-key').value.trim(), w_model: document.getElementById('cfg-w-model').value.trim(), w_url: document.getElementById('cfg-w-url').value.trim(), w_key: document.getElementById('cfg-w-key').value.trim(), mcp_model: document.getElementById('cfg-mcp-model').value.trim(), mcp_url: document.getElementById('cfg-mcp-url').value.trim(), mcp_key: document.getElementById('cfg-mcp-key').value.trim() };
Object.keys(cfg).forEach(function(k) { if (!cfg[k] && cfg[k] !== 0) delete cfg[k]; });
localSet('agent_config', JSON.stringify(cfg));
document.getElementById('settings-panel').classList.remove('open');
// 写 .env 触发文件监听热更新
apiFetch(CHAT_SERVER + '/api/save_config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfg) }).then(function(r) { return r.json(); }).then(function(d) {
addMessage('system', d.success ? '\u914d\u7f6e\u5df2\u4fdd\u5b58\u5e76\u70ed\u66f4\u65b0\u751f\u6548' : (d.message || d.error || '\u4fdd\u5b58\u5931\u8d25'), '\u7cfb\u7edf');
}).catch(function(e) { addMessage('system', '\u4fdd\u5b58\u5931\u8d25: ' + e.message, '\u7cfb\u7edf'); });
}
function applyAndRestart() { saveSettings(); if (!confirm('\u5c06\u628a\u914d\u7f6e\u5199\u5165.env\u5e76\u91cd\u542f\u670d\u52a1\uff0c\u786e\u8ba4\uff1f')) return;
var cfg = JSON.parse(localGet('agent_config') || '{}'); addMessage('system', '\u6b63\u5728\u5e94\u7528\u914d\u7f6e\u5e76\u91cd\u542f...', '\u7cfb\u7edf');
apiFetch(CHAT_SERVER + '/api/restart', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfg) }).then(function(r) { return r.json(); }).then(function(d) { addMessage('system', d.message || '\u91cd\u542f\u4e2d', '\u7cfb\u7edf'); setTimeout(function() { location.reload(); }, 5000); }).catch(function(e) { addMessage('system', '\u91cd\u542f\u8bf7\u6c42\u5931\u8d25: ' + e.message, '\u7cfb\u7edf'); });
}
function fetchModels(mid, did, uid, kid) {
var url = document.getElementById(uid).value.trim(); var key = document.getElementById(kid).value.trim();
if (!url || !key) { addMessage('system', '\u8bf7\u5148\u586b\u5199API URL\u548cKey', '\u7cfb\u7edf'); return; }
url = url.replace(/\/+$/, '') + '/models'; addMessage('system', '\u6b63\u5728\u83b7\u53d6\u6a21\u578b\u5217\u8868...', '\u7cfb\u7edf');
fetch(url, { headers: { 'Authorization': 'Bearer ' + key } }).then(function(r) {
if (!r.ok) { addMessage('system', 'HTTP ' + r.status + ' \u8bf7\u624b\u52a8\u8f93\u5165', '\u7cfb\u7edf'); return; }
return r.text();
}).then(function(t) {
if (!t) return;
try { var d = JSON.parse(t); var models = d.data || d.models || []; if (!models.length) { addMessage('system', '\u672a\u83b7\u53d6\u5230\u6a21\u578b', '\u7cfb\u7edf'); return; }
var names = models.map(function(m) { return m.id; }).filter(function(v,i,a){ return a.indexOf(v)===i; }).sort();
var dl = document.getElementById(did);
if (dl) { dl.innerHTML = ''; names.forEach(function(n) { var o = document.createElement('option'); o.value = n; dl.appendChild(o); }); }
var inp = document.getElementById(mid);
if (!inp.value && names.length > 0) inp.value = names[0];
inp.placeholder = names.length + ' 个模型可选,点击输入框查看';
addMessage('system', '\u5df2\u52a0\u8f7d ' + names.length + ' \u4e2a\u6a21\u578b\uff0c\u70b9\u51fb\u8f93\u5165\u6846\u67e5\u770b', '\u7cfb\u7edf');
} catch (e) { addMessage('system', '\u54cd\u5e94\u975eJSON: ' + t.substring(0, 80), '\u7cfb\u7edf'); }
}).catch(function(e) { addMessage('system', '\u83b7\u53d6\u5931\u8d25: ' + e.message, '\u7cfb\u7edf'); });
}
function toggleKey(inputId, btn) { var inp = document.getElementById(inputId);
if (inp.type === 'password') { var raw = inp.value || ''; var masked = raw.length > 8 ? raw.substring(0, 4) + '****' + raw.substring(raw.length - 4) : raw; inp.type = 'text'; inp.value = masked; inp.dataset.raw = raw; btn.textContent = '\u{1f648}'; }
else { inp.type = 'password'; inp.value = inp.dataset.raw || inp.value; btn.textContent = '\u{1f441}'; }
}
// ====== Agent Test ======
async function testAgent(type) { saveSettings(); var label = type === 'main' ? '\u4e3bAgent' : type === 'writer' ? 'WriterAgent' : 'MCPAgent';
var testMsg = type === 'main' ? 'Hi, reply OK' : type === 'writer' ? 'Use utc_now to get time' : 'Use utc_now to get time';
addMessage('system', 'Testing ' + label + '...', '\u7cfb\u7edf');
try { var r = await fetch(API_BASE + '/threads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!r.ok) { addMessage('system', '\u274c ' + label + ': Cannot create thread HTTP ' + r.status, '\u7cfb\u7edf'); return; }
var d = await r.json(); var tid = d.thread_id;
var r2 = await fetch(API_BASE + '/threads/' + tid + '/runs/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assistant_id: ASSISTANT_ID, thread_id: tid, input: { messages: [{ role: 'user', content: testMsg }] } }) });
if (!r2.ok) { addMessage('system', '\u274c ' + label + ': Stream failed HTTP ' + r2.status, '\u7cfb\u7edf'); return; }
var reader = r2.body.getReader(), decoder = new TextDecoder(), buf = '', found = false;
var timeout = setTimeout(function() { if (!found) addMessage('system', '\u26a0\ufe0f ' + label + ': Timeout, API reachable', '\u7cfb\u7edf'); }, 30000);
try { while (true) { var chunk = await reader.read(); if (chunk.done) break; buf += decoder.decode(chunk.value, { stream: true });
var lines = buf.split('\n'); buf = lines.pop();
for (var i = 0; i < lines.length; i++) { var line = lines[i]; if (!line.startsWith('data: ')) continue;
try { var msgData = JSON.parse(line.slice(6)); if (msgData.messages) { for (var j = 0; j < msgData.messages.length; j++) { var msg = msgData.messages[j]; if (msg.content && msg.content.trim()) { found = true; clearTimeout(timeout); reader.cancel(); addMessage('system', '\u2705 ' + label + ': Connected, model responded', '\u7cfb\u7edf'); return; } } } } catch (e) {} }
}
} catch (e) { if (!found) { clearTimeout(timeout); addMessage('system', '\u274c ' + label + ': Read error - ' + e.message, '\u7cfb\u7edf'); } }
if (!found) { clearTimeout(timeout); addMessage('system', '\u26a0\ufe0f ' + label + ': No response, API reachable', '\u7cfb\u7edf'); }
} catch (e) { addMessage('system', '\u274c ' + label + ': ' + e.message, '\u7cfb\u7edf'); }
}
// ====== Token Display ======
function estimateTokens(text) { var cn = (text.match(/[\u4e00-\u9fff]/g) || []).length; var en = text.length - cn; return Math.ceil(cn * 0.6 + en * 0.25); }
function updateTokenDisplay() { var total = 0; messagesDiv.querySelectorAll('.message .bubble').forEach(function(b) { total += estimateTokens(b.innerText || ''); });
var cfg = JSON.parse(localGet('agent_config') || '{}'); var max = cfg.max_tokens || 256000; var el = document.getElementById('token-info');
if (max > 0) { el.textContent = '\u5df2\u7528 ~' + total + ' / ' + max + ' tokens (' + Math.round(total / max * 100) + '%)'; el.style.color = total > max * 0.8 ? '#ef4444' : 'var(--text-secondary)'; }
else { el.textContent = '~' + total + ' tokens'; }
}
setInterval(updateTokenDisplay, 3000);
// ====== Model Selector ======
function showModelOptions(inpId, dlId) {
var dl = document.getElementById(dlId);
if (!dl || dl.options.length === 0) return;
// 移除已有面板
var old = document.getElementById('model-options-panel');
if (old) old.remove();
var inp = document.getElementById(inpId);
var rect = inp.getBoundingClientRect();
var panel = document.createElement('div');
panel.id = 'model-options-panel';
panel.style.cssText = 'position:fixed;z-index:99999;background:white;border:1px solid #ccc;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.15);max-height:200px;overflow-y:auto;min-width:200px';
panel.style.left = rect.left + 'px';
panel.style.top = (rect.bottom + 4) + 'px';
panel.style.width = Math.max(rect.width, 200) + 'px';
for (var i = 0; i < dl.options.length; i++) {
var opt = dl.options[i];
var item = document.createElement('div');
item.textContent = opt.value;
item.style.cssText = 'padding:6px 10px;cursor:pointer;font-size:12px;color:#333';
item.onmouseover = function() { this.style.background = '#e8edff'; };
item.onmouseout = function() { this.style.background = ''; };
item.onclick = (function(v) { return function() { inp.value = v; panel.remove(); }; })(opt.value);
panel.appendChild(item);
}
document.body.appendChild(panel);
setTimeout(function() {
document.addEventListener('click', function rm() { panel.remove(); document.removeEventListener('click', rm); }, {once: true});
}, 100);
}
// ====== Init ======
checkLogin();
document.getElementById('login-password').addEventListener('keydown', function(e) { if (e.key === 'Enter') doLogin(); });
@@ -0,0 +1,739 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>MCP 查询</title>
<script src="lib/marked.min.js"></script>
<script src="lib/highlight.min.js"></script>
<style>
:root { --bg: #f0f2f5; --chat-bg: #ffffff; --accent: #4f6ef7; --accent-dark: #3b54d4; --text: #1a1a2e; --text-secondary: #6b7280; --border: #e5e7eb; --sidebar-w: 220px; --skillbar-w: 240px; --radius: 12px; --radius-sm: 8px; --shadow: 0 1px 3px rgba(0,0,0,0.06); }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif; background:var(--bg); display:flex; height:100vh; }
::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:transparent; } ::-webkit-scrollbar-thumb { background:#d1d5db; border-radius:3px; }
.app-container { display:flex; width:100%; height:100vh; background:var(--chat-bg); overflow:hidden; }
.sidebar { width:var(--sidebar-w); min-width:var(--sidebar-w); max-width:var(--sidebar-w); background:#f8f9fb; border-right:1px solid var(--border); display:flex; flex-direction:column; transition:width 0.25s ease; overflow:hidden; flex-shrink:0; }
.sidebar.collapsed { width:0; min-width:0; max-width:0; border-right:none; }
.sidebar-header { padding:16px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border); }
.sidebar-header span { font-weight:600; font-size:15px; color:var(--text); }
.new-chat-btn { background:var(--accent); color:white; border:none; padding:7px 14px; border-radius:var(--radius-sm); cursor:pointer; font-size:13px; font-weight:500; }
.new-chat-btn:hover { background:var(--accent-dark); }
.thread-list { flex:1; overflow-y:auto; padding:6px; }
.thread-item { padding:10px 12px; border-radius:var(--radius-sm); cursor:pointer; font-size:13px; display:flex; align-items:center; justify-content:space-between; margin:2px 0; color:var(--text-secondary); }
.thread-item:hover { background:#eef0f5; color:var(--text); }
.thread-item.active { background:#e8edff; color:var(--accent); font-weight:500; }
.chat-container { flex:1; min-width:0; display:flex; flex-direction:column; background:var(--chat-bg); }
.chat-header { padding:14px 20px; background:linear-gradient(135deg,var(--accent) 0%,#6366f1 100%); color:white; font-size:16px; font-weight:600; display:flex; align-items:center; gap:8px; flex-shrink:0; }
.chat-header .title { flex:1; }
.toggle-btn { background:rgba(255,255,255,0.15); border:none; color:white; padding:6px 10px; border-radius:6px; cursor:pointer; font-size:14px; }
.toggle-btn:hover { background:rgba(255,255,255,0.25); }
.export-btn { background:rgba(255,255,255,0.15); border:none; color:white; cursor:pointer; font-size:13px; padding:6px 10px; border-radius:6px; }
.export-btn:hover,.settings-btn:hover { background:rgba(255,255,255,0.25); }
.settings-btn { background:rgba(255,255,255,0.15); border:none; color:white; cursor:pointer; font-size:16px; padding:6px 10px; border-radius:6px; }
.messages { flex:1; overflow-y:auto; padding:20px 24px; display:flex; flex-direction:column; gap:16px; }
.message { display:flex; flex-direction:column; max-width:85%; animation:fadeIn 0.2s ease; }
@keyframes fadeIn { from{opacity:0;transform:translateY(4px);} to{opacity:1;transform:translateY(0);} }
.message.user { align-self:flex-end; }
.message.agent,.message.tool,.message.system { align-self:flex-start; }
.message .bubble { padding:12px 16px; border-radius:var(--radius); word-break:break-word; overflow-wrap:break-word; white-space:pre-wrap; line-height:1.7; font-size:14.5px; position:relative; box-shadow:var(--shadow); }
.user .bubble { background:linear-gradient(135deg,#e8edff 0%,#dce4ff 100%); color:var(--text); border-bottom-right-radius:4px; }
.agent .bubble { background:#fff; color:var(--text); border:1px solid #f0f0f0; border-bottom-left-radius:4px; }
.tool .bubble { background:#fffdf5; color:#8b6914; font-size:13px; border:1px solid #fef3c7; }
.system .bubble { background:#f9fafb; color:var(--text-secondary); font-size:13px; border:1px solid #f3f4f6; }
.message .label { font-size:11px; margin-bottom:4px; color:var(--text-secondary); padding-left:4px; font-weight:500; }
.agent .bubble h1,.agent .bubble h2,.agent .bubble h3 { margin:16px 0 8px; font-weight:700; color:var(--text); }
.agent .bubble h1 { font-size:1.3em; border-bottom:1px solid #eee; padding-bottom:6px; }
.agent .bubble h2 { font-size:1.15em; } .agent .bubble h3 { font-size:1.05em; }
.agent .bubble p { margin:0 0 8px; } .agent .bubble ul,.agent .bubble ol { padding-left:20px; margin:8px 0; }
.agent .bubble li { margin:3px 0; }
.agent .bubble a { color:var(--accent); text-decoration:none; font-weight:500; }
.agent .bubble a:hover { text-decoration:underline; }
.agent .bubble blockquote { border-left:3px solid var(--accent); margin:10px 0; padding:6px 14px; color:var(--text-secondary); background:#f8f9ff; border-radius:0 6px 6px 0; font-style:italic; }
.agent .bubble :not(pre)>code { background:#f1f3f5; color:#e53e3e; padding:2px 6px; border-radius:4px; font-size:0.88em; font-weight:500; }
.agent .bubble pre { background:#1a1b26; border-radius:var(--radius-sm); margin:12px 0; overflow:hidden; box-shadow:0 2px 8px rgba(0,0,0,0.12); }
.agent .bubble pre .code-header { display:flex; justify-content:space-between; align-items:center; padding:8px 14px; background:#252636; color:#9ca3af; font-size:11.5px; border-bottom:1px solid #313244; }
.agent .bubble pre .code-header .lang { text-transform:uppercase; }
.agent .bubble pre .code-header .copy-code { background:rgba(255,255,255,0.06); border:none; color:#9ca3af; cursor:pointer; font-size:11px; padding:4px 8px; border-radius:4px; }
.agent .bubble pre .code-header .copy-code:hover { color:#fff; background:rgba(255,255,255,0.12); }
.agent .bubble pre>code { display:block; padding:14px 16px; color:#cdd6f4; line-height:1.65; font-size:13.5px; white-space:pre-wrap; word-break:break-word; }
.agent .bubble table { border-collapse:collapse; margin:12px 0; width:100%; font-size:13.5px; border-radius:var(--radius-sm); overflow:hidden; box-shadow:var(--shadow); }
.agent .bubble th { background:#f0f3ff; color:var(--text); font-weight:600; padding:10px 14px; border:1px solid #e2e6f0; text-align:left; }
.agent .bubble td { padding:9px 14px; border:1px solid #eef1f7; }
.agent .bubble tr:nth-child(even) td { background:#fafbff; }
.agent .bubble tr:hover td { background:#f0f3ff; }
.agent .bubble hr { border:none; border-top:1px solid #e5e7eb; margin:16px 0; }
.agent .bubble img { max-width:100%; border-radius:6px; }
.agent .bubble strong { font-weight:700; }
.input-area { display:flex; padding:14px 20px; background:var(--chat-bg); border-top:1px solid var(--border); gap:8px; align-items:center; flex-shrink:0; }
#user-input { flex:1; padding:11px 16px; border:1.5px solid #e5e7eb; border-radius:24px; background:#f8f9fb; color:var(--text); outline:none; font-size:14px; }
#user-input:focus { border-color:var(--accent); background:#fff; box-shadow:0 0 0 3px rgba(79,110,247,0.1); }
.send-btn { padding:10px 18px; background:var(--accent); border:none; border-radius:20px; color:white; font-weight:600; cursor:pointer; font-size:13.5px; }
.send-btn:hover { background:var(--accent-dark); box-shadow:0 2px 8px rgba(79,110,247,0.3); }
.send-btn:disabled { opacity:0.5; cursor:not-allowed; }
.stop-btn { padding:10px 16px; background:#ef4444; border:none; border-radius:20px; color:white; font-weight:600; cursor:pointer; font-size:13.5px; display:none; }
.stop-btn:hover { background:#dc2626; }
.copy-btn { background:none; border:none; color:#cbd5e0; cursor:pointer; font-size:11px; padding:3px 6px; position:absolute; top:8px; right:10px; opacity:0; transition:all 0.2s; border-radius:4px; }
.message:hover .copy-btn { opacity:1; }
.copy-btn:hover { color:var(--accent); background:#f0f3ff; }
.drop-zone { border:2px dashed #d1d5db; border-radius:var(--radius-sm); padding:18px; text-align:center; color:#9ca3af; margin:0 20px 8px; display:none; }
.drop-zone.active { border-color:var(--accent); background:#f0f4ff; color:var(--accent); }
.skillbar { width:var(--skillbar-w); min-width:var(--skillbar-w); max-width:var(--skillbar-w); background:#f8f9fb; border-left:1px solid var(--border); display:flex; flex-direction:column; transition:width 0.25s; overflow:hidden; flex-shrink:0; }
.skillbar.collapsed { width:0; min-width:0; max-width:0; border-left:none; }
.skillbar-header { padding:16px; font-weight:600; font-size:15px; border-bottom:1px solid var(--border); background:linear-gradient(135deg,var(--accent) 0%,#6366f1 100%); color:white; display:flex; justify-content:space-between; align-items:center; }
.skill-actions { padding:10px; border-bottom:1px solid var(--border); display:flex; flex-wrap:wrap; gap:6px; }
.skill-actions button,.skill-actions label.btn { background:var(--accent); color:white; border:none; padding:6px 10px; border-radius:6px; cursor:pointer; font-size:12px; }
.skill-actions button:hover,.skill-actions label.btn:hover { background:var(--accent-dark); }
.skill-item { padding:9px 12px; border-bottom:1px solid #f3f4f6; font-size:13px; display:flex; flex-wrap:wrap; align-items:flex-start; }
.skill-item:hover { background:#f0f2f5; }
.skill-item .skill-info { flex:1; min-width:0; }
.skill-item .skill-name { font-weight:600; color:var(--text); }
.skill-item .skill-desc { font-size:11.5px; color:var(--text-secondary); word-break:break-word; margin-top:2px; }
.confirm-popup { position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.4); display:flex; align-items:center; justify-content:center; z-index:9999; backdrop-filter:blur(2px); }
.confirm-box { background:white; padding:28px; border-radius:var(--radius); max-width:500px; width:90%; box-shadow:0 8px 30px rgba(0,0,0,0.15); }
.confirm-text { white-space:pre-wrap; word-break:break-word; margin-bottom:20px; max-height:300px; overflow-y:auto; font-size:14.5px; color:var(--text); }
.confirm-buttons { display:flex; gap:10px; justify-content:flex-end; }
.confirm-buttons button { padding:9px 22px; border:none; border-radius:var(--radius-sm); cursor:pointer; font-weight:600; font-size:14px; }
.btn-approve { background:#10b981; color:white; } .btn-approve:hover { background:#059669; }
.btn-reject { background:#ef4444; color:white; } .btn-reject:hover { background:#dc2626; }
.timeout-note { font-size:12px; color:#9ca3af; margin-top:12px; text-align:right; }
.spinner { border:3px solid #e5e7eb; border-top:3px solid var(--accent); border-radius:50%; width:20px; height:20px; animation:spin 0.8s linear infinite; }
@keyframes spin { to{transform:rotate(360deg);} }
.login-overlay { position:fixed; top:0; left:0; width:100%; height:100%; background:linear-gradient(135deg,#667eea 0%,#764ba2 100%); display:flex; align-items:center; justify-content:center; z-index:10000; }
.login-box { background:white; padding:36px; border-radius:16px; width:360px; text-align:center; box-shadow:0 16px 48px rgba(0,0,0,0.2); animation:fadeIn 0.3s ease; }
.login-box h2 { margin-bottom:24px; color:var(--text); font-size:22px; }
.login-box input { width:100%; padding:11px 14px; margin:8px 0; border:1.5px solid #e5e7eb; border-radius:10px; font-size:15px; outline:none; }
.login-box input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(79,110,247,0.1); }
.login-box .login-btn { width:100%; padding:11px; background:linear-gradient(135deg,#667eea 0%,#764ba2 100%); color:white; border:none; border-radius:10px; font-size:16px; font-weight:600; cursor:pointer; margin-top:14px; }
.login-box .login-btn:hover { box-shadow:0 4px 16px rgba(102,126,234,0.4); }
.login-error { color:#ef4444; font-size:13px; margin-top:10px; min-height:20px; }
.settings-panel { position:fixed; top:0; right:0; width:340px; height:100vh; background:white; box-shadow:-4px 0 20px rgba(0,0,0,0.12); z-index:9998; transform:translateX(100%); transition:transform 0.25s ease; padding:20px; overflow-y:auto; }
.settings-panel.open { transform:translateX(0); }
.settings-panel h3 { font-size:16px; margin-bottom:12px; color:var(--text); }
.settings-panel label { display:block; font-size:12px; color:var(--text-secondary); margin-bottom:2px; margin-top:8px; }
.settings-panel input { width:100%; padding:7px 10px; border:1.5px solid #e5e7eb; border-radius:6px; font-size:13px; outline:none; }
.settings-panel input:focus { border-color:var(--accent); }
.settings-panel .save-btn { margin-top:16px; width:100%; padding:10px; background:var(--accent); color:white; border:none; border-radius:8px; font-weight:600; cursor:pointer; }
.settings-panel .close-btn { position:absolute; top:12px; right:12px; background:none; border:none; font-size:20px; cursor:pointer; color:#999; }
.settings-panel .agent-section { border:1px solid #e5e7eb; border-radius:8px; padding:10px; margin-bottom:10px; }
.settings-panel .agent-section h4 { font-size:13px; margin-bottom:6px; color:var(--accent); }
.settings-panel .row { display:flex; gap:6px; }
.settings-panel .row input { flex:1; }
.settings-panel .eye-btn { background:var(--accent); border:none; cursor:pointer; font-size:12px; padding:6px 8px; color:white; border-radius:4px; white-space:nowrap; }
.settings-panel .eye-btn:hover { background:var(--accent-dark); }
.model-select { width:100%; margin-top:4px; padding:6px; border:1px solid var(--border); border-radius:4px; font-size:12px; background:white; }
.subtitle { font-size:12px; color:rgba(255,255,255,0.7); margin-left:8px; font-weight:400; }
.hljs { color:#cdd6f4; background:#1a1b26; }
.hljs-keyword,.hljs-selector-tag,.hljs-literal,.hljs-section,.hljs-link { color:#cba6f7; }
.hljs-string,.hljs-title,.hljs-name,.hljs-type,.hljs-attr,.hljs-symbol,.hljs-bullet,.hljs-addition,.hljs-variable,.hljs-template-tag,.hljs-template-variable { color:#a6e3a1; }
.hljs-comment,.hljs-quote,.hljs-deletion,.hljs-meta { color:#6c7086; font-style:italic; }
.hljs-number,.hljs-regexp,.hljs-built_in,.hljs-builtin-name { color:#fab387; }
.hljs-attribute,.hljs-tag,.hljs-selector-class,.hljs-selector-id { color:#89b4fa; }
.hljs-params { color:#cdd6f4; }
.hljs-function .hljs-title,.hljs-class .hljs-title { color:#89b4fa; }
.hljs-property,.hljs-selector-pseudo { color:#89dceb; }
</style>
</head>
<body>
<div class="app-container">
<div class="sidebar" id="sidebar">
<div class="sidebar-header"><span>查询记录</span><button class="new-chat-btn" onclick="createNewChat()"> 新查询</button></div>
<div class="thread-list" id="threadList"></div>
</div>
<div class="chat-container">
<div class="chat-header">
<button class="toggle-btn" onclick="toggleSidebar('sidebar')" id="toggle-sidebar-btn"></button>
<span class="title">MCP 查询</span>
<span class="subtitle">可读写 · 禁止删除</span>
<button class="export-btn" onclick="exportChat()">↓ 导出</button>
<button class="settings-btn" onclick="toggleSettings()"></button>
<button class="toggle-btn" onclick="toggleSidebar('skillbar')" id="toggle-skillbar-btn"></button>
</div>
<div class="messages" id="messages"></div>
<div id="loading-indicator" style="display:none;margin:0 20px 8px;align-items:center;"><div class="spinner"></div><span style="color:#666;margin-left:8px;">思考中...</span></div>
<div class="drop-zone" id="drop-zone">拖拽文件到此处上传</div>
<div class="input-area">
<input type="text" id="user-input" placeholder="输入消息..." />
<input type="file" id="file-input" multiple style="display:none" />
<button class="send-btn" onclick="document.getElementById('file-input').click()">📎</button>
<button class="send-btn" id="send-btn" onclick="sendMessage()">发送</button>
<button class="stop-btn" id="stop-btn" onclick="stopGeneration()">⏹ 停止</button>
</div>
<div style="padding:4px 20px;font-size:11px;color:var(--text-secondary);text-align:center;" id="token-info"></div>
</div>
<div class="skillbar" id="skillbar">
<div class="skillbar-header">技能管理</div>
<div class="skill-actions">
<label class="btn" for="skill-zip-upload">上传技能包</label>
<input type="file" id="skill-zip-upload" accept=".zip,.md" style="display:none" onchange="uploadSkillZip(this)">
<button onclick="reloadSkills()">热更新</button>
</div>
<div style="flex:1;overflow-y:auto;" id="skillList"><div style="padding:12px;color:#666;">加载中...</div></div>
</div>
</div>
<div class="settings-panel" id="settings-panel">
<button class="close-btn" onclick="toggleSettings()">x</button>
<h3>Agent 配置</h3>
<p style="font-size:11px;color:#999;margin-bottom:10px;">Temperature 即时生效。模型/URL/Key 对应 .env 变量,修改后需重启。</p>
<div class="agent-section">
<h4>主 Agent (LLM_MODEL / LLM_API_KEY / LLM_BASE_URL)</h4>
<label>模型</label><div class="row"><input type="text" class="model-select" id="cfg-m-model" placeholder="输入模型名或点击获取" onclick="showModelOptions('cfg-m-model','dl-m-model')" /><datalist id="dl-m-model" style="display:none"></datalist><button class="eye-btn" onclick="fetchModels('cfg-m-model','dl-m-model','cfg-m-url','cfg-m-key')" title="获取模型列表">获取</button></div>
<label>API URL</label><input type="text" id="cfg-m-url" placeholder="https://open.bigmodel.cn/api/paas/v4" />
<label>API Key</label><input type="password" id="cfg-m-key" placeholder="sk-xxx" />
<label>Temperature</label><input type="range" id="cfg-m-temp" min="0" max="2" step="0.1" value="0.7" style="width:100%" /><span id="cfg-m-temp-val" style="font-size:12px;color:var(--text-secondary)">0.7</span>
<label>上下文窗口 (tokens)</label><input type="number" id="cfg-m-maxtk" value="256000" min="0" style="width:100%" />
<button class="save-btn" onclick="testAgent('main')" style="background:#10b981;margin-top:8px;">测试连接</button>
</div>
<div class="agent-section">
<h4>Writer Agent (LLM_MODEL_W / LLM_API_KEY_W / LLM_BASE_URL_W)</h4>
<label>模型</label><div class="row"><input type="text" class="model-select" id="cfg-w-model" placeholder="输入模型名或点击获取" onclick="showModelOptions('cfg-w-model','dl-w-model')" /><datalist id="dl-w-model" style="display:none"></datalist><button class="eye-btn" onclick="fetchModels('cfg-w-model','dl-w-model','cfg-w-url','cfg-w-key')" title="获取模型列表">获取</button></div>
<label>API URL</label><input type="text" id="cfg-w-url" placeholder="https://api.minimax.chat/v1" />
<label>API Key</label><input type="password" id="cfg-w-key" placeholder="sk-xxx" />
<button class="save-btn" onclick="testAgent('writer')" style="background:#10b981;margin-top:8px;">测试连接</button>
</div>
<div class="agent-section">
<h4>MCP Agent (LLM_MODEL_M / LLM_API_KEY_M / LLM_BASE_URL_M)</h4>
<label>模型</label><div class="row"><input type="text" class="model-select" id="cfg-mcp-model" placeholder="输入模型名或点击获取" onclick="showModelOptions('cfg-mcp-model','dl-mcp-model')" /><datalist id="dl-mcp-model" style="display:none"></datalist><button class="eye-btn" onclick="fetchModels('cfg-mcp-model','dl-mcp-model','cfg-mcp-url','cfg-mcp-key')" title="获取模型列表">获取</button></div>
<label>API URL</label><input type="text" id="cfg-mcp-url" placeholder="https://api.minimax.chat/v1" />
<label>API Key</label><input type="password" id="cfg-mcp-key" placeholder="sk-xxx" />
<button class="save-btn" onclick="testAgent('mcp')" style="background:#10b981;margin-top:8px;">测试连接</button>
</div>
<button class="save-btn" onclick="saveSettings()">保存全部</button>
<button class="save-btn" onclick="applyAndRestart()" style="background:#f59e0b;margin-top:8px;">应用并重启</button>
</div>
<div class="login-overlay" id="login-overlay">
<div class="login-box">
<h2>MCP 查询登录</h2>
<input type="text" id="login-username" placeholder="用户名" autocomplete="username" />
<input type="password" id="login-password" placeholder="密码" autocomplete="current-password" />
<button class="login-btn" onclick="doLogin()">登 录</button>
<div class="login-error" id="login-error"></div>
</div>
</div>
<script>
const ASSISTANT_ID = 'simple_agent';
const API_BASE = window.location.protocol + '//' + window.location.hostname + ':2024';
const CHAT_SERVER = window.location.protocol + '//' + window.location.hostname + ':8765';
const messagesDiv = document.getElementById('messages');
const threadListDiv = document.getElementById('threadList');
const skillListDiv = document.getElementById('skillList');
const loadingIndicator = document.getElementById('loading-indicator');
let currentThreadId = null, threads = [], authToken = sessionStorage.getItem('auth_token') || '';
let abortController = null;
const messageMap = new Map(), processedConfirms = new Set(), pendingConfirms = new Map(), existingMsgTexts = new Set();
var pendingCharts = [];
// URL user identity
var urlParams = new URLSearchParams(window.location.search);
var embedUser = urlParams.get('user') || 'default';
var embedToken = urlParams.get('token') || '';
var currentUser = embedUser || 'default';
// 迁移旧存储
if (!localStorage.getItem(userKey('mcp_threads')) && localStorage.getItem('mcp_threads')) {
var keysToMigrate = [];
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i);
if (k && (k === 'mcp_threads' || k === 'mcp_config' || k.startsWith('mcp_msg_cache_') || k === 'mcp_last_thread')) {
keysToMigrate.push(k);
}
}
keysToMigrate.forEach(function(k) { localStorage.setItem(userKey(k), localStorage.getItem(k)); localStorage.removeItem(k); });
}
if (embedUser && embedToken) {
authToken = embedToken;
sessionStorage.setItem('auth_token', embedToken);
document.getElementById('login-overlay').style.display = 'none';
}
function userKey(k) { return currentUser + '_mcp_' + k; }
function localGet(k) { return localStorage.getItem(userKey(k)); }
function localSet(k, v) { localStorage.setItem(userKey(k), v); }
// ====== UI Helpers ======
function toggleSidebar(id) {
var el = document.getElementById(id);
el.classList.toggle('collapsed');
if (id === 'sidebar') document.getElementById('toggle-sidebar-btn').textContent = el.classList.contains('collapsed') ? '\u25b6' : '\u25c0';
else document.getElementById('toggle-skillbar-btn').textContent = el.classList.contains('collapsed') ? '\u25c0' : '\u25b6';
}
function escapeHtml(text) {
var map = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'};
return String(text).replace(/[&<>"']/g, function(m) { return map[m]; });
}
function showError(msg) {
addMessage('system', msg, '系统');
loadingIndicator.style.display = 'none';
document.getElementById('stop-btn').style.display = 'none';
document.getElementById('send-btn').disabled = false;
}
// ====== Markdown ======
if (typeof marked !== 'undefined') {
marked.setOptions({ breaks: true, gfm: true });
if (typeof hljs !== 'undefined') {
marked.setOptions({ highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) { try { return hljs.highlight(code, { language: lang }).value; } catch (e) {} }
return code;
}});
}
var renderer = new marked.Renderer();
renderer.code = function(code, lang) {
var langLabel = lang || 'code';
var escaped = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var highlighted = (typeof hljs !== 'undefined' && lang && hljs.getLanguage(lang)) ? hljs.highlight(code, { language: lang }).value : escaped;
return '<pre><div class="code-header"><span class="lang">' + langLabel + '</span><button class="copy-code" onclick="copyCodeBlock(this)">复制</button></div><code class="hljs">' + highlighted + '</code></pre>';
};
marked.setOptions({ renderer: renderer });
}
function renderMarkdown(text) {
if (!text) return '';
try { return marked.parse(text); } catch (e) { return escapeHtml(text).replace(/\n/g, '<br>'); }
}
function copyCodeBlock(btn) {
var code = btn.parentElement.nextElementSibling;
if (code) {
navigator.clipboard.writeText(code.textContent).then(function() {
btn.textContent = '已复制';
setTimeout(function() { btn.textContent = '复制'; }, 1500);
}).catch(function() {});
}
}
// ====== Message Rendering ======
function addMessage(role, content, label, msgId) {
msgId = msgId || ('msg-' + Date.now() + '-' + Math.random().toString(36).substr(2, 6));
var div = document.createElement('div');
div.className = 'message ' + role;
var html = label ? '<div class="label">' + label + '</div>' : '';
var body;
if (role === 'agent') body = renderMarkdown(content);
else if (role === 'tool') body = '<pre><code>' + escapeHtml(content) + '</code></pre>';
else body = escapeHtml(content).replace(/\n/g, '<br>');
html += '<div class="bubble" id="' + msgId + '">' + body + '<button class="copy-btn" onclick="copyMsg(\'' + msgId + '\')">\ud83d\udccb</button></div>';
div.innerHTML = html;
messagesDiv.appendChild(div);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function copyMsg(id) {
var el = document.getElementById(id); if (!el) return;
var clone = el.cloneNode(true); var cb = clone.querySelector('.copy-btn'); if (cb) cb.remove();
navigator.clipboard.writeText(clone.innerText).catch(function() {});
}
function addMessagePlain(role, content, label) {
var div = document.createElement('div');
div.className = 'message ' + role;
var html = label ? '<div class="label">' + label + '</div>' : '';
html += '<div class="bubble">' + escapeHtml(content).replace(/\n/g, '<br>') + '</div>';
div.innerHTML = html;
messagesDiv.appendChild(div);
}
function renderHistoryMessage(msg) {
if (msg.type === 'human' || msg.role === 'user') addMessagePlain('user', msg.content, '你');
else if (msg.type === 'ai') {
var c = (msg.content || '').replace(/<think>.*?<\/think>/gs, '').trim();
if (c) addMessage('agent', c, 'Agent');
if (msg.tool_calls) {
for (var i = 0; i < msg.tool_calls.length; i++) {
var tc = msg.tool_calls[i];
addMessagePlain('tool', tc.name + '(' + JSON.stringify(tc.args) + ')', '工具调用');
}
}
} else if (msg.type === 'tool') {
var tc = msg.name + ': ' + (msg.content || '');
if ((msg.content||'').indexOf('![图表]') >= 0) {
addMessage('agent', msg.content, msg.name || '工具结果');
} else {
addMessagePlain('tool', tc, '工具结果');
}
}
}
// ====== Thread Management ======
function loadThreads() {
var stored = localGet('mcp_threads');
threads = stored ? JSON.parse(stored) : [];
renderThreadList();
fetch(API_BASE + '/threads/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (Array.isArray(data) && data.length > 0) {
var serverIds = new Set();
data.forEach(function(t) { serverIds.add(t.thread_id);
if (!threads.find(function(l) { return l.id === t.thread_id; })) {
threads.unshift({ id: t.thread_id, title: '', createdAt: Date.now() });
}
});
threads = threads.filter(function(t) { return serverIds.has(t.id); });
saveThreads();
renderThreadList();
}
}).catch(function() {});
}
function saveThreads() { localSet('mcp_threads', JSON.stringify(threads)); }
function renderThreadList() {
threadListDiv.innerHTML = '';
threads.forEach(function(t) {
var div = document.createElement('div');
div.className = 'thread-item' + (t.id === currentThreadId ? ' active' : '');
// 不显示删除按钮——MCP页面禁止删除
div.innerHTML = '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;" onclick="switchThread(\'' + t.id + '\')">' + escapeHtml(t.title || t.id.substring(0, 8)) + '</span>';
threadListDiv.appendChild(div);
});
}
async function createThread() {
var resp = await apiFetch(API_BASE + '/threads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!resp.ok) throw new Error('创建失败');
return (await resp.json()).thread_id;
}
async function createNewChat() {
try { var threadId = await createThread(); threads.unshift({ id: threadId, title: '', createdAt: Date.now() });
saveThreads(); currentThreadId = threadId; renderThreadList(); messagesDiv.innerHTML = ''; messageMap.clear(); }
catch (err) { addMessage('system', '创建新对话失败: ' + err.message, '系统'); }
}
async function switchThread(threadId) {
currentThreadId = threadId; localSet('mcp_last_thread', threadId);
renderThreadList(); messagesDiv.innerHTML = ''; messageMap.clear(); existingMsgTexts.clear();
var cached = localGet('mcp_msg_cache_' + threadId);
var hasCache = false;
if (cached) {
try {
var cmsgs = JSON.parse(cached);
if (cmsgs.length > 0) {
for (var j = 0; j < cmsgs.length; j++) renderHistoryMessage(cmsgs[j]);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
hasCache = true;
}
} catch (e) {}
}
if (!hasCache) {
messagesDiv.innerHTML = '<div class="message system"><div class="bubble" style="display:flex;align-items:center;gap:8px;"><div class="spinner"></div><span>加载中...</span></div></div>';
setTimeout(function() {
if (currentThreadId === threadId && messagesDiv.children.length <= 1) {
messagesDiv.innerHTML = '<div class="message system"><div class="bubble">暂无对话内容</div></div>';
}
}, 3000);
}
apiFetch(API_BASE + '/threads/' + threadId + '/state').then(async function(resp) {
if (!resp.ok) return;
var data = await resp.json();
var msgs = data && data.values ? data.values.messages || [] : [];
if (msgs.length === 0) return;
localSet('mcp_msg_cache_' + threadId, JSON.stringify(msgs));
if (!hasCache || msgs.length !== JSON.parse(cached).length) {
messagesDiv.innerHTML = ''; messageMap.clear();
for (var i = 0; i < msgs.length; i++) renderHistoryMessage(msgs[i]);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
}).catch(function(){});
}
// ====== Confirm Flow ======
async function checkConfirmPending(confirmId) {
if (pendingConfirms.has(confirmId)) return pendingConfirms.get(confirmId);
var p = apiFetch(CHAT_SERVER + '/api/confirm_info?confirm_id=' + encodeURIComponent(confirmId))
.then(async function(r) { if (r.ok) { var d = await r.json(); return d.result === 'pending'; } return true; })
.catch(function() { return true; });
pendingConfirms.set(confirmId, p); return p;
}
async function processMessage(msg) {
if (msg.type === 'human' || msg.role === 'user') return;
var content = msg.content || '';
var confirmMatch = content.match(/\[NEED_USER_CONFIRM_FILE\|([a-f0-9\-]+)\]/);
if (confirmMatch) { var cid = confirmMatch[1]; if (processedConfirms.has(cid)) return; processedConfirms.add(cid);
var isPending = await checkConfirmPending(cid); if (!isPending) return; showConfirmPopupForWrite(cid); throw { type: 'CONFIRM_PENDING' }; }
var dedupKey = (msg.type||'') + '|' + content.substring(0, 100);
if (messageMap.has(dedupKey)) return; messageMap.set(dedupKey, true);
var txt = (msg.content || '').substring(0, 100);
if (existingMsgTexts.has(txt)) return;
if (msg.type === 'ai') {
var c = (msg.content || '').replace(/<think>.*?<\/think>/gs, '').trim();
if (c && pendingCharts.length > 0) {
var parts = c.split(/(?<=[。!?\n])/);
var summary = parts.slice(0, Math.min(2, parts.length)).join('').trim();
var rest = parts.slice(Math.min(2, parts.length)).join('').trim();
if (summary) addMessage('agent', summary, 'Agent');
pendingCharts.forEach(function(chart) { addMessage('agent', chart, '图表'); });
pendingCharts = [];
if (rest) addMessage('agent', rest, 'Agent');
} else if (c) {
addMessage('agent', c, 'Agent');
}
if (msg.tool_calls) { for (var i = 0; i < msg.tool_calls.length; i++) { var tc = msg.tool_calls[i]; addMessage('tool', tc.name + '(' + JSON.stringify(tc.args) + ')', '工具调用'); } }
} else if (msg.type === 'tool') {
var tc = msg.content || '';
if (tc.indexOf('![图表]') >= 0) {
var imgs = tc.match(/!\[.*?\]\(\/workspace\/.+?\.svg\)/g);
if (imgs) imgs.forEach(function(img) { pendingCharts.push(img); });
} else {
addMessage('tool', msg.name + ': ' + tc, '工具结果');
}
}
}
function showConfirmPopupForWrite(confirmId) {
var popup = document.createElement('div'); popup.id = 'confirm-popup'; popup.className = 'confirm-popup';
popup.innerHTML = '<div class="confirm-box"><div class="confirm-text">文件不在安全区域内,是否写入?</div><div class="confirm-buttons"><button class="btn-approve">同意写入</button><button class="btn-reject">拒绝</button></div></div>';
document.body.appendChild(popup);
popup.querySelector('.btn-approve').addEventListener('click', async function() { popup.remove();
try { var r = await apiFetch(CHAT_SERVER + '/api/confirm_write', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({confirm_id:confirmId, choice:'approve'}) });
var d = await r.json(); addMessage('system', d.success ? '\u2705 已写入: ' + (d.path||'') : '写入失败: ' + (d.error||d.message), '系统'); }
catch(e) { addMessage('system', '请求失败: ' + e.message, '系统'); }
});
popup.querySelector('.btn-reject').addEventListener('click', async function() { popup.remove();
try { await apiFetch(CHAT_SERVER + '/api/confirm_write', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({confirm_id:confirmId, choice:'reject'}) }); } catch(e){}
addMessage('system', '已拒绝写入', '系统');
});
document.addEventListener('keydown', function esc(e) { if (e.key === 'Escape') popup.remove(); }, { once: true });
}
// ====== Stream Processing ======
async function processStream(response) {
var reader = response.body.getReader(); var decoder = new TextDecoder(); var buffer = '', first = false;
try { while (true) { var result = await reader.read(); if (result.done) break;
buffer += decoder.decode(result.value, { stream: true }); var lines = buffer.split('\n'); buffer = lines.pop();
for (var i = 0; i < lines.length; i++) { var line = lines[i]; if (!line.startsWith('data: ')) continue;
try { var data = JSON.parse(line.slice(6));
if (data.__interrupt__) { showInterruptPopup(data.__interrupt__); reader.cancel(); loadingIndicator.style.display='none'; stopUI(); return; }
if (data.messages) { if (!first) { loadingIndicator.style.display='none'; first=true; }
for (var j=0;j<data.messages.length;j++) { try { await processMessage(data.messages[j]); } catch(e) { if (e&&e.type==='CONFIRM_PENDING') { reader.cancel(); loadingIndicator.style.display='none'; stopUI(); return; } throw e; } }
}
} catch(e) { if (e&&e.type==='CONFIRM_PENDING') { reader.cancel(); loadingIndicator.style.display='none'; stopUI(); return; } }
}
}
} catch(e) { if (e.name!=='AbortError') console.error('Stream error:',e); }
loadingIndicator.style.display='none'; stopUI();
if (pendingCharts.length > 0) {
pendingCharts.forEach(function(chart) { addMessage('agent', chart, '图表'); });
pendingCharts = [];
}
if (currentThreadId) {
var allBubbles = messagesDiv.querySelectorAll('.message');
var cachedMsgs = [];
allBubbles.forEach(function(m) {
if (m.classList.contains('user')) cachedMsgs.push({type:'human',role:'user',content:m.querySelector('.bubble').innerText.trim()});
else if (m.classList.contains('agent')) cachedMsgs.push({type:'ai',role:'assistant',content:m.querySelector('.bubble').innerText.trim()});
else if (m.classList.contains('tool')) cachedMsgs.push({type:'tool',role:'tool',name:'',content:m.querySelector('.bubble').innerText.trim()});
});
localSet('mcp_msg_cache_' + currentThreadId, JSON.stringify(cachedMsgs));
}
}
function showInterruptPopup(data) {
var popup = document.createElement('div'); popup.id='confirm-popup'; popup.className='confirm-popup';
popup.innerHTML = '<div class="confirm-box"><div class="confirm-text">' + escapeHtml(data.message||'是否继续?') + '</div><div class="confirm-buttons"><button class="btn-approve">同意</button><button class="btn-reject">拒绝</button></div><div class="timeout-note">30分钟后超时</div></div>';
document.body.appendChild(popup);
popup.querySelectorAll('button').forEach(function(b) { b.addEventListener('click', async function() { popup.remove(); await resumeWithCommand(data, b.classList.contains('btn-approve')?'approve':'reject'); }); });
document.addEventListener('keydown', function esc(e) { if (e.key==='Escape') popup.remove(); }, { once:true });
}
async function resumeWithCommand(data, val) {
var payload = { assistant_id:ASSISTANT_ID, thread_id:currentThreadId, input:null, config:{configurable:{mcp_mode:true}}, command:{resume:{__interrupt_id__:data.__interrupt_id__,value:val}} };
startUI(); try { var resp = await apiFetch(API_BASE+'/threads/'+currentThreadId+'/runs/stream',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); await processStream(resp); } catch(e) { showError('恢复失败: '+e.message); }
}
function stopGeneration() { if (abortController) { abortController.abort(); abortController=null; } stopUI(); }
function startUI() { loadingIndicator.style.display='flex'; document.getElementById('stop-btn').style.display='inline-block'; document.getElementById('send-btn').disabled=true; }
function stopUI() { loadingIndicator.style.display='none'; document.getElementById('stop-btn').style.display='none'; document.getElementById('send-btn').disabled=false; }
// ====== Send Message ======
async function sendMessage() {
if (!currentThreadId) { try { currentThreadId=await createThread(); threads.unshift({id:currentThreadId,title:'',createdAt:Date.now()}); saveThreads(); renderThreadList(); messagesDiv.innerHTML=''; messageMap.clear(); } catch(err) { addMessage('system','创建对话失败','系统'); return; } }
var input = document.getElementById('user-input'); var text = input.value.trim(); if (!text) return;
addMessage('user', text, '你'); input.value='';
pendingCharts = [];
var ct = threads.find(function(t){return t.id===currentThreadId;}); if (ct&&!ct.title){ct.title=text.substring(0,30);saveThreads();renderThreadList();}
existingMsgTexts.clear();
messagesDiv.querySelectorAll('.bubble').forEach(function(b){ existingMsgTexts.add(b.innerText.trim().substring(0,100)); });
startUI(); abortController = new AbortController();
var cfg = JSON.parse(localGet('mcp_config')||'{}'); var config={configurable:{mcp_mode:true}}; if(cfg.temperature) config.configurable.temperature=cfg.temperature;
try {
var resp = await fetch(API_BASE+'/threads/'+currentThreadId+'/runs/stream',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({assistant_id:ASSISTANT_ID,thread_id:currentThreadId,config:config,input:{messages:[{role:'user',content:text}]}}),signal:abortController.signal});
await processStream(resp);
} catch(e) { if (e.name==='AbortError') addMessage('system','已停止生成','系统'); else showError('请求失败: '+e.message); }
abortController=null;
}
// ====== Export ======
function exportChat() {
var msgs=[]; messagesDiv.querySelectorAll('.message').forEach(function(m){var bubble=m.querySelector('.bubble');if(!bubble)return;var clone=bubble.cloneNode(true);var cb=clone.querySelector('.copy-btn');if(cb)cb.remove();var role=m.classList.contains('user')?'用户':m.classList.contains('agent')?'Agent':m.classList.contains('tool')?'工具':'系统';msgs.push('## '+role+'\n\n'+clone.innerText+'\n');});
var text='# MCP 查询记录\n\n'+new Date().toLocaleString()+'\n\n---\n\n'+msgs.join('\n---\n\n');
var blob=new Blob([text],{type:'text/markdown;charset=utf-8'}); var a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='mcp-query-'+new Date().toISOString().slice(0,10)+'.md'; a.click(); URL.revokeObjectURL(a.href);
}
// ====== File Upload ======
var dropZone=document.getElementById('drop-zone');
['dragenter','dragover'].forEach(function(e){document.addEventListener(e,function(ev){ev.preventDefault();dropZone.style.display='block';dropZone.classList.add('active');});});
['dragleave','drop'].forEach(function(e){document.addEventListener(e,function(ev){ev.preventDefault();if(e==='dragleave'){dropZone.style.display='none';dropZone.classList.remove('active');}});});
dropZone.addEventListener('drop',function(e){e.preventDefault();dropZone.style.display='none';dropZone.classList.remove('active');uploadFiles(e.dataTransfer.files);});
document.getElementById('file-input').addEventListener('change',function(){uploadFiles(this.files);this.value='';});
async function uploadFiles(files){for(var i=0;i<files.length;i++){var f=files[i];var fd=new FormData();fd.append('file',f);var type=f.name.match(/\.(txt|log|md|json|csv|yml|yaml|xml|html|css|js|py|java|cpp|c|h)$/i)?'knowledge':'memory';try{var r=await apiFetch(CHAT_SERVER+'/upload?type='+type,{method:'POST',body:fd});if(r.ok)addMessage('system','已上传: '+f.name,'系统');}catch(e){addMessage('system','上传失败: '+f.name,'系统');}}}
// ====== Shortcuts ======
document.addEventListener('keydown',function(e){if(e.target.tagName==='INPUT'&&e.target.id==='user-input'&&e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}else if(e.ctrlKey&&e.key==='n'){e.preventDefault();createNewChat();}else if(e.ctrlKey&&e.key==='e'){e.preventDefault();exportChat();}else if(e.key==='Escape'){var popup=document.getElementById('confirm-popup');if(popup){popup.remove();stopUI();}}});
// ====== API + Auth ======
async function apiFetch(url, options) {
options = options || {};
if (authToken) { options.headers = options.headers || {}; options.headers['Authorization'] = 'Bearer ' + authToken; options.headers['X-User'] = currentUser; }
return fetch(url, options);
}
function checkLogin() {
if (authToken) {
apiFetch(CHAT_SERVER + '/skills/list').then(function(r) {
if (r.ok) {
document.getElementById('login-overlay').style.display = 'none';
loadThreads();
var lt = localGet('mcp_last_thread');
if (lt && threads.find(function(t) { return t.id === lt; })) switchThread(lt);
else if (threads.length === 0) createNewChat();
fetchSkills();
} else { authToken = ''; sessionStorage.removeItem('auth_token'); }
}).catch(function() {});
}
}
async function doLogin() {
var username = document.getElementById('login-username').value.trim();
var password = document.getElementById('login-password').value;
var errorEl = document.getElementById('login-error');
if (!username || !password) { errorEl.textContent = '请输入用户名和密码'; return; }
try {
var resp = await fetch(CHAT_SERVER + '/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: username, password: password }) });
var data = await resp.json();
if (resp.ok && data.token) {
authToken = data.token;
sessionStorage.setItem('auth_token', data.token);
document.getElementById('login-overlay').style.display = 'none';
loadThreads();
if (threads.length === 0) createNewChat();
fetchSkills();
} else { errorEl.textContent = data.error || '登录失败'; }
} catch (e) { errorEl.textContent = '连接失败: ' + e.message; }
}
// ====== Skills (只展示,不显示删除/恢复按钮 —— MCP页面禁止删除) ======
async function fetchSkills() {
try { var results = await Promise.all([apiFetch(CHAT_SERVER+'/skills/list')]); var normal = await results[0].json(); renderSkillList(normal); }
catch (e) { skillListDiv.innerHTML = '<div style="padding:12px;color:#d666;">获取失败</div>'; }
}
function renderSkillList(normal) {
var html = '<div style="font-weight:bold;padding:8px 12px;border-bottom:1px solid #ddd;">已安装技能</div>';
if (!normal || !normal.length) html += '<div style="padding:12px;color:#666;">暂无</div>';
else normal.forEach(function(s) { html += '<div class="skill-item"><div class="skill-info"><div class="skill-name">' + escapeHtml(s.name) + '</div><div class="skill-desc">' + escapeHtml(s.description || '') + '</div></div></div>'; });
skillListDiv.innerHTML = html;
}
async function reloadSkills() { try { var r = await apiFetch(CHAT_SERVER+'/skills/reload',{method:'POST'}); var d = await r.json(); if (r.ok) { addMessage('system', d.message, '系统'); fetchSkills(); } } catch (e) { addMessage('system', '热更新失败', '系统'); } }
async function uploadSkillZip(input) { var file = input.files[0]; if (!file) return; var fd = new FormData(); fd.append('file', file);
try { var r = await apiFetch(CHAT_SERVER+'/skills/upload',{method:'POST',body:fd}); var d = await r.json(); if (r.ok) { addMessage('system', d.message, '系统'); fetchSkills(); } } catch (e) { addMessage('system', '上传失败', '系统'); } input.value = ''; }
// ====== Settings ======
function toggleSettings() {
var p = document.getElementById('settings-panel'); p.classList.toggle('open');
if (p.classList.contains('open')) {
var cfg = JSON.parse(localGet('mcp_config') || '{}');
var mSel = document.getElementById('cfg-m-model'); ensureOption(mSel, cfg.m_model);
document.getElementById('cfg-m-url').value = cfg.m_url || ''; document.getElementById('cfg-m-key').value = cfg.m_key || '';
document.getElementById('cfg-m-temp').value = cfg.temperature || 0.7; document.getElementById('cfg-m-temp-val').textContent = cfg.temperature || 0.7;
var wSel = document.getElementById('cfg-w-model'); ensureOption(wSel, cfg.w_model);
document.getElementById('cfg-w-url').value = cfg.w_url || ''; document.getElementById('cfg-w-key').value = cfg.w_key || '';
var mcpSel = document.getElementById('cfg-mcp-model'); ensureOption(mcpSel, cfg.mcp_model);
document.getElementById('cfg-mcp-url').value = cfg.mcp_url || ''; document.getElementById('cfg-mcp-key').value = cfg.mcp_key || '';
document.getElementById('cfg-m-maxtk').value = cfg.max_tokens || 256000;
}
}
function ensureOption(inp, val) { if (!val || !inp) return; if (!inp.value) inp.value = val; }
document.getElementById('cfg-m-temp').addEventListener('input', function() { document.getElementById('cfg-m-temp-val').textContent = this.value; });
function saveSettings() {
var cfg = { temperature: parseFloat(document.getElementById('cfg-m-temp').value), m_model: document.getElementById('cfg-m-model').value.trim(), m_url: document.getElementById('cfg-m-url').value.trim(), m_key: document.getElementById('cfg-m-key').value.trim(), w_model: document.getElementById('cfg-w-model').value.trim(), w_url: document.getElementById('cfg-w-url').value.trim(), w_key: document.getElementById('cfg-w-key').value.trim(), mcp_model: document.getElementById('cfg-mcp-model').value.trim(), mcp_url: document.getElementById('cfg-mcp-url').value.trim(), mcp_key: document.getElementById('cfg-mcp-key').value.trim() };
Object.keys(cfg).forEach(function(k) { if (!cfg[k] && cfg[k] !== 0) delete cfg[k]; });
localSet('mcp_config', JSON.stringify(cfg));
document.getElementById('settings-panel').classList.remove('open');
apiFetch(CHAT_SERVER + '/api/save_config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfg) }).then(function(r) { return r.json(); }).then(function(d) {
addMessage('system', d.success ? '配置已保存并热更新生效' : (d.message || d.error || '保存失败'), '系统');
}).catch(function(e) { addMessage('system', '保存失败: ' + e.message, '系统'); });
}
function applyAndRestart() { saveSettings(); if (!confirm('将把配置写入.env并重启服务,确认?')) return;
var cfg = JSON.parse(localGet('mcp_config') || '{}'); addMessage('system', '正在应用配置并重启...', '系统');
apiFetch(CHAT_SERVER + '/api/restart', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfg) }).then(function(r) { return r.json(); }).then(function(d) { addMessage('system', d.message || '重启中', '系统'); setTimeout(function() { location.reload(); }, 5000); }).catch(function(e) { addMessage('system', '重启请求失败: ' + e.message, '系统'); });
}
function fetchModels(mid, did, uid, kid) {
var url = document.getElementById(uid).value.trim(); var key = document.getElementById(kid).value.trim();
if (!url || !key) { addMessage('system', '请先填写API URL和Key', '系统'); return; }
url = url.replace(/\/+$/, '') + '/models'; addMessage('system', '正在获取模型列表...', '系统');
fetch(url, { headers: { 'Authorization': 'Bearer ' + key } }).then(function(r) {
if (!r.ok) { addMessage('system', 'HTTP ' + r.status + ' 请手动输入', '系统'); return; }
return r.text();
}).then(function(t) {
if (!t) return;
try { var d = JSON.parse(t); var models = d.data || d.models || []; if (!models.length) { addMessage('system', '未获取到模型', '系统'); return; }
var names = models.map(function(m) { return m.id; }).filter(function(v,i,a){ return a.indexOf(v)===i; }).sort();
var dl = document.getElementById(did);
if (dl) { dl.innerHTML = ''; names.forEach(function(n) { var o = document.createElement('option'); o.value = n; dl.appendChild(o); }); }
var inp = document.getElementById(mid);
if (!inp.value && names.length > 0) inp.value = names[0];
inp.placeholder = names.length + ' 个模型可选,点击输入框查看';
addMessage('system', '已加载 ' + names.length + ' 个模型,点击输入框查看', '系统');
} catch (e) { addMessage('system', '响应非JSON: ' + t.substring(0, 80), '系统'); }
}).catch(function(e) { addMessage('system', '获取失败: ' + e.message, '系统'); });
}
// ====== Agent Test ======
async function testAgent(type) { saveSettings(); var label = type === 'main' ? '主Agent' : type === 'writer' ? 'WriterAgent' : 'MCPAgent';
var testMsg = type === 'main' ? 'Hi, reply OK' : type === 'writer' ? 'Use utc_now to get time' : 'Use utc_now to get time';
addMessage('system', 'Testing ' + label + '...', '系统');
try { var r = await fetch(API_BASE + '/threads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!r.ok) { addMessage('system', '✗ ' + label + ': Cannot create thread HTTP ' + r.status, '系统'); return; }
var d = await r.json(); var tid = d.thread_id;
var r2 = await fetch(API_BASE + '/threads/' + tid + '/runs/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assistant_id: ASSISTANT_ID, thread_id: tid, input: { messages: [{ role: 'user', content: testMsg }] } }) });
if (!r2.ok) { addMessage('system', '✗ ' + label + ': Stream failed HTTP ' + r2.status, '系统'); return; }
var reader = r2.body.getReader(), decoder = new TextDecoder(), buf = '', found = false;
var timeout = setTimeout(function() { if (!found) addMessage('system', '✗ ' + label + ': Timeout, API reachable', '系统'); }, 30000);
try { while (true) { var chunk = await reader.read(); if (chunk.done) break; buf += decoder.decode(chunk.value, { stream: true });
var lines = buf.split('\n'); buf = lines.pop();
for (var i = 0; i < lines.length; i++) { var line = lines[i]; if (!line.startsWith('data: ')) continue;
try { var msgData = JSON.parse(line.slice(6)); if (msgData.messages) { for (var j = 0; j < msgData.messages.length; j++) { var msg = msgData.messages[j]; if (msg.content && msg.content.trim()) { found = true; clearTimeout(timeout); reader.cancel(); addMessage('system', '✓ ' + label + ': Connected, model responded', '系统'); return; } } } } catch (e) {} }
}
} catch (e) { if (!found) { clearTimeout(timeout); addMessage('system', '✗ ' + label + ': Read error - ' + e.message, '系统'); } }
if (!found) { clearTimeout(timeout); addMessage('system', '✗ ' + label + ': No response, API reachable', '系统'); }
} catch (e) { addMessage('system', '✗ ' + label + ': ' + e.message, '系统'); }
}
// ====== Token Display ======
function estimateTokens(text) { var cn = (text.match(/[\u4e00-\u9fff]/g) || []).length; var en = text.length - cn; return Math.ceil(cn * 0.6 + en * 0.25); }
function updateTokenDisplay() { var total = 0; messagesDiv.querySelectorAll('.message .bubble').forEach(function(b) { total += estimateTokens(b.innerText || ''); });
var cfg = JSON.parse(localGet('mcp_config') || '{}'); var max = cfg.max_tokens || 256000; var el = document.getElementById('token-info');
if (max > 0) { el.textContent = '已用 ~' + total + ' / ' + max + ' tokens (' + Math.round(total / max * 100) + '%)'; el.style.color = total > max * 0.8 ? '#ef4444' : 'var(--text-secondary)'; }
else { el.textContent = '~' + total + ' tokens'; }
}
setInterval(updateTokenDisplay, 3000);
// ====== Model Selector ======
function showModelOptions(inpId, dlId) {
var dl = document.getElementById(dlId);
if (!dl || dl.options.length === 0) return;
var old = document.getElementById('model-options-panel');
if (old) old.remove();
var inp = document.getElementById(inpId);
var rect = inp.getBoundingClientRect();
var panel = document.createElement('div');
panel.id = 'model-options-panel';
panel.style.cssText = 'position:fixed;z-index:99999;background:white;border:1px solid #ccc;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.15);max-height:200px;overflow-y:auto;min-width:200px';
panel.style.left = rect.left + 'px';
panel.style.top = (rect.bottom + 4) + 'px';
panel.style.width = Math.max(rect.width, 200) + 'px';
for (var i = 0; i < dl.options.length; i++) {
var opt = dl.options[i];
var item = document.createElement('div');
item.textContent = opt.value;
item.style.cssText = 'padding:6px 10px;cursor:pointer;font-size:12px;color:#333';
item.onmouseover = function() { this.style.background = '#e8edff'; };
item.onmouseout = function() { this.style.background = ''; };
item.onclick = (function(v) { return function() { inp.value = v; panel.remove(); }; })(opt.value);
panel.appendChild(item);
}
document.body.appendChild(panel);
setTimeout(function() {
document.addEventListener('click', function rm() { panel.remove(); document.removeEventListener('click', rm); }, {once: true});
}, 100);
}
// ====== Init ======
checkLogin();
document.getElementById('login-password').addEventListener('keydown', function(e) { if (e.key === 'Enter') doLogin(); });
</script>
</body>
</html>
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,37 @@
# MCP HTTP MCP server
# {
# "name": "airport_monitor",
# "type": "mcp",
# "url": "http://192.168.1.100:8080/mcp",
# "description": "机场软件运行状态查询",
# "timeout": 30,
# "health_timeout": 5,
# "max_retries": 3,
# "recovery_check_interval": 60
# },
# LangGraph
# {
# "name": "data_analyst",
# "type": "langgraph",
# "url": "http://192.168.1.200:2024",
# "assistant_id": "data_analyst",
# "description": "数据分析专家",
# "timeout": 60,
# "health_timeout": 5,
# "max_retries": 3,
# "recovery_check_interval": 120
# },
# HTTP API
# {
# "name": "log_collector",
# "type": "http",
# "url": "http://192.168.1.50:3000/api/query",
# "health_url": "http://192.168.1.50:3000/health",
# "description": "日志收集与检索服务",
# "timeout": 30,
# "health_timeout": 5,
# "max_retries": 3,
# "recovery_check_interval": 60
# }
@@ -0,0 +1,84 @@
# 远端子智能体配置说明
## 配置文件: `remote_skills.json`
放在项目根目录(与 `mcp_config.json` 同级),格式:
```json
{
"skills": [
{
"name": "skill_name", // 唯一名称,会生成 delegate_to_skill_name 工具
"type": "http", // http / langgraph / mcp
"url": "http://host:port/path",
"health_url": "http://host:port/health", // 可选,健康检查端点
"description": "描述", // 给 LLM 看的工具描述
"timeout": 30, // 调用超时(秒)
"health_timeout": 5, // 健康检查超时(秒)
"max_retries": 3, // 连续失败多少次后标记不可用
"recovery_check_interval": 60 // 不可用后多久重试健康检查(秒)
}
]
}
```
## 三种连接方式
### 1. HTTP API
```json
{
"name": "log_collector",
"type": "http",
"url": "http://192.168.1.50:3000/api/query",
"health_url": "http://192.168.1.50:3000/health",
"description": "日志收集与检索服务"
}
```
- POST `url` 发送 `{"instruction": "..."}`
- 返回 `{"result": "..."}``{"content": "..."}`
- health_url 返回 200-399 即为健康
### 2. LangGraph Remote
```json
{
"name": "data_analyst",
"type": "langgraph",
"url": "http://192.168.1.200:2024",
"assistant_id": "data_analyst",
"description": "数据分析专家"
}
```
- 通过 langgraph-sdk 调用远程 LangGraph 服务
- 需要 `pip install langgraph-sdk`
- 健康检查调用 `/ok` 端点
### 3. MCP Server
```json
{
"name": "airport_monitor",
"type": "mcp",
"url": "http://192.168.1.100:8080/mcp",
"description": "机场软件运行状态查询"
}
```
- 通过 langchain-mcp-adapters 连接 MCP server
- 健康检查:尝试获取 tools 列表
- 成功则返回可用工具,失败则标记不可用
## 健康检查机制
| 状态 | 行为 |
|------|------|
| 健康 | 正常调用,30秒内不重复检查 |
| 首次失败 | 重试,每次调用前检查 |
| 连续失败 ≥ max_retries | 标记不可用,返回"当前不可用" |
| 不可用后 recovery_check_interval 秒 | 重新检查,成功则自动恢复 |
## 使用方式
1. 配置 `remote_skills.json`
2. 重启服务
3. 主 Agent 工具列表自动增加 `delegate_to_<skill_name>` 工具
4. LLM 根据描述自动选择合适的远端技能
5. 技能不可用时返回"该功能当前不可用"
6. 恢复后自动重新可用
+119
View File
@@ -0,0 +1,119 @@
aiosqlite==0.22.1
altgraph==0.17.5
annotated-types==0.7.0
anthropic==0.100.0
anyio==4.13.0
APScheduler==3.11.2
attrs==26.1.0
blockbuster==1.5.26
certifi==2026.4.22
cffi==2.0.0
charset-normalizer==3.4.7
click==8.3.3
cloudpickle==3.1.2
colorama==0.4.6
croniter==6.2.2
cryptography==46.0.7
defusedxml==0.7.1
distro==1.9.0
docstring_parser==0.18.0
drawpyo==0.2.5
et_xmlfile==2.0.0
fonttools==4.63.0
fpdf2==2.8.7
googleapis-common-protos==1.74.0
grpcio==1.78.0
grpcio-health-checking==1.78.0
grpcio-tools==1.78.0
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
httpx-sse==0.4.3
idna==3.13
importlib_metadata==8.7.1
iniconfig==2.3.0
jiter==0.14.0
jsonpatch==1.33
jsonpointer==3.1.1
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
jsonschema_rs==0.44.1
langchain==1.2.17
langchain-anthropic==1.4.3
langchain-core==1.3.3
langchain-mcp-adapters==0.2.2
langchain-openai==1.2.1
langchain-protocol==0.0.15
langgraph==1.1.10
langgraph-api==0.8.7
langgraph-checkpoint==4.0.3
langgraph-checkpoint-sqlite==3.0.3
langgraph-cli==0.4.24
langgraph-prebuilt==1.0.13
langgraph-runtime-inmem==0.28.0
langgraph-sdk==0.3.14
langsmith==0.8.2
lxml==6.1.0
mcp==1.27.0
openai==2.35.1
openpyxl==3.1.5
opentelemetry-api==1.41.1
opentelemetry-exporter-otlp-proto-common==1.41.1
opentelemetry-exporter-otlp-proto-http==1.41.1
opentelemetry-proto==1.41.1
opentelemetry-sdk==1.41.1
opentelemetry-semantic-conventions==0.62b1
orjson==3.11.9
ormsgpack==1.12.2
packaging==26.2
pathspec==1.1.1
pefile==2024.8.26
pillow==12.2.0
pluggy==1.6.0
protobuf==6.33.6
psutil==7.2.2
pycparser==3.0
pydantic==2.13.4
pydantic-settings==2.14.0
pydantic_core==2.46.4
Pygments==2.20.0
pyinstaller==6.20.0
pyinstaller-hooks-contrib==2026.5
PyJWT==2.12.1
pypdf==6.12.2
pytest==9.0.3
python-dateutil==2.9.0.post0
python-docx==1.2.0
python-dotenv==1.2.2
python-multipart==0.0.27
python-pptx==1.0.2
PyYAML==6.0.3
referencing==0.37.0
regex==2026.4.4
requests==2.33.1
requests-toolbelt==1.0.0
rpds-py==0.30.0
ruff==0.15.12
setuptools==82.0.1
# Editable Git install with no remote (simple-agent-template==0.1.0)
six==1.17.0
sniffio==1.3.1
sqlite-vec==0.1.9
sse-starlette==3.3.4
starlette==1.0.0
structlog==25.5.0
tenacity==9.1.4
tqdm==4.67.3
truststore==0.10.4
typing-inspection==0.4.2
typing_extensions==4.15.0
tzdata==2026.2
tzlocal==5.3.1
urllib3==2.6.3
uuid_utils==0.14.1
uvicorn==0.46.0
watchfiles==1.1.1
xlsxwriter==3.2.9
xxhash==3.7.0
zipp==3.23.1
zstandard==0.25.0

Some files were not shown because too many files have changed in this diff Show More